CIゲートの最適化
CIを高速化するときの受け入れプロトコル -- どの時計を対象にするのか、キープか差し戻しかを何本のサンプルで決めるのか、そして速くなったゲートが依然としてまったく同じテストを走らせていることをどう証明するのか。
このページが埋める空白
CIゲートを速くするための部品となるルールは、このガイドの4つのページにすでに分散して書かれています。実行ティアは、ゲートが照らし合わせられる正典としてのT1予算を持ちます。CIランナーのサイジングは計測を持ち、それをジョブレベルで取ることを要求します。スイート全体の予算は、所要時間はテスト数の隣でしか読めないというルールを持ちます。cargo-nextest § 移行後は、Rustスイートにおける直列化の分割とリトライの証拠を持ちます。
しかしそのどれもが、これらのルールが仕えているはずの受け入れ契約を持っていません。すなわち、テストする内容を変えずに、ゲートを速くするという契約です。4ページに分かれたままでは、これらのルールは4つの穴を残します。最適化はその穴をまっすぐ通り抜けて、より速くグリーンで終わりながら、そのどれも証明していない、ということが起こり得ます。改善した数字は、誰も待っていない数字かもしれません。改善は、たまたま運の良かったホスト実行1本かもしれません。スイートは、あるテストを静かに落として別のテストを拾っただけかもしれません。そして高速化の一部を、パスオンリトライが帳簿の外で肩代わりしているかもしれません。
このページは、その4つの穴を塞ぐプロトコルです — 8つのステップからなり、それぞれが新しいことだけを述べて、すでに存在するメカニズムを指し示します。本文の実測値は、このサイト自身が動いている Rust 製フロントエンドビルドオーケストレーター zudo-front-builder(zfb)のPRゲート最適化(Takazudo/zudo-front-builder#2701)から取ったもので、どれも属する時計でラベル付けされています。それがステップ1だからです。
ステップ1:時計を最初に定義する
「ゲートが速くなった」は、1つの文をまとった3つの異なる主張であり、それぞれ独立に動きます。
| 時計 | 何を測るか | 改善すると何が得られるか |
|---|---|---|
| 必須のクリティカルパスジョブ | そのジョブ自身の startedAt → completedAt | マージのレイテンシ — PRが実際に待っているチェック |
| ゲート全体のウォールクロック | ワークフロー全体で、最初のジョブの開始から最後のジョブの終了まで | オプショナルな依存ジョブも含めた、実行の終わり |
| 課金されるランナー分数 | その実行の全ジョブにわたる実行分数の合計 | 請求額。それ以外の何物でもない |
3つはありふれたトポロジーで簡単に乖離します。必須ジョブに依存するオプショナルなアーティファクト/ブラウザの消費側ジョブはその後に終わるので、マージがすでに解放された後も、ウォールクロックは何分も回り続けます — 必須ジョブの改善はウォールクロックをほとんど動かしませんが、それでも本物のレイテンシの勝ちです。逆方向では、オプショナルなマトリクスをパスでゲートすれば、課金分数は減る一方で、2つのレイテンシの数字はどちらもまったく動きません。どのジョブが必須かも、意見の問題ではありません。それは強制レイヤーがブロックする対象そのものであり、実行ティア § ゲートを宣言ではなく強制にするが、それを推測ではなくAPIから読み出す方法です。
時計に名前を付けることは、どのレバーが土俵に上がれるのかも決めます。レイテンシを狙うなら大きなランナーが選択肢に入ります(ルール1)。コストを狙うなら入りません。そちらはトリガー設計に応えます(ルール4)。対象の時計を計画の1行目で宣言し、それ以降のすべての数字に、どの時計から来たのかをラベル付けしてください。
ステップ2:ジョブレベルとステップレベルでベースラインを取る
最初の変更を入れる前に、後から読む人が再導出できるベースラインを記録します。つまり、数字とその出典 — どの実行ID、どのタイムスタンプのフィールドの組、そして波ごとに何サンプルか — をセットで残す、ということです。そのコマンドと、なぜ gh run list ではないのかは実行リストではなく、jobsエンドポイントを読むに、なぜ出典が数字と一緒に旅をしなければならないのかは出典をピン留めしていないタイミングの主張は、静かに劣化するにあります。最適化とはタイミングの主張の連鎖なので、その失敗モードを最後だけでなく、すべての環で受け継ぎます。
ジョブより1つ下、波が実際に着地するステップまで降りてください。セットアップ、インストール、前処理のステップは固定コストが隠れる場所であり、ジョブレベルの数字では40秒のセットアップ削減と40秒のテスト削減を区別できません — そしてそれこそが、ステップ3が何かを帰属させるために必要な区別です。
ベースラインには2つの境界があります。照らし合わせる予算は5つのティアにあるT1のもの — 実験のために発明した数字ではなく、ゲート自身の目標です。そしてベースラインはホスト実行でなければなりません。ローカル実行はT4の領分、強制ではなく利便性であり、ゲートが実際に走る際の競合をまったく背負っていないマシンでの計測だからです。
ステップ3:帰属できる波を1つずつ進める
変更は波に分けて出し、1つの波には帰属可能な変更クラスを1つだけ載せ、波の前に予測を、波の後に計測を記録します。予測は儀式ではありません — 予測に届かなかった変更を可視化できる、唯一の手段です。
実測のゲートでは、それがすぐに効きました。目玉の勝ちになるはずだったある畳み込みは、必須ジョブの時計で正味約19秒しか返しませんでした — 本物ではあるものの、予測をはるかに下回ります — 一方で、誰も重要だと目していなかったガード付きの前処理の掃除が、決定的な固定コスト削減を生みました。ベースライン対最終だけの報告なら、この2つを等しく評価してしまい、次のプロジェクトは間違った変更をコピーしていたはずです。
つまり、帰属できない波は、計測されていない波です。 5つの変更を1回の実行に束ねて買えるのは、その後のどんな判断にも使えない1つの数字だけです。そしてそれを避けるコストはゼロです。ステップ4が要求するサンプルは、どのみち波ごとに取るものだからです。
ステップ4:十分なサンプルを取り、どの統計量で決めたかを述べる
ホスト実行は、キュー待ちを取り除いた後でも実測でばらつきます — シグナルを分けることこそタイミングの振り返りの眼目です — したがってホスト実行1本では並行度やトポロジーの変更を決められませんし、ローカル実行1本はそもそも土俵にすら上がりません。
Warning
レイテンシの主張は、同一SHAのホスト実行を3本以上取った中央値です。 同じSHA、同じランナー形状、同じ選択テスト数、片側につき1変更。中央値は、ジョブの典型的な所要時間についての主張に対する正しい統計量であり、キープか差し戻しかを決めるのはこの値です。
テールについての主張は別の主張であり、別の統計量を取ります。 fullyParallel のフリップが5回以上の試行の最大値を比較するのは、PRの作者が実際に待つのは最も遅い試行だからです。どちらのルールも他方を置き換えません — 自分が書こうとしている文に合うほうを選んでください。
アーティファクトは勝った側だけでなく、すべてのサンプル分を保持してください。負けたサンプルこそ、そこにリトライが現れた瞬間にステップ6が必要とするものです。そしてサンプルが何であるかを忘れないでください。サンプルは起きたことを記述するだけで、上界を確立することは決してありません — タイミングのサンプルはテールの証明ではないは、締め切りが自分の忍耐ではなくプロダクトのものであるときのための、より厳しい版のこのルールです。
取り逃しやすいサンプルが1つあります。パイプラインが同じコミットで同じスイートをすでに2回走らせているとき — 必須チェックと同等の兄弟レッグ、あるいは再トリガーされたワークフロー — その対は、すでに代金を払い終えたツインレッグ比較です。もう1本買う前に、それを読んでください。
ステップ5:テスト数ではなく、正確なテストアイデンティティを証明する
数は衝突します。あるテストを落として別のテストを拾う選択の変更は、合計を手つかずのまま残すので、「前が63、後も63」は必要条件にすぎず、それ単体では何も証明しません。受け入れ条件はもっと厳しくなります。同数であること、かつ正規化したアイデンティティ一覧の差分が0バイトであること。その差分をアーティファクトとして保存し、ソート済み一覧のSHA-256を主張の隣にピン留めすること。 タイトルではなくアイデンティティです — 2つのスペックはタイトルを共有し得るのであり、まさにそれがクロスランのトリアージがアイデンティティをキーにする理由です。
# Prove the selected test set is unchanged. Equal COUNT is not enough: a dropped
# identity replaced by a new one keeps the total green. Canonicalize identities,
# diff both directions, and pin the sorted list's hash next to the claim.
set -euo pipefail
# Replace with the suite's own list-only command (cargo nextest list,
# playwright test --list, vitest list), run once per gate lane with that
# lane's own selection flags. Emit identity, never a bare title: nextest
# scopes test names to their binary and the same name recurs across
# binaries, so the identity is "<binary-id>::<test name>" -- keys[] alone
# lets sort -u collapse a test dropped from one binary into its namesake.
list_identities() {
cargo nextest list --workspace --message-format json \
| jq -r '."rust-suites" | to_entries[] | .key as $bin | .value.testcases | keys[] | "\($bin)::\(.)"' \
| sort -u
}
git checkout "$BASELINE_SHA" && list_identities > before.txt
git checkout "$CANDIDATE_SHA" && list_identities > after.txt
wc -l < before.txt; wc -l < after.txt # equal count: necessary, not sufficient
sha256sum before.txt after.txt # pin both digests beside the claim
diff before.txt after.txt > identity.diff # keep the artifact; set -e fails on any diffこの差分は双方向の集合一致であり、無視テストマニフェストのパリティとまったく同じ形です。現れたテストと消えたテストは、まったく同じように失敗しなければならず、さもなければチェックは片方向しか守っていません。Rustスイートなら、インベントリ側はすでに機械化されています — 移行チェックリストに従って突き合わせ、メンバーシップのガードでグループ所属を正直に保ってください。それが、新しく追加されたバイナリが、分割によって作られた直列化グループから静かに抜け落ちるのを止めるものです。
一覧が証明するのは、何が選択されたかです。実際に実行されたものは、その実行自身のJUnitから確認してください。実測のゲートでは、env-gateテストが前も後も63件で、どちらも同じ 45 / 2 / 16 のレーン分割になりました。数と所要時間を対にすることは、予算ドリフトのトリガーがすでに毎回の実行に要求している規律です — ここではその同じ対を、トレンドではなく前後のペアに向けているだけです。
ステップ6:失敗、リトライ、そしてリトライのコストを監査する
グリーンという結論が答えているのは「走ったものは通ったか?」です。「その実行はいくらかかったか?」には答えておらず、高速化の最中には、この2つは同じ問いです。パスオンリトライは成功ではなくトリアージのシグナルであり、リトライされた試行の時間は、あなたが削減したと主張している実行コストの一部です。
だから監査は、チェックの結論ではなくアーティファクトを読みます。Playwrightスイートなら、リトライパスのシグナルとその構造的なフォールバックはリトライパスの検知にあります。Rustスイートなら、リトライのコストを担う4つのJUnit要素と、その上のリトライ税の式は実際のnextest JUnitの形からリトライに値札を付けるにあります。リトライ予算そのものは、すべてのサンプルで固定してください。リトライ予算を消費して実測時間を買った波は、何も買っていません。
監査の範囲は数で述べてください。そうすれば読み手は、本物の監査とサマリーページの一瞥とを区別できます。実測のゲートの監査は、25個のJUnitファイルに含まれる50,301件の <testcase> レコードを対象とし、そのうちflaky・failure・errorのレコードはゼロでした — これは同時に、その正直な但し書きでもあります。健全なスイートはリトライの式をどれも走らせないからです。監査が証明したのは不在であって、パーサーがリトライを正しく値付けできることではありません。
インシデント対応から引き継ぐ順序のルールが1つあります。どんな再実行よりも先に、アーティファクトを保全すること。 ゲートオペレーターのプレイブックがこれをルールにしているのは、再実行がまさにその事象を読み解けるものにしていた証拠を上書きしたり失効させたりするからです — そして実験の中では、その証拠は同時にその波の計測結果でもあります。
ステップ7:波を正直に報告し、すべての勝ちにラベルを付ける
報告のルールは2つ。どちらも、きれいな物語に抗うためのものです。
予測に届かなかった波を、その予測とともに報告すること。 上の約19秒の畳み込みは、実際に結果を担った前処理の掃除の隣に、報告として並ぶべきものです。勝ちだけの報告は再利用できません。次の読み手には、どの変更が作るのに高くついて効果が薄かったのかが見えないからです。
それぞれの勝ちに、レイテンシ・コストのみ・その両方のいずれかのラベルを付けること。 無関係なPRからオプショナルなマトリクスをパスで外すのはコストのみの勝ちです。課金分数は下がり、マージのレイテンシは動かず、それを「ゲートが速くなった」と呼べば報告全体が読めなくなります。コストのみの勝ちのレバーはトリガー設計です — ランナーのレベルではルール4、ブランチのレベルでは重いCIをベースブランチでゲートするです。
ステップ8:最終受け入れはゲート全体を走らせ直す
最後の波のサンプルは、受け入れではありません。最終SHAでゲート全体を — 必須もオプショナルも、すべてのジョブを — 走らせ直してください。波は、個々のサンプルには決して現れない形で合成されますし、どの波の実行も最終的なツリーを含んではいなかったからです。
そのうえで、マージボタンの向こう側にある半分をアサートしてください。ワークフローが1つも始まらなかったせいでグリーンに見えるマージは、存在する中で最も広い偽のグリーンであり、それを排除するワークフローごとの上限付きポーリングがマージが実際にワークフローを走らせたか検証するです。トリガー、paths フィルタ、ジョブの依存関係に触れたゲート最適化は、まさにそのマージ後の実行が存在するかどうかを決めるサーフェスを編集してきたことになります。
Warning
移送先のレーンがグリーンで走るより先に、移送元のレーンを痩せさせてはいけません。 移行ルールはティア間を移動するテストのために書かれていますが、必須ジョブからオプショナルまたはスケジュールのレーンへ作業を移すゲート最適化は、パフォーマンスという口実をまとった同じ移動です。作業を速いレーンから取り除くのは、それを引き継ぐレーンが少なくとも一度グリーンで走らせた後にしてください。
実測の結果
| 時計 | ベースライン | 最終 |
|---|---|---|
必須の health ジョブ | 24m23s | 18m30s |
| ゲート全体のウォールクロック | 25m40s | 19m42s |
どちらの行も、zudo-front-builder のPRゲート最適化(Takazudo/zudo-front-builder#2701)の全波にわたる、ベースラインから最終までの値です。最終実行までの計測サンプル全体では、25個のJUnitファイル、50,301件の <testcase> レコード、リトライ/failure/errorのレコードはゼロ、関連パスのテストインベントリは不変でした。
Note
cargo-nextest § 移行後は同じゲートから 22m22s → 18m54s を引用していますが、2つは矛盾していません。あちらの数字は、直列化の分割実験だけについて同一SHAのサンプル3本を取った、必須の health ジョブの中央値です — 1つの波を、その波自身の始点と終点で測ったものです。上の 24m23s → 18m30s の行は、同じジョブの時計をすべての波にわたって、最初のベースラインから最終SHAまで測ったものです。同じ時計、違う区間。2つのページが1つのゲートを引用するときは、時計と区間の両方にラベルを付けてください。さもないと、小さいほうの数字が大きいほうへの反論として読まれてしまいます。
まとめ
| 症状 | 誤った直感 | 正しいレバー |
|---|---|---|
| 「ゲートが遅い」 | gh run list が出す数字を最適化する | まず時計に名前を付ける(ステップ1) — 必須ジョブ、ゲート全体のウォールクロック、課金分数は3つの主張であり、ワークフローのタイムスタンプはそのすべてにキュー待ちを混ぜ込む |
| ホスト実行が1本、速く返ってきた | それで出す | 同一SHAのホスト実行3本以上の中央値(ステップ4)。1本のサンプルは、判断の衣をまとったノイズ |
| オプショナルな消費側ジョブが、必須ジョブの数分後にまだ終わっている | 実は改善していない | 必須ジョブの時計では改善している — ウォールクロックは別の主張なので、混ぜずに両方を報告する(ステップ1) |
| 5つの変更を出して計測は1回、ゲートは速くなった | 5つすべてを評価する | 帰属できる波を1つずつ。予測を前に記録し、後で計測する(ステップ3) |
| テスト数が前後で同一 | カバレッジ不変が証明された | 正規化アイデンティティの0バイト差分+SHA-256のピン留め、そしてJUnitによる分割の確認(ステップ5) |
| 最終チェックがグリーン | 実行コストはジョブの所要時間のとおり | アーティファクト内のレポーター固有のリトライ記録を監査し、リトライ予算をサンプル間で固定する(ステップ6) |
| 問題は請求額で、レイテンシは足りている | ランナーをダウンサイズする | トリガー設計 — ベースブランチのゲートとスケジュール — をコストのみの勝ちとして明示的に報告する(ステップ7、ルール4) |
| 必須ジョブが速くなった | 成功を宣言する | 最終SHAでゲート全体を走らせ直し、そのうえでマージ後のpushワークフローが実際に走ったことをアサートする(ステップ8) |
一貫する筋道:ここでのすべてのステップは、数字を「他人が検証できる主張」に変えている。 名前のない時計、統計量のないサンプル、アイデンティティ一覧のない件数、アーティファクト監査のないグリーンチェックは、どれも同じ欠陥です — 間違いようがなく、したがって正しくもなり得ない結果です。
次に読むページ
CIランナーのサイジング § タイミングの振り返りはジョブレベルのタイムスタンプを読む — ここでのすべてのステップが立脚する計測と、手軽なコマンドが間違っている理由。
実行ティア — ゲートが最適化の対象とするT1予算、サンプル間で固定するリトライ予算、そしてステップ8が組み合わせる移行ルール。
スイート全体の予算 § 予算ドリフトのトリガー — 毎回の実行で所要時間の隣に件数を出すこと、そして予算を超えたときの手当ての順序。
cargo-nextest § 移行後:証拠を失わずに最適化する — このプロトコルの1つの波をRustスイートで端から端まで実践した記録と、ステップ6の下敷きとなるJUnitのリトライの形。
CI スキップマーカーとマージ衛生 § マージが実際にワークフローを走らせたか検証する — ステップ8のマージ後のアサーションと、それを捕まえるために存在する偽のグリーン。
フレイク根本原因カタログ & デフレイキングレシピ — このプロトコルが借りている、テストアイデンティティ、ツインレッグ比較、そして再実行前のアーティファクト保全のルール。
フェイルクローズなライブ証明と証拠契約 § タイミングのサンプルはテールの証明ではない — 締め切りが自分の忍耐ではなくプロダクトのものであるとき、サンプルの代わりになるもの。
リリースラウンド § 重いCIをベースブランチでゲートする — ステップ7がラベル付けを求める、コストのみのレバーのブランチレベルでの形。
最適化の範囲
同じスイートの高速化では、件数だけでなくcanonical test identitiesと実環境の証拠を維持します。置換・統合には保証の対応、頻度低下・退役にはライフサイクルの検知時期・リスク判断が必要です。AIスイート精査は選択肢を比較し、課金実行量・待ち時間・概算を区別します。変更範囲に応じた検証は選択と結果再利用を扱います。一時的なredを許容する開発でマージ待ちを外しても、それだけでrunner費用は減りません。