仕事でCI全般のお悩み相談されることが多くて .circleci/config.yml
や .gitlab-ci.yml
をリファクタリングすることがよくあるのですが、その時に一番意識してるキャッシュ戦略について長年自分の中の暗黙知になっていて明文化できてなかったので書きます。
前置き
用語の定義
GitLab CIとCircleCIで用語が違っているので説明しやすくするために最初に定義します
- キャッシュ:CIのビルドの実行を高速化するために保存しておくもの
- ジョブ : CIで実行するスクリプト群の最小単位
- ワークフロー:複数のジョブの集合で1コミットで実行される最小単位
- GitLab CIだとpipeline、CirclrCIだとworkflowにあたる
その他
- あくまで僕のポリシーなので異論は認めます
GitLab CIとCircleCIの両方に共通すること
キャッシュを過度に使いすぎない
複数のジョブ間でファイルを受け渡すためにキャッシュを利用することがよくあると思うのですが、個人的にこれはアンチパターンだとおもています。
理由はジョブ間が密に依存しているとCIの設定ファイル全体が複雑になり、リファクタリングの障壁になるためです。僕がCIの設定ファイルのリファクタリングをする時もまずはじめにこの手のキャッシュ依存をなくし、各ジョブをステートレスにしてワークフロー全体をシンプルにしています。
許容できるケース
CircleCIの persist_to_workspace
と attach_workspace
です。
https://circleci.com/docs/ja/2.0/configuration-reference/#persist_to_workspace
これは同一ワークフロー内でジョブ間でファイルを受け渡すための機能なので無問題。
特に persist_to_workspace
を複数回読んだ後に attach_workspace
を呼び出すとそれまでの persist_to_workspace
で保存した結果全てが取り出せて便利です。
GitLab CIだとキャッシュのKeyに $CI_PIPELINE_ID
*1を入れることで persist_to_workspace
と attach_workspace
に近いことはできますが、GitLab CIだと1つのジョブで1種類のキャッシュしか使えない関係で前述のように複数のキャッシュをマージして受け渡せないという問題があり、キャッシュに依存しすぎてると詰むことになります。
GitLab CIだとArtifactsでファイルの受け渡しをするのであれば可。
11/6追記
GitLab CIでもArtifactsでジョブ間でファイルのやり取りできたのを失念してたので修正。
ないと困るものはキャッシュにしない
キャッシュはあくまでもビルドを速くするために存在するので、キャッシュがあればラッキー、キャッシュがなくてもビルドが多少遅くなるだけでビルド自体は成功するって形の方がハマりが少ないです。
10/30 12:00追記
ここで言っているキャッシュというのは、例えばジョブの前段でライブラリをインストールして後続のジョブでそのライブラリがインストールされていることを前提にしているようなケースで引き回しているキャッシュのことを言っていて、ビルドの最終成果物であるArtifactsとは異なります。
ライブラリのインストールに時間にもよるんだけど基本的にはジョブわけない方がシンプルでメンテしやすいです。
persist_to_workspace
使うかどうかは好みによります。(個人的にはこの程度なら使わなくてもいいのでは派)
キャッシュをバージョニングする
キャッシュを互換性のないレベルで仕様変更するために古いキャッシュが使われないようにしたいことがあると思います。
GitLab CIだとリポジトリ単位でCIのキャッシュの全クリアができるのですが、CircleCIだとそういう機能がありません。
僕はよく v1-xxxx
のような感じに先頭にバージョン情報を付与していて、必要に応じてインクリメントしています。
複数のブランチでキャッシュを共有できるようにする
キャッシュのkeyにブランチ名が入っているとその異なるブランチ間でキャッシュの共有ができません。
そうすると初めてブランチをpushした時に必ずキャッシュが存在しない状態でフルで bundle install
や npm install
されるような状態になるので、CI全体の時間で見ると遅くなります。
複数ブランチでキャッシュが共有されるとライブラリのインストールやバージョンアップが複数ブランチで同時進行しているとインストール合戦になってキャッシュがうまく使われなくなるデメリットがありますが、そういうのはレアケースなので複数ブランチでキャッシュが共有されて普段のビルドが速くなる方がメリットの方が多いと思ってます。
GitLab CI固有の話
GitLab CIのキャッシュの仕様
- 同一のkeyに対して上書きができる
- キャッシュのkeyは完全一致
- keyに一致したキャッシュが存在しなければ取得されない
- GitLab CIのキャッシュの実態はzipファイルなので、キャッシュのkey名でzipファイルが作られていると考えればイメージしやすいでしょう
CircleCI固有の話
CircleCIのキャッシュの仕様
CircleCIのキャッシュはGitLab CIに比べてトリッキーなので注意が必要です。
同一のkeyで一度キャッシュが作られたら上書きができない
save_cache
で同名keyでキャッシュが保存できなくても「そのkey名で既にキャッシュを作成してるから作らなかったよ」ってログが出るだけでエラーにはならないため気づきづらいです。
https://circleci.com/docs/ja/2.0/configuration-reference/#save_cache
そのためCircleCIでは myapp-{{ checksum "package-lock.json" }}
のようにキャッシュ全体のdigestをキャッシュのkeyに付与する必要があります。
キャッシュのkeyは前方一致
割と勘違いされがちなのですが、CircleCIのキャッシュのkeyは完全一致ではなく前方一致です。(完全一致するkeyがなければ前方一致するkeyのうち最新のものが返る)
またCircleCIの restore_cache
では下記のようにキャッシュのkeyを複数設定でき、上から順番に評価されます。
- restore_cache: keys: - v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "Gemfile.lock" }}-{{ .Branch }} - v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "Gemfile.lock" }} - v1-{{ .Environment.CIRCLE_JOB }} - v1
この場合のキャッシュがヒットするルールはこんな感じです
- 同一ジョブ・同一Gemfile.lock・同一ブランチでキャッシュが存在すれば復元される
- 1でキャッシュが復元できなかった場合、同一ジョブ・同一Gemfile.lock・別ブランチの一番新しいキャッシュが復元される
- 大抵の場合masterブランチで保存したキャッシュが復元される
- 2でキャッシュが復元できなかった場合、同一ジョブ・異なるGemfile.lock・別ブランチの最新のキャッシュが復元される
- 3でキャッシュが復元できなかった場合、同一バージョン(v1)のキャッシュが復元される
- 4でキャッシュが復元できなかった場合、キャッシュは復元されない
https://circleci.com/docs/ja/2.0/configuration-reference/#restore_cache
どうしても最新の状態でキャッシュを保存し続けたい時
同一key名だとキャッシュを上書きすることができないのですが、下記のように save_cache
で epoch
(エポックタイム)を含んだkeyのキャッシュを作ることで、 restore_cache
で最新のキャッシュ復元できるようになります。
- restore_cache: keys: - v1- - run: # キャッシュを作成する処理 - save_cache: key: v1-{{ epoch }} paths: - /caches/image.tar
せやかて
CircleCIのキャッシュの仕様を完全理解した状態で設定を書くのは無理ゲーだし設定ファイルも複雑になりがちなので他の人が作ったorbを使うのがいいでしょう。
Rubyのリポジトリであれば下記orbに僕のCircleCIキャッシュの知見が全て詰まっているので何も考えなくてもいい感じにキャッシュを活用することができます。
https://circleci.com/orbs/registry/orb/sue445/ruby-orbs
詳しくは下記を参照