- はじめに — キャッシュを大きくしてもCI時間は変わらなかった
- キャッシュヒットはサイズではなくキーの問題だ
- ハッシュ入力を絞り込む三つのレバー
- affected判定 — 実行しないことが最速の実行だ
- リモートキャッシュを共有するときに生まれる信頼境界
- キャッシュ汚染とヘルメティシティが同じ問題である理由
- ヒット率を正直に測る
- おわりに — キャッシュは記憶装置ではなく契約だ
- 参考資料
はじめに — キャッシュを大きくしてもCI時間は変わらなかった
モノレポCIが遅いという報告が上がると、最初に出てくる提案はたいてい同じです。「キャッシュがすぐ追い出されるから容量を増やそう」。そこでストレージの上限を上げ、ランナーのディスクを大きくし、アーティファクトの保存期間を延ばします。それでも翌週にはCIはやっぱり12分かかります。
原因はたいてい容量ではありません。キャッシュは書き込みはできているのに、参照で外れ続けているのです。ビルドのたびに微妙に違う環境変数がハッシュに紛れ込んでいたり、タイムスタンプが成果物に埋め込まれていたり、ランナーイメージが変わるたびにツールチェーンのバージョンがずれていたり、キャッシュキーにコミットSHAが入っていて最初から再利用できないキーを作っていたり。この状態でストレージを増やしても、ゴミが長く残るだけです。
この記事はTurborepo・Nx・Bazel・GitHub Actionsのキャッシュを材料に、正確なキャッシュキーとヘルメティックな入力という一つの軸でモノレポCIキャッシュを整理します。ビルドが遅くなる一般的な理由はビルドはなぜ遅いのかの回で扱い、コンテンツアドレス指定ストレージの原理はコンテンツアドレス指定ストレージの回にあります。ここではモノレポCIに特化した部分だけを見ていきます。
キャッシュヒットはサイズではなくキーの問題だ
すべてのビルドキャッシュは同じ契約の上に成り立っています。入力が同じなら出力も同じ。キャッシュキーはこの「入力」をハッシュ化した値で、ヒット率はキーがどれだけ正確に入力を捉えているかに比例します。キーが実際より広ければ(不要なものまで含めば)ヒットが出ず、キーが実際より狭ければ(重要な入力を漏らせば)間違った結果を再利用してしまいます。
ツールごとに、この契約の守り方は異なります。
| ツール | キャッシュ単位 | ハッシュに入るもの | ヘルメティシティの保証 | リモートキャッシュ |
|---|---|---|---|---|
| Turborepo | パッケージ単位のタスク | パッケージソース、宣言したenv、内部依存パッケージのハッシュ、タスク定義 | 規約ベース。宣言していない環境変数はstrictモードで遮断 | Vercelホスティングまたは OpenAPI互換の自前サーバー |
| Nx | プロジェクト単位のタスク | namedInputsで定義したファイル集合、env、依存プロジェクトのハッシュ | 規約ベース。inputsの定義がそのまま契約 | Nx Cloudまたは自前ホスティングのキャッシュ |
| Bazel | アクション(コマンド1つ) | 宣言された入力ファイル、コマンドライン、環境、ツールチェーン全体 | サンドボックスで強制。宣言していないファイルは見えない | gRPCリモートキャッシュプロトコル |
| GitHub Actions cache | 任意のディレクトリのtarball | ユーザーが文字列で直接書いたキー | なし。全面的に作成者の責任 | リポジトリスコープ、ブランチスコープ |
この表から読み取るべきは性能の順位ではなく、責任の所在です。Bazelはサンドボックスで宣言されていない入力を物理的に遮断するので、ヘルメティシティをツール自身が強制します。TurborepoとNxは「宣言したものがすべて」という規約を置き、ユーザーがそれを守ることを期待します。GitHub Actionsキャッシュはキー文字列を人間が手で書きます — もっとも柔軟で、もっとも頻繁に間違えます。
だから実務での最初の診断は、いつも同じです。なぜミスが出たのかをツールに尋ねることです。キャッシュが追い出されたからなのか、キーが違うからなのか、そもそもキャッシュ対象ではなかったのかを区別しない限り、どんな対処も推測にすぎません。
ハッシュ入力を絞り込む三つのレバー
キーが間違う原因は、実務ではほぼ常に三つのうちのどれかです。
第一に、ファイル入力が広すぎます。デフォルトはたいてい「パッケージ内のすべてのファイル」です。READMEを直しただけでもテストが再実行され、そのパッケージに依存する下流のサブグラフ全体が無効化されます。タスクごとに実際の入力だけを宣言する必要があります。
// turbo.json — タスクごとに入力を絞り込む
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**"]
},
"test": {
"inputs": ["src/**", "tests/**", "vitest.config.ts"],
"outputs": []
}
}
}
Nxは同じことをnamedInputsで行います。一度定義しておけば各プロジェクトで使い回せる構造なので、大規模なリポジトリでも管理が楽です。
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.md"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": { "inputs": ["production", "^production"], "cache": true }
}
}
第二に、環境変数が漏れ込みます。これは両方向に間違えます。CIベンダーが注入するGITHUB_RUN_IDのような値がハッシュに入るとヒットが永遠に出なくなり、逆にビルド結果を実際に変えるNODE_ENVやAPIエンドポイントがハッシュから漏れていると、開発ビルドが本番キャッシュとして再利用されてしまいます。
Turborepoのstrict環境モードがこの問題の標準的な解法です。デフォルトがstrictで、env・globalEnvで明示していない変数はタスクのランタイムでまったく見えません。ビルドが突然失敗するなら、その変数がもともと隠れて使われていたという証拠です。
{
"globalEnv": ["NODE_ENV"],
"globalPassThroughEnv": ["CI", "GITHUB_ACTIONS"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL", "SENTRY_*"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
passThroughEnvは値がタスクには渡りますが、ハッシュには入りません。ロギング用のフラグのように結果を変えない変数専用です。ここに結果を変える変数を入れた瞬間、キャッシュは嘘をつき始めます。
第三に、成果物に非決定的な値が埋め込まれます。ビルドタイムスタンプ、絶対パス、ビルド番号、ランダムなチャンクID。これはキーの問題ではなく出力の問題ですが、結果的に上位タスクの入力を変えてキャッシュチェーンを壊してしまいます。再現可能なビルドにおける標準的な対応は、SOURCE_DATE_EPOCHをコミット時刻に固定し、パスを相対化することです。
# コミット時刻をビルドタイムスタンプとして固定する
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
# 同じ入力で2回ビルドし、成果物がバイト単位で一致するか確認
pnpm build && cp -r dist /tmp/build-a
rm -rf dist && pnpm build && cp -r dist /tmp/build-b
diff -r /tmp/build-a /tmp/build-b && echo "reproducible"
この3段階の確認は、キャッシュ作業の初日にやるべきことです。成果物が再現しないなら、その上に積み上げるキャッシュの議論はすべて無意味になります。
affected判定 — 実行しないことが最速の実行だ
キャッシュヒットもタダではありません。リモートキャッシュの参照はネットワークの往復であり、成果物のダウンロードと展開にも時間がかかります。タスクが数千個あるリポジトリでは、「全部参照して全部ヒット」よりも「そもそも対象ではないので参照すらしない」ほうがはるかに速いのです。
# Turborepo — origin/main以降に変更されたパッケージとその依存元だけ
turbo run build test --filter="...[origin/main]"
# Nx — プロジェクトグラフとgit履歴からaffectedを判定
nx affected -t build test --base=origin/main --head=HEAD
# 何がなぜ選ばれたのかを目で確認
nx show projects --affected --base=origin/main
nx graph --affected --base=origin/main
# Bazel — 変更されたファイルから逆依存先を問い合わせる
bazel query "rdeps(//..., set($(git diff --name-only origin/main)))" --output=label
ここでよくある間違いがベースコミットを取り違えることです。PRワークフローで--base=origin/mainを使うと、mainが先に進んでいるときに無関係な変更までaffectedに入ってきます。正確な基準はマージ基準点です。
BASE="$(git merge-base origin/main HEAD)"
nx affected -t build --base="$BASE" --head=HEAD
そして浅いクローンでは、この計算自体が不可能です。GitHub Actionsのcheckoutアクションはデフォルトがdepth 1なので、affectedを使うワークフローは履歴を十分に取得する必要があります。
- uses: actions/checkout@v4
with:
fetch-depth: 0 # または少なくともマージ基準点が含まれる分だけ
affected判定が信頼を失うポイントも知っておく価値があります。プロジェクトグラフに捕捉されない暗黙の依存 — ランタイムに文字列として組み立てるモジュールパス、生成コード、共有設定ファイル、Dockerベースイメージ — があると、変更されていてもaffectedから漏れてしまいます。こうした項目はsharedGlobalsやglobalDependenciesに明示的に入れてグローバル無効化の対象として宣言しておくほうが安全です。ヒット率が多少落ちても、間違った結果を通過させるよりましです。
リモートキャッシュを共有するときに生まれる信頼境界
リモートキャッシュの恩恵は共有範囲に比例します。CIランナー同士だけで共有すれば同じコミットの再実行でしか得しませんが、開発者のマシンまで含めれば、朝mainを取り込んだ人がビルドなしですぐに作業を始められます。
ただし共有範囲を広げた瞬間、一つ問題が生まれます。キャッシュに書き込める主体が、そのまま他の人のビルド成果物を決めてしまうのです。ノートPCから汚染された成果物を一つアップロードすれば、それをダウンロードした全員のビルドがその結果を使います。だから実務のルールはシンプルです。
- 書き込みは信頼されたCIのみ。開発者のマシンとフォークPRは読み取り専用です。
- 書き込み主体は再現可能な環境でのみ実行。固定されたコンテナイメージ、固定されたツールチェーンバージョン。
- 成果物に署名。ダウンロード時の検証に失敗したものはミスとして扱います。
Turborepoは署名をサポートしています。turbo.jsonで有効にしてキーを環境変数として渡すとHMAC-SHA256署名が付き、検証に失敗したアーティファクトは無視されてキャッシュミスとして扱われます。
{ "remoteCache": { "signature": true } }
# CI: 書き込み権限を持つ信頼されたジョブ
export TURBO_API="https://cache.example.internal"
export TURBO_TEAM="platform"
export TURBO_TOKEN="***"
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="***"
turbo run build
# 開発者マシン / フォークPR: 読み取り専用トークンのみ配布
export TURBO_TOKEN="read-only-***"
自前ホスティングも難しくありません。TurborepoはリモートキャッシュAPIのOpenAPI仕様を公開しているので、S3互換ストレージの手前に薄いサーバーを置けば済みます。BazelはgRPCリモートキャッシュプロトコルを使い、書き込み権限をジョブ単位で分離するフラグがあります。
# 読み取り専用の利用者(開発者、フォークPR)
bazel build //... \
--remote_cache=grpcs://cache.example.internal \
--noremote_upload_local_results
# 書き込み主体(信頼されたCI)
bazel build //... \
--remote_cache=grpcs://cache.example.internal \
--remote_upload_local_results
キャッシュ汚染とヘルメティシティが同じ問題である理由
2026年は、CIキャッシュがサプライチェーン攻撃の経路であるという事実が繰り返し確認された年でした。5月11日のTanStack事故では、汚染されたキャッシュがmainブランチのスコープに書き込まれた後、悪意あるパッケージバージョンが連鎖的に配布されたと報告されています。GitHubは6月26日のチェンジログで、信頼されていないトリガーに対して読み取り専用のキャッシュトークンを発行するようデフォルトを変更しました。
変更内容は正確にはこうです。リポジトリの書き込み権限なしにトリガーされうるイベント — pull_request_target、issue_comment、フォークPRから派生したworkflow_run — であり、なおかつ実行コンテキストとキャッシュスコープがデフォルトブランチのSHAに由来する場合、キャッシュトークンは読み取り専用になります。push、schedule、workflow_dispatchのような信頼されたトリガーと、デフォルトブランチ以外のスコープを使うpull_request・releaseは読み書き権限を維持します。したがって、キャッシュへの書き込みを行っていたワークフローが上記の条件に該当するなら、書き込みはpushトリガーのワークフローに移し、残りは復元のみを行うように分割する必要があります。
ここで重要なのは、これがアクセス制御の措置であって根本解決ではないという点です。根本的な問題は、キャッシュ項目が「何から作られたのか」を証明しない、という点にあります。ヘルメティックなビルドではキャッシュキーが入力全体のハッシュなので、汚染された成果物を正当なキーに押し込むのは困難です。逆にキーが人間の書いた文字列であれば — GitHub Actionsキャッシュがそうです — キーさえ合わせればどんな内容でも入れられます。
実務で守るべきルールをまとめるとこうなります。
- 信頼境界をまたぐキャッシュ共有をなくすこと。フォークPRとデフォルトブランチが同じキー空間を使わないよう、キーにスコープを入れます。
- 信頼されていない入力から作られたファイルをキャッシュに入れないこと。GitHubはこのパターンを検出するCodeQLクエリを提供しています — コードインジェクションによるキャッシュ汚染、信頼できないファイルのキャッシュ。
- キャッシュ項目のTTLを短く設定すること。GitHubのデフォルトは最終アクセス基準の7日間スライディングなので、汚染が1週間生き残ることがあります。
- 依存関係キャッシュとビルド成果物キャッシュを分離すること。前者はロックファイルのハッシュによって決定論的ですが、後者は実行結果なので要求される信頼のレベルが異なります。
CIパイプライン全体の信頼境界については、CIエージェントとプロンプトインジェクションの回でより広く扱いました。
ヒット率を正直に測る
キャッシュ作業の成果報告でもっともよくある誇張が三つあります。
同じマシンで2回実行した時間を比較すること。2回目の実行はローカルキャッシュにヒットします。リモートキャッシュが実際に機能しているかは分かりません。計測は必ず新しいランナー、新しいクローンで行う必要があります。
affectedのスキップとキャッシュヒットを合算して数えること。この二つは異なる最適化であり、失敗の現れ方も違います。affectedが過小判定すると間違ったビルドが通過し、キャッシュが過小ヒットするとただ遅くなるだけです。合算してしまうと、どちらが悪化したのか分からなくなります。
タスク件数基準のヒット率だけを見ること。些細なlintタスク900個がヒットして重いビルドタスク10個がミスすれば、ヒット率は98パーセントなのにCI時間はそのままです。ヒット率は節約された時間基準でも合わせて見る必要があります。
計測そのものはツールが提供してくれます。
# Turborepo — 実行サマリーをJSONで残し、キャッシュ状態を集計する
turbo run build --summarize
jq '[.tasks[] | {task: .taskId, status: .cache.status, ms: .execution.duration}]' \
.turbo/runs/*.json
# 二つの実行のサマリーを比較し、どの入力が違ったのかを探す
turbo run build --dry=json > /tmp/run-a.json
# (別の環境で再実行)
diff <(jq -S . /tmp/run-a.json) <(jq -S . /tmp/run-b.json)
# Bazel — 二つの実行のアクションログを比較し、非ヘルメティックな要素を探す
bazel build //... --execution_log_compact_file=/tmp/exec-ci.log
bazel build //... --execution_log_compact_file=/tmp/exec-local.log
# アクションキーが同じなのにヒットがなければ設定の問題、キーが違うなら入力が違うということ
Bazelドキュメントの診断原則は、ツールを問わずそのまま当てはまります — アクションキーが違えば入力が違うのであり、アクションキーが同じなのにヒットがなければキャッシュ設定が問題だということです。この二つの分岐を先に切り分けるだけで、調査範囲は半分に減ります。
目標とすべき数値についても正直に書いておきます。リモートキャッシュが正常に動作しているリポジトリでは、mainに対する小さなPRのヒット率は90パーセント台に達します。繰り返しの実行で80パーセントを下回るなら、キーかストレージ側に問題があると見るのが合理的です。ただしこの数字はリポジトリの構造やタスクの分布によって大きく変わるので、他人のベンチマークではなく自分のリポジトリでの2週間のトレンドで判断すべきです。
おわりに — キャッシュは記憶装置ではなく契約だ
キャッシュのチューニングをストレージ容量の問題として捉えると、ほぼ必ず失敗します。キャッシュとは「入力が同じなら出力も同じ」という契約であり、ヒット率はその契約をどれだけ正確に記述できているかの指標です。
- まず再現性から確認してください。同じ入力で2回ビルドしてバイトが違うなら、キャッシュの議論はその後の話です。
- タスクごとに
inputsとenvを絞り込み、strict環境モードで漏れている変数をあぶり出してください。ビルドが壊れたなら、もともと隠れて使われていた入力を見つけたということです。 - affectedはキャッシュより手前の最適化です。代わりに暗黙の依存はグローバル無効化の対象として明示してください。
- リモートキャッシュへの書き込みは信頼されたCIに限定し、署名を有効にし、信頼されていないトリガーは読み取り専用にしてください。2026年の事故は、すべてこの境界がなかったために起きました。
- 計測は新しいランナー・新しいクローンで、affectedとキャッシュを分離して、節約された時間を基準に行ってください。
キャッシュが大きいから速くなるCIはありません。入力を正確に把握しているCIだけが速くなるのです。
参考資料
- Turborepo — キャッシュの概念ドキュメント
- Turborepo — 環境変数とstrictモード
- Turborepo — Remote Cachingと自己ホスティング、アーティファクト署名
- Nx — Run Only Tasks Affected by a PR
- Nx — Distribute Task Execution (Nx Agents)
- Bazel — Remote Caching
- Bazel — Debugging Remote Cache Hits(実行ログの比較)
- GitHub Docs — Dependency caching reference
- GitHub Changelog — Read-only Actions cache for untrusted triggers (2026-06-26)
- GitHub Changelog — Actionsキャッシュ10GB超過を許可 (2025-11-20)
- CodeQL — Cache Poisoning via low-privileged code injection
- CodeQL — Cache Poisoning via caching of untrusted files
- ビルドはなぜ遅いのか(関連記事)
- コンテンツアドレス指定ストレージ(関連記事)
- CIエージェントとプロンプトインジェクションのサプライチェーン(関連記事)
현재 단락 (1/143)
モノレポCIが遅いという報告が上がると、最初に出てくる提案はたいてい同じです。「キャッシュがすぐ追い出されるから容量を増やそう」。そこでストレージの上限を上げ、ランナーのディスクを大きくし、アーティフ...