概述

你大概率见过这种发布流程:开发同学构建一个 Docker 镜像,打上 dev 标签推到开发仓库;测试通过后,重新打 staging 标签再推一遍;最后上线时,又打 prod 标签推第三次。同一个镜像,构建一次,推送三次,标签换了三个。

这看起来没什么问题——直到有一天,你发现 staging 环境验证的镜像和 prod 环境运行的镜像根本不是同一个。

制品晋升(Artifact Promotion)解决的就是这个问题。核心思想很简单:镜像只构建一次,通过 digest(内容哈希)唯一标识,跨环境晋升时复制的是同一个不可变制品,而不是重新打标签。这听起来像是常识,但我在多个团队落地后发现,真正把这条流水线跑通的团队不到三成。原因不是技术多复杂,而是涉及仓库架构、权限模型、安全门禁、回滚策略等多层决策,每层都有坑。

这篇文章拆解制品晋升流水线的分层设计,结合我在某新能源物流平台 120+ 微服务 K8s 迁移中落地的晋升管道,聊 4 个关键生产决策和踩过的坑。

制品晋升到底是什么

先说清楚概念。很多团队把"制品晋升"和"重新打标签"混为一谈,这是第一个误区。

重新打标签的做法:

# 构建一次
docker build -t registry.company.com/app:dev-1.0.0 .
docker push registry.company.com/app:dev-1.0.0

# "晋升"到 staging —— 重新 tag 再推
docker tag registry.company.com/app:dev-1.0.0 registry.company.com/app:staging-1.0.0
docker push registry.company.com/app:staging-1.0.0

# "晋升"到 prod —— 又 tag 又推
docker tag registry.company.com/app:staging-1.0.0 registry.company.com/app:prod-1.0.0
docker push registry.company.com/app:prod-1.0.0

这有什么问题?表面上三次推送的都是同一个镜像 ID,但 docker tag 只是在仓库里创建了一个新的标签引用。标签是可变的——有人可以在你不知情的情况下覆盖 staging-1.0.0 指向另一个镜像。你 staging 环境验证的镜像和 prod 环境拉取的镜像,在标签层面看起来一样,实际可能已经是两个不同的制品了。

制品晋升的做法:

# 构建一次,只推到 dev 仓库
docker build -t registry-dev.company.com/app:1.0.0 .
docker push registry-dev.company.com/app:1.0.0

