要点

  • 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回だった。

9月22日までの30日間、試行ごとのリリース所要時間。成功したリリースの大半は5分から8分に収まり、失敗はあらゆる所要時間で一日を通して散らばり、週次の中央値の線は横ばいのまま 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だ。