- 引言 —— 只导入一次,必然会过时
- 把导入清单当代码来管理
- 外部收集 —— 用 skopeo sync 拉取
- 检查环节 —— 手动导入 Trivy 数据库
- 签名与 SBOM —— 没有透明性日志的地方怎么用 cosign
- 内部分发 —— 双仓库模式与 Harbor
- 定期重新导入操作手册
- 结语 —— 流水线的寿命就是清单文件的寿命
- 参考资料
引言 —— 只导入一次,必然会过时
离线安装指南不少,但大多数只讲到第一次安装为止。真正拖垮团队的是安装之后的事。
三个月后,新服务要上线,又发现需要额外的十二个镜像。六个月后,漏洞排查结果出来了,但内部扫描器的漏洞数据库还是半年前的,结果没法信。九个月后,需要升级 base 镜像,却没人能还原出当初是谁、通过什么路径、把哪个标签带进来的 —— 当初做导入的人早就换到别的团队了。
本文要讲的就是这个问题:不是"怎么导入一次",而是怎么设计一套能反复导入的结构。下面这些工具是本文核实的基准。
| 工具 | 核实的版本或基准 | 核实时间 | 出处 |
|---|---|---|---|
| skopeo | skopeo-sync 手册(main 分支文档) | 2026-07-31 | skopeo-sync.1.md |
| oras | 1.3 | 2026-07-31 | oras push |
| Trivy DB | trivy-db tag 2、trivy-java-db tag 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 |
把导入清单当代码来管理
流水线的第一颗扣子不是工具,而是清单。如果这份清单存在某个人的脑子里或者一个 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 源格式,原样照搬。它支持 images、images-by-tag-regex、images-by-semver、credentials、tls-verify、cert-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)按手册的说法,只有 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 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 cp 和 oras 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-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 二进制里的,作为外部数据库不可用时的兜底方案。也就是说,一旦配置出错,误配置检查可能会悄无声息地退回到那份过时的内嵌规则包上。如果扫描结果干净得不太寻常,就该怀疑是不是走了这条路径。
签名与 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 里,摘要也一并记录着,哪怕负责人换了三轮,流水线依然能照常运转。
而且在评估代理缓存、复制这类功能时,永远先问同一个问题:这个功能是不是以存在一条通往上游的网络路径为前提的? 如果是,那它在离线网络里就不成立。就这一个问题,能替你省下两个小时的架构会议。
参考资料
현재 단락 (1/226)
离线安装指南不少,但大多数只讲到第一次安装为止。真正拖垮团队的是安装之后的事。