# 获取 digest(镜像的内容哈希,不可变)
DIGEST=$(skopeo inspect docker://registry-dev.company.com/app:1.0.0 --format '{{.Digest}}')
# 输出示例: sha256:a1b2c3d4e5f6...

# 晋升到 staging —— 按 digest 复制,不重新构建
skopeo copy docker://registry-dev.company.com/app:1.0.0 \
  docker://registry-staging.company.com/app:1.0.0

# 晋升到 prod —— 同样按 digest 复制
skopeo copy docker://registry-staging.company.com/app:1.0.0 \
  docker://registry-prod.company.com/app:1.0.0

关键区别:晋升不是"重新打标签再推一遍",而是跨仓库复制同一个不可变制品。镜像的 digest(SHA256 内容哈希)在整个生命周期中不变,dev 环境验证的是什么,prod 环境运行的就是什么。

这里有个概念要解释清楚。Docker 镜像有两个标识:tag(标签)和 digest(摘要)。标签是人可读的版本号,比如 1.0.0,可变——同一个标签可以被重新指向不同的镜像。digest 是镜像内容的 SHA256 哈希,比如 sha256:a1b2c3d4...,不可变——只要镜像内容变了,digest 就变。生产环境用 digest 锁定镜像,就不用担心标签被覆盖导致"跑的和测的不是同一个"。

分层架构:三个仓库 + 四道门禁

我们在某新能源物流平台落地制品晋升时,设计了一个三层仓库 + 四道门禁的架构。120+ 微服务的镜像全部走这条管道。

三层仓库隔离

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Dev Registry   │────▶│  Staging Registry │────▶│  Prod Registry  │
│                 │     │                   │     │                 │
│ • 开发可写       │     │ • CI 可写         │     │ • 只接受晋升     │
│ • 所有人可读     │     │ • 测试团队可读     │     │ • 生产可读       │
│ • 无安全扫描     │     │ • 必须通过扫描     │     │ • 必须签名验证    │
│ • 保留 7 天     │     │ • 保留 30 天      │     │ • 永久保留       │
└─────────────────┘     └──────────────────┘     └─────────────────┘

三层仓库物理隔离,不是同一个 Harbor 实例里的三个项目——是三个独立的 Harbor 实例,分别部署在不同网络区域。这个决策后面会讲为什么。

四道质量门禁

门禁位置检查内容失败动作
构建门禁Dev 构建后镜像大小 ≤ 500MB、SBOM 生成拒绝推送
安全门禁Dev→Staging 晋升Trivy 漏洞扫描、无 Critical CVE阻断晋升
集成门禁Staging 部署后冒烟测试、SLO 基线检查阻断 Prod 晋升
合规门禁Staging→Prod 晋升签名验证(cosign)、审计记录阻断部署

每道门禁对应流水线里的一个 Stage,任何一个 Stage 失败,整条晋升管道中断。这和传统的"构建完直接推到生产仓库"完全不同——传统方式里,安全扫描往往是部署后补做的,出问题时镜像已经在生产环境跑了。

决策一:按 digest 晋升还是按 tag 晋升

这是整个设计中最基础的决策,也是最容易被忽视的。

tag 晋升的坑

我接手过一个团队的流水线改造,他们原来用 tag 晋升,流程是 dev-1.0.0 → staging-1.0.0 → prod-1.0.0。有一次凌晨线上炸了,执行回滚:

# 回滚到上一个版本
kubectl set image deployment/api-gateway \
  api-gateway=registry.company.com/app:prod-0.9.9

镜像拉下来了,容器启动了,但行为和预期不一样——最后排查发现,prod-0.9.9 这个标签在上次发布后被"顺手"覆盖过,指向的已经不是之前那个稳定版本了。标签是可变的,docker tag 命令可以随时把同一个标签指向另一个镜像,没有审计、没有告警。

这就是 tag 晋升的根本问题:标签是可变的引用,不保证内容一致性。

digest 晋升的正确姿势

digest 是镜像内容的 SHA256 哈希。同一个 digest 一定对应同一个镜像内容,不可能被覆盖——内容变了 digest 就变了。用 digest 晋升,意味着晋升的是"一个确定的镜像内容",而不是"一个标签引用"。

# 获取镜像 digest
DIGEST=$(skopeo inspect \
  --tls-verify=false \
  docker://registry-dev.company.com/app:1.0.0 \
  --format '{{.Digest}}')

echo "晋升镜像 digest: $DIGEST"
# sha256:a1b2c3d4e5f67890...

# 按 digest 复制到 staging 仓库
skopeo copy \
  --src-tls-verify=false \
  --dest-tls-verify=false \
  docker://registry-dev.company.com/app@${DIGEST} \
  docker://registry-staging.company.com/app:1.0.0

# 记录 digest 到审计日志
echo "$(date -Iseconds) | app:1.0.0 | $DIGEST | dev→staging" >> /data/promotion-log.csv

注意 skopeo copy 的源地址用的是 app@${DIGEST} 而不是 app:1.0.0。这样即使 dev 仓库的 1.0.0 标签后来被覆盖了,你复制的仍然是当初那个确定的内容。

我的推荐

所有环境晋升用 digest,tag 只作为人类可读的辅助标识。生产部署清单里用 image: registry-prod.company.com/app@sha256:a1b2c3d4...,而不是 image: registry-prod.company.com/app:1.0.0。

有人觉得用 digest 部署不直观,看不出版本号。我的做法是:部署清单里同时写 tag 和 digest,K8s 拉取时以 digest 为准:

# k8s 部署清单 —— tag 帮助人看版本,digest 保证拉到正确镜像
spec:
  containers:
    - name: api-gateway
      image: registry-prod.company.com/app:1.0.0@sha256:a1b2c3d4e5f6...

这种写法在 containerd 和 Docker 1.13+ 都支持。tag 存在仅为了可读性,实际拉取的是 digest 对应的镜像层。

决策二:Skopeo 还是 Docker CLI 做跨仓库复制

跨仓库复制镜像是晋升的核心操作。两种方式:docker pull + docker tag + docker push 三步走,或者 skopeo copy 一步到位。

Docker CLI 的问题

传统三步走的链路是这样的:

远端仓库 → 本机 Docker Daemon → 本地磁盘缓存 → 本机 Docker Daemon → 目标仓库

问题一堆:

  1. 必须先完整拉到本地。一个 500MB 的镜像,拉下来占 500MB 磁盘,推上去又占 500MB。CI 机器磁盘扛不住批量晋升。
  2. 依赖 Docker Daemon。CI Runner 上必须跑 Docker-in-Docker 或挂载宿主机 socket,安全风险大。
  3. 多架构镜像处理不直观。manifest list 需要额外处理,三步走搞不定。
  4. 慢。pull + tag + push 三个步骤,每个都要网络往返和磁盘 I/O。

Skopeo 的优势

Skopeo 是一个容器镜像操作工具,核心能力是在不同镜像仓库之间直接复制镜像,不需要 Docker Daemon,不需要落地到本地磁盘。

源仓库 ──────────────────────────────▶ 目标仓库
         (Skopeo 直接在仓库间复制)
# 跨仓库直接复制,不经过本地磁盘
skopeo copy \
  --src-creds "dev-bot:${DEV_TOKEN}" \
  --dest-creds "staging-bot:${STAGING_TOKEN}" \
  --src-tls-verify=false \
  --dest-tls-verify=false \
  docker://registry-dev.company.com/app:1.0.0 \
  docker://registry-staging.company.com/app:1.0.0

关键优势:

对比维度docker pull/tag/pushskopeo copy
Docker Daemon 依赖必须不需要
磁盘占用镜像完整落地本地零(流式传输)
多架构镜像需额外处理 manifest list自动处理
速度(500MB 镜像)约 90s(pull+tag+push)约 30s(单次复制)
CI Runner 权限Docker socket 或 DinD仅需网络和仓库凭证

那 90s 和 30s 的差距哪来的?Docker 三步走要两次完整网络传输(一次 pull 一次 push)加上本地磁盘读写,Skopeo 只有一次源到目标的流式传输。在批量晋升 20+ 微服务镜像时,差距会放大到分钟级。

踩坑:Skopeo 的 TLS 证书

Skopeo 默认校验 TLS 证书。如果你的内网 Harbor 用的是自签证书(很常见),Skopeo 会拒绝连接。两个选择:

# 方案一:跳过 TLS 校验(不推荐生产用)
skopeo copy --src-tls-verify=false --dest-tls-verify=false ...

# 方案二:把自签 CA 证书加入信任链(推荐)
# 将 Harbor 的 CA 证书复制到系统信任目录
cp harbor-ca.crt /etc/ssl/certs/harbor-ca.crt
update-ca-certificates
# 之后 Skopeo 默认校验通过,不需要 --tls-verify=false

我在生产环境强推方案二。跳过 TLS 校验意味着晋升链路可以被中间人攻击——镜像在复制过程中被替换,你完全不知道。内网也不应该例外。

决策三:物理隔离三个 Harbor 还是逻辑隔离三个项目

三层仓库的实现有两种方案:一个 Harbor 实例建三个 Project,或者部署三个独立的 Harbor 实例。

逻辑隔离:一个 Harbor 三个 Project

Harbor (单实例)
├── dev/app          ← 开发项目
├── staging/app      ← 测试项目
└── prod/app         ← 生产项目

简单,省资源,一个 Harbor 实例搞定。但问题在于:这三个 Project 共享同一个 Harbor 进程、同一个数据库、同一套认证体系。一旦 Harbor 实例本身出问题(比如数据库挂了、磁盘满了),三个环境同时不可用。更要命的是安全边界——如果某人的 Harbor 管理员权限被泄露,他能同时看到 dev 和 prod 的所有镜像。

物理隔离:三个 Harbor 实例

Harbor-Dev (dev 网络区)      Harbor-Staging (staging 网络区)     Harbor-Prod (prod 网络区)
├── 独立数据库                ├── 独立数据库                      ├── 独立数据库
├── 独立存储后端              ├── 独立存储后端                     ├── 独立存储后端
├── 独立认证                  ├── 独立认证                         ├── 独立认证
└── 机器人账号 dev-bot        └── 机器人账号 staging-bot           └── 机器人账号 prod-bot

三个 Harbor 实例部署在三个网络区域,彼此之间通过 Skopeo 复制镜像。安全边界清晰:dev 环境的 CI Runner 无法直接访问 prod 仓库,prod 仓库只接受来自 staging 的晋升复制。

我的推荐:按规模选

团队规模镜像数量推荐方案理由
< 10 个微服务< 50 个镜像逻辑隔离运维成本低,Harbor 实例少
10-50 个微服务50-200 个镜像逻辑隔离 + 严格 RBAC用 Harbor 的项目配额和不可变标签规则弥补
> 50 个微服务> 200 个镜像物理隔离安全边界清晰,故障隔离

我们 120+ 微服务选的是物理隔离。初期也尝试过逻辑隔离——在一个 Harbor 里建了 dev/staging/prod 三个 Project,用 RBAC 控制权限。用了两个月发现两个问题:

  1. 镜像清理策略难配。dev 项目保留 7 天、prod 项目永久保留,但同一个 Harbor 实例的 GC(垃圾回收)是全局的,没法按 Project 分别配置 GC 频率。结果 dev 项目的垃圾镜像堆积,把磁盘撑满了,prod 项目的镜像推送也跟着失败。

  2. 审计日志混在一起。三个 Project 的操作日志在同一个审计表里,排查晋升链路问题时需要在海量日志里过滤,效率很低。

物理隔离后,每个 Harbor 实例独立配置存储和 GC,审计日志分开,排查问题快多了。

关于 Harbor 的具体配置和管理,之前写过一篇 相关文章:镜像推上去就不管了?Harbor 制品仓库的权限管控、漏洞扫描与 200GB 存储治理,可以对照看仓库层面的治理。

决策四:安全门禁怎么设计才不误杀

晋升管道里加安全扫描门禁是标准操作,但很多团队踩的坑是:扫描门禁误报率太高,要么放任漏洞进生产,要么误杀到团队干脆绕过门禁。

扫描门禁的四层设计

我们设计了一个四层安全门禁,核心思路是分层拦截、分层降级:

制品构建 → [层1: 基础镜像扫描] → [层2: 应用层扫描] → [层3: 许可证检查] → [层4: 签名验证] → 晋升完成
层扫描内容工具阻断策略
第 1 层基础镜像已知漏洞Trivy + OPACritical CVE 阻断
第 2 层应用依赖漏洞Trivy fs 扫描High CVE 告警 + Critical 阻断
第 3 层开源许可证合规syft + OPAGPL 系列阻断
第 4 层镜像签名cosign verify签名不匹配阻断

踩坑:Trivy 的 CVE 误报

Trivy 默认扫描所有已知 CVE,但很多 CVE 对你的镜像实际没有影响。比如一个 Python 镜像里报了 50 个 CVE,其中 30 个是 C 语言库的漏洞,Python 应用根本不调用那些库。如果全部阻断,晋升管道天天红,开发同学直接找运维"放行"。

我们的做法是引入 CVE 例外规则(Harbor 原生支持)。对确认不影响的 CVE,在 Harbor 的 CVE 例外列表里标记,附上理由和审批人。例外规则有过期时间——默认 30 天,到期后如果 CVE 还在,重新评估。

# Trivy 扫描脚本,区分阻断和告警
trivy image --severity CRITICAL --exit-code 1 \
  registry-staging.company.com/app:1.0.0

# 退出码 1 = 有 Critical CVE,阻断晋升
# 退出码 0 = 无 Critical,允许晋升

但 Trivy 的 --exit-code 只做了阻断/通过的二分。实际还需要更精细的策略——某些 Critical CVE 在特定条件下不构成实际威胁。我们在 Trivy 和晋升管道之间加了一层 OPA 策略引擎:

# OPA 策略:允许特定 CVE 在特定条件下豁免
package image.promotion

default allow = false

allow {
  count(blocked_cves) == 0
}

blocked_cves[cve] {
  cve := input.scan_results.vulnerabilities[_]
  cve.severity == "CRITICAL"
  not cve_ignored(cve.id)
}

# 已审批的 CVE 豁免列表
cve_ignored(id) {
  id == "CVE-2024-1234"  # 审批理由:Python 不调用受影响库
}

关于 CI/CD 安全门禁的完整设计,包括误报率从 70% 降到 5% 的治理过程,可以看 相关文章:70% 误报率降到 5%:CI/CD 安全门禁的四层防御与误报治理实录。

签名验证:最后一道门

签名验证放在最后一层。镜像在构建后用 cosign 签名,晋升到 prod 仓库前验证签名。如果镜像在晋升管道中被篡改(哪怕 digest 变了一个 bit),签名验证直接失败。

# 构建后签名
cosign sign --key cosign.key \
  registry-dev.company.com/app:1.0.0

# 晋升到 prod 前验证签名
cosign verify --key cosign.pub \
  registry-staging.company.com/app:1.0.0

签名密钥的私钥只在构建管道里可用,晋升管道只有公钥。这样即使晋升管道被入侵,攻击者也无法伪造签名镜像。

完整晋升管道实现

把上面的决策组合起来,完整的制品晋升管道长这样:

#!/bin/bash
# promote.sh —— 制品晋升脚本
# 用法: ./promote.sh <image-name> <tag> <from-env> <to-env>

set -euo pipefail

IMAGE_NAME="${1}"
TAG="${2}"
FROM_ENV="${3}"  # dev | staging
TO_ENV="${4}"    # staging | prod

REGISTRY_DEV="registry-dev.company.com"
REGISTRY_STAGING="registry-staging.company.com"
REGISTRY_PROD="registry-prod.company.com"

# 根据环境选择源和目标仓库
case "${FROM_ENV}" in
  dev)     SRC="${REGISTRY_DEV}" ;;
  staging) SRC="${REGISTRY_STAGING}" ;;
  *) echo "不支持的源环境: ${FROM_ENV}"; exit 1 ;;
