4.10 CI/CD 生产实践——多环境、审批与交付流水线
预计阅读时间:17 分钟
📖 目录
4.9:CI/CD 持续部署 介绍了 GitHub Actions 的基础流水线。生产环境的 CI/CD 需要更多工程细节:多环境分支策略、审批门禁、artifact 版本管理、密钥轮换和回滚机制。本文用一个完整的交付流水线案例串联这些实践。
学习目标
- 理解 Trunk-based Development 与多环境分支策略的对应关系
- 能编写包含 lint、test、build、deploy 分层的生产级 GitHub Actions 流水线
- 掌握用 GitHub Environments + Required reviewers 配置审批门禁
- 能设计 SemVer + Git SHA 双 tag 的镜像版本管理与回滚方案
- 掌握 Secrets 的分环境管理与定期轮换策略
- 理解 CI/CD 成熟度模型并能评估自身流水线所处阶段
前置知识
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门——GitHub Actions 基础流水线
- 2.9:Git 版本控制 Git 版本控制——分支模型、PR 与 tag 语义
- 3.1:Docker 容器入门 Docker 容器入门——镜像构建与 docker compose 部署基础
- 4.1:Ansible 自动化 Ansible 自动化配置管理入门——服务端部署与配置管理
1. 多环境分支策略
| 环境 | 分支 | 触发器 | 审批 |
|---|---|---|---|
| 开发(dev) | feature/* | PR 创建 / push | 自动 |
| 集成(staging) | develop | PR merged to develop | Code Review + 自动测试 |
| 预发(preprod) | release/* | 手动触发 | QA 审批 |
| 生产(prod) | main | release tag push | 运维审批 |
Git Flow vs Trunk-based Development
| 维度 | Git Flow | Trunk-based(推荐) |
|---|---|---|
| 主分支 | main + develop 双长分支 | 单主干 main,随时可发布 |
| 功能分支生命周期 | 数周(feature → develop → release → main) | 1-2 天,短命分支即合并 |
| 发布方式 | release 分支冻结 + 修复 | tag 即发布,小步高频 |
| 合并冲突概率 | 高(长分支偏离主干) | 低 |
| 热修复 | hotfix 分支双向合并,易漏 | 主干直接修 + tag 发布 |
| 适用场景 | 发布节奏慢、强合规(金融/电信) | SaaS、发布频率 ≥ 每周 |
环境配置管理
多环境的核心矛盾:同一份代码,不同环境的地址、密钥、开关不同。推荐的配置分层:
# 代码仓库内只放非敏感、可公开的默认配置
config/
├── base.yaml # 所有环境公共项
├── dev.yaml # 环境差异(域名、副本数)
├── staging.yaml
└── prod.yaml
# 敏感项(数据库密码、API Key)不进代码仓库:
# 方案 A:GitHub Actions 的 Environment Secrets(推荐入门)
# 方案 B:运行时从 Vault / Secrets Manager 拉取(见第 8 节)
# 方案 C:部署时由 CI 注入 .env 文件,仓库只留 .env.example
# 流水线中按环境选择配置文件
- name: Select config
run: |
cp config/${{ github.ref_name }}.yaml config/active.yaml
echo "${{ secrets.ACTIVE_ENV_FILE }}" > .env
本地预检:把错误拦在 push 之前
CI 的反馈循环再快也要分钟级,本地预检把它压到秒级。pre-commit 只做三件"几乎不误报"的事,避免拖慢提交:
# .pre-commit-config.yaml(pre-commit 框架)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets # 阻止密钥提交进仓库
# Node 项目用 husky 在 commit 前跑 lint(package.json)
# "husky": { "hooks": { "pre-commit": "npm run lint" } }
# 纯 git 钩子写法(.git/hooks/pre-commit)
#!/bin/sh
npm run lint || exit 1
npx tsc --noEmit || exit 1
# 本地预检守则:
# 1. 总时长 < 60s,超过就砍掉不必要的内容
# 2. 全绿才能 push,红色直接拦下
# 3. 与 CI 分工:lint/typecheck 本地跑,矩阵测试/镜像构建留给 CI
2. 生产级流水线模板
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [develop]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run typecheck
test:
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
redis:
image: redis:7-alpine
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
needs: test
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract version
id: version
run: |
if [[ $GITHUB_REF == refs/tags/v* ]]; then
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
else
echo "version=sha-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
fi
- name: Build & Push Docker image
uses: docker/build-push-action@v5
with:
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
if: github.ref == 'refs/heads/main'
environment: staging
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
run: |
# SSH 到 staging 服务器拉取新版本
ssh ${{ secrets.STAGING_HOST }} "
docker compose pull web
docker compose up -d web
"
deploy-production:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
environment: production
runs-on: ubuntu-latest
concurrency: production
steps:
- name: Deploy to production
run: |
ssh ${{ secrets.PROD_HOST }} "
docker compose pull web
docker compose up -d web
"
- name: Health check
run: |
sleep 15
curl -f https://example.com/health
- name: Rollback on failure
if: failure()
run: |
ssh ${{ secrets.PROD_HOST }} "
cd /opt/app && \
sed -i 's|image: myapp:.*|image: myapp:${{ env.PREV_VERSION }}|' docker-compose.yml && \
docker compose up -d web
"
3. 审批门禁(Environment + Review)
GitHub Environments 的 Required reviewers 功能为敏感环境添加人工审批:
# 在仓库 Settings > Environments 中配置:
# production 环境:
# - Required reviewers: 2 人
# - Wait timer: 5 分钟
# - Deployment branches: main only(仅 main 分支可部署生产)
# workflow 中通过 environment 字段关联
deploy-production:
environment: production # 自动触发审批流程
# 部署审批通过后自动执行,拒绝则跳过
其他 CI 平台的审批实现
# Jenkins——Input Step 实现人工批准后继续
stage('Deploy to Production') {
input {
message "确认部署到生产环境?"
ok "批准部署"
submitter "ops-team,release-manager" # 只有这两个组的成员可批准
parameters {
string(name: 'VERSION', defaultValue: env.IMAGE_TAG, description: '镜像版本')
}
}
steps {
sh "sed -i 's|image: myapp:.*|image: myapp:${params.VERSION}|' docker-compose.yml"
sh "docker compose up -d web"
}
}
# GitLab——受保护环境 + 审批规则(UI 配置)
# Settings > CI/CD > Protected environments > production
# 允许部署的分支:main only
# 审批人数:2(部署前必须 2 人批准)
# .gitlab-ci.yml 中声明环境:
deploy-prod:
stage: deploy
environment:
name: production
url: https://example.com
script:
- docker compose pull web
- docker compose up -d web
only:
- tags
门禁设计的通用原则
门禁不是越多越好:每加一道门,交付延迟就增加一分。判断一道门该不该存在的标准是——它拦得住真实的错误吗?永远全绿的门(仪式感门禁)趁早删掉;拦得住但不常触发的门(如安全扫描)降级为定期巡检。
| 门禁 | 拦截目标 | 放置位置 | 通过标准 |
|---|---|---|---|
| Lint / Typecheck | 语法与低级错误 | PR 阶段 | 零 error(warning 可选) |
| 单元测试 | 逻辑回归 | PR 阶段 | 全绿 + 覆盖率不下降 |
| 镜像漏洞扫描(Trivy) | 高危漏洞进产物 | 构建后 | 无 CRITICAL 级 CVE |
| 集成 / E2E 测试 | 跨服务破坏 | staging 部署后 | 关键路径用例通过 |
| 人工审批 | 高风险变更 | 生产部署前 | 按风险分级要求人数(见第 9 节) |
| 健康检查 | 部署失败外溢 | 部署后 15 分钟内 | /health 连续 3 次 200 |
4. Artifact 版本管理
容器镜像的 tag 策略直接影响回滚速度和可追溯性:
| 策略 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| SemVer tag | v1.2.3 | 语义清晰,与 Git tag 一致 | 需手动打 tag |
| Git SHA | sha-a1b2c3d | 唯一可追溯,自动生成 | 不直观 |
| latest | latest | 部署方便 | 不可追溯,不应上生产 |
# 生产推荐:SemVer tag + Git SHA
docker build -t myapp:v1.2.3 -t myapp:sha-a1b2c3d .
# 回滚时先把 compose 文件的 image 指回上一版本 tag,再重新部署
sed -i 's|image: myapp:.*|image: myapp:v1.2.2|' docker-compose.yml
docker compose up -d --pull=always web
语义化版本与产物命名规范
# SemVer 规则:MAJOR.MINOR.PATCH + 预发布后缀(-rc.1)
# MAJOR:不兼容的 API 变更
# MINOR:向后兼容的新功能
# PATCH:向后兼容的缺陷修复
# 产物命名规范(镜像 / 安装包 / 软件包统一):
# 镜像:<registry>/<team>/<app>:<version>-<sha>
ghcr.io/platform/checkout:v2.4.1-ga1b2c3d
# 安装包:<app>_<version>_<os>_<arch>.<ext>
checkout_2.4.1_linux_amd64.deb
# 版本来源单一化:发布脚本统一打 Git tag → 生成 SemVer → 构建产物
# 禁止各环境各自生成版本号,保证「代码-镜像-版本」一一对应
| 发布类型 | Git tag | 镜像 tag | 回滚目标 |
|---|---|---|---|
| 正式版 | v2.4.1 | v2.4.1-ga1b2c3d | 上一正式版 |
| 候选版 | v2.5.0-rc.1 | v2.5.0-rc1-ga1b2c3d | 当前稳定版 |
| 热修复 | v2.4.2 | v2.4.2-ga1b2c3d | v2.4.1 |
5. 密钥管理
# GitHub Actions Secrets(仓库 Settings > Secrets and variables)
# 每个环境独立配置
# dev/staging/prod 各自一套密钥
# 不要在 workflow 文件中写明文密钥
# 正确做法:
- name: Configure env
run: |
echo "${{ secrets.PROD_ENV_FILE }}" > .env
docker compose up -d
# 密钥轮换策略:
# - 每 90 天自动轮换数据库密码
# - 轮换后立即更新 GitHub Secrets
# - 紧急泄漏时立即轮换(可在 GitHub UI 操作)
6. 回滚策略
| 方式 | 操作 | 速度 |
|---|---|---|
| 镜像回滚 | 改 compose image 为旧 tag 后 docker compose up -d web | 秒级 |
| Git revert | git revert HEAD && git push | 分钟级(重新走 CI) |
| 蓝绿切换 | 切 Nginx 上游指向旧版本 | 秒级 |
金丝雀与蓝绿部署对比
| 维度 | 金丝雀(Canary) | 蓝绿(Blue/Green) |
|---|---|---|
| 原理 | 新版本先放 5% 流量,逐步放量 | 新旧两套环境并存,一次性切换 |
| 步骤 | ① 部署 canary 副本 ② 分流 5% ③ 观察指标 ④ 25%/50%/100% 逐步放量 | ① 部署 green 并验证 ② 切 Nginx/网关上游 ③ 观察 ④ 回收 blue |
| 回滚速度 | 立即把新版本流量切回 0% | 切回旧上游,秒级 |
| 资源成本 | 低(仅多一份小副本) | 高(双倍环境常驻) |
| 适用 | 持续交付、发布频率高 | 大版本升级、关联数据库迁移 |
| 陷阱 | 小流量下统计偏差,异常难发现 | 长连接未断开导致切回不干净 |
# 金丝雀发布脚本要点(K8s + Istio 为例)
# 1. 部署 v2 Deployment(replicas: 1)
# 2. VirtualService 权重:v1 95% / v2 5%
# 3. 观察 15 分钟错误率与 P99(v2 错误率 > 0.5% 立即权重归零)
# 4. 逐步 25% → 50% → 100%,全程监控
kubectl apply -f deploy-v2.yaml
kubectl apply -f virtualservice-95-5.yaml
sleep 900 && check_metrics
kubectl apply -f virtualservice-50-50.yaml
sleep 900 && check_metrics
kubectl apply -f virtualservice-100-0.yaml
回滚决策:什么时候"撤",比怎么撤更重要
回滚的犹豫期往往比回滚本身更久。提前把触发条件写进 runbook,事故时照单执行:
- 立即回滚:错误率 > 1%、P99 超过 SLO 持续 5 分钟、核心链路不可用、数据写坏
- 观察后再定:仅个例告警、指标缓慢劣化、与旧版本行为差异(先对比新旧版本配置与流量)
- 不回滚直接修:热修复已在路上(10 分钟内)、回滚本身比修复风险更高(如回滚会触发重复支付)
- 回滚后的动作:保留现场(日志、监控截图、流量记录)→ 通知相关方 → 复盘会上定位根因,避免"滚回来就完事"
7. 流水线成熟度模型
| 级别 | 特点 |
|---|---|
| L1 手动 | SSH 上服务器手动部署 |
| L2 基础 CI | 有 lint + test,但部署仍手动 |
| L3 自动 CI/CD | 全自动构建 + 部署 staging,生产有审批门禁 |
| L4 渐进式交付 | Canary / 蓝绿部署,自动回滚,监控驱动 |
8. 密钥轮换自动化
手动改 Secrets 容易漏、慢、留后门。生产团队应把密钥的读取与轮换交给密钥管理系统:
# 方案 A:HashiCorp Vault——流水线运行前动态拉取,密钥不落地
- name: Fetch secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.example:8200
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
secret/data/prod/db DATABASE_URL | db_url ;
secret/data/prod/api APP_API_KEY
# 数据库密码轮换(Vault 数据库动态凭据引擎):
# vault write database/rotate-root/my-db
# 轮换后旧密码立即失效,应用无需重启(动态凭据自动续租)
# 方案 B:AWS Secrets Manager 自动轮换
# 配置 Lambda 轮换函数,RDS 密码 30 天自动轮换
aws secretsmanager rotate-secret \
--secret-id prod/db --rotation-rules '{"AutomaticallyAfterDays": 30}'
# 季度轮换演练:
# 1. 按最小权限创建临时密钥并部署验证
# 2. 验证应用无感知切换后吊销旧密钥
# 3. 记录轮换耗时,目标 < 10 分钟
9. 发布日历与变更管理
发布不只是「跑流水线」。生产事故多发生在变更后,规范变更流程比优化构建速度更重要:
| 环节 | 负责人 | 检查项 |
|---|---|---|
| 变更申请 | 开发 | 变更内容、影响范围、回滚方案、数据库迁移脚本 |
| 评审 | 技术负责人 | 代码评审 + 变更风险分级(低/中/高) |
| 发布窗口 | 运维 | 避开业务高峰(电商:夜间;B2B:周末) |
| 审批 | 运维 + 业务负责人 | 高风险变更双人审批(对应第 3 节门禁) |
| 发布后观察 | 监控值班 | 错误率 / 延迟 / 资源使用 30-60 分钟观察期 |
| 复盘 | 全组 | 发布失败 → 根因分析、改进项、回滚耗时记录 |
# 发布日历(示例)
# 周一 10:00-12:00 例行低风险发布(PATCH 级)
# 周三 14:00-16:00 中风险发布(MINOR 级,需审批)
# 周五 16:00 后 禁止发布(避免周末事故)
# 每季度一次 MAJOR 升级,提前 2 周发布通知
# 变更冻结期(change freeze):
# - 财年末审计周、双十一/黑五等大促前 1 周
# - 冻结期只接受安全补丁与紧急修复(需 VP 级审批)
变更风险分级与审批矩阵
| 级别 | 变更示例 | 审批要求 | 发布窗口 |
|---|---|---|---|
| 低风险 | 文案修改、配置开关、依赖 PATCH 升级 | 代码评审 + 流水线全绿 | 任意时段(避开大促窗口) |
| 中风险 | 接口行为变更、依赖 MINOR 升级、慢查询修复 | 技术负责人审批 + 回滚方案 | 发布日历时段 |
| 高风险 | 数据库迁移、核心链路重构、认证/支付变更 | 技术负责人 + 运维双人审批、金丝雀放量 | 夜间低峰 + 值班人待命 |
分级的关键是让"风险"有共识定义:影响用户数、影响面宽度、回滚难度三个维度各打 1-3 分,总分 ≥7 即高风险——用表格代替"我觉得挺危险"的口头判断,变更评审会才能快而准。
10. 交付可观测性:让流水线自己可诊断
流水线也会"生病":构建越来越慢、失败率悄悄上升、发布到恢复的耗时越来越长。用 DORA 四指标给交付过程装上仪表盘,问题在变成事故前被发现:
| 指标 | 定义 | 优秀目标 | 数据来源 |
|---|---|---|---|
| 变更前置时间(Lead Time) | 代码合入到上线的时间 | < 1 天 | PR merged → 部署时间 |
| 部署频率(Deploy Frequency) | 生产部署次数 / 周 | ≥ 每天 | 部署流水线执行记录 |
| 变更失败率(CFR) | 失败部署 / 总部署 | < 15% | 部署后 1 小时内回滚或修复 |
| 恢复时间(MTTR) | 故障到恢复的时间 | < 1 小时 | 告警开始 → 服务恢复 |
# 用 GitHub Actions API 统计部署频率与失败率(每月跑一次)
gh run list --workflow=deploy.yml --limit 100 \
--json status,conclusion,createdAt,updatedAt \
| jq -r '.[] | "\(.conclusion) \(.createdAt) \(.updatedAt)"' > /tmp/runs.txt
# 失败率 = failure 行数 / 总行数
awk '$1=="failure"{f++} END{print "failure_rate:", f/NR*100"%"}' /tmp/runs.txt
# 发布后 30 分钟观察窗自动化(错误率是回滚决策的依据)
curl -s "http://prometheus:9090/api/v1/query" \
--data-urlencode 'query=sum(rate(http_requests_total{job="web",status=~"5.."}[5m]))/sum(rate(http_requests_total{job="web"}[5m]))' \
--data-urlencode 'time='"$(date +%s)"''
# 判断规则:错误率 > 1% 或 P99 > SLO,持续 5 分钟 → 触发回滚流程
把四指标画进 Grafana,月度复盘直接看趋势:Lead Time 变长说明门禁过重或构建变慢,CFR 上升说明测试覆盖在退化——指标会告诉你流水线该往哪个方向调。
常见错误
- 镜像 tag 用
latest上生产——生产环境必须用明确的 SemVer 或 SHA tag,latest无法追溯和回滚 - Secrets 硬编码在 workflow 文件中——永远不要在
.yml中写明文密钥,使用secrets.*引用 GitHub Secrets - 健康检查超时设置过短——容器启动后应用可能需要更长时间初始化,将
sleep和重试机制结合使用 - 没有数据库迁移回滚脚本——每个 migration 必须配套 down script,否则应用回滚后数据库 schema 不兼容
- 所有环境共享同一套密钥——dev/staging/prod 应各自独立配置 Secrets,避免测试密钥泄漏到生产
- PR 门禁总耗时超过 10 分钟——开发者会开始批量提交、绕过测试。把重活(矩阵测试、E2E)放到合入后或 staging 部署后,PR 阶段只留秒级门禁
- 回滚脚本没验证过——事故时的回滚动作与日常部署路径不同,最容易在关键时刻出错。每季度做一次回滚演练,把耗时记进发布台账
最佳实践
- 采用 Trunk-based Development——短生命周期的 feature branch,快速合并到 develop,避免长期分支合并冲突
- 容器镜像双 tag——同时打 SemVer(如
v1.2.3)和 Git SHA(如sha-a1b2c3d),兼顾语义清晰和唯一追溯 - 流水线分层执行——lint → test → build → deploy,每层失败立即终止,节省资源和时间
- 生产部署使用 concurrency 锁——防止多个生产部署同时执行导致状态不一致
- 定期轮换密钥——每 90 天轮换一次数据库密码和 API Key,泄漏时立即轮换
- 本地预检与 CI 分工——lint/typecheck 在本地 pre-commit 完成,CI 专注矩阵测试、镜像构建与部署,反馈循环各司其职
- 用 DORA 指标复盘交付——季度复盘对比 Lead Time、部署频率、变更失败率、恢复时间四指标,用数据决定流水线优化方向
练习题
- 为一个 Node.js 项目编写完整的 GitHub Actions workflow,包含 lint、test(矩阵测试 Node 18/20/22)、build 镜像并推送到 GHCR,要求生产部署前有审批门禁。
- 设计一个蓝绿部署方案:用 Docker Compose 管理两个容器组(blue/green),通过 Nginx 上游切换实现零停机回滚,编写对应的回滚脚本。
- 模拟生产事故:在 staging 环境故意部署一个有 bug 的版本,验证自动健康检查失败后能否正确触发回滚流程,并记录回滚耗时。
- 给现有流水线配置"发布后 30 分钟观察窗":采集错误率与 P99,设置自动回滚触发条件(错误率 > 1% 持续 5 分钟),并做一次演练。
- 用 DORA 四指标评估你当前的交付流程:列出每个指标的计算方法与当前数据,找出最薄弱的一环并给出改进方案。
学习检查点
学完本章后,请检验自己是否掌握以下内容:
| 检查项 | 自测问题 | 验证方法 |
|---|---|---|
| 概念理解 | 能用自己的话解释 CI/CD 在生产环境中的最佳实践 | 尝试向他人讲解 |
| 命令操作 | 能不查文档完成 CI/CD 生产配置、流水线优化 | 在终端实际执行 |
| 原理掌握 | 能说出 CI/CD 的安全控制和审计机制 | 画出流程图 |
| 故障排查 | 能独立排查生产环境部署失败、回滚问题 | 模拟故障并修复 |
| 最佳实践 | 能说明为什么需要为生产环境配置蓝绿部署和金丝雀发布 | 对比不同方案 |
本章总结
生产级 CI/CD 的核心是把"构建、发布、部署"的每个环节显式化、可审计化:分支策略决定触发范围,审批门禁守住生产入口,SemVer + SHA 双 tag 让每次发布可追溯、可秒级回滚,密钥独立分环境并定期轮换。流水线成熟度从手动部署到渐进式交付逐级提升,每一步都依赖清晰的门禁与回滚能力兜底。
延伸阅读
- 4.9:CI/CD 持续部署 CI/CD 持续部署入门
- 6.7:GitOps 入门 GitOps 入门——ArgoCD 与 Flux
- container_ops01 Docker Compose 生产部署实战