HN 日本語サマリー

← 一覧へ戻る
インフラ・DevOps

AIコーディングがCIをボトルネックにしたため、LinearはCIを再構築した

AI coding has made CI a bottleneck, so we reworked ours to keep up (linear.app)

298 pointsby julian_digital365 コメント

要約

AIによるコード生成の加速により、継続的インテグレーション(CI)の処理時間がボトルネックとなり、インフラコストの増加と開発者フィードバックの遅延を招いていました。Linearは、インフラのアップグレード、ジョブの最適化、セットアップの効率化、テスト実行の改善などを通じて、CIのパフォーマンスを大幅に向上させました。

全文翻訳

AIコーディングがCIのボトルネックになったため、追いつくように再構築した

今年の初め、LinearのCTOであるTuomasから「CIコストが高い」というタイトルの課題を私に割り当てられたとき、私はLinearを開きました。ついでに、CIをより速くしてほしいとも彼は望んでいました。

エージェントはコードの出荷を指数関数的に速くしましたが、それらの変更の検証は同じ速度で追いついていません。すべてのPRは依然としてCIを通過する必要があるため、開発が加速するにつれて、CIはボトルネックとなり、インフラコストを押し上げ、開発者やエージェントをフィードバック待ちで長く待たせることになります。

LinearでCIのパフォーマンスを向上させる追求において、私たちはPRがCIで待つ時間と消費するランナー時間を最適化しました。今年の初めからテストスイートはほぼ4倍になったにもかかわらず、プルリクエストの待ち時間を6分以上から5分強に短縮し、テストあたりのランナー時間を約半分に削減しました。

[画像: 1月から9月までのテストカバレッジの増加(青線)とマシン時間の削減(白線)を示すパフォーマンス指標チャート。]

[画像: 1月から9月までのテストカバレッジの増加(青線)とマシン時間の削減(白線)を示すパフォーマンス指標チャート。]

これは1月第1週を基準としたテストスイートのパフォーマンスです。マシン時間あたりのテストを追跡する白い線は、テストシャーディングを追加して待ち時間を短縮し、より多くのマシン時間を消費したときにスパイクし、チェックアウトの遅延問題中に再びスパイクしました。

広義には、CIを4つの方法で改善しました。

  • インフラとツールのアップグレード
  • 他の作業をゲートするジョブの最適化
  • 重複したセットアップの削減
  • テスト実行の効率化

Linearのコードベースは主にTypeScriptですが、これらの最適化の多くは、言語やツールチェーン全体に適用できます。

インフラとツールのアップグレード⁠

初期の成果のいくつかは、CI自体の最適化をほとんど必要としませんでした。ワークロードをGitHub Actionsから、より高速なCPU、高性能ストレージ、および優れたキャッシュインフラストラクチャを備えたサードパーティランナーに移行したことで、同じパイプラインを実行するためのより高速なマシンが得られました。切り替えの前後の2日間を比較すると、ジョブは平均で34%高速に実行され、tscのような一部のワークロードは52%削減されました。

別途、ツールチェーンを近代化することも成果を上げました。ネイティブTypeScriptコンパイラであるtsgoに切り替えたことで、tscチェックの週次中央値が73%削減され、タイプチェックからボトルネックを完全に解消するのに十分な大きさでした。

タイプチェッカーなしでLintを実行⁠

Lintingも別の初期ターゲットでした。カスタムLintルールのいくつかは、制限を強制するため、または自動修正を適用するために、TypeScriptの型情報に依存していました。これは、すべてのLint実行がそれらのルールを評価する前に完全な型グラフを構築する必要があることを意味し、Lintingを最もメモリ集約的なCIジョブの1つにしていました。

抽象構文木(AST)に対する静的解析を使用するようにルールを書き直し、型情報なしで関数ライクな構造とガードパターンを特定できるようにしました。これにより、ESLintはTypeScriptを完全にドロップでき、API Lint時間を68%、リポジトリ全体のLint時間を55%削減しました。メモリ使用量も大幅に減少しました。

型情報への依存を削除したことで、構文のみを操作するルールは移植が容易であるため、Oxlintへの後続の移行も容易になりました。Oxlint自体は、Lintingに費やされるCIランナーの時間を削減しました。

