← 返回

CI/CD 进阶:构建与发布分离,用 Release Manifest 管控生产发布

作者:林 | 系列:DevOps 实战 | 适合读者:后端/运维/DevOps 工程师


一、问题:你的生产发布靠什么保证"发的是测试通过的版本"?

很多团队的生产发布流程是这样的:

  1. 测试在测试环境验证通过
  2. 开发或运维在生产流水线里手动填一个 IMAGE_TAG
  3. 部署到生产

问题来了,这个 IMAGE_TAG 是谁填的?填的对不对?怎么证明测试环境用的就是这个 tag?

常见翻车场景:

  • 运维填错了 tag,把没测试过的版本发到生产
  • 测试验证的是 commit abc1234,但生产发的是 def5678
  • 出了问题想回滚,但不确定上一个稳定版本是哪个 tag
  • 多人协作时,谁发的、发了什么、什么时候发的,全靠聊天记录

核心矛盾:生产发布的"输入"没有约束,任何人都能填任何 tag。


二、解决方案:Release Manifest

2.1 核心思路

核心约束是一次构建、多环境部署,生产只提升已经验证的制品。

具体来说:

  1. dev/test 流水线负责构建镜像、推送 ACR、发布测试环境
  2. 测试环境发布成功后,生成一份 release-manifest.json(发布清单)
  3. 测试人员验证通过后,把 manifest 状态标记为 TEST_PASSED
  4. prod 流水线只接受一个输入:RELEASE_ID
  5. prod 流水线根据 RELEASE_ID 读取 manifest,只发布 manifest 里记录的镜像
flowchart TD Commit["代码提交"] --> Build["构建、测试并推送制品"] Build --> Manifest["生成发布清单"] Manifest --> Test["部署测试环境"] Test --> Verify{"验证通过"} Verify -- "否" --> Reject["终止该发布清单"] Verify -- "是" --> Approve["批准清单"] Approve --> Prod["按 RELEASE_ID 部署生产"] Prod --> Record["记录实际部署结果"]

关键约束:prod 不接受 IMAGE_TAG 输入,只接受 RELEASE_ID。镜像版本由 manifest 决定,不由人手填。

2.2 为什么不用 IMAGE_TAG?

方式问题
手填 IMAGE_TAG容易填错、无法追溯、没有审批
用 Git commit SHA多服务时需要记住多个 SHA
latest不可追溯,回滚困难
RELEASE_ID + manifest版本锁定、可追溯、有审批

三、Release Manifest 规范

3.1 manifest 文件结构

test 发布成功后生成:

{
  "release_id": "20260517-001",
  "status": "TEST_DEPLOYED",
  "branch": "dev",
  "commit": "a3f9c21b",
  "image_tag": "20260517-001-a3f9c21",
  "test_namespace": "app-test",
  "build_url": "https://ci.example.com/builds/12345",
  "services": [
    {
      "name": "auth",
      "image": "registry.example.com/app/auth:20260517-001-a3f9c21"
    },
    {
      "name": "system",
      "image": "registry.example.com/app/system:20260517-001-a3f9c21"
    },
    {
      "name": "gateway",
      "image": "registry.example.com/app/gateway:20260517-001-a3f9c21"
    }
  ],
  "created_at": "2026-05-17 15:30:00",
  "tester": "",
  "test_passed_at": "",
  "prod_released_at": ""
}

测试人员验证通过后,更新状态:

{
  "status": "TEST_PASSED",
  "tester": "张三",
  "test_passed_at": "2026-05-17 17:20:00"
}

生产发布完成后:

{
  "status": "PROD_RELEASED",
  "prod_released_at": "2026-05-17 18:00:00",
  "prod_released_by": "李四"
}

3.2 IMAGE_TAG 命名规范

不要只用短 commit SHA,建议用组合格式:

IMAGE_TAG="$(date +%Y%m%d)-${CI_PIPELINE_ID:-001}-${CI_COMMIT_SHORT_SHA}"

例如:20260517-001-a3f9c21

一眼能看出:

  • 哪天构建的(20260517)
  • 第几次构建(001)
  • 对应哪个 commit(a3f9c21)

3.3 状态流转模型

只需要三个状态就够用:

状态至少要区分测试已部署、测试已通过、生产发布中、生产已完成和失败。发布中状态用于阻止并发重复部署,失败状态保存失败阶段和重试依据。