esac

case "${TO_ENV}" in
  staging) DST="${REGISTRY_STAGING}" ;;
  prod)    DST="${REGISTRY_PROD}" ;;
  *) echo "不支持的目标环境: ${TO_ENV}"; exit 1 ;;
esac

# 凭证从环境变量读取,不硬编码
SRC_USER="promotion-bot"
SRC_PASS="${FROM_ENV^^}_REGISTRY_TOKEN"  # DEV_REGISTRY_TOKEN 或 STAGING_REGISTRY_TOKEN
SRC_PASS="${!SRC_PASS}"

DST_USER="promotion-bot"
DST_PASS="${TO_ENV^^}_REGISTRY_TOKEN"
DST_PASS="${!DST_PASS}"

echo "=========================================="
echo "制品晋升: ${IMAGE_NAME}:${TAG}"
echo "  ${FROM_ENV} → ${TO_ENV}"
echo "=========================================="

# Step 1: 获取源镜像 digest
echo "[1/5] 获取源镜像 digest..."
DIGEST=$(skopeo inspect \
  --creds "${SRC_USER}:${SRC_PASS}" \
  docker://${SRC}/${IMAGE_NAME}:${TAG} \
  --format '{{.Digest}}')
echo "  digest: ${DIGEST}"

# Step 2: 安全扫描(仅 dev→staging 需要)
if [[ "${TO_ENV}" == "staging" ]]; then
  echo "[2/5] 执行安全扫描..."
  trivy image --severity CRITICAL --exit-code 1 \
    --ignore-unfixed \
    docker://${SRC}/${IMAGE_NAME}@${DIGEST}
  if [[ $? -ne 0 ]]; then
    echo "  安全扫描未通过,阻断晋升"
    exit 1
  fi
  echo "  安全扫描通过"