他の作業をゲートするジョブの最適化⁠

基盤となるインフラストラクチャと個々のチェックがより高速に実行されるようになったため、CI全体をシステムとして見直しました。これにより、すべての上に位置する小さなジョブに注目が集まりました。すべての実行は、PRがどのパスに触れたか、そしてそれらのテストが同じ入力に対して既にパスしたかどうかを確認することから始まります。私たちはジョブレベルでそれらのチェックをゲートするため、スキップされた作業はランナーを予約しませんが、それはそれらをクリティカルパスに直接配置します。8つのAPIテストシャーディングのいずれも、それらが完了するまで開始できません。そのため、わずかな遅延でも不均衡に重要になります。

各ジョブが必要とするものだけを取得⁠

いくつかのワークフローは、次に実行するものを決定する変更検出ジョブから始まります。たとえば、PRにデータベースマイグレーションが含まれているかどうかを確認し、関連するデータベースCIチェックをスケジュールするために使用されるシグナルを出力します。これらのジョブは、サブセットしか必要としないにもかかわらず、完全な作業ツリーをチェックアウトしていました。フェッチの深さを制限したことで、最も遅いゲートが94秒から20秒に短縮され、作業ツリーを必要としないジョブからはチェックアウトを完全に削除し、それらに費やされる時間を27秒から7秒に削減しました。コミットプッシュおよびマージキューイベントでは、パスをdiffする必要があるため、履歴が限定されたスパースでブロブレスなチェックアウトで十分であることがわかり、さらに11秒ほど節約できました。

[画像: パフォーマンス分布ヒストグラム:青いバー(最適化後の中央値8秒)と灰色のバー(最適化前の中央値26秒)は、最適化後のロード時間の短縮を示しています。]

[画像: パフォーマンス分布ヒストグラム:青いバー(最適化後の中央値8秒)と灰色のバー(最適化前の中央値26秒)は、最適化後のロード時間の短縮を示しています。]

変更検出ジョブの中央値期間は26秒から8秒に、p90は31秒から12秒に、最も遅い実行は138秒から37秒に低下しました。

チェックアウトの耐障害性を向上⁠

基盤となるランナーインフラストラクチャを切り替えた後、ジョブでのチェックアウト時間(actions/checkoutを使用)が長くなり、時々ハングすることがわかりました。サードパーティランナーはGitHubのネットワーク外にあるため、GitHubに到達するために直接IPリンクに依存しています。プロバイダーは、ハングがそのリンクの断続的な劣化に起因すると特定しました。いくつかのワークフローはチェックアウトから始まるため、フェッチが停止するとCI実行全体が遅延する可能性があります。

ネットワークの不安定性に対する耐性を高めるため、actions/checkoutを独自のコンポジットアクションに置き換え、バックオフ付きでリトライするようにしました。また、GIT_HTTP_LOW_SPEED_LIMITとGIT_HTTP_LOW_SPEED_TIMEを設定して、ハングする代わりに約30秒後に接続が停止するようにし、チェックアウトキャッシュも使用するようにしました。これにより、チェックアウト完了を待ってクリティカルパスのジョブがアイドル状態になる実行がはるかに少なくなりました。

クリティカルパス上のものを最小限に抑える⁠

クリティカルパス上のすべてのジョブがそこにある必要はありませんでした。マージ前の最終チェックの一部としてキャッシュマーカーを書き込んでいたため、テストがパスした後でもPRがマージキューに留まる可能性がありました。これを、テストシャーディングが完了した後に実行されるが何もゲートしないジョブに移動しました。これにより、APIプルリクエストとマージキューエントリごとに、マージパスから42秒が削減されました。

これらの変更を組み合わせることで、キャッシュミス時のAPIプルリクエストに必要なチェックから約1分が削減され、ランナースタートも削減されました。

重複したセットアップを削減⁠

そこから、ランナーの起動、パッケージのインストール、ビルド依存関係のプロビジョニングなど、すべてのジョブで繰り返されるセットアップコストに目を向けました。そのオーバーヘッドにより、数秒の有用な作業しか行わないジョブでも、数分間のインフラストラクチャ時間を消費することになります。この問題に対処するために取ったいくつかのステップを以下に示します。

