Skip to content
Published on

设计离线镜像导入流水线 —— skopeo、Harbor,以及一份不会过时的重新导入手册

分享
Authors

引言 —— 只导入一次,必然会过时

离线安装指南不少,但大多数只讲到第一次安装为止。真正拖垮团队的是安装之后的事。

三个月后,新服务要上线,又发现需要额外的十二个镜像。六个月后,漏洞排查结果出来了,但内部扫描器的漏洞数据库还是半年前的,结果没法信。九个月后,需要升级 base 镜像,却没人能还原出当初是谁、通过什么路径、把哪个标签带进来的 —— 当初做导入的人早就换到别的团队了。

本文要讲的就是这个问题:不是"怎么导入一次",而是怎么设计一套能反复导入的结构。下面这些工具是本文核实的基准。

工具核实的版本或基准核实时间出处
skopeoskopeo-sync 手册(main 分支文档)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 OCI按文档页面标注为 Helm 4.2.32026-07-31Helm Registries

把导入清单当代码来管理

流水线的第一颗扣子不是工具,而是清单。如果这份清单存在某个人的脑子里或者一个 wiki 页面上,半年后必然会和实际情况对不上。把它放进 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 源格式,原样照搬。它支持 imagesimages-by-tag-regeximages-by-semvercredentialstls-verifycert-dir 这些键。可以用正则或 semver 范围来指定标签这一点很重要,但在离线环境里,最好尽量避免用范围指定,把标签钉死。 用范围的话,每次导入进来的东西都可能不一样,清单文件也就没法再充当权威版本了。

清单里还有一样必须一起记录下来的东西 —— 摘要(digest)

# 把标签指向的摘要和清单一起固定下来
skopeo inspect docker://docker.io/library/postgres:16.6 \
  | jq -r '.Digest' \
  | tee -a DIGESTS.txt

标签是会变的。三个月后再拉同一个标签,内容可能已经不一样了。离线审计中会被问到"这和上次导入的是不是同一个东西",要回答这个问题,就必须有摘要记录。

外部收集 —— 用 skopeo sync 拉取

收集这一步在能连接互联网的 DMZ 设备上进行。这里的关键是不使用 Docker daemon。skopeo 不需要 daemon,直接和仓库对话,所以用来收集的中转设备可以保持最小化配置。

#!/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 —— 当源指向一份镜像列表(多架构 manifest)时,不只拉取匹配当前操作系统和架构的那一个,而是全部拉取。如果离线网络里混有 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 chart、SBOM、策略包、漏洞数据库全都需要。把这些当成 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 layout 目录 —— 适合用介质搬运的形式
oras push --oci-layout /staging/inbound/20260731/oci:policies-2026.07 policies.tar.gz

跨仓库复制有 oras cp,查看 manifest 有 oras manifest fetch。不过截至这次核实,我在 oras push 文档页面正文中确认到的只有 push 系列的语法和 OCI layout 选项。oras cporas pull 的确切参数,请在对应命令的文档页面上核实之后再写进脚本。

检查环节 —— 手动导入 Trivy 数据库

收集和分发之间必须有一个检查环节。离线环境里检查之所以困难,不在于扫描器本身,而在于扫描器的数据通常是从互联网更新的。Trivy 把漏洞数据库以 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

介质带进内部之后,推送到内部仓库。这里媒体类型(media type)很关键。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 二进制里的,作为外部数据库不可用时的兜底方案。也就是说,一旦配置出错,误配置检查可能会悄无声息地退回到那份过时的内嵌规则包上。如果扫描结果干净得不太寻常,就该怀疑是不是走了这条路径。

签名与 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

# 跳过透明性日志比对,只验证密钥和 payload
cosign verify --check-claims=false --key cosign.pub registry.internal.example:5000/apps/api:1.4.2

不过 --insecure-ignore-tlog--private-infrastructure,以及用镜像初始化离线 TUF root 的具体步骤,截至这次核实,我没能在 sigstore 验证文档的正文里得到确认。如果流水线需要这三项,请拿导入的 cosign 二进制的帮助信息和 sigstore 文档直接核实之后再应用。用猜测来填补验证流程,比完全不验证还糟。 因为最后留下的只是一条"已验证"的记录,实际上什么都没确认过。

SBOM 的优先级比签名更高。离线环境里如果没有 SBOM,半年后一个新漏洞公开时,就没法回答"我们集群里有没有那个包"这个问题。想重新扫描镜像,得再导入一次扫描器数据库,而已经删除的镜像连排查的机会都没有。

# 在收集时一并生成 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 chart 作为 OCI 制品搬运

如果用单独的 chart 仓库来管理,就会多出一条导入路径。统一用 OCI 制品的话,就能和镜像共用同一个仓库、同一套认证、同一套导入流程。

# DMZ 收集 —— 把外部 chart 拉取成 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

不能只导入 chart 就完事。chart 所引用的镜像必须单独导入。 这个疏漏是离线环境里最常出的事故。应该在流水线里加一个步骤,chart 一到手就立刻提取镜像引用,并反映到清单文件里。

# 提取 chart 引用的镜像列表 —— 用来更新导入清单的依据
helm template tmp ./charts/postgresql-16.4.5.tgz \
  | grep -E '^\s+image:' \
  | awk '{print $2}' \
  | tr -d '"' \
  | sort -u

注意引用的镜像会因 values 文件而不同。只有套用实际部署会用的 values 来提取,结果才准确。如果漏掉了某个按条件才启用的 sidecar 或 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 漏洞数据库每周一次漏洞信息过时得最快扫描通过,但已知漏洞其实原样还在
Trivy 检查规则包每月一次误配置规则会更新悄悄退回内嵌兜底方案,用过时规则做检查
base 镜像每月一次操作系统包的安全补丁所有派生镜像共享同一个漏洞
应用镜像与发布周期同步与服务发布联动发布卡在等待导入审核上
Kubernetes 发行版制品每季度一次补丁版本会累积证书・CVE 响应滞后,升级跨度变大
Helm chart 与其引用的镜像chart 变更时只上传 chart 会缺镜像部署当天出现 ImagePullBackOff
内部 CA 与签名公钥到期前 90 天密钥轮换周期仓库 TLS 失败,导致整个集群的镜像拉取中断
SBOM与镜像导入同步事后排查的唯一依据新 CVE 公开时无法界定影响范围

把这张表放进日历,指定负责人。在离线环境里,"等需要的时候再做"这种计划,最后永远会变成"需要之后要花三周"。

自动化重新导入的脚本骨架大致如下。核心是先计算与上一次导入之间的差异。每次都全量搬运的话,介质容量和审核时间都扛不住。

#!/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 里,摘要也一并记录着,哪怕负责人换了三轮,流水线依然能照常运转。

而且在评估代理缓存、复制这类功能时,永远先问同一个问题:这个功能是不是以存在一条通往上游的网络路径为前提的? 如果是,那它在离线网络里就不成立。就这一个问题,能替你省下两个小时的架构会议。

参考资料