CI/CD 进阶:构建与发布分离,用 Release Manifest 管控生产发布
作者:林 | 系列:DevOps 实战 | 适合读者:后端/运维/DevOps 工程师
一、问题:你的生产发布靠什么保证"发的是测试通过的版本"?
很多团队的生产发布流程是这样的:
- 测试在测试环境验证通过
- 开发或运维在生产流水线里手动填一个 IMAGE_TAG
- 部署到生产
问题来了,这个 IMAGE_TAG 是谁填的?填的对不对?怎么证明测试环境用的就是这个 tag?
常见翻车场景:
- 运维填错了 tag,把没测试过的版本发到生产
- 测试验证的是 commit abc1234,但生产发的是 def5678
- 出了问题想回滚,但不确定上一个稳定版本是哪个 tag
- 多人协作时,谁发的、发了什么、什么时候发的,全靠聊天记录
核心矛盾:生产发布的"输入"没有约束,任何人都能填任何 tag。
二、解决方案:Release Manifest
2.1 核心思路
核心约束是一次构建、多环境部署,生产只提升已经验证的制品。
具体来说:
- dev/test 流水线负责构建镜像、推送 ACR、发布测试环境
- 测试环境发布成功后,生成一份 release-manifest.json(发布清单)
- 测试人员验证通过后,把 manifest 状态标记为
TEST_PASSED - prod 流水线只接受一个输入:
RELEASE_ID - prod 流水线根据
RELEASE_ID读取 manifest,只发布 manifest 里记录的镜像
关键约束: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 就知道哪些版本可以发生产 缺点:只记录了镜像,没有测试人、测试时间等元数据
五、两条流水线设计
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-001、20260517-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策略,一旦推送就不能覆盖。
九、总结
核心原则
- Build Once, Deploy Many:同一个镜像从测试到生产,不重新构建
- prod 不填 tag:只填 RELEASE_ID,由 manifest 决定发什么
- 测试验证有记录:TEST_PASSED 状态必须有明确的标记人和时间
- 不可变制品:镜像一旦推送到 ACR,不能覆盖
- 全流程可追溯:从代码提交到生产上线,每一步都有记录
下限可落地方案
如果你现在就想落地,按这个顺序来:
第一步:IMAGE_TAG 改成组合格式(日期-流水线号-commit)
第二步:dev/test 流水线生成 release-manifest.json
第三步:manifest 存到单独的 Git 仓库
第四步:prod 流水线改为只接受 RELEASE_ID
第五步:prod 流水线校验 manifest status == TEST_PASSED
第六步:接入飞书通知,把 RELEASE_ID 打出来不需要一步到位,先把"prod 不填 tag"这条底线守住,其他的逐步完善。