Skip to content

Split View: 폐쇄망 이미지 반입 파이프라인 설계 — skopeo, Harbor, 그리고 썩지 않는 재반입 런북

✨ Learn with Quiz
|

폐쇄망 이미지 반입 파이프라인 설계 — skopeo, Harbor, 그리고 썩지 않는 재반입 런북

들어가며 — 한 번의 반입은 반드시 썩습니다

폐쇄망 설치 가이드는 많습니다. 그런데 대부분 첫 설치까지만 다룹니다. 실제로 팀을 갉아먹는 건 그다음입니다.

3개월 뒤 새 서비스가 배포되면서 이미지 열두 개가 추가로 필요해집니다. 6개월 뒤 취약점 점검 결과가 내려오는데, 사내 스캐너의 취약점 DB가 반년 전 것이라 결과를 믿을 수 없습니다. 9개월 뒤 base 이미지를 올려야 하는데 그때 누가 어떤 태그를 어떤 경로로 들여왔는지 아무도 재구성하지 못합니다. 최초 반입을 한 사람은 이미 다른 팀입니다.

이 글은 그 문제를 다룹니다. 한 번 들여오는 방법이 아니라, 반복해서 들여오는 구조를 설계하는 방법입니다. 아래 도구들을 기준으로 확인했습니다.

도구확인한 버전 또는 기준확인 시점출처
skopeoskopeo-sync 매뉴얼 (main 브랜치 문서)2026-07-31skopeo-sync.1.md
oras1.32026-07-31oras push
Trivy DBtrivy-db 태그 2, trivy-java-db 태그 12026-07-31Trivy Self-Hosting
Harbor2.14.02026-07-31Harbor Docs
Helm OCI문서 페이지 기준 Helm 4.2.32026-07-31Helm Registries

반입 목록을 코드로 관리한다

파이프라인의 첫 단추는 도구가 아니라 목록입니다. 목록이 사람 머릿속이나 위키 페이지에 있으면 반년 뒤 반드시 실제와 어긋납니다. Git 저장소에 두고, 반입 작업은 그 파일의 커밋으로만 시작하게 만드십시오.

skopeo가 읽을 수 있는 형식을 그대로 정본으로 쓰는 것이 가장 실용적입니다. 별도 변환 스크립트가 필요 없기 때문입니다.

# images.yaml — 반입 목록 정본. 이 파일의 diff 가 곧 반입 신청서입니다.
docker.io:
  images:
    library/postgres:
      - '16.4'
      - '16.6'
    library/redis:
      - '7.4.1'
  images-by-tag-regex:
    library/busybox: ^1\.36.*$
registry.k8s.io:
  images:
    ingress-nginx/controller:
      - 'v1.12.0'
    metrics-server/metrics-server:
      - 'v0.7.2'
quay.io:
  tls-verify: true
  images:
    prometheus/prometheus:
      - 'v3.1.0'
    prometheus/node-exporter:
      - 'v1.8.2'
ghcr.io:
  images:
    aquasecurity/trivy:
      - '0.58.1'

이 형식은 skopeo sync 매뉴얼에 정의된 YAML 소스 형식 그대로입니다. images, images-by-tag-regex, images-by-semver, credentials, tls-verify, cert-dir 키를 지원합니다. 태그를 정규식이나 semver 범위로 지정할 수 있다는 점이 중요한데, 폐쇄망에서는 범위 지정을 최대한 피하고 태그를 못 박는 편이 낫습니다. 범위를 쓰면 반입할 때마다 무엇이 들어왔는지 달라지고, 그러면 목록 파일이 정본 역할을 못 합니다.

목록에 반드시 함께 적어야 할 것이 하나 더 있습니다. 다이제스트입니다.

# 태그가 가리키는 다이제스트를 목록과 함께 고정합니다
skopeo inspect docker://docker.io/library/postgres:16.6 \
  | jq -r '.Digest' \
  | tee -a DIGESTS.txt

태그는 움직입니다. 3개월 뒤 같은 태그를 다시 뜨면 다른 내용물이 올 수 있습니다. 폐쇄망 감사에서 "지난번에 반입한 것과 같은 것입니까"라는 질문에 답하려면 다이제스트 기록이 있어야 합니다.

외부 수집 — skopeo sync로 떠내기

수집 단계는 인터넷이 되는 DMZ 장비에서 수행합니다. 여기서 도커 데몬을 쓰지 않는 것이 핵심입니다. skopeo는 데몬 없이 레지스트리와 직접 대화하므로, 반입용 스테이징 장비를 최소 구성으로 유지할 수 있습니다.

#!/usr/bin/env bash
# collect.sh — DMZ 수집 장비
set -euo pipefail

BATCH="$(date +%Y%m%d)"
OUT="/staging/inbound/${BATCH}"
mkdir -p "${OUT}"

# 목록 파일 하나로 여러 레지스트리를 한 번에 떠냅니다.
# --scoped 를 붙이면 원본 레지스트리 경로가 접두어로 붙어 이름 충돌이 사라집니다.
skopeo sync \
  --src yaml \
  --dest dir \
  --scoped \
  --all \
  --keep-going \
  images.yaml "${OUT}"

du -sh "${OUT}"
find "${OUT}" -maxdepth 3 -type d | head -30

각 플래그의 의미는 매뉴얼에 정의된 그대로입니다.

  • --scoped — "여러 이미지가 같은 이름을 가질 수 있으므로 목적지에 저장할 때 원본 이미지 경로를 접두어로 붙인다". 여러 레지스트리에서 동명의 이미지를 받을 때 필수입니다.
  • --all — 소스가 이미지 목록(멀티 아키텍처 매니페스트)을 가리키는 경우, 현재 OS와 아키텍처에 맞는 것만이 아니라 전부 복사합니다. amd64와 arm64 노드가 섞인 폐쇄망이라면 반드시 필요합니다.
  • --keep-going — 복사 중 오류가 나도 로그만 남기고 계속 진행합니다. 200개 이미지를 뜨는데 세 번째에서 멈추는 상황을 막습니다.
  • --preserve-digests — 다이제스트를 보존하고, 보존할 수 없으면 실패합니다. 반입 추적성이 중요하면 켜십시오.

