OpenShift Airgapped: Mirroring Container Images
Author: Fabrice JAMMES (LinkedIn). Duration: 25-35 minutes
Objective
Clusters running in restricted networks (“airgapped”) cannot pull images directly from the internet (i.e. docker.io). You must mirror the images you need into a registry the cluster can reach, and then make the cluster use that mirror.
There are two fundamentally different ways to achieve this:
- A. Cluster-wide transparent redirection — configure OpenShift itself (
ImageTagMirrorSet) to silently redirect everydocker.iopull to your local mirror. Charts and Deployments stay untouched. - B. Explicit reference — point each chart/Deployment directly at the mirror registry (
image.registry=...). No cluster-level redirection is configured.
In this guided lab you’ll deploy the same nginx chart both ways, observe how the resulting Pods differ, and weigh the trade-offs of each approach.
Prerequisites
- A local clone of
openshift-advanced, withopenshift-advanced/labsas your working directory (cd openshift-advanced/labs) — every relative path below is relative to it - An OpenShift cluster with cluster-admin rights (
oc/kubectlconfigured) — both approaches below require patchingimage.config.openshift.io/cluster helmv3+,skopeo, and a container engine able to run a local registry (e.g.podman run registry:2)- The
nginx-chartused in the Helm on OpenShift migration lab — this lab reuses its OpenShift-compatible values (nginx-values-v2.yaml, port 8080, non-rootsecurityContext) so that the only variable left is where the image comes from
Pre-requisite — start a local mirror registry and copy the image into it
WARNING — do not start the local registry, this has been performed as a pre-requisite
local-registryis a single container running on the host, not inside the cluster — it’s shared infrastructure for the whole lab: both Approach A and Approach B mirror their images into this very same instance, andLOCAL_REGISTRYis reused everywhere below. Don’t recreate it. Check first whether it’s already running and reachable:
Do not run the commands below
Question: Why does patching image.config.openshift.io/cluster require waiting before the change is live on the nodes — and why poll registries.conf instead of oc wait machineconfigpool/worker --for=condition=Updated?
Approach A — Transparent mirroring with ImageTagMirrorSet
Mirror the image preserving Docker Hub’s implicit library/ namespace — this matters, see the question below:
Then tell OpenShift to redirect every docker.io pull to your mirror:
Recommendation: don’t use
oc wait machineconfigpool/worker --for=condition=Updatedhere. TheUpdatedcondition can stayTruefor a short moment after thekubectl apply, while the MachineConfig Operator detects the newImageTagMirrorSetand kicks off a new rollout —oc waitwould then return immediately, before the rollout even starts. The loop above polls/etc/containers/registries.confon the node directly until thedocker.iorule shows up. Without this synchronization, a learner in a hurry could runhelm installbefore the mirror rule is actually live on the node.
Now deploy the chart without changing anything about the image — same values file as in the Helm migration lab:
Question: Look at the Pod’s image reference (kubectl get pod -l app=nginx-mirror -o jsonpath='{.items[0].spec.containers[0].image}') and at kubectl describe pod -l app=nginx-mirror (Events). Where did the image actually come from — docker.io or your mirror? How can you tell?
Bonus — what happens when the mirror doesn’t have the image?
mirrorSourcePolicy: NeverContactSource means CRI-O will only look at $LOCAL_REGISTRY for any docker.io image — including ones you haven’t mirrored yet. Try it with an image you haven’t copied anywhere:
Note: use a non-
library/image such ascurlimages/curlhere, notubuntu/nginx/alpine. On a shared cluster, another lab might define its ownImageTagMirrorSetorImageContentSourcePolicyfordocker.io/library(withoutNeverContactSource). Becausedocker.io/libraryis a more specific prefix than our rule’sdocker.io, it would win for any “official” Docker Hub image and silently fall back todocker.io, masking the failure this exercise is meant to show. Runoc get imagetagmirrorset,imagedigestmirrorset -o yamlif you want to see what else is configured cluster-wide.
This hangs trying to attach (Ctrl+C to get back your prompt). Check why:
Question: What does the pull error reference — docker.io/curlimages/curl or your mirror? What does this tell you, compared to the previous question?
Approach B — Explicit registry reference in the chart values
This time, mirror the image to a path of your own choosing (no library/ needed — you’re not relying on any redirect rule):
Deploy the same chart and values, but this time tell Helm explicitly which registry to use — via the chart’s image.registry value:
Question: What does the Pod’s image reference look like now? Does the node actually pull anything?
Comparing the two approaches
Observing the difference with helm get values and kubectl describe
Run both commands on each release and compare the output:
Approach A — helm get values does not mention image.registry (it was never set):
And kubectl describe doesn’t reveal anything either — both Image and Image ID still point to docker.io:
Approach B — helm get values explicitly shows the local registry:
And kubectl describe is fully honest — both Image and Image ID point to the local registry:
Key insight: in Approach A, nothing in the Pod spec or status reveals the redirect — both Image and Image ID still reference docker.io, exactly as if the mirror didn’t exist. The only way to confirm it’s active is at the node level (registries.conf), or indirectly when the mirror is missing an image and the pull error references the mirror path (see the bonus exercise after Approach A). In Approach B, there is no ambiguity.
A — ImageTagMirrorSet (transparent) |
B — explicit image.registry |
|
|---|---|---|
| Chart / Deployment changes | None — references stay docker.io/... |
Every chart/values must reference the mirror |
| Cluster-level configuration | ImageTagMirrorSet + insecureRegistries patch (cluster-admin, MachineConfigPool rollout / node reboot) |
insecureRegistries patch only (still cluster-admin + rollout) |
| Visibility | Image reference in the Pod spec is misleading (looks like docker.io, isn’t) |
Image reference is honest and explicit |
| Portability | Works for any image referencing docker.io, including third-party charts you don’t control |
Only works for images/charts you can configure yourself |
| Failure mode | mirrorSourcePolicy: NeverContactSource makes pulls fail hard if the mirror is missing the image — easy to overlook since the chart “looks normal” |
A typo in image.registry fails immediately and visibly at deploy time |
Cleanup
Key Takeaways
- Two strategies, one goal: an
ImageTagMirrorSet/ImageDigestMirrorSetredirects pulls transparently at the infrastructure level (no chart changes, but cluster-admin + node rollout required and the Pod spec becomes misleading); an explicitimage.registryoverride is honest but pushes the airgapped concern into every chart you deploy. docker.io“official” images live underlibrary/—nginxreally meansdocker.io/library/nginx. Forgetting this when mirroring for a tag/digest mirror set is a classic, silent failure mode (the redirected pull 404s).- Patching
image.config.openshift.io/clusteris a node-level, disruptive operation — it flows through the MachineConfig Operator and aMachineConfigPoolrollout (rolling node reboot), not just an API object update. Don’t rely onoc wait machineconfigpool/<pool> --for=condition=Updatedto confirm it landed: the condition can stayTruebefore the rollout even starts, and on single-node clusters (CRC)machineconfigpool/workerhas zero machines and is permanently, vacuouslyUpdated=True— the real rollout happens onmachineconfigpool/masterinstead. Poll/etc/containers/registries.confon a node directly, as both the prerequisite and Approach A do. - Container image storage is content-addressed: identical layers are never re-pulled, no matter which tag or registry path was used to reference them —
kubectl describe podwill tell you “already present on machine” even for a reference the node has never seen before.
Reference / full solution
- Full demo scripts:
ex1-airgapped-helm.sh(Approach A) andex2-airgapped-helm-explicit-registry.sh(Approach B) - Mirror set manifest:
image-tag-mirror-set.yaml - Helm chart:
nginx-chartand OpenShift-compatible valuesnginx-values-v2.yaml