キャッシュはキーの作り方がすべてだ
一言でいうと
キャッシュが守るべきルールは1つです。キャッシュを削除しても、同じアーティファクトが出ることです。キャッシュは時間を短縮するだけの仕組みであり、結果を変えてしまった瞬間、それはキャッシュではなく隠れた入力です。そのルールを守らせるのが、キーの設計です。
なぜ必要なのか
キャッシュを導入する動機は、いつも速度です。ところが、キーをいい加減に作ると、両方向で負けます。キーが細かすぎると何も一致せず、キャッシュがあってもないのと同じになり、キーが緩すぎると、古い内容のまま通るビルドが生まれます。後者のほうがはるかに悪い状況です。失敗すべきものが成功してしまうからで、その失敗は、キャッシュが期限切れになった数週間後に、無関係な人の変更で表に出ます。
GitLabのドキュメントは、この性質をはっきり書いています。「キャッシュは最適化であり、常に動作することが保証されているわけではありません。必要なジョブごとに、キャッシュされたファイルを再作成しなければならないことがあります。」つまり、キャッシュがなくても成り立つ構造でなければなりません。ジョブがキャッシュの存在を前提にしているなら、それはキャッシュではなく依存関係です。
キーに何を入れるのか
キーは、「この内容を再利用してよい条件」を文字列で書いたものです。そのため、結果に影響を与えるものはすべて入れる必要があります。
- ロックファイルのハッシュ。依存関係が変わったら、キーが自然に変わらなければなりません。手でバージョンを上げるキーは、必ずいつか忘れられます。
- ツールのバージョン。同じロックファイルでも、コンパイラやランタイムのバージョンが違えば、作られるものが違います。
- OSとアーキテクチャ。amd64で作ったネイティブ拡張をarm64で再利用すると、静かに壊れます。
- キャッシュの用途。依存関係のキャッシュとビルドの中間生成物のキャッシュを、1つのキーに混ぜません。
GitHub Actionsのドキュメントの標準形が、この形をそのまま表しています。
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
GitLabは、同じことをcache:key:filesで行います。特定のファイルの内容に結び付いたキーを作ってくれる機能です。
プレフィックスフォールバックが与えるものと、奪うもの
restore-keysは、完全に一致するキーがないときに、プレフィックスで始まるキーを探します。公式ドキュメントは、順番に走査し、部分一致が複数あれば最も新しく作られたキャッシュを返すと書いています。
利点ははっきりしています。ロックファイルを1行直しただけで依存関係をすべて新しく取得するのではなく、以前のものを土台にして、差分だけを取得します。危険も同じところにあります。プレフィックスによるフォールバックで取得した内容は、今のロックファイルと一致するという保証がありません。そのため、フォールバックは、「計算し直しても結果が同じもの」にだけ使います。ダウンロードのキャッシュは安全で、コンパイルの成果物を検査なしに再利用するのは危険です。キーが完全に一致したときだけステップを省略し、プレフィックス一致のときは、内容を埋めたあとで通常の手順をもう一度実行するのが安全な形です。
もう1つあります。GitHub Actionsのキャッシュエントリは、一度作られると内容を変更できません。ドキュメントは、既存のキャッシュの内容を変更することはできず、新しいキーで新しいキャッシュを作るよう書いています。そのため、「キーはそのままにして、内容だけ直せばいいだろう」という考えは通用しません。
キャッシュとアーティファクトは別のもの
GitLabのドキュメントの区別が最も簡潔です。キャッシュは、インターネットからダウンロードする依存関係のようなものに使い、アーティファクトは、ステップの間で中間結果を渡すために使います。キャッシュはランナーのマシンに残り、アーティファクトはサーバーに保存されてダウンロードできます。
判断基準は1つです。なくなってもよいかです。なくなっても時間がよけいにかかるだけなら、キャッシュです。なくなると次のステップがそもそも動かないなら、アーティファクトです。テストレポートやカバレッジの結果をキャッシュに入れているパイプラインをときどき見かけますが、キャッシュはエビクションされることがあるので、失敗したその瞬間の証拠が消えてしまいます。
範囲と汚染
キャッシュは信頼境界でもあります。どのブランチでもキャッシュを保存できるようにしておくと、そのブランチにプッシュできる人なら誰でも、以降のビルドの結果に手を加えられます。そのため、プラットフォームは範囲を狭く設定しています。
- GitHub Actions: 実行は現在のブランチと既定のブランチのキャッシュを復元でき、PRの実行は対象のブランチのキャッシュも使えます。兄弟のブランチの間では共有されず、子のブランチが作ったキャッシュを親のブランチの実行が使うこともできません。
- GitLab: 既定では、保護されたブランチと保護されていないブランチは、キャッシュを共有しません。
上限とエビクションも、知っておくべき値です。GitHub Actionsは、リポジトリ1つあたり既定で10GBで、7日を超えて使われなかったエントリを削除し、容量が必要になれば、最終アクセス時刻が古いものから削除します。そのため、キーをコミット単位で細かく分けすぎると、キャッシュ同士が互いを押し出して、ヒット率がかえって下がります。
現場での姿
- ヒット率を測らないと、キャッシュが動作しているのかどうかもわかりません。ヒットとミスをログに1行ずつ残して数え始めると、たいてい「思ったより一致していなかった」という事実が先に明らかになります。
- ロックファイルを変えたのに、ビルドが相変わらず通ります。プレフィックスによるフォールバックで取得した古い依存関係を、そのまま使っている可能性が高いです。
- キャッシュを削除したらビルドが壊れました。キャッシュにしかなかったファイルを、誰かが成果物の一部として使っていたという意味です。
参考
- GitLabのキャッシュ: https://docs.gitlab.com/ci/caching/
- GitHub Actionsの依存関係のキャッシュ: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
- GitLabのジョブのアーティファクト: https://docs.gitlab.com/ci/jobs/job_artifacts/
- Dockerのビルドキャッシュ: https://docs.docker.com/build/cache/
次のラボですること
キャッシュマネージャーをシェルで自作します。ロックファイルのハッシュとツールのバージョン、OSをつなげてキーを作り、そのキーで保存して取り出すスクリプトを書きます。続いて、ロックファイルを1行直してキーが自然に変わるかを確認し、プレフィックスによるフォールバックで古い内容を取得したあと、そのまま通すとどうなるかを反例として作ってみます。最後に、キャッシュを空にしたまま再実行して、アーティファクトのハッシュが同じかどうかを照合する検査と、ヒット率を数えて報告するステップを付けます。