这是我们模型发布流水线的第一版。没有编排框架,没有 operator,没有 CRD。一个服务里的状态机、一个 CI workflow、SSH,就这些。
写下来是因为它的形状比实现有意思。
触发很简单:新的模型配方进仓库,先过一遍 contract 校验,通过了发布就自己开始。没有人点任何按钮。
两半
花了最久才想明白的一件事:这里其实是两个性质不同的控制问题,各自需要不同的机制。
流量和节点状态本质是记账:谁在接客户流量、谁被排除在外、这条 lane 对外声明了哪些能力。这部分在控制面 API 后面,由发布引擎去调。
机器是 SSH 问题:拉镜像、起容器、跑健康检查、停掉旧的。这部分在 CI runner 里。
flowchart LR
R["新配方进仓库"] --> K["contract 校验"]
K --> E["Rollout Engine
持有状态机"]
E <-->|"前进 / 回退步骤"| API["控制面 API
流量、标记、能力"]
E -->|"repository_dispatch
event_type: release-step"| GH["release-step.yml
一个 job 一个 runner"]
GH --> T["scripts/release/attach_queue_lane.py"]
T -->|ssh| M["目标机器
deploy.py, docker up/down"]
GH -->|"POST /v1/releases/step-runs/:id/callback"| E
引擎从不碰机器,runner 从不碰流量状态。两边只通过一个接口互相认识:出去一个 dispatch,回来一个 callback。
每个前进步骤都配一个回退步骤
这是第二版我会原样保留的部分。
引擎的步骤表是成对写的。发布就是沿着列表往前走,失败就沿着已经生效过的那些步骤往回走。
| # | 前进 | 做什么 |
|---|---|---|
| 1 | recheck_preconditions |
确认 contract 校验之后条件仍然成立 |
| 2 | mark_canary_nodes |
新节点只接流水线流量 |
| 3 | exclude_current_from_canary |
现役节点不进流水线流量 |
| 4 | assert_smoke_passed |
新节点上流水线测试套件全绿 |
| 5 | clear_canary_marks |
新节点可以开始接客户流量 |
| 6 | assert_capability_coverage |
新版本覆盖这条 lane 对外提供的全部能力 |
| 7 | open_capability |
把新能力加进 lane 的声明里 |
| 8 | soak |
两个版本同时服务,比对新版本的输出 |
| 9 | drain_current_nodes |
现役节点停止领新活 |
| 10 | retire_current_nodes |
现役节点退出 lane |
| # | 回退 | 撤销什么 |
|---|---|---|
| 1 | release_canary_marks |
第 3 步的排除 |
| 2 | close_capability |
第 7 步 |
| 3 | undrain_current_nodes |
第 9 步 |
| 4 | revoke_new_ticket |
把 lane 从新版本手里收回 |
| 5 | finish_failed |
记录失败原因 |
关于这张表有两点值得说。
回退列表比前进列表短,这是对的,不是漏写。前进里有好几步是断言——assert_smoke_passed 什么都没改,也就没什么可撤销。只有真正改了状态的步骤才需要逆操作。
顺序也有讲究:回退是倒着走的,而且只走已经生效过的那些。引擎记着走到哪一步了,从那里往回退。
一个步骤是怎么跑的
引擎自己不执行任何东西。它发一个 repository_dispatch,event_type 是 release-step,payload 里写明要跑哪个工具,然后等 callback。
sequenceDiagram
participant E as Rollout Engine
participant W as release-step.yml
participant M as 机器
E->>W: repository_dispatch(工具名, run_id, callback_url)
W->>W: 先把 status 写成 not_run
W->>W: 校验 payload,按白名单解析工具
W->>W: 校验 callback 凭证
W->>M: ssh 过去执行工具
M-->>W: status, overall, checks.json, log
W->>E: POST /v1/releases/step-runs/:id/callback
E->>E: 通过 → 下一个前进步骤
E->>E: 失败 → 走回退列表
整个 workflow 是一个 job 跑在一个 runner 上,顺序是这样:
1 | ONE job on ONE runner |
其中三行是踩过坑才加上的。
第一步就把 status 写成 not_run。 这样后面任何环节挂掉,结果文件已经存在、写着”根本没跑”,而不是文件不存在。从引擎那边看,”文件缺失”和”执行失败”长得一模一样,但它们是两回事。
动任何东西之前先校验 callback 凭证。 否则就会出现:SSH 进生产机器、改完状态、然后发现自己没法把做了什么报回去。便宜的检查要先做。
工具必须走白名单解析。 payload 是 dispatch 带来的,里面写着工具名。如果拿这个名字去拼路径,那么任何能发 dispatch 的人都能执行仓库里的任意脚本。白名单把一组固定的名字映射到一组固定的文件,名字不在表里就直接失败。
机器那一侧,从头到尾
一个”把这条 lane 挂到新引擎上”的步骤,最终会走到这里:
1 | release-step.yml |
工具只写四个文件,调用方也只关心这四个:
status:一个词,机器可读的结果overall:整步的通过与否checks.json:逐项检查的细节,失败时看的就是它log:凌晨两点拿来 grep 的原始日志
callback 那一步读这四个文件、POST 回去,引擎把 checks.json 原样存下来。这样排查用的材料能活过 runner——runner 几分钟后就没了。
为什么用 callback 而不是轮询
引擎当然可以去轮询 CI 的 job 状态。我们没这么做,因为 job 状态回答的是另一个问题:它只说明 workflow 退出码是 0,不说明健康检查过了。工具完全可能检查没过但退出码是 0,workflow 也可能在工具成功之后被人取消。
callback 带回来的是工具自己产出的结果,那才是状态机需要的东西。