- Published on
閉域網イメージ搬入パイプライン設計 — skopeo、Harbor、そして腐らない再搬入ランブック
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 一度の搬入は必ず腐ります
- 搬入リストをコードとして管理する
- 外部収集 — skopeo syncで取り出す
- 検査区間 — Trivy DBを手作業で持ち込む
- 署名とSBOM — 透明性ログのない場所でのcosign
- 内部配布 — 二重レジストリパターンとHarbor
- 定期再搬入ランブック
- おわりに — パイプラインの寿命はリストファイルの寿命です
- 参考資料
はじめに — 一度の搬入は必ず腐ります
閉域網インストールガイドはたくさんあります。ところがほとんどが最初のインストールまでしか扱いません。実際にチームを疲弊させるのはその後です。
3ヶ月後、新しいサービスがデプロイされてイメージが十二個追加で必要になります。6ヶ月後、脆弱性点検の結果が下りてくるのに、社内スキャナーの脆弱性DBが半年前のもので結果を信頼できません。9ヶ月後、baseイメージを上げなければならないのに、誰がどのタグをどんな経路で持ち込んだか誰も再構成できません。最初に搬入した人はすでに別のチームにいます。
この記事はその問題を扱います。一度持ち込む方法ではなく、繰り返し持ち込む構造を設計する方法です。以下のツールを基準に確認しました。
| ツール | 確認したバージョンまたは基準 | 確認時点 | 出典 |
|---|---|---|---|
| skopeo | skopeo-syncマニュアル(mainブランチ文書) | 2026-07-31 | skopeo-sync.1.md |
| oras | 1.3 | 2026-07-31 | oras push |
| Trivy DB | trivy-dbタグ2、trivy-java-dbタグ1 | 2026-07-31 | Trivy Self-Hosting |
| Harbor | 2.14.0 | 2026-07-31 | Harbor Docs |
| Helm OCI | 文書ページ基準Helm 4.2.3 | 2026-07-31 | Helm 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機器で行います。ここでDockerデーモンを使わないことが核心です。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)はマニュアル基準でdockerとdirの二つです。メディアで運ぶので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 cpとoras 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-db | application/vnd.aquasec.trivy.db.layer.v1.tar+gzip |
| trivy-java-db | application/vnd.aquasec.trivy.javadb.layer.v1.tar+gzip |
| trivy-checks | application/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バージョンと文書のバージョンが違う場合は、コマンドの動作をステージングで先に確認してください。
定期再搬入ランブック
ここまでが構造で、ここからがこの記事の実質的な結論です。上のパイプラインをどれだけうまく作っても、定期的に回らなければ半年後には使い物にならなくなります。資産ごとに有効期限が違うので、一つの周期でまとめてはいけません。
| 資産 | 再搬入周期 | 根拠 | 放置したときの症状 |
|---|---|---|---|
| Trivy脆弱性DB | 週1回 | 脆弱性情報がもっとも早く古くなる | スキャンは通過するのに実際には既知の脆弱性がそのまま存在 |
| Trivy検査バンドル | 月1回 | ミスコンフィグルールの更新 | 内蔵フォールバックへ静かに戻り、古いルールで検査 |
| baseイメージ | 月1回 | OSパッケージのセキュリティパッチ | すべての派生イメージが同じ脆弱性を共有 |
| アプリケーションイメージ | デプロイ周期に合わせる | サービスリリースと連動 | 搬入審議待ちでリリースが遅れる |
| Kubernetes配布アーティファクト | 四半期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にあり、ダイジェストが一緒に記録されていれば、担当者が三回代わってもパイプラインは回り続けます。
そしてプロキシキャッシュや複製のような機能を検討するときは、常に同じ問いを先に立ててください。この機能は上位へ行くネットワーク経路を前提としているか。 前提としているなら、閉域網では成立しません。この一つの問いが、アーキテクチャ会議二時間分を節約してくれます。