🗄️

CIのキャッシュメカニズム: ブランチベースのキャッシュとキーベースのキャッシュ

2024/02/28に公開

仕事で使っているBitriseのキャッシュメカニズムを、ブランチベースのキャッシュからキーベースのキャッシュに切り替えることにしました。
ただ、これらのメカニズムの違いや、何を解決したかったのかをちゃんと理解してなかったので調べました。

なお、本記事ではCIサービスはBitrise、ビルド対象はAndroidアプリとして言及します。
他のCIサービスやビルド対象については分かりかねますが、例えばCircleCIなどでもキーベースキャッシュは採用されていたりしますし、iOSアプリもCocoaPodsなどのキャッシュ保存などでキャッシュを考慮することがある為、ある程度読み替えて使える話はあるのかなと思います。

そもそもどうしてCIでキャッシュを保存すると良いのか

https://bitrise.io/blog/post/guide-to-dependency-caching-and-build-caching

https://bitrise.io/blog/post/ci-cd-caching-with-bitrise-what-is-cache-and-why-you-should-care-about-caching

CI環境でキャッシュを保持する理由は、端的に言えば、「CIにかかる時間を短縮することで待ち時間および費用面のコストを削減する」ことが理由です。
まずここを説明するに当たって、通常アプリを開発する環境とCIでビルドだけ行う環境にどういう違いがあるのかを説明します。

ローカル環境とCI環境の違い

ローカル環境でアプリ開発をする場合、軽微な修正であれば数秒でビルドが完了し、実機などで動作確認ができます。これは、IDEやSDKなどの支援により、様々なキャッシュの再利用やインクリメンタルビルドなどのテクニックを駆使して待ち時間削減の恩恵を受けているためです。
ただ、これら高速化のテクニックも、例えばGitリポジトリをcloneしてきた直後の初回ビルドやキャッシュを消したタイミングなど関しては適用できるキャッシュがないためそれなりに時間がかかります。

CI環境下のビルド環境は、毎回まっさらな環境が作成されます。よって、利用できるキャッシュは存在しないので、高速化をとりうる余地がありません。ならば明示的にキャッシュを作成・保存し、次のビルド環境でそのキャッシュを読み込むことが出来れば効率化が図れるかも知れません。

依存関係のキャッシュ

Androidアプリではどんなアプリであれ(公式、サードパーティー関わらず)ライブラリに依存しています。
build.gradledependencies やビルドに必要なプラグイン(KotlinのGradleプラグインなど)は、ビルドしたいアプリがビルドするために依存しているものとなりますが、その依存するライブラリ類を取得しないとビルドが出来ないということになります。

この依存関係の解決は、キャッシュが存在しないなら原則としてネットワーク経由でダウンロードすることになりますし、毎回CIが走るたびに行われてしまいます。

基本的にMavenリポジトリのアーティファクトにはバージョンが振られており、バージョンを指定したダウンロードで入手出来るアーティファクトは原則として同じバイナリを取得できます。毎回おなじバイナリをダウンロードするのは効率が悪いため、キャッシュとして保持すると効率的です。

また、キャッシュを保存する場所は、ネットワーク的にCIインスタンス環境の近くである(と考えられる)のですが、Mavenリポジトリから取得されるデータは遠くにあったり貧弱な環境だったりすることもあり常に速度が安定しているとも限らないため、そういった問題を緩和する目的でもキャッシュがあると安定するかも知れません。

なお、依存関係のキャッシュについて、個人的には「やって損なし」という考えですが、 キャッシュ取得の時間 + キャッシュ保存の時間 < 依存関係のダウンロードにかかる時間 という関係式が成り立っているときに限り効果があると見なせます。

ビルドキャッシュ

ローカル環境での開発のような迅速さをCI環境でも利用できるようにするため、ビルド時の中間ファイルを保持・書き戻しをすることで効率を上げることができます。

たいていのビルドシステムはシステムのどこかに中間ファイルを書き出すため、そのフォルダを保持・書き戻しすればキャッシュできていることになります。

ただし、ローカル環境でもしばしば問題になるのですが、キャッシュが悪さをしてビルドができなくなったりアプリが壊れたりすることが多少発生することがあります。また、このとき発生するエラーは直感的でなく原因究明が難しいことが多いので、起こりうるエラーとしては厄介な部類だと個人的に感じます。

個人的な見解ですが、ビルドキャッシュを用いる高速化はあまり乗り気ではないです。私はCI環境には再現性と安定性を求めるため、キャッシュによる不定期のエラーはそれに反するためです。
しかしコスト削減を徹底的に求めるなら、考えても良い手段ではあると思います。

ちなみに、中間ファイルは肥大化する傾向があるため、キャッシュ保持の時間とキャッシュ書き戻しの時間が逆にボトルネックにならないように、チューニング後の経過推移を観察する必要があるように個人的に思います。

ブランチベースのキャッシュメカニズム

https://devcenter.bitrise.io/en/dependencies-and-caching/branch-based-caching.html

https://web.archive.org/web/20230320195711/https://bitrise.io/blog/post/ci-cd-caching-with-bitrise-dependency-caching-then-vs-now
(※ よくまとまっている記事なんですが、なぜか削除されているのでアーカイブ版を置いときます)

ブランチベースのキャッシュメカニズムは、Gitのブランチ名に基づきキャッシュを保持する仕組みです。Bitriseでは少なくとも2023年2月頃までは唯一正式にサポートされていたキャッシュメカニズムです(2月にキーベースキャッシュが登場したため)。

