[Version 1] 手写的模型发布流水线

新的模型配方进仓库,发布自己就开始了。一个引擎持有状态机和流量 API,一个 CI job 负责一步、用 SSH 操作机器。每一个前进步骤都配一个回退步骤。

Posted by Jessie Jia on 2026-10-08

这是我们模型发布流水线的第一版。没有编排框架,没有 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
2
3
4
5
6
7
8
9
10
11
12
ONE job on ONE runner
├─ 初始化结果 (not_run) ← 先把 $RESULT_DIR/status 写上
├─ 校验 payload,解析工具 ← 白名单:名字 → 文件
├─ checkout infra-bootstrap / perf-tuning / ci-pipeline
├─ 确认工具文件真的存在
├─ 校验 callback 凭证 ← 在动任何东西之前
├─ 下发 SSH key
├─ 装隧道客户端
├─ 执行工具 ← attach_queue_lane.py → deploy.py
│ 写出 $RESULT_DIR/{status,overall,checks.json,log}
├─ 回报结果 (callback) ← 读这几个文件,POST 回去
└─ 清理 ← 删掉 SSH key

其中三行是踩过坑才加上的。

第一步就把 status 写成 not_run。 这样后面任何环节挂掉,结果文件已经存在、写着”根本没跑”,而不是文件不存在。从引擎那边看,”文件缺失”和”执行失败”长得一模一样,但它们是两回事。

动任何东西之前先校验 callback 凭证。 否则就会出现:SSH 进生产机器、改完状态、然后发现自己没法把做了什么报回去。便宜的检查要先做。

工具必须走白名单解析。 payload 是 dispatch 带来的,里面写着工具名。如果拿这个名字去拼路径,那么任何能发 dispatch 的人都能执行仓库里的任意脚本。白名单把一组固定的名字映射到一组固定的文件,名字不在表里就直接失败。

机器那一侧,从头到尾

一个”把这条 lane 挂到新引擎上”的步骤,最终会走到这里:

1
2
3
4
release-step.yml
→ scripts/release/attach_queue_lane.py
→ ssh 到机器
→ deploy.py (拉镜像、起容器、健康检查)

工具只写四个文件,调用方也只关心这四个:

  • status:一个词,机器可读的结果
  • overall:整步的通过与否
  • checks.json:逐项检查的细节,失败时看的就是它
  • log:凌晨两点拿来 grep 的原始日志

callback 那一步读这四个文件、POST 回去,引擎把 checks.json 原样存下来。这样排查用的材料能活过 runner——runner 几分钟后就没了。

为什么用 callback 而不是轮询

引擎当然可以去轮询 CI 的 job 状态。我们没这么做,因为 job 状态回答的是另一个问题:它只说明 workflow 退出码是 0,不说明健康检查过了。工具完全可能检查没过但退出码是 0,workflow 也可能在工具成功之后被人取消。

callback 带回来的是工具自己产出的结果,那才是状态机需要的东西。