HN 日本語サマリー

← 一覧へ戻る
プログラミング

actions/setup-goを置き換えてGolang CIをスケーリングする

Scaling Golang CI by Replacing actions/setup-go (cloudx.ai)

31 pointsby peterldowns1 コメント

要約

CloudX.aiは、GitHub Actionsの公式アクションであるactions/setup-goを独自開発のcloudx-io/setup-goに置き換えることで、GolangのCIテストジョブの実行時間を69%短縮したと発表しました。この改善は、Golangのビルドキャッシュを活用し、並列ジョブ間のパフォーマンス干渉や古いキャッシュ値のロードといった問題を解決したことによります。この新しいアクションはオープンソース化されています。

全文翻訳

エンジニアリング Lukas SchwabとPeter Downs • 2026年9月16日 Golang CIを並列実行する際に、Golangのビルドキャッシュを活用することで、パフォーマンスを向上させる新しい方法を見つけました。GitHubの公式actions/setup-goアクションを、ドロップイン互換のものに置き換えることで、テストジョブの実行時間を69%短縮しました。cloudx-io/setup-goをオープンソース化しましたので、皆様も同様の改善を行うことができます。 GitHubの公式actions/setup-goステップは、並列ジョブがお互いのパフォーマンスに干渉し、古いキャッシュ値を継続的にロードするという問題があります。私たちのモノレポでのバックテストでは、少数の並列Golangテストジョブ(リンティング用、テスト用、ビルド用)という一般的な状況において、デフォルトのアクションが行う作業の86%が完全に不要であることが示唆されています。 中程度に複雑なGoプロジェクトを管理している場合、同様のパフォーマンス改善が期待できます。CI測定方法をご覧ください。あるいは、ご自身で試してみてください。 高速CIを重視する 私たちは多くの新製品や機能をリリースしており、そのペースは時間とともに実際に増加しています。これは偶然ではありません。私たちはこれを可能にするために必要なツールやプロセスに多額の投資を行っています。「ソフトウェアファクトリ」の中心には、コード変更が本番環境で問題を引き起こさないことを保証するテストスイートと継続的インテグレーション(CI)ワークフローがあります。私たちのテストがすべての変更に対して信頼性高く、迅速に実行されれば、顧客に問題を引き起こす心配なく、素晴らしいスピードで開発できます。 これは私たちにとって重要であるため、CIジョブの速度を測定し、投資しています。CloudXリポジトリにコードをプッシュした場合、そのコードがビルドされるか、テストがパスするか、リンター規則を遵守しているかという明確な答えを90秒以内に得られることを目標としています。 スピードはさまざまな方法で達成できますが、最終的には、物事を速くしたいのであれば、アルゴリズム的な改善を行う必要があります。私たちはすでにWarp Buildを使用して、高速でコスト効率の高いマシンでCIジョブを実行しています。製品の表面積とともにテストスイートがスケーリングするにつれて、actions/setup-goが成功への道筋を整えていないことに気づきました。 actions/setup-goが並列ジョブで失敗する理由 GitHubのactions/setup-goは、GitHub ActionsでGoをインストールして実行するためのGitHubが推奨する方法です。内部的にactions/cacheを使用して、ローカルのGoモジュールキャッシュとビルドキャッシュディレクトリを保存および復元します。原則として、これにより、あるジョブ実行からダウンロードされたモジュールソースコードとビルド/テスト成果物が、リポジトリ内の後続のすべてのジョブ実行で利用可能になるはずです。以下は、デフォルトのactions/setup-goのキャッシュキー構築方法です。 setup-go-${os}-${arch}-go-${goVersion}-${hashFiles('**/go.mod')} このキャッシュキーは非常に不完全です。活発に開発されている典型的な製品では、コード変更のほんの一部だけがターゲットオペレーティングシステム、アーキテクチャ、Goバージョン、またはgo.modファイルを変更します。 ジョブがこのハッシュキーを最初に計算すると、最終的なキャッシュ状態をGitHubキャッシュサービスに永続化します。そのキー要素のいずれかを変更する次の変更まで、すべてのCI実行が最初の値をロードします。アプリケーションを変更すると、この最初の実行から復元されたgoビルドモジュールアーカイブは弱まります。各後続のビルドは、より多くの作業をゼロから行うことになります。復元されたgoテストの出力も古くなるため、各後続のジョブはより多くのテストを再実行します。go.modを更新するまで、CIは劣化し続けます! さらに、actions/setup-goを並列実行する複数のジョブは、異なるローカルキャッシュ状態をGitHubキャッシュサービスに書き込もうとします。これは、ランナー上の最終的なGoキャッシュ状態がソースコードと実行されたコマンドの両方に依存するため、異なります。例えば、リンティングジョブとテストジョブを並列で実行するとします。 1# 非常に単純化された .github/workflows/go.yaml 2jobs: 3 test: 4 steps: 5 - uses: actions/checkout@v7 6 - uses: actions/setup-go@v7 7 - run: go test ./... 8 lint: 9 steps: 10 - uses: actions/checkout@v7 11 - uses: actions/setup-go@v7 12 - uses: golangci/golangci-lint-action@v9 両方のジョブが同じデフォルトキャッシュキーを解決し、その値の書き込みを競合します。リンティングジョブが最初に終了したと仮定します。それは、更新されたテストキャッシュ状態を含まない値を保存します。後続のテストジョブは、キャッシュキーが変更されるまでその古い値を使用し続け、したがって不必要にテストを再実行します。 Goツールチェーンキャッシュ コードベースのリンティング、ビルディング、テストは、メモ化の理想的な候補です。それらの出力(リンターメッセージ、ビルド済みバイナリ、テスト結果)はソースコードの純粋な関数であるべきです。入力が変更されていない限り、出力を保存して再計算するのではなく再利用できます。 標準Goツールチェーンのいくつかの部分は、出力をファイルシステムに保存し、ゼロから再計算するのではなく既存の出力を再利用できるかどうかを確認します。 キャッシュ 制御環境変数 デフォルトのLinuxの場所 モジュールキャッシュ GOMODCACHE $GOPATH/pkg/mod ビルドキャッシュ GOCACHE ~/.cache/go-build テストキャッシュ GOCACHE ~/.cache/go-build Goのモジュールキャッシュは、モジュール依存関係のソースコードのダウンロードにかかる時間を節約します。これはgo mod downloadで明示的にトリガーできますが、go buildでも暗黙的にトリガーされます。ここでは、go.modのパッケージ識別子によって整理されたソースコード以外に、神秘的なものはありません。 $ head -5 "$(go env GOPATH)/pkg/mod/github.com/peterldowns/pgmigrate@v0.3.1/pgmigrate.go" package pgmigrate import ( "context" "database/sql" go.modを変更すると、新しい依存関係を追加したり既存のものをアップグレードしたりする際に、新しいダウンロードがトリガーされます。 Goのビルドキャッシュとテストキャッシュは、実際にはGOCACHEディレクトリに一緒に配置されており、一般的な構造を共有しています。ビルドプロセスとテストプロセスの両方が、キャッシュキーとして使用するために完全な入力をハッシュします。これらのハッシュはプレフィックスごとにサブディレクトリに整理され、再利用可能なプロセス出力のファイル名として使用されます。 $ tree "$(go env GOCACHE)" | head -6 ~/.cache/go-build ├── 00 │ ├── 000131ed61b57fbbb4ad26f5862b2aae4b648b33be167a7212fc680e7af79a93-a │ ├── 000147cede28c3ff98a24adc10ac43dfbe11d00b4707945236c461031c0a4a5c-a │ ├── 000427faba12a1cf22e9396ebc98ada8dbf714d648c1746176d26779c47bf05a-d │ ├── 0008027ee2ff469b29ad5f95fca2a6d74fba5caa5371dd80e2f5f3ba7f117bc8-d ファイル名の末尾が-dのファイルはデータペイロードであり、-aで終わるファイルはインデックスとして機能します。もちろん、ビルドプロセスとテストプロセスは異なるデータペイロードを生成します。 go buildはパッケージアーカイブ、最終バイナリにリンクされる中間ファイルを保存します。 go testは標準出力、標準エラー、およびテスト実行の最終終了コードを保存します。 Goテストランナーはテストプロセスを監視し、どのファイルを読み取ったかを自動的に検出し、その内容をキャッシュキーの入力として組み込みます。 これらのツールキャッシュの根底にある原則は同じです。それらは、完全かつ最小限の依存関係セットからキーを作成することでヒット率を最大化し、ミスは必要不可欠な場合にのみ発生するようにします。ミスが発生するたびに、新しい結果は常にキャッシュに永続化され、将来のプロセスで再利用できるようになります。 これは単一の永続ファイルシステムでは見事に機能しますが、CIランナーは単一の永続ファイルシステムの利点を持っていません。GitHub Actionsでは、これらのツールチェーンキャッシュは、まったく異なる設計優先順位を持つ別のキャッシュに格納することによって、一時的なランナー間で密かにやり取りされます。 GitHub Actionsキャッシュ GitHubの基本的なactions/cacheは、キーとファイルパスを知っているだけです。独自の設計のキーをGitHubのキャッシュサービスに与えます。キャッシュサービスがキーを認識した場合、対応するキャッシュファイルをランナーにロードします。認識しない場合は、何もロードしません。このプライマリキーのルックアップが失敗した場合にのみ、actions/cacheはCIジョブが完了した後、これらのファイルをキャッシュサービスに保存します。 actions/cacheは、ジョブが成功し、そのキーに正確な一致がなかった場合にのみ、GitHub Actionsキャッシュサービスに新しいブロブを書き込みます。書き込まれると、Actionsキャッシュ内のキーと値のペアは不変になります。 あるキーの下でGitHubキャッシュサービスにオブジェクトを書き込むと、そのキーと値のペアは不変になります。後続の呼び出しでそのキーに対して異なる値を永続化しようとしても拒否されます。 そこには...