完整流转:


四、Manifest 存储方案

4.1 方案一:Git 仓库(推荐)

单独建一个仓库存放发布记录:

release-records/
├── test-passed/
│   ├── 20260517-001.json
│   ├── 20260517-002.json
│   └── 20260518-001.json
├── prod-released/
│   ├── 20260517-001.json
│   └── 20260518-001.json
└── README.md

优点

  • 有完整历史记录(Git log)
  • 谁改的、什么时候改的,Git 都能追溯
  • 可以 Code Review(MR 审批)
  • prod 流水线直接 clone 仓库读取

工作流程

发布清单使用追加式事件或受保护的状态更新。测试部署、批准、生产开始和生产完成分别记录操作者、时间、清单摘要与流水线地址。不要通过移动文件丢失原路径语义,生产流水线应校验批准记录与清单摘要一致。

4.2 方案二:OSS 对象存储

# 上传
ossutil cp release-manifest.json oss://release-records/test-passed/20260517-001.json

# prod 读取
ossutil cp oss://release-records/test-passed/20260517-001.json .

优点:简单,不需要额外 Git 仓库 缺点:审计不如 Git 直观,没有 MR 审批机制

4.3 方案三:ACR Tag 标记

测试通过后,给镜像打 prod-candidate 标签:

# 测试通过后打标签
docker tag registry.example.com/app/auth:20260517-001-a3f9c21 \
  registry.example.com/app/auth:prod-candidate-20260517-001

# 多个服务都打
for svc in auth system gateway; do
  docker tag registry.example.com/app/${svc}:20260517-001-a3f9c21 \
    registry.example.com/app/${svc}:prod-candidate-20260517-001
done

优点:直观,看 ACR 就知道哪些版本可以发生产 缺点:只记录了镜像,没有测试人、测试时间等元数据

推荐组合使用:Git 仓库存 manifest + ACR 打 prod-candidate tag。manifest 记录元数据,tag 方便快速查看。

五、两条流水线设计

5.1 dev/test 流水线

职责:构建、推镜像、发布测试环境、生成 manifest

# dev-test-pipeline.yml
name: Dev/Test Pipeline

