Split View: 폐쇄망 컨테이너 이미지 반입 — podman save와 skopeo는 다른 도구입니다
폐쇄망 컨테이너 이미지 반입 — podman save와 skopeo는 다른 도구입니다
- 들어가며 — RPM 다음에는 반드시 이미지가 옵니다
- 트랜스포트를 먼저 이해합니다
- podman save와 load — 단순하고 확실한 경로
- skopeo copy — 로컬을 거치지 않고 옮깁니다
- skopeo sync — 폐쇄망 미러링의 정답
- 서명을 실제로 강제합니다
- 대상 아키텍처를 반드시 지정합니다
- 쿠버네티스까지 이어집니다
- 마치며 — 도구를 규모에 맞춰 고르세요
- 직접 해보기
- 이전 / 다음 편
- 참고 자료
들어가며 — RPM 다음에는 반드시 이미지가 옵니다
패키지 반입 절차가 자리를 잡으면 바로 다음 요구가 들어옵니다. 컨테이너 이미지도 같은 매체로 들여보내 달라는 것입니다.
도구는 두 계열이 있고 자주 혼동됩니다.
podman save/podman load— 로컬 이미지 저장소를 거칩니다. 먼저 pull 해서 로컬에 두고, 그걸 파일로 내보내고, 안쪽에서 다시 로컬로 불러들입니다skopeo copy/skopeo sync— 로컬 저장소를 거치지 않습니다. 출발지에서 목적지로 직접 옮깁니다
이미지가 한두 개면 어느 쪽이든 됩니다. 수십 개가 되고 그게 정기 작업이 되면 차이가 분명해집니다.
트랜스포트를 먼저 이해합니다
skopeo를 쓰려면 트랜스포트 표기를 알아야 합니다. containers-transports 문서 기준으로 폐쇄망에서 쓰는 것은 이 정도입니다.
| 표기 | 문서상 의미 |
|---|---|
docker:// | "An image in a registry implementing the 'Docker Registry HTTP API V2'." |
dir: | "An existing local directory path storing the manifest, layer tarballs and signatures as individual files." |
docker-archive: | "An image is stored in the docker-save(1) formatted file." |
oci: | "An image in a directory structure compliant with the 'Open Container Image Layout Specification' at path." |
oci-archive: | "a tar(1) archive with contents compliant with the 'Open Container Image Layout Specification'." |
containers-storage: | "An image located in a local containers storage." |
docker-archive:에는 문서가 명시하는 함정이 있습니다. "a write to a docker-archive: destination completely overwrites path, replacing it with the single provided image." 같은 tar 파일에 이미지를 하나씩 추가하려다 앞의 것을 지우는 사고가 여기서 나옵니다. 여러 이미지를 한 파일에 담으려면 dir:이나 oci:를 쓰거나, podman save의 다중 이미지 옵션을 써야 합니다.
이름 해석 규칙도 기억해 두면 좋습니다. 문서는 "If name does not contain a slash, it is treated as docker.io/library/name"라고 정의합니다. 폐쇄망에서 짧은 이름을 쓰면 존재하지 않는 외부 호스트를 향하게 된다는 뜻입니다.
podman save와 load — 단순하고 확실한 경로
이미지 몇 개를 옮기는 데는 이쪽이 편합니다.
# 연결망 장비: 이미지를 받는다 (대상 아키텍처를 명시)
podman pull --arch amd64 registry.access.redhat.com/ubi9/ubi:9.4
# 단일 이미지를 아카이브로 내보낸다
podman save --quiet -o ubi9.tar registry.access.redhat.com/ubi9/ubi:9.4
# 여러 이미지를 하나의 아카이브에 담는다 (docker-archive 형식에서만 지원)
podman save --multi-image-archive -o bundle.tar \
registry.access.redhat.com/ubi9/ubi:9.4 \
registry.access.redhat.com/ubi9/nginx-124:latest
# OCI 형식으로 내보낸다
podman save -o ubi9-oci.tar --format oci-archive registry.access.redhat.com/ubi9/ubi:9.4
--format이 받는 값은 문서에 네 가지로 명시되어 있습니다. docker-archive는 "A tar archive interoperable with docker load(1)", oci-archive는 "A tar archive using the OCI Image Format", oci-dir는 "A directory using the OCI Image Format", docker-dir는 "dir transport with v2s2 manifest type"입니다.
--multi-image-archive, -m은 "Allow for creating archives with more than one image. Additional names are interpreted as images instead of tags. Only supported for --format=docker-archive"입니다. OCI 형식과는 함께 쓸 수 없습니다.
안쪽에서 불러들이는 쪽은 더 단순합니다.
# 폐쇄망: 아카이브에서 로컬 저장소로 불러온다
podman load -i bundle.tar
# 불러온 이미지 확인
podman images
podman load의 옵션은 --input, -i와 --quiet, -q 그리고 도움말뿐입니다. 문서는 이 명령이 "restores an archive created by podman save as the same image, preserving its layers, history and tags"라고 설명합니다.
문서가 덧붙이는 운영 주의사항이 하나 있습니다. "Use the environment variable TMPDIR to change the temporary storage location of container images. Podman defaults to use /var/tmp." 대용량 이미지를 불러올 때 임시 공간이 부족해 실패하는 경우가 있으니, 폐쇄망 서버의 파티션 구성에 따라 이 변수를 지정해야 할 수 있습니다.
주의: podman save에는 서명 관련 옵션이 없습니다. 공식 문서의 옵션 목록에는 --compress, --format, --help, --multi-image-archive, --output, --quiet, --uncompressed 일곱 가지뿐입니다. 이미지에 서명을 붙이려면 뒤에 나오는 skopeo 쪽을 써야 합니다.
skopeo copy — 로컬을 거치지 않고 옮깁니다
skopeo copy의 문서 설명에는 폐쇄망에서 중요한 문장이 있습니다. "Uses the system's trust policy to validate images, rejects images not trusted by the policy." 복사 자체가 정책 검사를 거친다는 뜻이라, 정책을 제대로 설정해 두면 반입 경로가 곧 검증 경로가 됩니다.
# 레지스트리에서 반입 매체의 디렉터리로
skopeo copy \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
# 반입 매체에서 사내 레지스트리로
skopeo copy \
dir:/media/transfer/images/ubi9 \
docker://registry.internal.example.com/base/ubi9:9.4
# 레지스트리에서 레지스트리로 직접 (양쪽에 닿을 수 있는 경우)
skopeo copy \
docker://quay.io/skopeo/stable:latest \
docker://registry.internal.example.com/skopeo:latest
폐쇄망에서 놓치기 쉬운 옵션 두 개를 짚습니다.
# 멀티 아키텍처 이미지를 전부 복사한다
skopeo copy --all \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
# 필요한 아키텍처만 골라서 복사한다
skopeo copy --multi-arch=linux/amd64,linux/arm64 \
docker://quay.io/skopeo/stable:latest \
docker://registry.internal.example.com/skopeo:latest
# 재시도를 켠다 (기본값은 재시도 없음)
skopeo copy --retry-times 3 \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
--all, -a의 문서 설명은 "If source-image refers to a list of images, instead of copying just the image which matches the current OS and architecture ... attempt to copy all of the images in the list, and the list itself"입니다. 기본 동작이 현재 시스템에 맞는 하나만 복사하는 것이라는 점이 핵심입니다. x86_64 노트북에서 받아 ARM 서버로 반입하면 에러 없이 잘못된 것이 들어갑니다.
--multi-arch는 기본값이 system이며 all, index-only, 그리고 쉼표로 구분한 플랫폼 목록을 받습니다. 문서는 index-only와 플랫폼 목록이 "sparse manifest lists"를 만들며 "usually fail unless the referenced per-architecture images are already present in the destination, or the target registry supports sparse indexes"라고 경고합니다.
--retry-times는 "The number of times to retry. By default, no retries are attempted"입니다. 기본값이 재시도 없음이라는 점은 대용량 이미지를 다루는 반입 스크립트에서 반드시 챙겨야 합니다.
skopeo sync — 폐쇄망 미러링의 정답
여러 이미지를 정기적으로 옮긴다면 이쪽입니다. 공식 문서의 설명 문장이 그대로 이 시리즈의 주제입니다.
"Synchronize images between registry repositories and local directories. Synchronization is achieved by copying all the images found at source to destination - useful when synchronizing a local container registry mirror or for populating registries running inside of air-gapped environments."
출발지와 목적지의 트랜스포트를 별도 플래그로 받는 것이 copy와 다른 점입니다. 소스는 docker, dir, yaml을, 목적지는 docker, dir을 받습니다.
# 연결망: 저장소의 모든 태그를 반입 매체 디렉터리로
skopeo sync --src docker --dest dir \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
# 폐쇄망: 매체에서 사내 레지스트리로
skopeo sync --src dir --dest docker \
/media/transfer/images/ubi:9.4 registry.internal.example.com/base/
# 같은 이름의 이미지가 여러 출처에서 올 때 경로 충돌을 막는다
skopeo sync --src docker --dest dir --scoped \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
# 실제로 옮기지 않고 무엇이 옮겨질지만 본다
skopeo sync --src docker --dest dir --dry-run \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
문서는 소스가 docker일 때 "If no image tag is specified, skopeo sync copies all the tags found in that repository"라고 명시합니다. 태그를 지정하지 않으면 전부 가져온다는 뜻이니, 반입 용량을 계산할 때 주의해야 합니다.
--scoped는 "Prefix images with the source image path, so that multiple images with the same name can be stored at destination"입니다. 문서 예시에 따르면 이 옵션을 쓰면 매체에 registry.example.com/busybox:1-glibc 형태의 경로가 만들어지고, 쓰지 않으면 busybox:1-glibc가 됩니다. 여러 레지스트리에서 같은 이름을 가져올 때 반드시 필요합니다.
--dry-run("Run the sync without actually copying data to the destination")과 --keep-going("If any errors occur during copying of images, those errors are logged and the process continues syncing rest of the images and finally fails at the end")은 반입 작업 자동화에 특히 유용합니다.
반입 목록이 커지면 YAML로 관리하는 편이 낫습니다. 문서가 정의하는 형식은 이렇습니다.
# sync.yml — 반입 대상 이미지 목록
registry.access.redhat.com:
images:
ubi9/ubi:
- "9.4"
- "latest"
ubi9/nginx-124: []
images-by-tag-regex:
ubi9/python-311: ^3\.11-[0-9]+$
tls-verify: true
quay.io:
images:
skopeo/stable:
- latest
# YAML 목록대로 한 번에 동기화한다
skopeo sync --src yaml --dest docker sync.yml registry.internal.example.com/mirror/
문서에 따르면 빈 목록은 모든 태그를 의미하고, 태그 자리에 다이제스트를 적을 수도 있으며, images-by-tag-regex는 정규식으로, images-by-semver는 semver 제약으로 태그를 고릅니다. 이 파일 자체를 반입 기록으로 남기면 4편의 매니페스트와 같은 역할을 합니다.
skopeo sync에는 --multi-arch가 없습니다. 멀티 아키텍처가 필요하면 --all, -a를 씁니다.
서명을 실제로 강제합니다
폐쇄망에서 이미지 서명이 의미를 가지려면 검증이 강제되어야 합니다. 그 설정이 policy.json입니다.
문서에 따르면 정책은 기본적으로 $HOME/.config/containers/policy.json에서 읽고, 없으면 /etc/containers/policy.json에서 읽습니다. 구조는 전역 default와 트랜스포트별 transports 두 부분이며, "The global default set of policy requirements is mandatory"입니다.
{
"default": [{"type": "reject"}],
"transports": {
"docker": {
"registry.internal.example.com": [
{
"type": "signedBy",
"keyType": "GPGKeys",
"keyPath": "/etc/pki/containers/internal-signing-key.gpg"
}
]
},
"dir": {
"": [{"type": "insecureAcceptAnything"}]
}
}
}
요구 유형별 의미는 문서에 이렇게 정의되어 있습니다. reject는 "This requirement rejects every image, and every signature", insecureAcceptAnything은 "This requirement accepts any image (but note that other requirements in the array still apply)", signedBy는 "This requirement requires an image to be signed using "simple signing" with an expected identity, or accepts a signature if it is using an expected identity and key"입니다. keyType은 문서상 현재 GPGKeys만 지원되며, keyPath, keyPaths, keyData 중 정확히 하나가 있어야 합니다.
매칭 규칙도 알아 둘 필요가 있습니다. "If multiple policy requirements match a given image, only the requirements from the most specific match apply, the more general policy requirements definitions are ignored." 넓은 범위에 엄격한 정책을 걸어 두고 좁은 범위에 예외를 두면, 그 좁은 범위에서는 넓은 쪽 정책이 적용되지 않습니다.
서명을 붙이는 것은 skopeo 쪽입니다.
# 사내 레지스트리로 밀어 넣으면서 서명한다
skopeo copy --sign-by security@example.com \
dir:/media/transfer/images/ubi9 \
docker://registry.internal.example.com/base/ubi9:9.4
--sign-by는 "Add a "simple signing" signature using that key ID for an image name corresponding to destination-image"입니다. sigstore 방식이 필요하면 --sign-by-sigstore-private-key가 있습니다.
레지스트리가 서명 저장을 지원하지 않는다면 분리 서명 저장소를 씁니다. registries.d 문서는 lookaside를 "URL of the signature storage. This URL is used for reading existing signatures, and if lookaside-staging does not exist, also for adding or removing them"로, lookaside-staging을 "URL of the signature storage, used for editing it (adding or deleting signatures)"로 정의합니다.
# /etc/containers/registries.d/internal.yaml
docker:
registry.internal.example.com:
lookaside: http://sigstore.internal.example.com/signatures
lookaside-staging: file:///srv/signatures-staging
문서는 sigstore와 sigstore-staging 키가 더 이상 문서화되지 않는 옛 이름이라고 소스 주석에 명시하고 있으므로, 새로 작성한다면 lookaside 표기를 쓰세요.
대상 아키텍처를 반드시 지정합니다
이 절은 짧지만 실패 빈도가 높습니다.
# 아키텍처를 명시해서 받는다
podman pull --arch arm64 registry.access.redhat.com/ubi9/ubi:9.4
# OS와 아키텍처를 한 번에 (--arch, --os와 함께 쓸 수 없음)
podman pull --platform linux/arm64 registry.access.redhat.com/ubi9/ubi:9.4
--arch는 "Override the architecture, defaults to the host, of the image to be pulled", --platform은 "Specify the platform for selecting the image. (Conflicts with --arch and --os)"입니다. 기본값이 호스트라는 점이 문제의 출발점입니다.
--all-tags, -a에는 별도의 경고가 붙어 있습니다. "IMPORTANT: When using the all-tags flag, Podman does not iterate over the search registries in the containers-registries.conf(5) but always uses docker.io for unqualified image names." 폐쇄망 준비 작업에서 짧은 이름으로 이 옵션을 쓰면 의도하지 않은 레지스트리를 향합니다.
쿠버네티스까지 이어집니다
여기까지가 이미지를 폐쇄망 레지스트리에 올리는 데까지입니다. 그 이미지를 실제 클러스터가 쓰게 만드는 것은 별도의 주제이고, 이 블로그에 이미 정리해 둔 글들이 있습니다.
- k3s 폐쇄망 설치 완전 가이드 — 이미지 tarball 반입,
registries.yaml로 사내 레지스트리를 미러로 붙이기, 자체 서명 CA 신뢰 - k0s 폐쇄망 설치 가이드 — 같은 문제를 k0s에서 푸는 방법
- 폐쇄망 컨테이너 이미지 공급망 — 이미지 출처와 서명을 공급망 관점에서 다룬 글
- 폐쇄망 쿠버네티스 Day 2 운영 — 반입 이후의 운영
패키지 관리자 자체를 비교한 npm, uv, rpm, brew 비교도 rpm의 위치를 잡는 데 도움이 됩니다.
서브스크립션 없이 Red Hat 콘텐츠를 재배포하는 것은 계약 위반일 수 있으니 조직의 라이선스 조건을 먼저 확인하세요. Red Hat 컨테이너 이미지 역시 별도의 이용 약관이 적용되므로, 사내 레지스트리 미러 구성 전에 확인이 필요합니다.
마치며 — 도구를 규모에 맞춰 고르세요
이미지 두세 개면 podman save와 podman load로 충분합니다. 수십 개이고 정기 작업이면 skopeo sync가 정답이고, 그 사이에 skopeo copy가 있습니다.
어느 쪽이든 두 가지는 빠뜨리지 마세요. 아키텍처를 명시하는 것과 서명 검증을 정책으로 강제하는 것입니다. 둘 다 빠뜨려도 명령은 성공하고, 문제는 몇 주 뒤에 드러납니다.
명령과 옵션은 2026-08-15에 공식 문서에서 확인했습니다. RHEL 버전에 따라 podman과 skopeo의 제공 방식이 다르므로, 설치 방법은 사용 중인 버전의 문서로 다시 확인하세요.
직접 해보기
이전 / 다음 편
참고 자료
Bringing Container Images into an Air-Gapped Network — podman save and skopeo Are Different Tools
- Opening — images always follow RPMs
- Understand transports first
- podman save and load — the simple, dependable path
- skopeo copy — moving images without going through local storage
- skopeo sync — the right answer for air-gapped mirroring
- Actually enforcing signatures
- Always specify the target architecture
- It continues into Kubernetes
- Closing — pick the tool to match the scale
- Try it yourself
- Previous / next in the series
- References
Opening — images always follow RPMs
Once the package transfer procedure is in place, the very next request arrives. Bring the container images in on the same medium, please.
There are two families of tools here, and they get confused constantly.
podman save/podman load— they go through the local image store. You pull first so the image lands locally, export that to a file, and load it back into a local store on the insideskopeo copy/skopeo sync— they do not go through a local store. They move the image straight from source to destination
With one or two images, either family works. Once it is dozens of images and it has become a recurring job, the difference is obvious.
Understand transports first
To use skopeo you have to know the transport notation. Going by the containers-transports documentation, these are the ones you use in an air-gapped network.
| Notation | Meaning per the documentation |
|---|---|
docker:// | "An image in a registry implementing the 'Docker Registry HTTP API V2'." |
dir: | "An existing local directory path storing the manifest, layer tarballs and signatures as individual files." |
docker-archive: | "An image is stored in the docker-save(1) formatted file." |
oci: | "An image in a directory structure compliant with the 'Open Container Image Layout Specification' at path." |
oci-archive: | "a tar(1) archive with contents compliant with the 'Open Container Image Layout Specification'." |
containers-storage: | "An image located in a local containers storage." |
docker-archive: carries a trap the documentation spells out: "a write to a docker-archive: destination completely overwrites path, replacing it with the single provided image." This is exactly where the accident of adding images to the same tar file one at a time — and wiping out the previous one — comes from. To hold several images in one file, use dir: or oci:, or use the multi-image option of podman save.
The name resolution rule is worth committing to memory as well. The documentation defines it as "If name does not contain a slash, it is treated as docker.io/library/name". In an air-gapped network that means a short name points at an external host that does not exist for you.
podman save and load — the simple, dependable path
For moving a handful of images, this side is more convenient.
# Connected machine: pull the image (state the target architecture)
podman pull --arch amd64 registry.access.redhat.com/ubi9/ubi:9.4
# Export a single image as an archive
podman save --quiet -o ubi9.tar registry.access.redhat.com/ubi9/ubi:9.4
# Put several images into one archive (only supported by the docker-archive format)
podman save --multi-image-archive -o bundle.tar \
registry.access.redhat.com/ubi9/ubi:9.4 \
registry.access.redhat.com/ubi9/nginx-124:latest
# Export in OCI format
podman save -o ubi9-oci.tar --format oci-archive registry.access.redhat.com/ubi9/ubi:9.4
The documentation states four accepted values for --format. docker-archive is "A tar archive interoperable with docker load(1)", oci-archive is "A tar archive using the OCI Image Format", oci-dir is "A directory using the OCI Image Format", and docker-dir is "dir transport with v2s2 manifest type".
--multi-image-archive, -m is "Allow for creating archives with more than one image. Additional names are interpreted as images instead of tags. Only supported for --format=docker-archive". It cannot be combined with the OCI formats.
The loading side on the inside is simpler still.
# Air-gapped side: load from the archive into the local store
podman load -i bundle.tar
# Check the loaded images
podman images
The options for podman load are --input, -i and --quiet, -q, plus the help flag. That is all. The documentation describes the command as one that "restores an archive created by podman save as the same image, preserving its layers, history and tags".
There is one operational caveat the documentation adds. "Use the environment variable TMPDIR to change the temporary storage location of container images. Podman defaults to use /var/tmp." Loading a large image sometimes fails because the temporary space runs out, so depending on how the partitions on your air-gapped server are laid out, you may have to set this variable.
Note: podman save has no signing options at all. The official option list contains exactly seven entries — --compress, --format, --help, --multi-image-archive, --output, --quiet, --uncompressed. To attach a signature to an image you have to use the skopeo side, covered below.
skopeo copy — moving images without going through local storage
The documentation for skopeo copy contains one sentence that matters a great deal in an air-gapped network. "Uses the system's trust policy to validate images, rejects images not trusted by the policy." The copy itself goes through a policy check, which means that if you configure the policy properly, the transfer path becomes the verification path.
# From the registry to a directory on the transfer medium
skopeo copy \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
# From the transfer medium to the internal registry
skopeo copy \
dir:/media/transfer/images/ubi9 \
docker://registry.internal.example.com/base/ubi9:9.4
# Registry to registry directly (when you can reach both sides)
skopeo copy \
docker://quay.io/skopeo/stable:latest \
docker://registry.internal.example.com/skopeo:latest
Two options are easy to miss in an air-gapped setting.
# Copy every image in a multi-architecture list
skopeo copy --all \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
# Copy only the architectures you actually need
skopeo copy --multi-arch=linux/amd64,linux/arm64 \
docker://quay.io/skopeo/stable:latest \
docker://registry.internal.example.com/skopeo:latest
# Turn retries on (the default is no retries)
skopeo copy --retry-times 3 \
docker://registry.access.redhat.com/ubi9/ubi:9.4 \
dir:/media/transfer/images/ubi9
The documentation for --all, -a reads "If source-image refers to a list of images, instead of copying just the image which matches the current OS and architecture ... attempt to copy all of the images in the list, and the list itself". The key point is that the default behaviour is to copy only the one image matching the current system. Pull on an x86_64 laptop and transfer to an ARM server and the wrong thing goes in without a single error.
--multi-arch defaults to system and accepts all, index-only, and a comma-separated list of platforms. The documentation warns that index-only and platform lists produce "sparse manifest lists" and "usually fail unless the referenced per-architecture images are already present in the destination, or the target registry supports sparse indexes".
--retry-times is "The number of times to retry. By default, no retries are attempted". That the default is no retries at all is something you must handle deliberately in any transfer script that deals with large images.
skopeo sync — the right answer for air-gapped mirroring
If you move many images on a regular schedule, this is the tool. The description sentence in the official documentation is the theme of this whole series, word for word.
"Synchronize images between registry repositories and local directories. Synchronization is achieved by copying all the images found at source to destination - useful when synchronizing a local container registry mirror or for populating registries running inside of air-gapped environments."
What differs from copy is that the source and destination transports are given as separate flags. The source accepts docker, dir, and yaml; the destination accepts docker and dir.
# Connected side: every tag in the repository into a directory on the transfer medium
skopeo sync --src docker --dest dir \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
# Air-gapped side: from the medium into the internal registry
skopeo sync --src dir --dest docker \
/media/transfer/images/ubi:9.4 registry.internal.example.com/base/
# Prevent path collisions when images with the same name come from several sources
skopeo sync --src docker --dest dir --scoped \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
# See what would move without actually moving anything
skopeo sync --src docker --dest dir --dry-run \
registry.access.redhat.com/ubi9/ubi /media/transfer/images
For a docker source the documentation states that "If no image tag is specified, skopeo sync copies all the tags found in that repository". Leave the tag off and you get everything, so be careful when you are budgeting the size of a transfer.
--scoped is "Prefix images with the source image path, so that multiple images with the same name can be stored at destination". According to the example in the documentation, with this option the medium ends up with paths shaped like registry.example.com/busybox:1-glibc, and without it you get busybox:1-glibc. It is mandatory when you pull the same name from several registries.
--dry-run ("Run the sync without actually copying data to the destination") and --keep-going ("If any errors occur during copying of images, those errors are logged and the process continues syncing rest of the images and finally fails at the end") are especially useful when you automate transfer jobs.
Once the transfer list grows, managing it in YAML is the better option. The format the documentation defines looks like this.
# sync.yml — the list of images to bring in
registry.access.redhat.com:
images:
ubi9/ubi:
- "9.4"
- "latest"
ubi9/nginx-124: []
images-by-tag-regex:
ubi9/python-311: ^3\.11-[0-9]+$
tls-verify: true
quay.io:
images:
skopeo/stable:
- latest
# Sync everything in one pass, following the YAML list
skopeo sync --src yaml --dest docker sync.yml registry.internal.example.com/mirror/
According to the documentation, an empty list means all tags, a digest can be written where a tag would go, images-by-tag-regex selects tags with a regular expression, and images-by-semver selects them with a semver constraint. Keep this file itself as a transfer record and it plays the same role the manifest from post 4 does.
skopeo sync has no --multi-arch. When you need multiple architectures, you use --all, -a.
Actually enforcing signatures
For image signing to mean anything in an air-gapped network, verification has to be enforced. That configuration lives in policy.json.
According to the documentation, the policy is read from $HOME/.config/containers/policy.json by default and from /etc/containers/policy.json when that does not exist. The structure has two parts, a global default and a per-transport transports section, and "The global default set of policy requirements is mandatory".
{
"default": [{"type": "reject"}],
"transports": {
"docker": {
"registry.internal.example.com": [
{
"type": "signedBy",
"keyType": "GPGKeys",
"keyPath": "/etc/pki/containers/internal-signing-key.gpg"
}
]
},
"dir": {
"": [{"type": "insecureAcceptAnything"}]
}
}
}
The documentation defines the meaning of each requirement type this way. reject is "This requirement rejects every image, and every signature", insecureAcceptAnything is "This requirement accepts any image (but note that other requirements in the array still apply)", and signedBy is "This requirement requires an image to be signed using "simple signing" with an expected identity, or accepts a signature if it is using an expected identity and key". Per the documentation keyType currently supports only GPGKeys, and exactly one of keyPath, keyPaths, or keyData must be present.
The matching rule is worth knowing too. "If multiple policy requirements match a given image, only the requirements from the most specific match apply, the more general policy requirements definitions are ignored." Hang a strict policy on a broad scope and then carve out an exception on a narrow scope, and inside that narrow scope the broader policy does not apply.
Attaching the signature is the skopeo side of the job.
# Sign while pushing into the internal registry
skopeo copy --sign-by security@example.com \
dir:/media/transfer/images/ubi9 \
docker://registry.internal.example.com/base/ubi9:9.4
--sign-by is "Add a "simple signing" signature using that key ID for an image name corresponding to destination-image". If you need the sigstore approach, --sign-by-sigstore-private-key is there.
If your registry does not support storing signatures, you use lookaside signature storage. The registries.d documentation defines lookaside as "URL of the signature storage. This URL is used for reading existing signatures, and if lookaside-staging does not exist, also for adding or removing them", and lookaside-staging as "URL of the signature storage, used for editing it (adding or deleting signatures)".
# /etc/containers/registries.d/internal.yaml
docker:
registry.internal.example.com:
lookaside: http://sigstore.internal.example.com/signatures
lookaside-staging: file:///srv/signatures-staging
The source comments in the documentation state that the sigstore and sigstore-staging keys are old names that are no longer documented, so when you write a new file, use the lookaside spelling.
Always specify the target architecture
This section is short but it accounts for a lot of failures.
# Pull with the architecture stated explicitly
podman pull --arch arm64 registry.access.redhat.com/ubi9/ubi:9.4
# OS and architecture in one go (cannot be used together with --arch or --os)
podman pull --platform linux/arm64 registry.access.redhat.com/ubi9/ubi:9.4
--arch is "Override the architecture, defaults to the host, of the image to be pulled", and --platform is "Specify the platform for selecting the image. (Conflicts with --arch and --os)". That the default is the host is where the whole problem starts.
--all-tags, -a carries a warning of its own. "IMPORTANT: When using the all-tags flag, Podman does not iterate over the search registries in the containers-registries.conf(5) but always uses docker.io for unqualified image names." Use this option with a short name while preparing an air-gapped transfer and you end up pointed at a registry you never intended.
It continues into Kubernetes
Everything above gets you as far as putting images into the air-gapped registry. Making an actual cluster use those images is a separate subject, and there are posts on this blog that already cover it.
- The complete k3s air-gapped installation guide — transferring image tarballs, attaching an internal registry as a mirror through
registries.yaml, trusting a self-signed CA - The k0s air-gapped installation guide — how to solve the same problem on k0s
- The air-gapped container image supply chain — image provenance and signing viewed as a supply chain question
- Air-gapped Kubernetes Day 2 operations — operations after the transfer
The comparison of the package managers themselves, npm, uv, rpm, and brew compared, also helps put rpm in context.
Redistributing Red Hat content without a subscription may violate your agreement, so check your organization's licensing terms first. Red Hat container images are covered by their own separate terms of use as well, so confirm those before you build an internal registry mirror.
Closing — pick the tool to match the scale
For two or three images, podman save and podman load are enough. For dozens of images on a regular schedule, skopeo sync is the right answer, and skopeo copy sits between the two.
Whichever you choose, do not skip these two things: stating the architecture explicitly, and enforcing signature verification through policy. Leave out either one and the command still succeeds — the problem surfaces weeks later.
Commands and options were verified against the official documentation on 2026-08-15. How podman and skopeo are shipped differs across RHEL versions, so confirm the installation method against the documentation for the version you are running.
Try it yourself
- Container Lab — see image layers and registry behaviour with your own eyes
- Hash Generator — get comfortable with the idea of an image digest
- Linux Terminal — practise the skopeo transport notation
Previous / next in the series
- Previous: Modularity and versions — reproducing the same state offline
- Next: The operations playbook — security patches, rollback, and CVE response