else
  echo "[2/5] 跳过安全扫描(staging→prod 由 staging 阶段已扫描)"
fi

# Step 3: 签名验证(仅 staging→prod 需要)
if [[ "${TO_ENV}" == "prod" ]]; then
  echo "[3/5] 验证镜像签名..."
  cosign verify --key /etc/cosign/cosign.pub \
    ${SRC}/${IMAGE_NAME}:${TAG}
  if [[ $? -ne 0 ]]; then
    echo "  签名验证失败,阻断晋升"
    exit 1
  fi
  echo "  签名验证通过"
else
  echo "[3/5] 跳过签名验证(dev→staging 阶段)"
fi

# Step 4: 按 digest 跨仓库复制
echo "[4/5] 执行跨仓库复制..."
skopeo copy \
  --src-creds "${SRC_USER}:${SRC_PASS}" \
  --dest-creds "${DST_USER}:${DST_PASS}" \
  docker://${SRC}/${IMAGE_NAME}@${DIGEST} \
  docker://${DST}/${IMAGE_NAME}:${TAG}
echo "  复制完成"

# Step 5: 记录审计日志
echo "[5/5] 记录审计日志..."
echo "$(date -Iseconds)|${IMAGE_NAME}:${TAG}|${DIGEST}|${FROM_ENV}→${TO_ENV}|$(whoami)" \
  >> /data/promotion-audit.log