on:
  push:
    branches: [develop]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 1. 检测变更的服务
      - name: Detect Changed Services
        id: changes
        run: |
          # 比较本次提交和上次提交的差异
          CHANGED=$(git diff --name-only HEAD~1 HEAD | grep '^services/' | cut -d'/' -f2 | sort -u)
          echo "services=$(echo $CHANGED | tr ' ' ',')" >> $GITHUB_OUTPUT

      # 2. Maven 构建变更的服务
      - name: Build Changed Services
        run: |
          IFS=',' read -ra SERVICES <<< "${{ steps.changes.outputs.services }}"
          for svc in "${SERVICES[@]}"; do
            mvn clean package -pl services/${svc} -am -DskipTests
          done

      # 3. 生成 IMAGE_TAG 和 RELEASE_ID
      - name: Generate Tags
        id: tags
        run: |
          IMAGE_TAG="$(date +%Y%m%d)-${GITHUB_RUN_NUMBER}-$(git rev-parse --short HEAD)"
          RELEASE_ID="$(date +%Y%m%d)-${GITHUB_RUN_NUMBER}"
          echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
          echo "release_id=${RELEASE_ID}" >> $GITHUB_OUTPUT

      # 4. 构建 Docker 镜像并推送
      - name: Build & Push Images
        run: |
          IFS=',' read -ra SERVICES <<< "${{ steps.changes.outputs.services }}"
          for svc in "${SERVICES[@]}"; do
            docker build -t $REGISTRY/${svc}:${{ steps.tags.outputs.image_tag }} services/${svc}/
            docker push $REGISTRY/${svc}:${{ steps.tags.outputs.image_tag }}
          done

      # 5. 生成 image-list.txt
      - name: Generate Image List
        run: |
          IFS=',' read -ra SERVICES <<< "${{ steps.changes.outputs.services }}"
          for svc in "${SERVICES[@]}"; do
            echo "${svc}=${REGISTRY}/${svc}:${{ steps.tags.outputs.image_tag }}" >> image-list.txt
          done

      # 6. 发布到 test 环境
      - name: Deploy to Test
        run: |
          while IFS='=' read -r name image; do
            kubectl set image deployment/${name} ${name}=${image} -n test
          done < image-list.txt
          # 等待部署完成
          while IFS='=' read -r name image; do
            kubectl rollout status deployment/${name} -n test --timeout=300s
          done < image-list.txt

      # 7. 生成 release-manifest.json
      - name: Generate Manifest
        run: |
          python3 - <<'PYEOF' > release-manifest.json
          import json, os, datetime

          release_id = "${{ steps.tags.outputs.release_id }}"
          image_tag = "${{ steps.tags.outputs.image_tag }}"
          commit = "$(git rev-parse --short HEAD)"
          branch = "${{ github.ref_name }}"

          services = []
          with open("image-list.txt") as f:
              for line in f:
                  line = line.strip()
                  if not line:
                      continue
                  name, image = line.split("=", 1)
                  services.append({"name": name, "image": image})

          manifest = {
              "release_id": release_id,
              "status": "TEST_DEPLOYED",
              "branch": branch,
              "commit": commit,
              "image_tag": image_tag,
              "build_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
              "services": services,
              "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
              "tester": "",
              "test_passed_at": "",
              "prod_released_at": ""
          }
          print(json.dumps(manifest, ensure_ascii=False, indent=2))
          PYEOF

      # 8. 提交 manifest 到 release-records 仓库
      - name: Commit Manifest
        run: |
          git clone https://token@github.com/org/release-records.git
          cp release-manifest.json release-records/test-passed/${{ steps.tags.outputs.release_id }}.json
          cd release-records
          git add .
          git commit -m "release: ${{ steps.tags.outputs.release_id }} deployed to test"
          git push

      # 9. 飞书通知
      - name: Notify
        run: |
          curl -X POST "$FEISHU_WEBHOOK" -H 'Content-Type: application/json' -d '{
            "msg_type": "interactive",
            "card": {
              "header": {
                "title": {"tag": "plain_text", "content": "【测试环境发布成功】"},
                "template": "blue"
              },
              "elements": [
                {"tag": "div", "text": {"tag": "lark_md", "content": "**Release ID**:${{ steps.tags.outputs.release_id }}\n**镜像版本**:${{ steps.tags.outputs.image_tag }}\n**服务**:${{ steps.changes.outputs.services }}\n**环境**:test\n**状态**:等待测试验证\n\n测试通过后,请将 Release ID 用于生产发布。"}}
              ]
            }
          }'

5.2 prod 流水线

职责:读取 manifest、校验状态、发布生产

关键约束

  • 不拉代码
  • 不构建镜像
  • 不推送镜像
  • 只接受 RELEASE_ID 输入
  • 只发布 status == TEST_PASSED 的 manifest
# prod-release-pipeline.yml
name: Prod Release Pipeline

on:
  workflow_dispatch:
    inputs:
      release_id:
        description: 'Release ID(从测试通过的发布记录中获取)'
        required: true
        type: string

