In the previous lab you mirrored a single image with skopeo copy and hand-wrote an ImageTagMirrorSet to redirect docker.io pulls to it. That works, but it doesn’t scale: every image needs its own skopeo copy, and the mirror-set YAML must be kept perfectly in sync with whatever you copied — get the library/ namespace wrong and pulls 404.
oc-mirror (the v2 plugin, GA since OpenShift 4.16) replaces both steps:
A single declarative ImageSetConfiguration lists everything you need (individual images, operator catalogs, even whole OCP release payloads).
One oc-mirror ... --v2 invocation mirrors all of it in one pass.
oc-mirror then generates the ImageTagMirrorSet/ImageDigestMirrorSet manifests for you, scoped to exactly what it mirrored — no manual YAML.
In this lab you’ll mirror two images (nginx + alpine) in a single pass, apply the mirror set oc-mirror generates, and deploy the same nginx chart — this time with an Alpine sidecar — to confirm both images are transparently redirected.
Prerequisites
A local clone of openshift-advanced, with openshift-advanced/labs as your working directory (cd openshift-advanced/labs) — every relative path below is relative to it
An OpenShift cluster with cluster-admin rights (oc/kubectl configured) — applying mirror sets requires patching image.config.openshift.io/cluster (see the previous lab for why this is a node-level, disruptive operation)
helm v3+, envsubst, and a container engine able to run a local registry (e.g. podman run registry:2)
Note: as of OCP 4.21, --v2 is mandatory — oc-mirror v1 still exists behind --v1 but is deprecated. Every command below passes --v2 explicitly.
The nginx-chart used in the Helm on OpenShift migration lab, and its mirror=true option which adds an Alpine sidecar container — used here to prove that both images mirrored in a single oc-mirror pass get redirected
Pre-requisite — local mirror registry and insecure-registry patch
WARNING — do not start the local registry, this has been performed as a pre-requisite
Same shared local-registry instance as the previous lab — check it’s up before continuing:
curl -s http://localhost:5000/v2/_catalog
Do not run the commands below
HOST_IP=$({ ip -4 -o addr show crc; ip -4 -o addr show virbr0; } 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)LOCAL_REGISTRY="${HOST_IP}:5000"podman run -d --name local-registry -p $HOST_IP:5000:5000 \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
registry:2
oc patch image.config.openshift.io/cluster --type=merge \
-p "{\"spec\":{\"registrySources\":{\"insecureRegistries\":[\"$LOCAL_REGISTRY\"]}}}"NODE=$(oc get nodes -l node-role.kubernetes.io/worker -o jsonpath='{.items[0].metadata.name}')until oc debug node/$NODE -- chroot /host cat /etc/containers/registries.conf | grep -q "$HOST_IP"; do sleep 5done
Step 1 — describe everything to mirror in one ImageSetConfiguration
# NOTE: Change image tags to your convenience to use different images from other trainees# Image tags are available on Docker Hub: https://hub.docker.com/_/nginxNGINX_VERSION="1.25.3"ALPINE_VERSION="3.19"export NGINX_VERSION ALPINE_VERSION
envsubst < 11_airgapped/manifests/imageset-config.yaml > /tmp/imageset-config-$USER.yaml
Question: Both images are referenced as docker.io/library/nginx and docker.io/library/alpine — i.e. with the library/ namespace spelled out. Why does that matter, given what you learned in the previous lab?
Answer
oc-mirror derives the source:/mirrors: paths of the mirror set it generates from the paths it actually copied. If you mirror docker.io/library/nginx and docker.io/library/alpine, the generated rule will redirect docker.io/library → $LOCAL_REGISTRY/library — which matches how nginx and alpine are referenced implicitly by Docker Hub “official image” conventions (and by the nginx chart’s default values.yaml, and its sidecar). Had you mirrored docker.io/nginx (without library/), oc-mirror would generate a rule for docker.io (or a narrower/different path) that wouldn’t match those implicit references, and pulls would 404 — the exact same library/ pitfall as the previous lab, just shifted from “you write the wrong path in the mirror set” to “you write the wrong path in the ImageSetConfiguration”.
Step 2 — mirror everything in one pass
HOST_IP=$({ ip -4 -o addr show crc; ip -4 -o addr show virbr0; } 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)LOCAL_REGISTRY="${HOST_IP}:5000"MIRROR_WORKSPACE="/tmp/oc-mirror-workspace-$USER"mkdir -p "$MIRROR_WORKSPACE"oc-mirror -c /tmp/imageset-config-$USER.yaml \
--workspace "file://$MIRROR_WORKSPACE"\
--dest-tls-verify=false \
"docker://$LOCAL_REGISTRY"\
--v2
This is the mirrorToMirror workflow: oc-mirror reads docker.io/library/{nginx,alpine}, pushes them straight to $LOCAL_REGISTRY, and uses $MIRROR_WORKSPACE/working-dir to keep its state and generated manifests. Output looks like:
🔀 workflow mode: mirrorToMirror
🕵 going to discover the necessary images...
🔍 collecting additional images...
🚀 Start copying the images...
📌 images to copy 2
Success copying docker.io/library/alpine:3.19 ➡️ <mirror_ip_address>:5000/library/
Success copying docker.io/library/nginx:1.25.3 ➡️ <mirror_ip_address>:5000/library/
=== Results ===
✓ 2 / 2 additional images mirrored successfully
📄 No images by digests were mirrored. Skipping IDMS generation.
📄 Generating ITMS file...
/tmp/oc-mirror-workspace-$USER/working-dir/cluster-resources/itms-oc-mirror.yaml file created
Question: The previous lab used skopeo copy ... --dest-tls-verify=false with one invocation per image. What two things does oc-mirror --v2 do differently here, just from the flags and output above?
Answer
One config, many images — additionalImages can list as many images (or operator catalogs, or OCP releases) as you need; oc-mirror mirrors all of them in a single invocation, instead of one skopeo copy per image.
--dest-tls-verify=false replaces --dest-skip-tls — oc-mirror v1 used --dest-skip-tls; in v2 the flag is the standard --dest-tls-verify boolean (default true), set to false for our plain-HTTP local registry. (--src-tls-verify exists symmetrically for an insecure source.)
Also note --v2 is mandatory here — running oc-mirror without --v1/--v2 refuses to start.
Step 3 — inspect the ImageTagMirrorSet oc-mirror generated for you
Question: Compare this generated ImageTagMirrorSet with the hand-written one from the previous lab (source: docker.io, mirrors: [$LOCAL_REGISTRY], mirrorSourcePolicy: NeverContactSource). What two differences stand out, and what’s the practical consequence of each?
Answer
Narrower source: docker.io/library instead of docker.io. The redirect now applies only to Docker Hub’s official-image namespace — exactly what was mirrored. A chart referencing docker.io/someorg/something (not under library/) would be left alone, whereas the previous lab’s broad source: docker.io rule would have redirected that too (and failed, since it was never mirrored).
No mirrorSourcePolicy: the field is omitted, which defaults to allowing fallback to the source registry (AllowContactingSource) — the opposite of the previous lab’s NeverContactSource. In a real disconnected cluster (no route to docker.io at all) this fallback simply times out rather than failing fast — about the same outcome either way. In a connected lab cluster like this one, it’s actually convenient: if you forget to mirror an image, the pull still succeeds (from docker.io) instead of hard-failing as in the previous lab’s bonus exercise. If you need the previous lab’s strict “never touch docker.io” guarantee, edit this file and add mirrorSourcePolicy: NeverContactSource before applying it.
As in the previous lab, polling registries.conf directly is more reliable than oc wait machineconfigpool/worker --for=condition=Updated: applying a newImageTagMirrorSet kicks off a fresh Machine Config Operator rollout, and Updated can briefly still read True from a previous rollout before the new one starts.
Step 5 — deploy nginx + its Alpine sidecar, unchanged
mirror=true adds a second container to the Pod — an alpine:3.19 sidecar running sleep infinity. Like the nginx container, it has no image.registry set: its image reference is the implicit docker.io/library/alpine:3.19, which is exactly the path the ImageTagMirrorSet from Step 4 redirects to $LOCAL_REGISTRY/library.
Step 6 — verify both images came from the mirror
kubectl get pod -l app=nginx \
-o jsonpath='{range .items[0].spec.containers[*]}{.name}: {.image}{"\n"}{end}'kubectl describe pod -l app=nginx | grep -A10 Events:
# WARNING: Do not run it, ask trainer which owns the registry.podman logs local-registry 2>&1 | grep GET
Question: Neither Image nor the Events mention your local registry — same as Approach A in the previous lab. How do the local-registry logs prove both images were actually pulled from the mirror, not from docker.io?
Answer
The Pod spec is unchanged and unhelpful, exactly like the previous lab’s Approach A:
nginx: nginx:1.25.3
alpine-sidecar: alpine:3.19
But local-registry’s access log shows GET requests for both repositories’ manifests and blobs, with oc-mirror’s pushes earlier and the nodes’ pulls later:
Two repositories appear in /v2/_catalog (library/nginx, library/alpine) — confirming a singleoc-mirror invocation populated the mirror for both images that the chart needs, and the ImageTagMirrorSet from Step 4 redirected both transparently.
Question: The output still says images to copy 2 and Success copying for both — so nothing was skipped. Yet the second run finishes in roughly a third of the time of the first. Where did the time go the first time, and where is state kept?
Answer
oc-mirror keeps a layer cache under ~/.oc-mirror/.cache (override with --cache-dir), independent of --workspace. The first run had to pull every layer of nginx:1.25.3 and alpine:3.19 from docker.io; the second run finds those layers already in the cache and only has to verify/push them to $LOCAL_REGISTRY (itself a no-op if the destination already has them, since image storage is content-addressed — same principle as Approach B in the previous lab).
This matters in practice when you iterate on an ImageSetConfiguration — e.g. adding a third image to additionalImages — or retry after a partial failure: oc-mirror won’t re-download what it already has, only the new/missing pieces.
One oc-mirror ... --v2, any number of images/catalogs
Mirror set manifest
Hand-written; must match what you copied
Generated from what was actually mirrored
Redirect scope
Whatever source:/mirrors: you typed (docker.io, broad)
Scoped to exactly the namespaces mirrored (docker.io/library)
mirrorSourcePolicy
Explicit choice (e.g. NeverContactSource)
Not set by oc-mirror — defaults to allowing fallback to source
Re-running
Re-copies everything every time
Local layer cache (~/.oc-mirror/.cache) skips unchanged downloads
Operator catalogs / OCP releases
Not supported by this approach
Same ImageSetConfiguration syntax, just add operators:/platform:
Cleanup
oc delete project airgapped-oc-mirror-$USER
# WARNING: do not run it, performed by lab manager.# While they exist, the mirror sets send docker.io pulls of the whole cluster to the local registrykubectl delete imagetagmirrorset itms-generic-0 local-registry-mirror --ignore-not-found
oc patch image.config.openshift.io/cluster --type=json -p '[{"op":"remove","path":"/spec/registrySources"}]'podman rm -f local-registry
rm -rf "$MIRROR_WORKSPACE" /tmp/imageset-config-$USER.yaml
Key Takeaways
oc-mirror --v2 is declarative and batched: one ImageSetConfiguration describes everything to mirror, one command mirrors it all — no per-image skopeo copy.
--v2 is mandatory on current oc-mirror clients, and --dest-tls-verify=false (a standard boolean flag) replaces the old v1-only --dest-skip-tls.
oc-mirror generates the ImageTagMirrorSet/ImageDigestMirrorSet for you, scoped to exactly the paths it mirrored — but it does not set mirrorSourcePolicy, so review the generated file (and add NeverContactSource yourself) if you need a hard guarantee against contacting the original source.
A local layer cache (~/.oc-mirror/.cache) makes iteration cheap — adding images to your ImageSetConfiguration or retrying a failed run doesn’t re-download what’s already cached.
The same ImageSetConfiguration mechanism is what scales this approach to operator catalogs and full OCP release payloads — the building block for a real disconnected-cluster mirror, not just a couple of test images.