摘要

  • 三十天里,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 次。

截至 9 月 22 日的 30 天内每次尝试的发布耗时:大多数成功发布落在五到八分钟之间,失败分散在一天中的各个时段和各种耗时上,每周中位数曲线保持平稳 每次尝试一个标记。圆圈是成功的发布,叉是失败的尝试,曲线是每周中位数。

阶段 中位数
本地关卡 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。