jobs:
  release:
    runs-on: ubuntu-latest
    environment: production  # 需要人工审批
    steps:
      # 1. 拉取发布记录仓库
      - name: Clone Release Records
        run: |
          git clone https://token@github.com/org/release-records.git

      # 2. 读取并校验 manifest
      - name: Validate Manifest
        id: manifest
        run: |
          RELEASE_ID="${{ inputs.release_id }}"
          MANIFEST="release-records/test-passed/${RELEASE_ID}.json"

          if [ ! -f "$MANIFEST" ]; then
            echo "::error::未找到测试通过记录:${RELEASE_ID}"
            exit 1
          fi

          STATUS=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['status'])")
          if [ "$STATUS" != "TEST_PASSED" ]; then
            echo "::error::Release ${RELEASE_ID} 状态不是 TEST_PASSED(当前状态:${STATUS})"
            exit 1
          fi

          # 输出服务列表和镜像
          SERVICES=$(python3 -c "
          import json
          m = json.load(open('$MANIFEST'))
          for s in m['services']:
              print(f\"{s['name']}={s['image']}\")
          ")
          echo "services<<EOF" >> $GITHUB_OUTPUT
          echo "$SERVICES" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

          echo "✅ Release ${RELEASE_ID} 校验通过,状态:${STATUS}"

      # 3. 发布到 prod 环境
      - name: Deploy to Prod
        run: |
          echo "${{ steps.manifest.outputs.services }}" | while IFS='=' read -r name image; do
            echo "Deploying ${name} → ${image}"
            kubectl set image deployment/${name} ${name}=${image} -n prod
          done

          # 等待部署完成
          echo "${{ steps.manifest.outputs.services }}" | while IFS='=' read -r name image; do
            kubectl rollout status deployment/${name} -n prod --timeout=600s
          done

      # 4. 健康检查
      - name: Health Check
        run: |
          sleep 30
          for svc in $(echo "${{ steps.manifest.outputs.services }}" | cut -d'=' -f1); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://prod.example.com/${svc}/health)
            if [ "$STATUS" != "200" ]; then
              echo "::error::${svc} 健康检查失败(HTTP ${STATUS})"
              exit 1
            fi
          done

      # 5. 更新 manifest 状态 + K8s annotation
      - name: Update Manifest
        if: success()
        run: |
          RELEASE_ID="${{ inputs.release_id }}"
          cd release-records

          # 更新 manifest 状态
          python3 - <<PYEOF
          import json
          with open("test-passed/${RELEASE_ID}.json") as f:
              m = json.load(f)
          m["status"] = "PROD_RELEASED"
          m["prod_released_at"] = "$(date '+%Y-%m-%d %H:%M:%S')"
          m["prod_released_by"] = "${{ github.actor }}"
          with open("test-passed/${RELEASE_ID}.json", "w") as f:
              json.dump(m, f, ensure_ascii=False, indent=2)
          PYEOF

          mv test-passed/${RELEASE_ID}.json prod-released/
          git add .
          git commit -m "release: ${RELEASE_ID} released to prod"
          git push

      # 5b. K8s Deployment 打 annotation,记录发布信息
      - name: Annotate Deployments
        if: success()
        run: |
          RELEASE_ID="${{ inputs.release_id }}"
          echo "${{ steps.manifest.outputs.services }}" | while IFS='=' read -r name image; do
            kubectl -n prod annotate deployment/${name} \
              release.example.com/release-id="${RELEASE_ID}" \
              release.example.com/image="${image}" \
              release.example.com/status="prod-released" \
              release.example.com/released-at="$(date '+%Y-%m-%d %H:%M:%S')" \
              release.example.com/released-by="${{ github.actor }}" \
              --overwrite
          done

      # 6. 失败回滚
      - name: Rollback on Failure
        if: failure()
        run: |
          echo "${{ steps.manifest.outputs.services }}" | while IFS='=' read -r name image; do
            echo "Rolling back ${name}..."
            kubectl rollout undo deployment/${name} -n prod
          done

      # 7. 飞书通知
      - name: Notify
        if: always()
        run: |
          STATUS="${{ job.status }}"
          TEMPLATE="green"
          CONTENT="✅ 生产发布成功"
          if [ "$STATUS" = "failure" ]; then
            TEMPLATE="red"
            CONTENT="❌ 生产发布失败,已自动回滚"
          fi

          curl -X POST "$FEISHU_WEBHOOK" -H 'Content-Type: application/json' -d "{
            \"msg_type\": \"interactive\",
            \"card\": {
              \"header\": {
                \"title\": {\"tag\": \"plain_text\", \"content\": \"【生产发布通知】\"},
                \"template\": \"${TEMPLATE}\"
              },
              \"elements\": [
                {\"tag\": \"div\", \"text\": {\"tag\": \"lark_md\", \"content\": \"**Release ID**:${{ inputs.release_id }}\n**状态**:${CONTENT}\n**发布人**:${{ github.actor }}\"}}
              ]
            }
          }"

六、测试验证与标记流程

6.1 测试人员操作流程

收到飞书通知:"Release 20260517-001 已部署到 test"
  ↓
在测试环境验证功能
  ↓
验证通过
  ↓
标记 TEST_PASSED(方式见下)
  ↓
通知运维:"Release 20260517-001 可以发生产"

6.2 标记 TEST_PASSED 的方式

方式一:手动触发 GitHub Action

# mark-test-passed.yml
name: Mark Test Passed

on:
  workflow_dispatch:
    inputs:
      release_id:
        description: 'Release ID'
        required: true
      tester:
        description: '测试人姓名'
        required: true