목적지 전송(--dest)은 매뉴얼 기준 dockerdir 두 가지입니다. 매체로 나를 것이므로 dir을 씁니다. 매체에 담기 전에 아카이브로 묶고 체크섬을 붙입니다.

# 매체 반입용 아카이브와 매니페스트
cd /staging/inbound
tar -cf "images-${BATCH}.tar" "${BATCH}"
sha256sum "images-${BATCH}.tar" > "images-${BATCH}.sha256"

# 반입 신청서에 첨부할 목록 스냅샷
cp images.yaml "images-${BATCH}.manifest.yaml"
cp DIGESTS.txt "images-${BATCH}.digests.txt"

이미지가 아닌 것들 — oras

폐쇄망에 들여야 할 것은 이미지만이 아닙니다. Helm 차트, SBOM, 정책 번들, 취약점 DB가 모두 필요합니다. 이것들은 OCI 아티팩트로 취급하면 이미지와 완전히 같은 경로로 나를 수 있습니다. 도구는 oras입니다.

# 임의 파일을 OCI 아티팩트로 밀어 넣기 (oras 1.3 기준)
oras push --artifact-type application/vnd.example.policy.v1+tar \
  registry.internal.example:5000/policies/kyverno:2026.07 \
  policies.tar.gz

# 파일별로 미디어 타입을 지정할 수도 있습니다
oras push registry.internal.example:5000/bundles/edge:2026.07 \
  bundle.tar:application/vnd.example.bundle \
  README.md:text/markdown
# OCI 레이아웃 디렉터리로 떠내기 — 매체로 나르기에 적합한 형태
oras push --oci-layout /staging/inbound/20260731/oci:policies-2026.07 policies.tar.gz

레지스트리 간 복사에는 oras cp, 매니페스트 확인에는 oras manifest fetch가 있습니다. 다만 이번 확인 시점에 oras push 문서 페이지 본문에서 확인한 것은 push 계열 문법과 OCI 레이아웃 옵션까지입니다. oras cporas pull의 정확한 플래그는 해당 명령의 문서 페이지에서 확인한 뒤 스크립트에 넣으십시오.

검사 구간 — Trivy DB를 손으로 들여오기

수집과 배포 사이에 검사 구간이 있어야 합니다. 폐쇄망에서 검사가 어려운 이유는 스캐너 자체가 아니라 스캐너의 데이터가 인터넷에서 갱신되기 때문입니다. Trivy는 취약점 DB를 OCI 아티팩트로 배포하므로, 이미지와 같은 방식으로 들여올 수 있습니다.

#!/usr/bin/env bash
# collect-trivy-db.sh — DMZ 수집 장비
set -euo pipefail
BATCH="$(date +%Y%m%d)"
mkdir -p "/staging/inbound/${BATCH}/trivy" && cd "/staging/inbound/${BATCH}/trivy"

# 공식 문서가 명시한 저장소와 태그
oras pull ghcr.io/aquasecurity/trivy-db:2
oras pull ghcr.io/aquasecurity/trivy-java-db:1
oras pull ghcr.io/aquasecurity/trivy-checks:latest