例えば、プルリクエストを提出し(A)、レビューの指摘を修正したものをコミットした(B)場合、まずAの時点でキャッシュを生成し、BではAで作成したキャッシュを利用できるようになります。

ブランチベースのキャッシュは以下のstepを用います。

問題点

ブランチベースのキャッシュは、ブランチをまたいでキャッシュをやりとりすることが出来ません。
例えばブランチXとブランチYで構成(build.gradleなどの依存関係に関与するファイル)が同一だったとしても、それぞれのブランチの初回ビルド時はキャッシュの恩恵を受けることが出来ません(ブランチXの初回ビルドが済んでいて、ブランチYで構成が一致しているならキャッシュを使い回してくれても良いのにね・・・)。

また、ブランチ内のキャッシュは上書きされます。構成が変化した後、revertなどがかかって構成が戻ったとしても、保持したキャッシュ自体は巻き戻ることなく肥大化したままとなるため、やや効率が良くありません。

キーベースのキャッシュメカニズム

https://devcenter.bitrise.io/en/dependencies-and-caching/key-based-caching

https://bitrise.io/blog/post/key-based-caching-is-now-out-of-beta

https://bitrise.io/blog/post/ci-cd-caching-with-bitrise-dependency-caching-with-bitrise

キーベースのキャッシュメカニズムは、キャッシュ保持の要件を満たしつつ、先ほど挙げた問題点を解決した仕組みです。
キー(文字列)と値(キャッシュ)の関係で管理され、技術的制約がなければ、どのブランチでいつ作成したキャッシュなのかによらず、キャッシュを読み込むことが出来ます。

キーにはファイル(群)のチェックサムを用いることができます。たとえばリポジトリ内の全てのbuild.gradleファイルから単一のチェックサムを算出すれば、build.gradleのいづれかかが更新されるとチェックサムが変わるため別のキーとなるので、これまでのキャッシュはヒットしなくなります(厳密にはBitriseでは設定によってはフォールバックします)。

先ほどの例で説明すると、ブランチXとブランチYが同じ構成だと、その構成で最初に生成されたキャッシュが以降のビルドでも使い回されます。また、それぞれのブランチの初回ビルド時もキャッシュが存在すれば使用してくれます。

また、構成が更新されて依存関係が追加・削除された場合、キャッシュキーが更新されるため、既存のキャッシュを上書きしません。これは、依存関係の調整をrevertしたとき、同じキャッシュを取得できるため、ムダがなく再現性も高いと言えます。
(なおBitriseでは同一キーでキャッシュが上書きできるため、ずっと同じキャッシュになる保証はないことを留意してください)

ちなみにキーベースのキャッシュは、ブランチベースのキャッシュのような動作をさせることができます。キーの要素としてブランチ名を使用すればいいのですが、せっかくのブランチをまたいで適用できる良さを消してしまうため、あまりお勧めしません。

キーベースのキャッシュは以下のstepを利用します。また、プロジェクトごとに設定が調整されているstepが提供されています。

Bitrise固有の話

  • Bitriseでは、キーベースキャッシュは圧縮アルゴリズムも改善されているため、単にブランチをまたいでキャッシュ適用が出来るようになった以上の効果があります
  • ブランチベースでもキーベースでも、キャッシュの保持期間は7日間で、7日を過ぎると削除されます。7日以内に利用すると保持期限はリセットされます。
  • ブランチベースのキャッシュにおける cache-pull は、当該ブランチにキャッシュがまだないときはデフォルトブランチのキャッシュを使うようにフォールバック動作します
    • ただし、後述するデフォルトルールにより、デフォルトブランチがコンテキストのキャッシュが存在しないことが殆どだと考えられます
  • ブランチベースのキャッシュ保存における cache-push は、初期設定においては run_if: ".IsCI | and (not .IsPR)" となっており、PR以外でのジョブ実行ではキャッシュ保持しないようになっています
  • いっぽうキーベースのキャッシュにおける save-cacherun_if: .IsCI となっています。ブランチに依存しないため柔軟にできるようです。
  • ブランチベースのキャッシュ保存における cache-push は、stepのパラメータに設定されたパスだけでなく、環境変数 $BITRISE_CACHE_INCLUDE_PATHS および $BITRISE_CACHE_EXCLUDE_PATHS に設定されているパスもキャッシュ保存・除外に利用します
    • android-build stepは内部的にこの仕組みを用いており、暗黙的にキャッシュ保存パスを設定してくれます
  • なお、キーベースのキャッシュ保存における save-cache は、そういった暗黙的な挙動がありません(個人的には分かりやすくて好み)
    • ただそれだと設定がめんどくさいので、Android(というかGradle)なら save-gradle-cache/restore-gradle-cache を使うとラクです
  • 技術的な制約として、BitriseではOSを横断したキャッシュ適用が出来ません(LinuxとmacOS間)。これはクロスプラットフォームアプリ開発をしているときにやらかしがちなミスなので気をつけたいです
    • そもそもキャッシュヒットしないように、 {{ .OS }}-{{ .Arch }}- というprefixを足しておくと良く、実際 save-gradle-cache などは標準でそのように設定されています

まとめ

ブランチベースのキャッシュとキーベースのキャッシュについて調べ、その差などを検討しました。
基本的にキーベースのキャッシュのほうが性能や表現力に優れており、Bitriseに関しては圧縮アルゴリズムなども改善されていることを確認しました。

新料金プランのみ利用できる機能ではあるのですが、利用できる機会があるなら使わないと損だなと思いました。

現場からは以上です。

Discussion