これはなに
CircleCIの便利な機能であるsave_cacheとrestore_cacheを使ったCIの最適化について、見落としがちな落とし穴を、CircleCIの仕様を確認しながら解説する試みです。
※persist_to_workspaceについては扱いません
想定読者
- CircleCIでとりあえずCIワークフローを組んだことがあるよという方
- ビルドやキャッシュの戦略についてはあんまり分かっていないよという方
CIワークフローってなんじゃいという方は、他に良い記事があるのでそちらでまずはCircleCIを学ぶと良いと思います。
なにが知れるの?
CircleCIのキャッシュを使った効果的なCI戦略と注意点
本題
まず、ここに一般的(抽象的)なワークフローがあります。
何かしらのランタイムとパッケージ管理ツールを使ったキャッシュ戦略が組まれていますね。
version: 2.1
parameters:
# python-version や ruby-version などに読み替えてください
runtime-version:
type: string
default: "1.0.0"
workflows:
lint_and_test:
jobs:
- build
- test:
requires:
- build
- lint:
requires:
- build
jobs:
build:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
steps:
- checkout
- restore_cache:
keys:
# pythonなら poetry.lock や uv.lock、rubyなら Gemfile.lock などになりますね
- v1-deps-{{ checksum "deps.lock" }}
- v1-deps-
- run:
name: Install Dependencies
command: |
# Commands to install dependencies
- save_cache:
key: v1-deps-{{ checksum "deps.lock" }}
paths:
# pythonなら.venvを、rubyならvendorを保存するようになると思います。
- ./path/to/dependencies
test:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
parallelism: 5
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ checksum "deps.lock" }}
- run:
name: Run Tests
command: |
# Commands to run tests
- store_test_results:
path: test-results
lint:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-<< pipeline.parameters.runtime-version >>
- run:
name: Run Linting
command: |
# Commands to run linting
ここではシンプルに、ビルドセクションで依存関係の解決を行い、キャッシュに保存しています。
後続のテスト(並行実行)やlintでは、ビルドセクションが保存してくれたキャッシュを使って、ビルドコストを削減しているというわけです。
この一見良さそうに見えるキャッシュ戦略ですが、2件ほど落とし穴があります。CircleCIのキャッシュの仕組みを確認しながら見ていきましょう。
落とし穴1. 部分一致キャッシュでキャッシュサイズが膨らんでいく
ビルド部分だけ見てみましょう。一旦わかりやすくするため、poetryを使ったPythonプロジェクトとして扱います。
build:
docker:
- image: cimg/python:3.13.3
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ checksum "poetry.lock" }}
- v1-deps-
- run:
name: Install Dependencies
command: |
poetry install
- save_cache:
key: v1-deps-{{ checksum "poetry.lock" }}
paths:
- ./.venv
restore_cacheで既存のキャッシュを当たっていますが、なぜかキーが2つ書いてありますね。
- restore_cache:
keys:
- v1-deps-{{ checksum "poetry.lock" }}
- v1-deps-
一つ目のキーは、依存関係のlockファイルのチェックサムをキーに含めています。
v1-deps-{{ checksum "poetry.lock" }}
依存関係が更新された場合、古いキャッシュが適用されては困りますから、このようにしておけばlockファイルが更新されるごとにキャッシュキーも変わり、新しいキャッシュが保存/取得できて良さそうです。
ただし、絶対一致だとコスト的には嬉しくないこともあります。
この絶対一致だけだと、例えば一つのライブラリを追加したり削除しただけでchecksumの値は変わってしまいますから、過去のキャッシュはヒットしなくなります。
その場合、変更のない他のライブラリのキャッシュも全て喪失することになります。これではもったいないですね。
これを防ぐのが二つ目のキーです。
`v1-deps-`
実は、CircleCIのrestore_cacheはキーを複数取ることができ、指定したキーを上から順に前方一致で調べていきます。公式ドキュメント
依存関係が若干更新された場合、一つめのv1-deps-{{ checksum "poetry.lock" }}はヒットしなくなりますが、二つ目のv1-deps-には何かしらの(古い)キャッシュがヒットしてくれます。
これにより、後続のパッケージマネージャによるインストール時に、新しく追加された依存関係のみを効率よくインストールし、新しいキャッシュキーv1-deps-{{ checksum "poetry.lock" }}に保存することができるようになります。
しかし、この戦略には落とし穴があります。以下のシナリオを考えてください。
最初のpoetry.lockには以下の5つの依存関係があったとします。この時のchecksumを便宜的に1として考えます。
| 依存関係 |
|---|
| fastapi |
| pydantic |
| httpx |
| redis-py |
| celery |
すると、v1-deps-1 には上記5件の依存関係が含まれた状態になります。
次に、プロジェクトに変更があって、依存関係が次のように更新されたとします。
| 依存関係 | 変更 |
|---|---|
| fastapi | 維持 |
| pydantic | 維持 |
| - 削除 | |
| - 削除 | |
| - 削除 | |
| playwright | + 追加 |
| google-genai | + 追加 |
この時のchecksumを2としましょう。
この状態で初めてビルドしたとします。
新しいチェックサムを使ったv1-deps-2は当然ヒットしません。
しかしながら、v1-deps-の部分一致キーがv1-deps-1を拾ってきてくれます。
その結果、restore_cacheが完了した時点でのvenvには上記5件の依存関係がインストールされた状態になります。部分一致キャッシュの成功です!
さらに、poetry installで追加された依存関係をインストールしましょう!するとどうでしょう、最終的なvenvのメンバーは以下の7件になりました。
| パッケージ | 状態 |
|---|---|
| fastapi | ◯ 必要(既存) |
| pydantic | ◯ 必要(既存) |
| httpx | × 不要 |
| redis-py | × 不要 |
| celery | × 不要 |
| playwright | ◯ 必要(新規) |
| google-genai | ◯ 必要(新規) |
これをsave_cacheでv1-deps-2に保存するわけですが、悲しいことに本来不必要な依存関係がキャッシュに入ってしまいました。
このようなことが今後続いていけば、キャッシュには使わない依存関係がどんどん溜まっていき、無駄なアップロード時間とダウンロード時間を使うようになってしまいます。
どうすればよかったか
パッケージマネージャによるインストールの時、lockファイルにある依存関係のみを残すようにしましょう。
そうすることで、古いキャッシュから持ってきた既存のインストール済みパッケージは流用でき、もはや使われていないパッケージは環境から削除することができます。おそらくほとんどのパッケージマネージャが以下のような、使っていない依存関係をクリーンナップするコマンドをサポートしているはずです。
| 言語 | コマンド |
|---|---|
| Python (poetry) | poetry sync |
| Ruby (bundler) | bundle clean |
落とし穴2. インタプリタの更新により使えないキャッシュがヒットする
次のシナリオは、インタプリタを更新した時です。
python3.13.3を3.14.3に更新した時のことを考えてみましょう。
このとき、インタプリタのバージョン変更のみを行う場合、lockファイルには変化がありません(あるかもしれませんが一旦なかったこととします)。
いつも通りCIを流しましょう。
ビルドセクションは何の問題もなく通ったように見えます。
問題は、これをrestoreして使おうとした時に起きます。
おそらく、テストやlintセクションで、依存関係がないよ!というエラーが出るでしょう。ビルドはパスしたのに、なぜでしょうか・・・?
ライブラリは、インタプリタのバージョン下に保存される
PythonでもRubyでも、パッケージ管理ツールを使う場合、概ねライブラリは以下のように、インタプリタのバージョンで区切られて保存されます。
.venv/lib/python3.13/site-packages/様々なパッケージら
当然、これらは内容が同じであってもpython3.14から使うことはできません。特に、C拡張などのビルドが必要なパッケージの場合、対象のランタイムや環境に合わせてインストール時にビルドをすることがありますから、これらは他のインタプリタや環境では使い回すことができません。
これを受けて、CIのビルドセクションで何が起こるかを見ていきましょう。
- restore_cache:
keys:
- v1-deps-{{ checksum "poetry.lock" }}
- v1-deps-
restore_cacheでは、lockファイルのチェックサムが変わらないので、3.13.3のキャッシュがヒットします。しかしながら、このキャッシュは3.14.3からでは役に立ちません。
- run:
name: Install Dependencies
command: |
poetry sync
実質的なキャッシュミスなので、poetryは3.13.3のvenvは無視して、3.14.3用に依存関係をインストールします。キャッシュミスになりましたが、問題なく環境を作ることができました。
しかし、問題は次です。
CircleCIのキャッシュはイミュータブル
作った環境でキャッシュを更新すれば完了です。しかし、次のコードは何もしません。
- save_cache:
key: v1-deps-{{ checksum "poetry.lock" }}
paths:
実はCircleCIのキャッシュは変更不可で、キーに何か入っていたらその後 save_cache をしても値は更新されないのです。公式ドキュメント
CircleCIのコンソールを見にいくと以下のメッセージが出ているでしょう
Skipping cache generation, cache already exists for key: xxxxxx
ビルドセクションで新しく作られた3.14.3用の環境は、どこにも保存できませんでした。
その結果、後続のlintやtestでは、restore_cacheによって3.13.3用の環境をロードし、依存関係の解決に失敗するというわけです。
どうすればよかったか
キャッシュのキーに環境やインタプリタのバージョンを含めましょう。
v1-deps-{{ arch }}-<< pipeline.parameters.python-version >>-{{ checksum "poetry.lock" }}
こうすることで、lockファイルだけでは判別できない環境やインタプリタによる差分を吸収することができます。
まとめ
- CircleCIのrestore_cacheにはキーを複数指定できて、上から順に前方一致でマッチングする
- キャッシュには最低限の依存関係だけを保存する
- CircleCIのsave_cacheはイミュータブルで、一度設定されたキーの内容は上書きできない
- 依存関係はインタプリタや環境にも依存するので、キャッシュキーにはlockファイルの情報だけではなく、それらの情報を含める必要がある
最後に修正したテンプレートを貼っておきます。
version: 2.1
parameters:
runtime-version:
type: string
default: "1.0.0"
workflows:
lint_and_test:
jobs:
- build
- test:
requires:
- build
- lint:
requires:
- build
jobs:
build:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ arch }}-<< pipeline.parameters.runtime-version >>-{{ checksum "deps.lock" }}
- v1-deps-{{ arch }}-<< pipeline.parameters.runtime-version >>-
- run:
name: Install Dependencies
command: |
# Commands to install dependencies
# 不要な依存関係はクリーンナップする!
- save_cache:
key: v1-deps-{{ arch }}-<< pipeline.parameters.runtime-version >>-{{ checksum "deps.lock" }}
paths:
- ./path/to/dependencies
test:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
parallelism: 5
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ arch }}-<< pipeline.parameters.runtime-version >>-{{ checksum "deps.lock" }}
- run:
name: Run Tests
command: |
# Commands to run tests
- store_test_results:
path: test-results
lint:
docker:
- image: cimg/some_language_runtime:<< pipeline.parameters.runtime-version >>
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ arch }}-<< pipeline.parameters.runtime-version >>-{{ checksum "deps.lock" }}
- run:
name: Run Linting
command: |
# Commands to run linting
よきCIライフを!
参考記事