ls -la
sha256sum ./* > TRIVY-DB.sha256

매체가 안쪽으로 들어오면 사내 레지스트리에 올립니다. 여기서 미디어 타입이 중요합니다. Trivy는 커스텀 미디어 타입으로 계층을 식별하므로, 일반 파일처럼 밀어 넣으면 Trivy가 못 읽습니다.

아티팩트미디어 타입
trivy-dbapplication/vnd.aquasec.trivy.db.layer.v1.tar+gzip
trivy-java-dbapplication/vnd.aquasec.trivy.javadb.layer.v1.tar+gzip
trivy-checksapplication/vnd.oci.image.manifest.v1+json
# 폐쇄망 내부 — 사내 레지스트리로 밀어 넣기 (공식 문서의 예시 형태)
oras push registry.internal.example:5000/trivy/trivy-db:2 db.tar.gz
oras push registry.internal.example:5000/trivy/trivy-java-db:1 javadb.tar.gz
oras push registry.internal.example:5000/trivy/trivy-checks:latest ./checks/
# 스캔 — 사내 저장소를 바라보게 지정합니다
trivy image \
  --db-repository registry.internal.example:5000/trivy/trivy-db \
  --java-db-repository registry.internal.example:5000/trivy/trivy-java-db \
  --checks-bundle-repository registry.internal.example:5000/trivy/trivy-checks \
  registry.internal.example:5000/apps/api:1.4.2

여기서 정직하게 밝혀 둘 부분이 있습니다. --skip-db-update, --skip-java-db-update, --offline-scan 같은 플래그는 널리 쓰이지만, 이번 확인 시점에 Trivy 에어갭 문서 페이지 본문에서 이 플래그들의 정확한 이름과 동작을 확인하지 못했습니다. 캐시 디렉터리 기본 경로도 마찬가지입니다. 파이프라인에 넣기 전에 반입한 Trivy 바이너리로 직접 확인하십시오.

# 반입한 바이너리에서 실제 플래그 이름을 확인합니다 — 추측하지 마십시오
trivy image --help | grep -iE 'db|offline|cache'
trivy --version

한 가지 더. Trivy 문서는 검사 번들이 빌드 시점에 Trivy 바이너리에 내장되어 있어 외부 DB를 못 쓸 때 폴백으로 사용된다고 설명합니다. 즉 설정 오류로 미스컨피그 검사가 조용히 낡은 내장 번들로 돌아갈 수 있다는 뜻입니다. 스캔 결과가 이상하게 깨끗하면 이 경로를 의심하십시오.

서명과 SBOM — 투명성 로그가 없는 곳의 cosign

폐쇄망에서 서명 검증은 반쪽이 됩니다. cosign의 기본 동작은 서명을 투명성 로그와 대조하는 것인데, 폐쇄망에서는 그 로그에 닿을 수 없습니다. 그래서 전략을 다르게 잡아야 합니다.

가장 확실한 방법은 자체 키 쌍을 쓰고 공개 키를 반입물에 포함시키는 것입니다.

# DMZ 구간 — 검사를 통과한 이미지에 사내 키로 서명
cosign sign --key /secure/cosign.key \
  registry.dmz.example:5000/apps/api@sha256:abc123...

# 공개 키를 반입 매니페스트에 함께 넣습니다
cp /secure/cosign.pub "/staging/inbound/${BATCH}/cosign.pub"
# 폐쇄망 내부 — 로컬 공개 키로 검증
cosign verify --key /etc/cosign/cosign.pub \
  registry.internal.example:5000/apps/api:1.4.2

원본 공급자가 서명한 것을 검증해야 하는 경우라면 사정이 다릅니다. 공식 문서는 로컬에 내려받은 이미지를 검증하는 경로와, 투명성 로그 대조를 건너뛰는 옵션을 제시합니다.

# 로컬로 내려받은 이미지 검증
cosign verify --key cosign.pub --local-image /staging/inbound/20260731/apps-api

# 투명성 로그 대조를 건너뛰고 키와 페이로드만 검증
cosign verify --check-claims=false --key cosign.pub registry.internal.example:5000/apps/api:1.4.2

다만 --insecure-ignore-tlog, --private-infrastructure, 그리고 오프라인 TUF 루트를 미러로 초기화하는 절차는 이번 확인 시점에 sigstore 검증 문서 본문에서 확인하지 못했습니다. 이 세 가지가 파이프라인에 필요하다면 반입한 cosign 바이너리의 도움말과 sigstore 문서에서 직접 확인한 뒤 적용하십시오. 검증 절차를 추측으로 채우면 검증을 안 하는 것보다 나쁩니다. "검증했다"는 기록만 남고 실제로는 아무것도 확인하지 않은 상태가 되기 때문입니다.

SBOM은 서명보다 우선순위가 높습니다. 폐쇄망에서 SBOM이 없으면, 반년 뒤 새 취약점이 공개되었을 때 "우리 클러스터에 그 패키지가 있는가"에 답할 방법이 없습니다. 이미지를 다시 스캔하려면 스캐너 DB를 또 반입해야 하고, 이미 삭제된 이미지는 조사조차 못 합니다.

# 수집 시점에 SBOM 을 함께 생성해 매체에 담습니다
trivy image --format cyclonedx \
  --output "sbom/apps-api-1.4.2.cdx.json" \
  registry.dmz.example:5000/apps/api:1.4.2

# SBOM 을 OCI 아티팩트로 함께 반입
oras push --artifact-type application/vnd.cyclonedx+json \
  registry.internal.example:5000/sbom/apps-api:1.4.2 \
  "sbom/apps-api-1.4.2.cdx.json"

SBOM을 이미지에 참조로 붙이는 방식(referrer)도 있지만, 레지스트리가 referrers API를 지원해야 합니다. 지원 여부가 확실하지 않으면 위처럼 별도 저장소 경로에 태그로 올려 두는 편이 안전합니다. 조회가 단순하고 어느 레지스트리에서나 동작합니다.

내부 배포 — 이중 레지스트리 패턴과 Harbor

반입 파이프라인의 구조는 두 개의 레지스트리와 그 사이의 검사 구간으로 정리됩니다.

구간위치역할여기서 하지 말아야 할 것
수집 레지스트리DMZ외부에서 받은 원본 보관, 다이제스트 기록, 서명운영 클러스터가 여기를 직접 바라보게 하기
검사 구간DMZ 또는 중계취약점 스캔, SBOM 생성, 정책 검사, 승인 기록실패한 아티팩트를 수동으로 통과시키기
반입 경로매체 또는 단방향 게이트웨이체크섬 검증, 심의 기록검증 없이 내부로 넣기
배포 레지스트리폐쇄망 내부클러스터가 바라보는 유일한 출처검사 없이 개별 개발자가 직접 푸시하게 두기

가장 흔한 설계 실수는 배포 레지스트리에 개발자가 직접 푸시할 수 있게 열어 두는 것입니다. 그 순간 이중 레지스트리 패턴이 무너지고, 검사 구간을 우회한 이미지가 클러스터에 들어옵니다. 배포 레지스트리에 쓰기 권한을 가진 주체는 반입 파이프라인 계정 하나여야 합니다.

Harbor를 내부 배포 레지스트리로 쓰는 경우 확인 시점 최신 릴리스는 2.14.0입니다. Harbor를 폐쇄망에 설치할 때는 오프라인 인스톨러를 받아야 하고, Harbor 자체의 컨테이너 이미지들이 그 인스톨러에 포함되어 있습니다. 반입 목록에 Harbor 인스톨러를 넣는 것을 잊지 마십시오.

여기서 반드시 짚고 넘어갈 것이 있습니다. Harbor의 프록시 캐시 기능은 완전 폐쇄망에서 쓸모가 없습니다. 프록시 캐시는 상위 레지스트리로 요청을 넘겨 받아 오는 구조이므로, 상위 레지스트리로 가는 네트워크 경로가 전제입니다. 경로가 없으면 캐시 미스가 곧 실패입니다. 이 기능이 유용한 경우는 "인터넷은 되지만 통제하고 싶은" 준폐쇄망이지, 라우팅 자체가 없는 환경이 아닙니다.

같은 이유로 Harbor의 복제(replication) 기능도 폐쇄망 경계를 넘지 못합니다. 복제는 DMZ 안쪽에서 수집 레지스트리들 사이를 정리하는 데 쓰고, 경계를 넘는 이동은 매체나 단방향 게이트웨이로만 하십시오. 이번 확인 시점에 Harbor 복제 설정과 프록시 캐시의 개별 문서 페이지는 URL이 바뀌어 본문을 확인하지 못했으므로, 지원 소스 레지스트리 종류와 트리거 방식 같은 세부 사항은 Harbor 2.14 문서에서 직접 확인하십시오.

Helm 차트를 OCI 아티팩트로 나르기

차트를 별도 차트 저장소로 관리하면 반입 경로가 하나 더 늘어납니다. OCI 아티팩트로 통일하면 이미지와 같은 레지스트리, 같은 인증, 같은 반입 절차를 씁니다.

# DMZ 수집 — 외부 차트를 tgz 로 받아 둡니다
helm pull oci://registry-1.docker.io/bitnamicharts/postgresql --version 16.4.5 -d ./charts
helm pull https://prometheus-community.github.io/helm-charts/prometheus-25.27.0.tgz -d ./charts
sha256sum ./charts/*.tgz > CHARTS.sha256
# 폐쇄망 내부 — 사내 레지스트리로 푸시
helm registry login registry.internal.example:5000
helm push ./charts/postgresql-16.4.5.tgz oci://registry.internal.example:5000/charts
helm push ./charts/prometheus-25.27.0.tgz oci://registry.internal.example:5000/charts
# 설치 — oci 참조에는 버전 지정이 필요합니다
helm show all oci://registry.internal.example:5000/charts/postgresql --version 16.4.5
helm template pg oci://registry.internal.example:5000/charts/postgresql --version 16.4.5
helm install pg oci://registry.internal.example:5000/charts/postgresql --version 16.4.5

차트만 반입하고 끝내면 안 됩니다. 차트가 참조하는 이미지가 별도로 반입되어야 합니다. 이 누락이 폐쇄망에서 가장 자주 나오는 사고입니다. 차트를 받은 즉시 이미지 참조를 뽑아 목록 파일에 반영하는 단계를 파이프라인에 넣으십시오.

# 차트가 참조하는 이미지 목록 추출 — 반입 목록에 반영할 근거
helm template tmp ./charts/postgresql-16.4.5.tgz \
  | grep -E '^\s+image:' \
  | awk '{print $2}' \
  | tr -d '"' \
  | sort -u

values 파일에 따라 참조 이미지가 달라진다는 점에 주의하십시오. 실제 배포에 쓸 values를 그대로 적용해서 추출해야 정확합니다. 조건부로 켜지는 사이드카나 init 컨테이너가 빠지면, 배포 당일에야 발견합니다.

helm template tmp ./charts/postgresql-16.4.5.tgz -f values-prod.yaml \
  | grep -E '^\s+image:' | awk '{print $2}' | tr -d '"' | sort -u

Helm 문서 페이지는 확인 시점에 Helm 4.2.3 기준이며, 해당 페이지에 Helm 4용으로 완전히 갱신되지 않았다는 경고가 붙어 있습니다. 반입할 Helm 버전과 문서 버전이 다르면 명령 동작을 스테이징에서 먼저 확인하십시오.

정기 재반입 런북

여기까지가 구조이고, 여기부터가 이 글의 실제 결론입니다. 위 파이프라인을 아무리 잘 만들어도 주기적으로 돌지 않으면 6개월 뒤 쓸모없어집니다. 자산마다 유효 기간이 다르기 때문에 하나의 주기로 묶으면 안 됩니다.

자산재반입 주기근거방치했을 때의 증상
Trivy 취약점 DB주 1회취약점 정보가 가장 빨리 낡음스캔은 통과인데 실제로는 알려진 취약점이 그대로 존재
Trivy 검사 번들월 1회미스컨피그 규칙 갱신내장 폴백으로 조용히 되돌아가 낡은 규칙으로 검사
base 이미지월 1회OS 패키지 보안 패치모든 파생 이미지가 같은 취약점을 공유
애플리케이션 이미지배포 주기에 맞춤서비스 릴리스와 연동반입 심의 대기로 릴리스가 밀림
쿠버네티스 배포판 아티팩트분기 1회패치 릴리스 누적인증서·CVE 대응이 늦어지고 업그레이드 폭이 커짐
Helm 차트와 참조 이미지차트 변경 시차트만 올리면 이미지가 없음배포 당일 ImagePullBackOff
사내 CA와 서명 공개 키만료 90일 전키 회전 주기레지스트리 TLS 실패로 전 클러스터 이미지 pull 중단
SBOM이미지 반입 시 동반사후 조사의 유일한 근거신규 CVE 공개 시 영향 범위를 산정할 수 없음

이 표를 캘린더에 넣고 담당자를 지정하십시오. 폐쇄망에서 "필요할 때 하겠다"는 계획은 언제나 "필요해진 다음에 3주 걸린다"로 끝납니다.

재반입을 자동화하는 스크립트의 골격은 다음과 같습니다. 핵심은 직전 반입과의 차이를 먼저 계산하는 것입니다. 매번 전량을 다시 나르면 매체 용량과 심의 시간이 감당이 안 됩니다.

#!/usr/bin/env bash
# reimport.sh — DMZ 수집 장비에서 주기 실행
set -euo pipefail

BATCH="$(date +%Y%m%d)"
PREV="$(ls -1d /staging/inbound/20* | sort | tail -1)"
OUT="/staging/inbound/${BATCH}"
mkdir -p "${OUT}"

echo "== 1. 현재 태그의 다이제스트 산출"
: > "${OUT}/DIGESTS.txt"
while read -r ref; do
  [ -z "${ref}" ] && continue
  d=$(skopeo inspect "docker://${ref}" 2>/dev/null | jq -r '.Digest') || d="ERROR"
  echo "${ref} ${d}" >> "${OUT}/DIGESTS.txt"
done < refs.txt

echo "== 2. 직전 반입과의 차이"
if [ -f "${PREV}/DIGESTS.txt" ]; then
  diff "${PREV}/DIGESTS.txt" "${OUT}/DIGESTS.txt" > "${OUT}/CHANGES.diff" || true
  CHANGED=$(grep -c '^>' "${OUT}/CHANGES.diff" || true)
  echo "변경된 참조: ${CHANGED}건"
  if [ "${CHANGED}" -eq 0 ]; then
    echo "변경 없음 — 이번 회차 반입 생략"
    exit 0
  fi
fi

echo "== 3. 변경분만 수집"
skopeo sync --src yaml --dest dir --scoped --all --keep-going images.yaml "${OUT}/images"

echo "== 4. 스캔 및 SBOM"
mkdir -p "${OUT}/sbom" "${OUT}/scan"
while read -r ref _; do
  name=$(echo "${ref}" | tr '/:' '__')
  trivy image --format cyclonedx --output "${OUT}/sbom/${name}.cdx.json" "${ref}" || true
  trivy image --severity HIGH,CRITICAL --format json \
    --output "${OUT}/scan/${name}.json" "${ref}" || true
done < "${OUT}/DIGESTS.txt"

echo "== 5. 심의 제출용 아카이브"
cd /staging/inbound
tar -cf "batch-${BATCH}.tar" "${BATCH}"
sha256sum "batch-${BATCH}.tar" > "batch-${BATCH}.sha256"
echo "제출 준비 완료: batch-${BATCH}.tar"

내부 반입 쪽에도 같은 수준의 스크립트가 필요합니다. 체크섬 검증, 배포 레지스트리 푸시, 반입 대장 기록까지 한 번에 끝나야 사람이 단계를 건너뛰지 않습니다.

#!/usr/bin/env bash
# ingest.sh — 폐쇄망 내부
set -euo pipefail
BATCH="$1"
SRC="/media/inbound/batch-${BATCH}.tar"

sha256sum -c "/media/inbound/batch-${BATCH}.sha256"
mkdir -p "/opt/inbound" && tar -xf "${SRC}" -C /opt/inbound

# 디렉터리 형태로 들어온 이미지를 배포 레지스트리로
skopeo sync --src dir --dest docker \
  "/opt/inbound/${BATCH}/images" registry.internal.example:5000/mirror/

# 반입 대장에 기록 — 나중에 "언제 무엇이 들어왔는가"의 유일한 근거
{
  echo "batch=${BATCH} at=$(date -Iseconds) by=${USER}"
  cat "/opt/inbound/${BATCH}/DIGESTS.txt"
} >> /var/log/airgap-ingest.log

마치며 — 파이프라인의 수명은 목록 파일의 수명입니다

반입 파이프라인에서 가장 오래 살아남는 것은 스크립트가 아니라 목록 파일입니다. 스크립트는 도구 버전이 바뀌면 다시 쓰게 되지만, "우리 클러스터가 필요로 하는 아티팩트는 이것들이다"라는 목록은 몇 년을 갑니다. 그 목록이 Git에 있고 다이제스트가 함께 기록되어 있으면, 담당자가 세 번 바뀌어도 파이프라인은 굴러갑니다.

그리고 프록시 캐시나 복제 같은 기능을 검토할 때는 항상 같은 질문을 먼저 하십시오. 이 기능은 상위로 가는 네트워크 경로를 전제하는가. 전제한다면 폐쇄망에서는 성립하지 않습니다. 이 한 가지 질문이 아키텍처 회의 두 시간을 아껴 줍니다.

참고 자료

Designing an Air-Gapped Image Import Pipeline — skopeo, Harbor, and a Reimport Runbook That Doesn't Rot

Introduction — A Single Import Always Rots

There's no shortage of air-gapped installation guides. Most of them only cover getting through the first install, though. What actually wears a team down is everything that comes after.

Three months later, a new service ships and twelve more images turn out to be needed. Six months later, a vulnerability scan comes back, but the in-house scanner's vulnerability DB is half a year old, so the results can't be trusted. Nine months later, you need to bump the base image, and nobody can reconstruct who brought which tag in through which path, and when. The person who did the original import is already on a different team.

This post is about that problem. It's about not how to bring something in once, but how to design a structure that brings things in repeatedly. Here are the tools this was checked against.

ToolVersion or reference checkedChecked onSource
skopeoskopeo-sync manual (main branch docs)2026-07-31skopeo-sync.1.md
oras1.32026-07-31oras push
Trivy DBtrivy-db tag 2, trivy-java-db tag 12026-07-31Trivy Self-Hosting
Harbor2.14.02026-07-31Harbor Docs
Helm OCIHelm 4.2.3, as stated on the docs page2026-07-31Helm Registries

Manage the Import List as Code

The first thing this pipeline needs isn't a tool — it's a list. If that list lives in someone's head or on a wiki page, six months from now it's guaranteed to be out of sync with reality. Keep it in a Git repository, and make sure an import job can only start from a commit to that file.

The most practical approach is to treat the format skopeo can read as the single source of truth, since it needs no separate conversion script.

# images.yaml — the source of truth for the import list. A diff to this file IS the import request.
docker.io:
  images:
    library/postgres:
      - '16.4'
      - '16.6'
    library/redis:
      - '7.4.1'
  images-by-tag-regex:
    library/busybox: ^1\.36.*$
registry.k8s.io:
  images:
    ingress-nginx/controller:
      - 'v1.12.0'
    metrics-server/metrics-server:
      - 'v0.7.2'
quay.io:
  tls-verify: true
  images:
    prometheus/prometheus:
      - 'v3.1.0'
    prometheus/node-exporter:
      - 'v1.8.2'
ghcr.io:
  images:
    aquasecurity/trivy:
      - '0.58.1'

This format matches the YAML source format defined in the skopeo sync manual exactly. It supports the keys images, images-by-tag-regex, images-by-semver, credentials, tls-verify, and cert-dir. It matters that you can specify tags by regex or semver range, but in an air-gapped setting, it's better to avoid range specifications as much as possible and pin tags exactly. Use a range and what actually comes in changes every time you import, and then the list file can no longer serve as the source of truth.

There's one more thing that absolutely must be recorded alongside the list: the digest.

# Pin, alongside the list, the digest that the tag points to
skopeo inspect docker://docker.io/library/postgres:16.6 \
  | jq -r '.Digest' \
  | tee -a DIGESTS.txt

Tags move. Pull the same tag again three months later and different content can come down. To answer the question an air-gap audit will ask — "is this the same thing you imported last time?" — you need a digest record.

External Collection — Pulling It Down With skopeo sync

The collection stage runs on a DMZ device that has internet access. The key thing here is not using a Docker daemon. skopeo talks directly to the registry with no daemon, so you can keep the staging device used for collection at a minimal footprint.

#!/usr/bin/env bash
# collect.sh — DMZ collection device
set -euo pipefail

BATCH="$(date +%Y%m%d)"
OUT="/staging/inbound/${BATCH}"
mkdir -p "${OUT}"

# Pull from several registries at once using a single list file.
# --scoped prefixes the destination path with the source registry path, so names don't collide.
skopeo sync \
  --src yaml \
  --dest dir \
  --scoped \
  --all \
  --keep-going \
  images.yaml "${OUT}"

du -sh "${OUT}"
find "${OUT}" -maxdepth 3 -type d | head -30

Each flag's meaning is exactly what's defined in the manual.

  • --scoped — "since multiple images can share a name, prefix the source image path when storing at the destination." Essential when pulling same-named images from multiple registries.
  • --all — when the source points to an image list (a multi-architecture manifest), copy all of them rather than just the one matching the current OS and architecture. Essential if your air-gapped network mixes amd64 and arm64 nodes.
  • --keep-going — if a copy fails partway through, log it and keep going. Prevents a run pulling 200 images from stopping on the third one.
  • --preserve-digests — preserves digests, and fails if it can't. Turn this on if import traceability matters.

The destination (--dest) is, per the manual, either docker or dir. Since you're carrying it out on physical media, use dir. Before putting it on the media, bundle it into an archive and attach a checksum.

# Archive and manifest for carrying on media
cd /staging/inbound
tar -cf "images-${BATCH}.tar" "${BATCH}"
sha256sum "images-${BATCH}.tar" > "images-${BATCH}.sha256"

# A snapshot of the list to attach to the import request
cp images.yaml "images-${BATCH}.manifest.yaml"
cp DIGESTS.txt "images-${BATCH}.digests.txt"

Things That Aren't Images — oras

Images aren't the only thing you need to bring in through the air gap. Helm charts, SBOMs, policy bundles, and vulnerability DBs are all needed too. Treat these as OCI artifacts and you can carry them through exactly the same path as images. The tool is oras.

# Push an arbitrary file in as an OCI artifact (as of oras 1.3)
oras push --artifact-type application/vnd.example.policy.v1+tar \
  registry.internal.example:5000/policies/kyverno:2026.07 \
  policies.tar.gz

# You can also specify a media type per file
oras push registry.internal.example:5000/bundles/edge:2026.07 \
  bundle.tar:application/vnd.example.bundle \
  README.md:text/markdown
# Pull down as an OCI layout directory — a form suited to carrying on media
oras push --oci-layout /staging/inbound/20260731/oci:policies-2026.07 policies.tar.gz

For copying between registries there's oras cp, and for checking a manifest there's oras manifest fetch. That said, as of this check, what I confirmed directly in the oras push documentation page's body was the push-family syntax and the OCI layout option. Check the exact flags for oras cp and oras pull on their own documentation pages before putting them in a script.

The Inspection Stage — Bringing In the Trivy DB by Hand

There needs to be an inspection stage between collection and distribution. What makes inspection hard in an air-gapped setting isn't the scanner itself — it's that the scanner's data normally updates from the internet. Trivy distributes its vulnerability DB as an OCI artifact, so it can be brought in the same way as an image.

#!/usr/bin/env bash
# collect-trivy-db.sh — DMZ collection device
set -euo pipefail
BATCH="$(date +%Y%m%d)"
mkdir -p "/staging/inbound/${BATCH}/trivy" && cd "/staging/inbound/${BATCH}/trivy"

# The repository and tags specified by the official documentation
oras pull ghcr.io/aquasecurity/trivy-db:2
oras pull ghcr.io/aquasecurity/trivy-java-db:1
oras pull ghcr.io/aquasecurity/trivy-checks:latest

ls -la
sha256sum ./* > TRIVY-DB.sha256

Once the media crosses back inside, push it up to the internal registry. The media type matters here. Trivy identifies its layers by custom media type, so if you push it in as a plain file, Trivy can't read it.

ArtifactMedia type
trivy-dbapplication/vnd.aquasec.trivy.db.layer.v1.tar+gzip
trivy-java-dbapplication/vnd.aquasec.trivy.javadb.layer.v1.tar+gzip
trivy-checksapplication/vnd.oci.image.manifest.v1+json
# Inside the air gap — push into the internal registry (in the form shown by the official docs)
oras push registry.internal.example:5000/trivy/trivy-db:2 db.tar.gz
oras push registry.internal.example:5000/trivy/trivy-java-db:1 javadb.tar.gz
oras push registry.internal.example:5000/trivy/trivy-checks:latest ./checks/
# Scan — point it at the internal repositories
trivy image \
  --db-repository registry.internal.example:5000/trivy/trivy-db \
  --java-db-repository registry.internal.example:5000/trivy/trivy-java-db \
  --checks-bundle-repository registry.internal.example:5000/trivy/trivy-checks \
  registry.internal.example:5000/apps/api:1.4.2

There's something to be honest about here. Flags like --skip-db-update, --skip-java-db-update, and --offline-scan are widely used, but as of this check, I could not confirm the exact names and behavior of these flags in the body of the Trivy air-gap documentation page. The same goes for the default cache directory path. Check these directly against the Trivy binary you actually imported before putting them into a pipeline.

# Confirm the actual flag names against the binary you imported — don't guess
trivy image --help | grep -iE 'db|offline|cache'
trivy --version

One more thing. The Trivy docs explain that the check bundle is embedded in the Trivy binary at build time, as a fallback used when the external DB isn't available. That means a configuration error can silently fall back to a stale embedded bundle for misconfiguration checks. If a scan result looks suspiciously clean, suspect this path.

Signatures and SBOMs — cosign Where There's No Transparency Log

Signature verification is only half-functional in an air gap. cosign's default behavior is to cross-check a signature against a transparency log, and in an air-gapped setting, that log is unreachable. So you have to take a different approach.

The most reliable method is to use your own key pair and include the public key in what you bring in.

# DMZ segment — sign an image that passed inspection with the in-house key
cosign sign --key /secure/cosign.key \
  registry.dmz.example:5000/apps/api@sha256:abc123...

# Include the public key together in the import manifest
cp /secure/cosign.pub "/staging/inbound/${BATCH}/cosign.pub"
# Inside the air gap — verify with the local public key
cosign verify --key /etc/cosign/cosign.pub \
  registry.internal.example:5000/apps/api:1.4.2

The situation is different if you need to verify something the original provider signed. The official documentation offers a path for verifying a locally downloaded image, and an option to skip the transparency-log check.

# Verify a locally downloaded image
cosign verify --key cosign.pub --local-image /staging/inbound/20260731/apps-api

# Skip the transparency-log check and verify only the key and payload
cosign verify --check-claims=false --key cosign.pub registry.internal.example:5000/apps/api:1.4.2

That said, as of this check, I could not confirm --insecure-ignore-tlog, --private-infrastructure, or the procedure for initializing an offline TUF root from a mirror in the body of the sigstore verification documentation. If your pipeline needs these three, check them directly against the help output of the cosign binary you imported and the sigstore documentation before applying them. Filling in a verification procedure with a guess is worse than not verifying at all. All you're left with is a record saying "we verified it," when in reality nothing was actually confirmed.

SBOMs take priority over signatures. Without an SBOM in an air-gapped setting, when a new vulnerability is disclosed six months from now, there's no way to answer "does our cluster have that package?" Re-scanning images requires re-importing the scanner DB, and you can't even investigate an image that's already been deleted.

# Generate the SBOM at collection time and carry it on the media
trivy image --format cyclonedx \
  --output "sbom/apps-api-1.4.2.cdx.json" \
  registry.dmz.example:5000/apps/api:1.4.2

# Import the SBOM together, as an OCI artifact
oras push --artifact-type application/vnd.cyclonedx+json \
  registry.internal.example:5000/sbom/apps-api:1.4.2 \
  "sbom/apps-api-1.4.2.cdx.json"

There's also a way to attach an SBOM to an image as a reference (referrer), but the registry has to support the referrers API. If support isn't certain, it's safer to upload it as a separate repository path with a tag, as shown above. Lookups stay simple and it works on any registry.

Internal Distribution — the Two-Registry Pattern and Harbor

The structure of an import pipeline boils down to two registries and an inspection segment between them.

SegmentLocationRoleWhat not to do here
Collection registryDMZStore originals from outside, record digests, signLet the production cluster look at this directly
Inspection segmentDMZ or relayVulnerability scanning, SBOM generation, policy checks, approval loggingManually pass a failed artifact through
Import pathPhysical media or a one-way gatewayChecksum verification, review loggingBring something in without verification
Distribution registryInside the air gapThe only source the cluster looks atLet individual developers push directly, with no inspection

The most common design mistake is leaving the distribution registry open for developers to push to directly. The moment you do that, the two-registry pattern collapses, and images that bypassed the inspection segment enter the cluster. The only entity with write access to the distribution registry should be a single import-pipeline account.

If you're using Harbor as the internal distribution registry, the latest release as of this check is 2.14.0. Installing Harbor in an air gap requires the offline installer, and Harbor's own container images are included in that installer. Don't forget to put the Harbor installer on your import list.

There's something that must be pointed out here. Harbor's proxy cache feature is useless in a fully air-gapped network. A proxy cache works by forwarding a request up to a parent registry and caching the result, which presupposes a network path to that parent registry. With no path, a cache miss is simply a failure. Where this feature is useful is a semi-air-gapped setting — "the internet works but we want to control it" — not an environment with no routing at all.

For the same reason, Harbor's replication feature doesn't cross the air-gap boundary either. Use replication to tidy things up between collection registries inside the DMZ, and only carry things across the boundary via physical media or a one-way gateway. As of this check, the individual documentation pages for Harbor's replication settings and proxy cache had moved URLs and I could not confirm the body text, so check details like supported source registry types and trigger methods directly in the Harbor 2.14 documentation.

Carrying Helm Charts as OCI Artifacts

Manage charts in a separate chart repository and you add one more import path. Unify on OCI artifacts and you use the same registry, the same authentication, and the same import procedure as images.

# DMZ collection — pull down external charts as .tgz
helm pull oci://registry-1.docker.io/bitnamicharts/postgresql --version 16.4.5 -d ./charts
helm pull https://prometheus-community.github.io/helm-charts/prometheus-25.27.0.tgz -d ./charts
sha256sum ./charts/*.tgz > CHARTS.sha256
# Inside the air gap — push to the internal registry
helm registry login registry.internal.example:5000
helm push ./charts/postgresql-16.4.5.tgz oci://registry.internal.example:5000/charts
helm push ./charts/prometheus-25.27.0.tgz oci://registry.internal.example:5000/charts
# Install — an oci reference requires a version to be specified
helm show all oci://registry.internal.example:5000/charts/postgresql --version 16.4.5
helm template pg oci://registry.internal.example:5000/charts/postgresql --version 16.4.5
helm install pg oci://registry.internal.example:5000/charts/postgresql --version 16.4.5

You can't stop at importing just the chart. The images the chart references have to be imported separately. This omission is the most common incident in air-gapped settings. Build a step into your pipeline that extracts image references the moment a chart is received and reflects them into the list file.

# Extract the list of images a chart references — the basis for updating the import list
helm template tmp ./charts/postgresql-16.4.5.tgz \
  | grep -E '^\s+image:' \
  | awk '{print $2}' \
  | tr -d '"' \
  | sort -u

Note that the referenced images depend on your values file. You have to extract using the actual values you'll deploy with, for this to be accurate. Miss a conditionally enabled sidecar or init container and you won't discover it until deployment day.

helm template tmp ./charts/postgresql-16.4.5.tgz -f values-prod.yaml \
  | grep -E '^\s+image:' | awk '{print $2}' | tr -d '"' | sort -u

The Helm documentation page is, as of this check, based on Helm 4.2.3, and that page carries a warning that it hasn't been fully updated for Helm 4 yet. If the Helm version you're importing differs from the documentation's version, verify command behavior in staging first.

The Periodic Reimport Runbook

Everything up to here has been the structure. From here on is this post's actual conclusion. No matter how well you build the pipeline above, if it doesn't run periodically, it's useless within six months. Different assets have different shelf lives, so you can't bundle them under a single cadence.

AssetReimport cadenceRationaleSymptom of neglect
Trivy vulnerability DBWeeklyVulnerability information goes stale fastestScan passes, but a known vulnerability is still actually present
Trivy check bundleMonthlyMisconfiguration rules get updatedSilently falls back to the embedded bundle, scanning with stale rules
Base imagesMonthlyOS package security patchesEvery derived image shares the same vulnerability
Application imagesMatched to release cadenceTied to service releasesReleases get stuck waiting on import review
Kubernetes distribution artifactsQuarterlyPatch releases accumulateCertificate/CVE response lags, and the upgrade jump grows
Helm charts and referenced imagesOn chart changeUploading only the chart means no imagesImagePullBackOff on deployment day
Internal CA and signing public key90 days before expiryKey rotation cadenceRegistry TLS failures halt image pulls cluster-wide
SBOMAlongside each image importThe only basis for after-the-fact investigationCan't scope impact when a new CVE is disclosed

Put this table on a calendar and assign an owner. In an air-gapped setting, a plan of "we'll do it when we need to" always ends up as "it takes three weeks once we need it."

The skeleton of a script that automates reimport looks like this. The key is computing the diff against the last import first. Carry the full set across every time and neither the media capacity nor the review time can keep up.

#!/usr/bin/env bash
# reimport.sh — runs periodically on the DMZ collection device
set -euo pipefail

BATCH="$(date +%Y%m%d)"
PREV="$(ls -1d /staging/inbound/20* | sort | tail -1)"
OUT="/staging/inbound/${BATCH}"
mkdir -p "${OUT}"

echo "== 1. Computing digests for the current tags"
: > "${OUT}/DIGESTS.txt"
while read -r ref; do
  [ -z "${ref}" ] && continue
  d=$(skopeo inspect "docker://${ref}" 2>/dev/null | jq -r '.Digest') || d="ERROR"
  echo "${ref} ${d}" >> "${OUT}/DIGESTS.txt"
done < refs.txt

echo "== 2. Diff against the previous import"
if [ -f "${PREV}/DIGESTS.txt" ]; then
  diff "${PREV}/DIGESTS.txt" "${OUT}/DIGESTS.txt" > "${OUT}/CHANGES.diff" || true
  CHANGED=$(grep -c '^>' "${OUT}/CHANGES.diff" || true)
  echo "Changed references: ${CHANGED}"
  if [ "${CHANGED}" -eq 0 ]; then
    echo "No changes — skipping this round's import"
    exit 0
  fi
fi

echo "== 3. Collecting only the changed set"
skopeo sync --src yaml --dest dir --scoped --all --keep-going images.yaml "${OUT}/images"

echo "== 4. Scanning and SBOM"
mkdir -p "${OUT}/sbom" "${OUT}/scan"
while read -r ref _; do
  name=$(echo "${ref}" | tr '/:' '__')
  trivy image --format cyclonedx --output "${OUT}/sbom/${name}.cdx.json" "${ref}" || true
  trivy image --severity HIGH,CRITICAL --format json \
    --output "${OUT}/scan/${name}.json" "${ref}" || true
done < "${OUT}/DIGESTS.txt"

echo "== 5. Archive for review submission"
cd /staging/inbound
tar -cf "batch-${BATCH}.tar" "${BATCH}"
sha256sum "batch-${BATCH}.tar" > "batch-${BATCH}.sha256"
echo "Ready to submit: batch-${BATCH}.tar"

The internal-import side needs a script at the same level of rigor. Checksum verification, pushing to the distribution registry, and logging to the import ledger all need to finish in one shot, so a human never gets the chance to skip a step.

#!/usr/bin/env bash
# ingest.sh — inside the air gap
set -euo pipefail
BATCH="$1"
SRC="/media/inbound/batch-${BATCH}.tar"

sha256sum -c "/media/inbound/batch-${BATCH}.sha256"
mkdir -p "/opt/inbound" && tar -xf "${SRC}" -C /opt/inbound

# Push images that came in as a directory into the distribution registry
skopeo sync --src dir --dest docker \
  "/opt/inbound/${BATCH}/images" registry.internal.example:5000/mirror/

# Log to the import ledger — the only basis later for "when did what come in"
{
  echo "batch=${BATCH} at=$(date -Iseconds) by=${USER}"
  cat "/opt/inbound/${BATCH}/DIGESTS.txt"
} >> /var/log/airgap-ingest.log

Closing — the Pipeline's Lifespan Is the List File's Lifespan

What survives longest in an import pipeline isn't the scripts — it's the list file. Scripts get rewritten as tool versions change, but a list saying "these are the artifacts our cluster needs" lasts for years. Keep that list in Git with digests recorded alongside it, and the pipeline keeps running even after the person responsible has changed three times over.

And whenever you're evaluating a feature like a proxy cache or replication, always ask the same question first: does this feature presuppose a network path going upstream? If it does, it doesn't hold up in an air-gapped network. This one question alone will save you two hours of architecture-meeting time.

References