echo "  审计记录已写入"

echo "=========================================="
echo "晋升完成: ${DST}/${IMAGE_NAME}:${TAG}"
echo "  digest: ${DIGEST}"
echo "=========================================="

这个脚本的关键设计:

  1. digest 贯穿全程。第一步获取的 digest 用于后续复制、审计、部署,全程不依赖 tag 的稳定性。
  2. 凭证从环境变量读取。不硬编码在脚本里,通过 CI/CD 平台的 Secret 注入。
  3. 分层门禁。dev→staging 做安全扫描,staging→prod 做签名验证,职责分离。
  4. 审计日志。每次晋升记录时间、镜像、digest、操作人,出问题可追溯。

生产环境的踩坑实录

坑一:Harbor 复制策略与 Skopeo 的冲突

Harbor 自带复制(Replication)功能,可以配置自动复制策略。一开始我们用 Harbor 的复制策略做 dev→staging 的镜像同步,同时 CI 管道里又用 Skopeo 手动复制。结果两套复制机制打架——同一个镜像被复制两次,staging 仓库里出现了重复的 manifest,digest 相同但 tag 引用混乱。

排查后发现,Harbor 复制策略和 Skopeo 手动复制不应该同时用在同一条晋升路径上。最终的选择是:晋升管道只用 Skopeo,Harbor 复制策略只用于跨区域灾备同步(比如生产仓库的异地容灾副本),两者不重叠。