CIイメージに共有依存関係をプリインストールする⁠

APIテストシャーディングはそれぞれ、実行ごとにaptを使用して同じPostgresクライアントをインストールするのに7〜8秒を費やしていました。これを、Nodeとクライアントを含む小さなCIベースイメージに移動したため、各シャーディングは実行準備のできた環境から開始できました。後で、セットアップ中にダウンロードすると時々ハングすることが判明した必要なネイティブビルドヘッダーをイメージに追加し、テールを短縮しました。

各ジョブが必要とする依存関係のみをインストールする⁠

Linearのコードベースはpnpmワークスペースとして管理されているモノレポです。APIテストワークフローは、APIパッケージとその依存関係のみを必要としていたにもかかわらず、ワークスペース全体をインストールしていました。インストールをAPIパッケージに制限したことで、pnpm installが44〜73秒から16〜18秒に短縮されました。API関連ジョブにも同じパターンを適用し、それらはリポジトリ全体をインストールし、後でほとんど実行されない依存関係キャッシュをアップロードしていました。

再構築する方が速い場合はキャッシュしない⁠

node_modulesのキャッシュもテストしましたが、再構築する方が速いことがわかりました。キャッシュキーは頻繁に変更されるロックファイルに依存しており、キャッシュヒットでも復元に約28秒かかりましたが、フィルタリングされたインストールでは約7.5秒でした。キャッシュは、目に見える利点なしに、保存時間と変動性を追加していました。

これらの3つの変更を組み合わせることで、シャーディングあたりのセットアップ時間を約44%削減し、110〜140秒から67〜73秒に短縮しました。

[画像: GitHub Actionsワークフロー比較:テスト-API実行のステップごとのタイミング内訳を表示。左の実行(4分57秒)と右の実行(3分14秒)は、パイプラインステージ全体でのパフォーマンス改善を示しています。]

[画像: GitHub Actionsワークフロー比較:テスト-API実行のステップごとのタイミング内訳を表示。左の実行(4分57秒)と右の実行(3分14秒)は、パイプラインステージ全体でのパフォーマンス改善を示しています。]

テストシャーディングのp95期間。

これ以外にも、回避できる他の形式の重複セットアップがありました。

変更されていないセットアップの再実行を回避する⁠

一部のセットアップ作業は、入力が変更された場合にのみ繰り返す必要があります。たとえば、APIコンテナは、PRがスキーマを変更していない場合でも、実行ごとに完全なデータベースマイグレーション履歴を再実行していました。そのようなケースでは、代わりに生成されたスキーマのスナップショットとブートストラップファイルをロードするように切り替え、データベースセットアップをコンテナあたり約12秒から1〜2秒に削減しました。

短いチェックを少ないジョブにバッチ処理する⁠

7つの独立したチェックはそれぞれ、リポジトリをチェックアウトし、依存関係をインストールしてから数秒の有用な作業を行う前に、ランナーを起動していました。これらを2つのジョブに統合し、その後7つのタスクをそれらの内部で並行して実行しました。これにより、同じセットアップオーバーヘッドを支払う回数が7回から2回に削減されました。6月の使用量に基づくと、この変更により、月間約87,000ランナー分が節約され、総CI使用量の11.8%に相当します。

[画像: CI/CDパイプラインのバッチ処理最適化の前後比較。実行時間と依存関係を示すテストワークフローのステップを表示。すべてのタスクが正常に完了したとマークされています。]

[画像: CI/CDパイプラインのバッチ処理最適化の前後比較。実行時間と依存関係を示すテストワークフローのステップを表示。すべてのタスクが正常に完了したとマークされています。]

テスト実行をより効率的にする⁠

各テストシャーディングの固定コストが下がったことで、APIスイートをより積極的に並列化できるようになりました。これはワークフローの中で最も大きく、最も頻繁に実行される部分の1つであったため、そこでの改善はマージ時間に大きな影響を与えました。

テストランナーが見るように作業をバランスさせる⁠

TypeScriptテストスイートに使用しているテストランナーであるVitestは、個々のテストの期間ではなく、ファイルごとに作業を分散します。これは、数個の異常に大きなテストファイルがシャーディングを支配し、他のシャーディングがはるかに早く完了しても、スイート全体の完了を事実上遅らせる可能性があることを意味しました。

