デプロイのたびにチェスエンジンを6本コンパイルしていた
要点
- 30日間でmistboardのリリースは中央値5分31秒かかり、479回の試行のうち148回が本番に届く前に失敗した。
- デプロイはプッシュごとにチェスエンジン6本をソースからコンパイルしていた。年に数回しか変わらないコードのために、毎回のビルドのうち2分半を費やしていた。
- 現在はGitHub Actionsのワークフローが一度だけビルドし、ピンとパッチのハッシュを付けて公開する。デプロイは19秒で取得と検証を済ませる。
- プッシュから公開までは5分36秒から2分19秒に、ホスト型CIは215秒から132秒になり、2度目のプッシュで潰されたリリースは月11件からゼロになった。
mistboardは1本のブランチから出荷している。最初に終わったClaude Codeのセッションがmainにプッシュし、そのプッシュがすべて本番リリースになる。ローカルのテストゲート、ホスト型CI、Railwayのデプロイ、公開サイトに対するスモークテストだ。9月22日はそれが35回起きた。
問題: 6分かかるリリース、3回に1回の失敗
リリースはステージごとに1行の計測結果を出力し、各セッションのトランスクリプトにそれが残る。9月22日までの30日間のトランスクリプトを解析すると、リリース試行は479回、1日16回だった。
1試行につき1つの印。丸は成功したリリース、×は失敗した試行、線は週次の中央値。
| ステージ | 中央値 |
|---|---|
| ローカルゲート | 64秒 |
| ホスト型CI | 約3.5分 |
| CI後、本番がそのコミットを配信するまでの待ち | 11秒、p90は52秒、最悪35分 |
| スモークテスト | 25秒 |
| リリース全体 | 5分31秒、p90は7分48秒 |
479回の試行のうち148回は本番に届く前に失敗しており、その理由は時間と同じくらい多くを物語っている。
| 試行が止まった理由 | 試行数 |
|---|---|
| ローカルゲート: フォーマットエラーかテスト、60〜90秒の時点で判明 | 41 |
ゲート中にmainが動いたため、プッシュが拒否された |
30 |
| ホスト型CIが失敗 | 17 |
| 2度目のプッシュがCI実行をキャンセルし、リリースの判定が出なかった | 11 |
| 進行中の対局のドレインが失敗、またはトークンがなかった | 10 |
| 本番のスモークテスト | 4 |
| 理由を出力する前に打ち切られた | 29 |
CI後の11秒の待ちが手がかりだ。これは、閑散な時間帯にはデプロイがCIとほぼ同時に終わっていたことを意味し、p90の52秒と最悪35分は、混み合った午後にはCIより後に終わっていたことを意味する。Railwayのビルドログ2本がその理由を示していた。
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
サーバーは6本のエンジンバイナリを動かしており、それぞれ.refファイルでコミットにピン留めされている。そしてビルドステップはデプロイごとに6本すべてをコンパイルしていた。年に数回しか変わらないコードのために、5分36秒のデプロイのうち2分半である。ビルドステップがソースのコピーの後に走り、プッシュごとにソースが変わるため、レイヤーは決してキャッシュされなかった。
解決策: エンジンを一度だけビルドし、ハッシュで取得する
GitHub Actionsのワークフローが、ピンやパッチが変わったときに6本のバイナリをコンパイルし、以前デプロイがやっていたのと同じ方法でそれぞれを検証し(Fairy-Stockfishはuciにバリアント一覧で応答し、パッチ済みの各ビルドは3つの局面でゲームカーネルの合法手数と一致する)、リリース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とイメージのlibcは一致しなくてよい。1つのテストが、ワークフローのトリガーパス、Railwayの監視パターン、デプロイステップをレシピの入力と揃った状態に保つ。
同じプロファイルから、より小さな修正が4つ出てきた。
- 別のプッシュに追い越されたリリースは、そのプッシュに追従する。
main上の新しいコミットがプッシュ済みのコミットを含み、それ自身のCI実行を持っている場合、リリースは失敗して全部やり直すのではなく、その実行を待ち、そのデプロイにスモークテストをかける。 - Biomeはコミット時にステージ済みファイルに対して走る。 pre-commitフックは型チェッカーだけを走らせていた。9月22日だけでフォーマットのみのコミットが3件あり、それぞれがプッシュ、CI実行、デプロイを引き起こしていた。
- プッシュゲートは変更が到達するウェブのテストだけを走らせる。
vitest --changedを使い、末端の編集なら3,358件ではなく4秒で6件のテストになる。ホスト型CIは引き続きすべてを走らせる。 - CIジョブは計測に基づいてサイズを決める。 280万局面を歩く一致性テスト1件がゲームスイートの34秒を占めていたので、専用ジョブに移した。ウェブ、サーバー、Postgresのスイートはシャーディングし、どのジョブも64秒から98秒に収まるようにした。またリリースは、実行のレポート用ジョブが終わるのを待たず、必須ジョブがグリーンになった時点で通過する。
結果
| 9月 | 現在 | |
|---|---|---|
| デプロイ、プッシュから公開まで | 閑散時4分18秒、通常5分36秒 | 2分19秒 |
| ビルド中のエンジンステップ | コンパイルに約150秒 | 取得と検証で19秒 |
| ホスト型CIの実行 | 175〜215秒 | 132秒 |
| ローカルゲート、ウェブのみの変更 | 64秒 | 20秒未満 |
| 2度目のプッシュで潰されたリリース | 月11件 | 0 |
ウェブのみのリリースは今、ローカルゲートに約20秒、CIに2分半(その裏でデプロイが終わる)、スモークテストに25秒を費やす。エンジンのビルドはランナー上で一度だけ153秒走り、新しい経路での最初のデプロイは218 MBをダウンロードし、6本のバイナリすべてを19秒で検証した。
残っているもの
GitHubのランナーのキュー待ち(観察した実行ではジョブあたり0〜40秒)と、Railwayの固定コスト(イメージのセットアップ、ウェブのビルド、イメージのプッシュと起動)。どちらも私が短縮できるものではない。
ローカルゲートは、負荷の高いマシン上で走るゲートだ。1台のラップトップに8セッション、ロードアベレージが100を超える状況で、そこだけで失敗したテストが2つあった。1つは偽のエンジンに起動用として200 msしか与えていないもの、もう1つはチェンジログのリンクごとにgitプロセスを立ち上げるもので、その数は137だった。どちらもアイドルなマシンでは何週間も通過していた。今はその予算を混み合った状態に合わせてある。
148件の失敗のうち30件は、ゲート実行中にmainが動いたためプッシュが拒否されたものだ。20秒のゲートはその窓を狭めるが、閉じはしない。キューなら閉じられるが、それは作る前にまず計測すべき次の項目だ。
この記事の数値はすべて、すでにトランスクリプトの中にあった。peersツールが誰が何をしているかを読み取るのと同じファイルだ。今は各リリースが自分の記録を書くようにしてあり、各ステージの所要時間と失敗理由を1行のJSONで残す。そしてnpm run release:profileが中央値、失敗の表、上のチャートを出力する。10月末にもう一度走らせ、落ち込みの入ったチャートを、あるいは入っていないチャートを載せるつもりだ。レシピ、ワークフロー、リリーススクリプトはbrianhliou/mistboardにある。scripts/engine-assets.sh、.github/workflows/build-engines.yml、scripts/release-prod.mjsだ。