坑二:机器人账号权限过大

最初给晋升管道的机器人账号配的是 Developer 权限——能推能拉能删。有一次 CI 脚本写错了,skopeo copy 的目标地址少了一段路径,镜像被推到了仓库根目录而不是项目目录下。清理时发现,机器人账号的 Developer 权限够删项目下的镜像,但删不了根目录的"野镜像",最后只能 Harbor 管理员手动处理。

修复方案:给机器人账号配最小权限——只有目标项目的写入权限和源项目的读取权限,没有删除权限。Harbor 的机器人账号支持精确到 Project 级别的权限控制,配合三个 Harbor 物理隔离,权限边界非常清晰。

# 机器人账号权限矩阵
# dev-bot:     dev 项目的 push 权限 + 所有项目的 pull 权限
# staging-bot: staging 项目的 push 权限 + dev 项目的 pull 权限
# prod-bot:    prod 项目的 push 权限 + staging 项目的 pull 权限

注意:prod-bot 不能有 dev 项目的 pull 权限——prod 环境不应该从 dev 仓库直接拉取镜像,晋升必须经过 staging。这叫"单向晋升路径",防止跳过 staging 直接从 dev 晋升到 prod。

坑三:GC 时序导致的磁盘暴涨

Harbor 的垃圾回收(GC)需要先设置镜像为"不可变"状态,然后执行 GC 清理无引用的 blob。但晋升管道里,一个镜像可能在 dev 仓库标记为可删除(保留期 7 天到期),GC 在清理它的同时,staging 仓库正在从 dev 复制这个镜像。

这导致了什么?staging 仓库的复制报错"源镜像不存在"——因为 GC 把 blob 删了。解决办法是调整 GC 策略:先清理 tag 引用,延迟 24 小时再清理 blob。给晋升管道留出窗口期。

# Harbor GC 策略调整
# 原策略:标记删除 → 立即 GC blob
# 新策略:标记删除 → 24h 缓冲期 → GC blob
# 这样晋升管道有 24 小时窗口完成跨仓库复制

坑四:多架构镜像的晋升

一开始我们的镜像都是单架构(amd64),晋升管道没问题。后来 ARM 服务器加入集群后,开始构建多架构镜像(amd64 + arm64)。Skopeo copy 默认只复制单架构的 manifest,多架构的 manifest list 没复制过去——staging 仓库拉到的镜像只有 amd64,arm64 节点拉不到镜像。

修复:Skopeo copy 加 --all 参数,复制完整的 manifest list:

# --all 复制所有架构的 manifest list
skopeo copy --all \
  docker://${SRC}/${IMAGE_NAME}@${DIGEST} \
  docker://${DST}/${IMAGE_NAME}:${TAG}

这个坑很隐蔽——skopeo copy 不加 --all 时不会报错,复制"成功"了,但 ARM 节点拉镜像时才发现 manifest list 不完整。如果你有多架构镜像,晋升脚本里必须加 --all。

排查这个问题花了不少时间。起初 ARM 节点报 ImagePullBackOff,以为是网络问题。crictl inspect 看了镜像 manifest,发现只有 amd64 的 entry,arm64 的根本没复制过来。翻 Skopeo 文档才知道 --all 是 opt-in 不是默认行为。建议晋升脚本里默认加 --all——即使你目前只有单架构镜像,加了这个参数也没副作用,以后加 ARM 节点时不会踩同一个坑。

如果嫌排查 manifest 麻烦,可以用 skopeo inspect --raw 看 manifest list 的完整结构,确认每个架构的 digest 都在。我在 CI 管道里加了一步校验:晋升后自动 inspect 目标镜像,断言 manifest list 包含期望的架构列表,不匹配就告警。这个校验用的是 jq 解析 Skopeo 的 JSON 输出,匹配 "platform.architecture" 字段,5 行脚本搞定,但省了很多事后排查的时间。

性能对比:晋升管道 vs 传统三步走

我们在 120+ 微服务的批量发布场景下做了实测对比。一次发布涉及 20 个微服务的镜像晋升:

维度传统 pull/tag/pushSkopeo + digest 晋升
总耗时(20 个镜像)38 分钟12 分钟
CI Runner 磁盘峰值8.2 GB0.3 GB
Docker Daemon 需求必须不需要
安全扫描部署后补做晋升前自动拦截
回滚准确率82%(tag 被覆盖导致)100%(digest 不可变)
审计追溯只能追到 tagdigest + 操作人 + 时间戳

12 分钟和 38 分钟的差距主要来自两个地方:Skopeo 单次流式传输 vs Docker 三步走的双倍网络 + 磁盘 I/O;以及安全扫描在晋升管道里自动执行,不需要人工触发。