これらの大きなファイルを、テストの構造を維持しながら、より小さく、より焦点を絞ったファイルに分割し、その後、シャーディングとランナーの構成を評価しました。今年の初めに3つから4つにシャーディングを増やしていましたが、8つに増やすことで、初期ベンチマークでクリティカルジョブが約19%高速になり、19%安価になりました。変更の1週間後、最も遅いシャーディングは5.25分から4.33分に低下しました。

Vitestは通常、すべてのテストファイルを分離します。これは、私たちの場合、各テストシャーディングでエンティティ、GraphQL、およびデコレータグラフを再構築することを意味しました。isolate: falseを持つオプトインVitestプロジェクトを導入し、安全なファイルが各ワーカー内でモジュールレジストリを共有できるようにしました。

[画像: モジュール状態キャッシュ最適化:前(ファイルごとの個別の状態)対後(単一の共有状態)。ワーカープロセス間での冗長な再構築を削減します。]

[画像: モジュール状態キャッシュ最適化:前(ファイルごとの個別の状態)対後(単一の共有状態)。ワーカープロセス間での冗長な再構築を削減します。]

これは私たちの最大の単一パフォーマンス改善であり、ボリュームで月間約17%の節約になりました。最も遅いシャーディングは、約300〜379秒から約195秒に低下し、総APIシャーディングランナー時間は、実行あたり約32.8分から22分に減少しました。

また、これは最も高い正確性リスクを伴う最適化でした。オプトインコメントをすべてのファイルに追加して適格性を明示し、共有状態に必要なティアダウンを追加しました。少数のファイルは、安全に解決できない方法で偽のタイマーや共有状態を使用していたため、それらは分離プロジェクトに残しました。また、エージェントが現在私たちのテストの大部分を書いているため、関連するエージェントスキルもこのパフォーマンスオプトインを考慮するように更新したため、生成されたテストはデフォルトで同じ制約に従います。

シャーディングはセットアップオーバーヘッドによって制限される⁠

シャーディング数を倍増するとセットアップに費やされるワークフロー時間も倍増するため、さらなるシャーディングは、シャーディングあたりの固定コストが低い場合にのみ効果があります。前述のセットアップ最適化は、8つのシャーディングを実用的なものにしたものです。シャーディングあたり110〜140秒の場合、8つのシャーディングはセットアップだけで15〜19分ものランナー時間を費やしていたでしょう。これはテスト自体よりも長いです。セットアップは現在約40秒なので、8つのシャーディングは、テストを2倍の距離で並列化しながら、以前の4つのシャーディングよりも合計セットアップ時間が短くなっています。

[画像: テストシャーディング最適化:4シャーディング(セットアップ8.3分)から8シャーディング(セットアップ7.5分)への増加と、テストワークロードのより良い並列化。]

[画像: テストシャーディング最適化:4シャーディング(セットアップ8.3分)から8シャーディング(セットアップ7.5分)への増加と、テストワークロードのより良い並列化。]

セットアップ最適化前、テストジョブは4つのシャーディングを使用し、セットアップに8.3分を費やしていました。その後、7.5分のセットアップで8つのシャーディングを実行できるようになりました。

システム全体で複利効果のある改善⁠

今年の初めにCIを改善するための意図的な努力をしていなかった場合、今日のテストスイートは約11分かかっていたでしょう。これは、開発者が現在待っている時間のほぼ2倍です。そして、仕事はここで終わりません。私たちのコードベースは成長し続けることは明らかです。現在、週に約2,000のテストを追加しています。その間CIを高速に保つことは継続的な努力であり、その多くはこのプロセスを通じて学んだことを新しいボトルネックに適用することになります。

[画像: CI/CDパフォーマンス改善のパイプラインステージ全体にわたる内訳。ツールチェーン、ゲートジョブ、セットアップ、および全体的な指標で-12%から-89%の削減率を示しています。]

[画像: CI/CDパフォーマンス改善のパイプラインステージ全体にわたる内訳。ツールチェーン、ゲートジョブ、セットアップ、および全体的な指標で-12%から-89%の削減率を示しています。]