KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 实战:一条从 push 到生产的完整流水线 — keel 龙骨
这一章回答:前六章讲的全是"判断",把判断落到 GitHub Actions 上长什么样?本章给出一条完整的、每一段选择都有出处的 workflow——从 push 到测试、镜像、预发、生产门禁、冒烟失败自动回退,并逐段解释为什么是这么写。
这一章回答:前六章讲的全是"判断",把判断落到 GitHub Actions 上长什么样?本章给出一条完整的、每一段选择都有出处的 workflow——从 push 到测试、镜像、预发、生产门禁、冒烟失败自动回退,并逐段解释为什么是这么写。
先说诚实边界:本章的 YAML 是逐段走读 + 结构校验,不是"在我机器上跑过"的实录。 Actions 平台的行为细节(environment 保护的交互、needs 的取值语义)以官方文档为准;但每一处写法背后的取舍,都来自前六章的判断,判断本身是可迁移的。
一、问题现场:把判断翻译成一张依赖图
场景:一个 FastAPI 应用,单人维护、有真实用户。目标不是"搭建企业级 CI/CD",而是把前六章的结论各兑现一处:
| 前六章的判断 | 在这条流水线里的落点 |
|---|---|
| CI 与 CD 是两条入口不同的链(第 02 章) | PR/push 触发测试;只有 main 的 push 才继续走镜像与部署 |
| 产物不可变、以 digest 为准(第 01 课 01/02 章) | 镜像 tag 只做检索便利,部署参数用的是 image job 输出的 digest |
| 幂等:敢重跑(第 02 章) | 部署脚本按 digest 部署;重跑同一 run 结果一致 |
| 最小权限与短时效凭据(第 04 章) | 顶层 permissions: contents: read,作业级按需授予;registry 用 GITHUB_TOKEN 免长期密钥 |
| 门禁按成本排(第 03 章) | 测试全绿才产镜像;预发自动放行;生产进 environment 门禁人工批准 |
| 上线后十分钟盯什么(第 01 课 05 章) | 部署 job 里自带冒烟脚本,失败即触发回滚 job |
整个流程一张图(编号与下文小节一致,虚线是失败路径):
flowchart TD
A["① push / merge 进 main"] --> B["② test 矩阵"]
B -->|"任一平台红"| X1["终止:不产镜像"]
B -->|全绿| C["③ image:构建并推送,产出 digest"]
C --> D["④ deploy-staging:自动进入"]
D --> E{"staging 冒烟"}
E -->|失败| X2["停在这里:生产永不看到这个 digest"]
E -->|通过| F["⑤ deploy-prod:environment 门禁 + 人工批准"]
F --> G{"生产冒烟"}
G -->|失败| H["⑥ rollback:回上一个 good digest"]
G -->|通过| I["发布记录归档"]
style A fill:#f4f1e8,stroke:#8a815c
style X1 fill:#f9e8e8,stroke:#a05252
style X2 fill:#f9e8e8,stroke:#a05252
style H fill:#f9e8e8,stroke:#a05252
二、完整 workflow 与逐段讲解
name: delivery
on:
push:
branches: [main]
pull_request:
permissions: # ① 顶层最小权限:默认只读
contents: read
concurrency:
group: delivery-${{ github.ref }}
cancel-in-progress: false # ② 部署链不允许被半路取消
jobs:
test:
strategy:
fail-fast: false # ③ 要完整诊断,不是省钱
matrix:
python: ["3.11", "3.12"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "${{ matrix.python }}"
- run: pip install -e ".[dev]"
- run: pytest -q
- run: ruff check .
image:
needs: test
if: github.ref == 'refs/heads/main' # ④ PR 只验证,不产镜像
permissions:
contents: read
packages: write # ⑤ 推 GHCR 所需,仅此作业持有
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
id: push
with:
context: .
push: true
tags: ghcr.io/${{ github.repository_owner }}/myapp:sha-${{ github.sha }}
deploy-staging:
needs: image
runs-on: ubuntu-latest
environment: staging # ⑥ 预发环境:自动放行,变量隔离
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.sh staging "${{ needs.image.outputs.digest }}"
- run: ./scripts/smoke.sh https://staging.example.com "${{ github.sha }}"
deploy-prod:
needs: [image, deploy-staging]
runs-on: ubuntu-latest
environment: # ⑦ 生产环境:可配必需审批人
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.sh production "${{ needs.image.outputs.digest }}"
- run: ./scripts/smoke.sh https://example.com "${{ github.sha }}"
rollback:
needs: [deploy-staging, deploy-prod]
if: ${{ failure() && needs.deploy-prod.result == 'failure' }}
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy.sh production "$(ssh prod 'cat /srv/myapp/last-good-digest')"
逐段说清楚每一处"为什么":
① 顶层只读,作业级按需授予。 这是第 04 章最小权限原则的直接落地:任何被注入的第三方 step 默认拿不到写权限。image 作业要推 GHCR 才加 packages: write,且只有它持有——deploy-* 作业不碰 registry(拉取是匿名的或机器本地已登录)。
② cancel-in-progress: false 是有意的。 第 02 章讲过 CI 运行可以被新 push 取消省钱,但部署作业被半路取消可能留下"一部分实例新版本"的中间态。部署链的并发组不取消,宁可排队。
③ fail-fast: false。 CI 的目的是信息。两个 Python 版本一个挂了,另一个的结果仍然能告诉你"是版本相关还是全量问题"。
④ if: github.ref == 'refs/heads/main'。 PR 的职责是验证改动,不是产出部署单元——PR 阶段推镜像只会让 registry 里堆满没有经过评审的产物。这是"两条链入口不同"在配置上的样子。
⑤ 用 GITHUB_TOKEN 而不是长期 Personal Access Token。 它随 run 创建、随 run 失效,权限已经被 ① 收窄——这是第 04 章"短时效凭证替代长期令牌"的最小实现。要推私有仓库或云厂商 registry 时,对应做法是 OIDC 换取短时效云凭据(permissions: id-token: write),同样不在仓库里存长期密钥。
⑥⑦ environment 是 Actions 里"环境"概念的载体。 staging 与 production 在仓库设置里各自配秘密变量与保护规则:production 可以设必需审批人(deploy job 会停在"Waiting for approval"直到人点批准)与部署窗口。这就是门禁金字塔里"人工门禁"的位置——它保护的不是代码质量,是放行节奏。
outputs.digest 是全链的骨架。 build-push-action 推送后输出的 digest 通过 job outputs 一路传给部署脚本——部署参数永远不是 main 或 latest 这种可变引用,而是这次运行实际产出的那串 sha256。第 01 课 01 章"发出去的东西是不是你以为的那个",在配置层就是这个 outputs 的传递链。
rollback 的条件值得单独看。 if: ${{ failure() && needs.deploy-prod.result == 'failure' }}——只写 failure() 会在 staging 失败时也触发回滚(staging 都没过,生产根本没部署,回滚无的放矢);加上 needs.deploy-prod.result == 'failure' 才把范围收窄到"生产部署/冒烟失败"。needs 作业的 result 取值(success / failure / cancelled / skipped)是编排失败路径时最常被搞错的细节。
回滚的资源从哪来? deploy.sh 的约定:每次部署成功冒烟后把该次 digest 写入 /srv/myapp/last-good-digest。回滚就是把这个文件里记录的上一个可用版本重新拉起——上一课(第 01 课 05 章)"回滚资源要提前备好"在脚本层的最小实现。手动回滚则用一条单独的 workflow_dispatch workflow,输入 digest 部署,同样幂等。
三、教学替身与生产替换点
scripts/deploy.sh 在教学场景里是"ssh 到单机执行 docker compose 拉起指定 digest"(下一门课第 07 章就是这个脚本的内部);真实替换点按形态升级:
| 教学替身 | 生产替换点 | 不变的是什么 |
|---|---|---|
| ssh + compose 拉指定 digest | kubectl set image(digest 引用) |
按 digest 部署、部署后冒烟 |
单机 last-good-digest 文件 |
K8s rollout undo / GitOps revert 提交 |
"上一个可用版本"必须是被验证过的 |
| 冒烟脚本 curl 核心路径 | 探针 + 基于错误率的自动回滚(Argo Rollouts 类) | 判断依据从"脚本退出码"换成指标,逻辑同构 |
GitOps 形态下这条链还有一处变形:deploy-* 作业不再直接操作集群,而是更新 manifest 里的镜像 digest 并提交,由集群内控制器对齐(第 01 课 06 章的 pull 型)。workflow 的其余部分(test → image → environment 门禁)完全不变——这也是把"验证"与"发放"分成两门课的原因:发放形态会变,验证逻辑不变。
动手:可观察结果
| 动作 | 产出物 | 判断"做完了"的标准 |
|---|---|---|
| 把本章 workflow 适配到自己的仓库跑通 | 一次绿色 run | staging 部署页能看到 digest 与 run 号的对应关系 |
给 production 配必需审批人 |
一次等待批准的部署 | run 停在 Waiting for approval,批准后才继续 |
| 故意推一个冒烟必挂的版本 | 一次自动回滚 | rollback job 运行且生产版本号回到旧 digest;记录全程耗时 |
把 rollback 条件改成裸 failure() |
staging 失败时的行为 | 观察到 rollback 仍被触发——亲眼确认为什么要写 needs.*.result |
| 断开 registry 重跑同一次部署 | 幂等验证 | 同一 digest 重复部署结果一致,无半成品 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
把 image 的 if 删掉后开 PR |
registry 里出现了什么 | PR 产物进入 registry——不可信产物污染制品库 |
给 deploy-prod 设 cancel-in-progress: true 并连续 push |
部署是否被取消 | 半路取消留下混合版本状态,正是 ② 要防的 |
把冒烟脚本从 deploy-prod 里删掉 |
坏版本多久被发现 | 从"1 分钟"变成"用户报告时"——冒烟是自动化的"上线后十分钟" |
在 deploy.sh 里用 main 标签代替 digest |
部署的是什么 | 并发 merge 时部署的可能不是本次 run 构建的那个产物 |
抹掉 last-good-digest 文件再触发回滚 |
回滚是否失败 | 回滚资源没有备份 = 没有回滚;这本身就是一次演练发现 |
自测题
- 为什么 PR 分支的 push 不应产出镜像?这个
if条件删掉后具体会坏在哪? rollback的条件为什么不能只写failure()?needs.*.result有哪几种取值?GITHUB_TOKEN和 Personal Access Token 在时效与权限来源上的本质差别是什么?outputs.digest的传递链对应第 01 课的哪条判断?如果中间某一环改成传 tag 名,会发生什么?- GitOps 形态下这条 workflow 哪些部分要变、哪些不变?为什么"验证逻辑不变"?
现在能解释什么
- 为什么一条"看起来能跑"的流水线和一条"敢在没人时按发布键"的流水线,差的是 environment 门禁、digest 传递和回滚资源这三处细节。
- 面试被问"画一下你们的 CI/CD"时,能按这张依赖图逐段讲出每一处写法的取舍,而不是背一遍 job 名称。
- 拿到任何一份现成 workflow,能用第 02 章的六零件模型 + 本章的七处标注位快速审出它的风险点。