这和我之前自研 Go DAG 调度引擎时的经验一致——部署耗时从 1.5 小时降到 5 分钟,其中制品晋升管道的优化贡献了大约 30% 的提速。更详细的 CI/CD 提速方案可以看 相关文章:CI/CD 部署提速实战:从 90 分钟到 5 分钟的全链路优化。

另一个容易被忽略的收益是审计成本降低。传统三步走没有结构化的审计日志,每次合规检查都要从 Docker registry 的 API 日志里拼凑记录。晋升管道的审计日志是结构化的 CSV,每次晋升一行——时间戳、镜像名、tag、digest、源环境、目标环境、操作人。合规审计时导出 CSV 给审计团队,5 分钟搞定,不需要人工翻日志。

晋升管道的监控与告警

晋升管道本身也需要监控。不是"加完就不管了"——管道断了,整个发布流程就停了。我们在三个关键点埋了监控指标:

三个监控指标

指标含义告警阈值
promotion_duration_seconds单次晋升耗时P95 > 120s
promotion_failure_total晋升失败次数(按原因分类)1 小时内 ≥ 3 次
promotion_queue_depth等待晋升的镜像数量> 10 持续 5 分钟

promotion_duration_seconds 突增通常意味着网络问题——Skopeo 跨仓库复制走的是 HTTPS,如果 dev 和 staging 仓库之间的网络带宽受限(比如跨可用区),复制耗时会从 30 秒飙到几分钟。我们在跨机房部署时遇到过,staging 仓库在 A 区、prod 仓库在 B 区,中间走的是专线,带宽只有 100Mbps,20 个镜像批量晋升时排队等了 8 分钟。

解决方法是加并发控制——Skopeo copy 支持同时复制多个镜像,但要限制并发数避免把带宽压满:

# 用 GNU parallel 控制并发数,最多 3 个镜像同时复制
ls images-to-promote.txt | parallel -j 3 \
  './promote.sh $(cat) dev staging'

审计日志的告警化

审计日志不只是存档用的,还可以做实时告警。我们用 Vector 把晋升审计日志从文件采集到 Loki,然后配了三条告警规则:

# 告警1:非晋升管道触发的镜像推送(绕过了管道直接推 prod)
# 审计日志中操作人不是 promotion-bot 的 prod 仓库写入
sum(rate(promotion_audit_log{env="prod",user!="promotion-bot",action="push"}[5m])) > 0

# 告警2:晋升间隔异常(同一个镜像 1 小时内晋升多次)
count by (image, tag) (
  promotion_audit_log{action="promote"}[1h]
) > 2

# 告警3:跨环境跳跃晋升(dev 直接晋升到 prod,跳过 staging)
sum(promotion_audit_log{from="dev",to="prod"}) > 0

第三条告警尤其重要。有人图省事,直接从 dev 仓库晋升到 prod 仓库跳过 staging——这违反了单向晋升路径的原则。告警触发后自动通知 SRE 群,要求追查是谁绕过了管道。

晋升管道自身的可用性

晋升管道跑在 CI Runner 上,Runner 挂了管道也停了。我们做了两层冗余:

  1. CI Runner 多节点。晋升任务不绑定到特定 Runner,任何空闲 Runner 都能跑。
  2. 降级策略。如果所有 Runner 都不可用,允许手动执行 promote.sh 脚本——脚本本身不依赖 CI 平台,只要有 Skopeo 和仓库凭证就能跑。

这个降级策略在实际中用过两次。一次是 GitLab CI 平台升级导致 Runner 全部离线,一次是 Runner 所在 K8s 集群节点 NotReady。两次都是手动执行脚本完成晋升,没耽误发布窗口。手动执行时注意一点:凭证从环境变量传入,不要在命令行明文写 token。用 export STAGING_REGISTRY_TOKEN=xxx 设置后再执行脚本,执行完 unset 清理。

常见问题

Q:已经有一个 Harbor 实例了,迁移到三个实例成本太高怎么办

可以先在单 Harbor 里建三个 Project,用 RBAC + 不可变标签规则做逻辑隔离,等镜像量上来再拆。关键是从一开始就用 digest 晋升和 Skopeo 复制——仓库架构可以后改,晋升管道的核心逻辑不变。

迁移到三个 Harbor 实例时有个顺序:先部署好 Staging 和 Prod 实例,配好复制策略和机器人账号,然后把新构建的镜像走新管道,旧镜像留在原 Harbor 等自然过期。不要一次性迁移所有镜像——不值得,而且批量 skopeo copy 大量镜像时的网络开销很可观。我们当时迁移 200+ 镜像用了 4 个小时,主要瓶颈是跨可用区带宽。