jobs:
  mark:
    runs-on: ubuntu-latest
    steps:
      - name: Clone & Update
        run: |
          git clone https://token@github.com/org/release-records.git
          cd release-records

          python3 - <<PYEOF
          import json, datetime
          rid = "${{ inputs.release_id }}"
          tester = "${{ inputs.tester }}"
          with open(f"test-passed/{rid}.json") as f:
              m = json.load(f)
          m["status"] = "TEST_PASSED"
          m["tester"] = tester
          m["test_passed_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
          with open(f"test-passed/{rid}.json", "w") as f:
              json.dump(m, f, ensure_ascii=False, indent=2)
          PYEOF

          git add .
          git commit -m "release: ${rid} test passed by ${{ inputs.tester }}"
          git push

方式二:直接编辑 Git 仓库

测试人员直接在 GitLab/GitHub 上编辑 test-passed/20260517-001.json,把 status 改成 TEST_PASSED,提 MR 审批。

方式三:飞书机器人命令

如果接了飞书机器人,可以在群里发:

/release pass 20260517-001 张三

机器人自动更新 manifest。


七、多服务发布策略

7.1 全量发布

所有服务一起发:

RELEASE_ID=20260517-001
# manifest 里有 auth, system, gateway
# 全部发布

7.2 部分发布

只发布部分服务(manifest 里记录了所有服务,但 prod 只发其中几个):

RELEASE_ID=20260517-001
RELEASE_SERVICES=auth,system  # 只发这两个

prod 流水线增加参数:

inputs:
  release_id:
    description: 'Release ID'
    required: true
  services:
    description: '要发布的服务(逗号分隔,留空表示全部)'
    required: false
    default: ''

7.3 单服务回滚

只回滚某个服务:

# 查看历史
kubectl rollout history deployment/auth -n prod

# 回滚 auth
kubectl rollout undo deployment/auth --to-revision=5 -n prod

八、踩坑总结

坑 1:manifest 和实际镜像不一致

问题:manifest 记录的镜像 tag 和 ACR 里实际的镜像不匹配。 原因:构建成功但推送失败,manifest 已经生成了。 解决:manifest 生成放在镜像推送成功之后

坑 2:RELEASE_ID 冲突

问题:同一天多次构建,RELEASE_ID 重复。 原因:只用了日期,没有流水线编号。 解决:RELEASE_ID 加上流水线编号:20260517-00120260517-002

坑 3:测试通过后又改了代码

问题:测试验证的是 commit abc1234,但生产发的是 manifest 里记录的版本,而这个版本其实已经被新的 commit 覆盖了。 解决:manifest 记录的是镜像 tag,不是 commit。镜像一旦推送到 ACR 就不会变,跟后续的代码提交无关。

坑 4:prod 流水线绕过了 manifest 检查

问题:有人在 prod 流水线里加了"紧急模式",可以跳过 manifest 直接填 IMAGE_TAG。 解决:prod 流水线永远不要开放 IMAGE_TAG 输入。紧急情况走 hotfix 流程,从 main 分支切 hotfix 分支,走完整的 build → test → prod 流程。

坑 5:manifest 仓库权限太大

问题:所有人都能编辑 manifest,测试通过的标记不靠谱。 解决:manifest 仓库设置保护分支,TEST_PASSED 标记需要 MR 审批。

坑 6:镜像被覆盖

问题:同一个 tag 被重新 push 了不同内容的镜像。 解决:ACR 开启不可变 tag策略,一旦推送就不能覆盖。


九、总结

核心原则

  1. Build Once, Deploy Many:同一个镜像从测试到生产,不重新构建
  2. prod 不填 tag:只填 RELEASE_ID,由 manifest 决定发什么
  3. 测试验证有记录:TEST_PASSED 状态必须有明确的标记人和时间
  4. 不可变制品:镜像一旦推送到 ACR,不能覆盖
  5. 全流程可追溯:从代码提交到生产上线,每一步都有记录

下限可落地方案

如果你现在就想落地,按这个顺序来:

第一步:IMAGE_TAG 改成组合格式(日期-流水线号-commit)
第二步:dev/test 流水线生成 release-manifest.json
第三步:manifest 存到单独的 Git 仓库
第四步:prod 流水线改为只接受 RELEASE_ID
第五步:prod 流水线校验 manifest status == TEST_PASSED
第六步:接入飞书通知,把 RELEASE_ID 打出来

不需要一步到位,先把"prod 不填 tag"这条底线守住,其他的逐步完善。


参考资料