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 文件再触发回滚 回滚是否失败 回滚资源没有备份 = 没有回滚;这本身就是一次演练发现

自测题

  1. 为什么 PR 分支的 push 不应产出镜像?这个 if 条件删掉后具体会坏在哪?
  2. rollback 的条件为什么不能只写 failure()?needs.*.result 有哪几种取值?
  3. GITHUB_TOKEN 和 Personal Access Token 在时效与权限来源上的本质差别是什么?
  4. outputs.digest 的传递链对应第 01 课的哪条判断?如果中间某一环改成传 tag 名,会发生什么?
  5. GitOps 形态下这条 workflow 哪些部分要变、哪些不变?为什么"验证逻辑不变"?

现在能解释什么

进入 keel 阅读