每次部署都要从源码编译六个象棋引擎
摘要
- 三十天里,mistboard 一次发布的中位耗时为 5 分 31 秒,479 次尝试中有 148 次在到达生产环境前失败。
- 每次推送,部署都会从源码编译六个象棋引擎:每次构建要花两分半钟,而这些代码一年只改动几次。
- 现在由一个 GitHub Actions 工作流只构建一次,并以其固定版本与补丁的哈希发布。部署只需 19 秒即可拉取并校验。
- 从推送到上线由 5 分 36 秒降到 2 分 19 秒,托管 CI 从 215 秒降到 132 秒,因第二次推送而中断的发布从每月 11 次降到 0。
mistboard 只从一个分支发布。哪个 Claude Code 会话先完成,就由它推送 main,而每次推送都是一次生产发布:本地测试关卡、托管 CI、Railway 部署、针对线上站点的冒烟测试。9 月 22 日这一天就发生了 35 次。
问题:一次发布六分钟,三次里有一次失败
每次发布都会为每个阶段打印一行耗时,每个会话的记录都会保留这些行。解析截至 9 月 22 日的 30 天记录,共得到 479 次发布尝试,每天 16 次。
每次尝试一个标记。圆圈是成功的发布,叉是失败的尝试,曲线是每周中位数。
| 阶段 | 中位数 |
|---|---|
| 本地关卡 | 64 秒 |
| 托管 CI | 约 3.5 分钟 |
| CI 之后,等待生产环境提供该提交 | 11 秒,p90 52 秒,最差 35 分钟 |
| 冒烟测试 | 25 秒 |
| 整次发布 | 5 分 31 秒,p90 7 分 48 秒 |
479 次尝试中有 148 次在到达生产环境前失败,失败原因和耗时一样说明问题:
| 尝试失败的原因 | 次数 |
|---|---|
| 本地关卡:格式错误或测试不通过,在 60 到 90 秒时发现 | 41 |
推送被拒绝,因为关卡运行期间 main 已前移 |
30 |
| 托管 CI 红灯 | 17 |
| 第二次推送取消了 CI 运行,发布因此没有结论 | 11 |
| 线上对局的清空操作失败或缺少令牌 | 10 |
| 一项生产冒烟测试 | 4 |
| 输出在给出结论前被截断 | 29 |
CI 之后那 11 秒的等待就是线索。它说明在空闲时段,部署与 CI 差不多同时结束;而 p90 的 52 秒和最差 35 分钟说明,在繁忙的下午,部署是在 CI 之后才结束的。两份 Railway 构建日志道出了原因:
fairy-stockfish-xiangqi fetch + make 12 s
fairy-stockfish-duck apply patch + make 13 s
fairy-stockfish-atomic apply patch + make 11 s
stockfish make, downloads nets 25 s
pikafish-jieqi make 13 s
pikafish make 40 s
npm run build 28 s
服务端运行六个引擎二进制文件,每个都由一个 .ref 文件锁定到某个提交,而构建步骤在每次部署时都会编译全部六个:在 5 分 36 秒的部署里占了两分半钟,而这些代码一年只改动几次。这一层从未命中缓存,因为构建步骤在拷贝源码之后运行,而每次推送都会改动源码。
解决办法:引擎只构建一次,按哈希拉取
当某个固定版本或补丁变化时,一个 GitHub Actions 工作流会编译这六个二进制文件,并按原先部署时的方式逐一校验(Fairy-Stockfish 对 uci 返回变体列表;每个打过补丁的构建在三个局面上与对局内核的合法着法数一致),然后作为 engines-<hash> 发布。哈希涵盖固定版本、补丁和一个配方版本号:
recipe_hash() {
{
echo "recipe-version=$RECIPE_VERSION"
echo "arch=$ARCH"
for input in $RECIPE_INPUTS; do
case "$input" in
*.ref) echo "$input=$(head -1 "$ROOT/$input" | tr -d '[:space:]')" ;;
*) echo "$input=$(sha256sum "$ROOT/$input" | cut -c1-64)" ;;
esac
done
} | sha256sum | cut -c1-12
}
部署会计算同样的哈希,下载对应的发布,校验 SHA256SUMS,并运行同样的校验步骤。没有任何东西解析为「latest」:若某个固定版本被升级但尚未发布,部署会失败并指出它想要的标签。这些二进制文件是静态链接的,因此运行器的 libc 与镜像的无需一致。有一项测试确保工作流的触发路径、Railway 的监听模式和部署步骤与配方的输入保持同步。
同一份剖析还带来了四项较小的修复:
- 被另一次推送超越的发布会跟上它。 当
main上有更新的提交包含了本次推送的提交,并且有自己的 CI 运行时,发布会等待那次运行并对那次部署做冒烟测试,而不是失败后把一切重跑一遍。 - Biome 在提交时对暂存文件运行。 此前的 pre-commit 钩子只跑类型检查;仅 9 月 22 日一天就有三次纯格式化提交,每次都带来一次推送、一次 CI 运行和一次部署。
- 推送关卡只运行改动涉及的网页测试,通过
vitest --changed实现:改动一个叶子文件只跑 6 个测试、4 秒,而不是 3,358 个。托管 CI 仍然全跑。 - CI 作业按实测数据划分规模。 一项遍历 280 万个局面的一致性测试在对局测试套件中占了 34 秒,已被拆成独立作业。网页、服务端和 Postgres 测试套件做了分片,使每个作业耗时落在 64 到 98 秒之间;发布只要必需作业变绿就算通过,而不必等到本次运行的汇报作业结束。
结果
| 九月 | 现在 | |
|---|---|---|
| 部署,从推送到上线 | 空闲时 4 分 18 秒,通常 5 分 36 秒 | 2 分 19 秒 |
| 构建中的引擎步骤 | 约 150 秒的编译 | 19 秒拉取并校验 |
| 托管 CI 运行 | 175 到 215 秒 | 132 秒 |
| 本地关卡,仅网页改动 | 64 秒 | 不到 20 秒 |
| 因第二次推送而中断的发布 | 每月 11 次 | 0 |
如今一次仅涉及网页的发布,在本地关卡约花 20 秒,在 CI 里花两分半钟(部署在此期间同时完成),冒烟测试 25 秒。引擎构建只跑了一次,在运行器上耗时 153 秒;新路径上的首次部署下载了 218 MB,并在 19 秒内校验完全部六个二进制文件。
还剩下什么
GitHub 的运行器排队,在我观察的那几次运行中每个作业 0 到 40 秒;以及 Railway 的固定开销:镜像准备、网页构建、推送和启动镜像。这两项都不是我能缩短的。
本地关卡是在负载沉重的机器上运行的那一关。一台笔记本上跑着八个会话、平均负载超过 100 时,有两项测试只在那里失败:一项只给假引擎 200 毫秒启动时间,另一项为每个更新日志链接启动一个 git 进程,共 137 个。这两项在空闲机器上已经通过了好几周。现在它们的预算是按繁忙状态设定的。
148 次失败中有 30 次是推送被拒,因为关卡运行期间 main 前移了。20 秒的关卡缩小了这个窗口,却没有关闭它;一个队列可以做到,而那是下一件该去测量、而非直接去建的事。
这篇文章里的每个数字,会话记录里其实早就有了,就是 peers 工具读取的那批文件,用来了解谁在做什么。现在每次发布都会自己写一条记录,一行 JSON,包含每个阶段的耗时和失败原因,npm run release:profile 会打印中位数、失败表格和上面那张图。我会在十月底再跑一次,然后把带有下降的图贴出来,或者贴一张没有下降的。配方、工作流和发布脚本都在 brianhliou/mistboard:scripts/engine-assets.sh、.github/workflows/build-engines.yml、scripts/release-prod.mjs。