Q:Jenkins / GitLab CI 怎么集成 Skopeo

把 Skopeo 打包成一个 CI Runner 的基础镜像就行。Jenkins 用 Shared Library 封装晋升步骤,GitLab CI 用一个 include template。核心是不要在 CI 脚本里直接写仓库凭证——用 CI 平台的 Secret 管理注入。

# GitLab CI 示例
stages:
  - build
  - promote

promote-to-staging:
  stage: promote
  image: 
    name: quay.io/skopeo/stable:latest
    entrypoint: [""]
  variables:
    SRC_REGISTRY: $DEV_REGISTRY
    DST_REGISTRY: $STAGING_REGISTRY
  script:
    - DIGEST=$(skopeo inspect --creds "dev-bot:$DEV_TOKEN" docker://$SRC_REGISTRY/app:$CI_COMMIT_TAG --format '{{.Digest}}')
    - skopeo copy --all --src-creds "dev-bot:$DEV_TOKEN" --dest-creds "staging-bot:$STAGING_TOKEN" docker://$SRC_REGISTRY/app@$DIGEST docker://$DST_REGISTRY/app:$CI_COMMIT_TAG
  only:
    - tags

Q:晋升失败了怎么回退

晋升失败通常有两种情况:安全扫描不通过、或者 Skopeo 复制网络超时。扫描不通过是正常拦截,不需要"回退"——镜像压根没进目标仓库。网络超时重试就行,Skopeo copy 是幂等的,重复执行不会产生重复镜像。

真正需要回退的是"镜像已经晋升到 prod 但发现运行有问题"的情况。这时不是回退晋升管道,而是回退部署版本——用前一个 digest 部署。这也是为什么审计日志必须记录每次晋升的 digest。

# 从审计日志找到上一个版本的 digest
grep "app:1.0.0" /data/promotion-audit.log | tail -2
# 输出:
# 2026-09-18T14:30:00+08:00|app:1.0.0|sha256:aaa111...|staging→prod|jenkins
# 2026-09-18T10:00:00+08:00|app:0.9.9|sha256:bbb222...|staging→prod|jenkins

# 用前一个 digest 回滚
kubectl set image deployment/api-gateway \
  api-gateway=registry-prod.company.com/app@sha256:bbb222...

总结

制品晋升不是一个新概念,但很多团队的落地止步于"重新打标签推三遍"。这篇文章拆解的 4 个决策,总结起来就几句话:

  1. 按 digest 晋升,不按 tag。digest 不可变,tag 可变。生产环境用 digest 锁定镜像,回滚才靠谱。
  2. 用 Skopeo 做跨仓库复制。不依赖 Docker Daemon,不落地本地磁盘,多架构镜像加 --all。
  3. 按规模选择仓库隔离方案。50 个微服务以上物理隔离三个 Harbor,以下逻辑隔离加严格 RBAC。机器人账号最小权限,单向晋升路径。
  4. 安全门禁分层设计,引入 CVE 例外规则。阻断 Critical CVE,告警 High CVE,已审批的例外有过期时间。签名验证放最后一道。

落地这条管道后,我们的部署流水线从"构建后直接推生产"变成了"构建 → 扫描 → 晋升 → 验证 → 部署"的分段管道,每个环节可审计、可阻断、可回退。120+ 微服务的批量发布从 38 分钟降到 12 分钟,变更失败率从 17% 降到 3% 以下。

制品晋升的核心理念是"不可变"——同一个镜像从构建到生产不变,变的只是它所在的位置。这个理念不复杂,但需要从仓库架构、晋升脚本、安全门禁、审计日志多个层面配套落地。一旦跑通,你就再也不用担心"staging 测的版本和 prod 跑的版本不一样"这个问题了。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Skopeo 镜像操作工具 — containers 组织,跨仓库镜像复制与检查工具
  2. Harbor 官方文档 - 管理 — Harbor 团队,仓库管理、复制策略与项目配额配置
  3. Harbor 1.8 安全与复制新特性 — Harbor 团队,跨仓库复制与 OIDC 集成能力
  4. Docker 镜像版本管理最佳实践 — SimCompile,从标签混乱到语义化版本的镜像治理
  5. 国内网络环境下的 Docker 镜像迁移与加速实战 — mToLa4mVs,用 Skopeo 替代 docker pull/tag/push 的实践
  6. Docker 镜像管理全攻略 — weixin_28419039,镜像分层、digest 与 tag 的底层机制解析
  7. 企业级 CI/CD 平台建设:从架构设计到最佳实践 — weixin_33391446,四层 CI/CD 架构设计与质量门禁