CircleCIにおけるsbtプロジェクトの依存ライブラリの最適なキャッシュ方法の考察

CircleCIにおけるキャッシュの仕組みというか仕様は、他のCIサービスと比べると、少し変わった特徴がある気がします。

といっても、自分は他にはTravisCIくらいしか詳しくないので、実はCircleCIのように色々工夫している方がむしろ最近は主流な可能性もありますが、そこは本題ではないし、詳しくもなく話せないので話しません。

まずは、公式ドキュメントがしっかり書かれているので、それをざっくりと読んでみてください。

circleci.com


完全に理解する必要はないですが、ある程度読んでいる前提でこの先の話は進めます。 *1

また、以前書いた件は依存ライブラリのみならずjob(?)をまたいだときにclassファイルやインクリメンタルコンパイルの情報を丸ごとキャッシュする話でしたが、

xuwei-k.hatenablog.com

今回はあくまで依存ライブラリのキャッシュだけについて話すので、同じキャッシュですが、ある意味全然違う話をします。



前述したように、自分はCircleCI以外だと、TravisCIくらいしか詳しくないので、それとの比較になってしまいますが、 TravisCIでのキャッシュは、良くも悪くも機能がシンプルというか、少ないというか、単純です。

基本的にキャッシュしたいディレクトリを .travis.yml で指定するだけです。


一方のCircleCiですが、もちろん

キャッシュしたいディレクトリを指定

は同様ですが、他に以下のような特徴があります

  • キャッシュには、Keyという概念を使って、名前をつける
  • Keyごとにキャッシュは不変であって、変更や直接的に削除さえ不可能*2 *3
  • Key毎に不変、ということから、ある意味導かれる性質というか、それを言い換えただけになるだが、Keyに変更がなければキャッシュを再生成してのアップロードは、実行されずにスキップされる
  • どのKeyのキャッシュを使うか?に関して、複数優先順位付きで指定可能で、目的のKeyと完全にマッチしない、微妙に別のキャッシュを使う、という設定にもできる

キャッシュ含めそれ以外でも、良くも悪くも(?)思想があって、単純に他のCIサービスを真似するわけではなく、わざと違っているような部分があるようですね(?)

その他キャッシュ以外 https://twitter.com/CircleCIJapan/status/1027378227965116417


ここまでCircleCIに共通の話で、sbtやScala特有の話が全く出てきていませんが、上記で説明したことが理解出来てないと、全くこの先の話が続かないというか理解できない気がするので、ある程度説明しました。

まだすぐにはsbtやScala特有の話にならないですが・・・しばらくお待ち下さい。

(あるいは結論だけ知りたい人は途中読み飛ばしてください)



"依存ライブラリのキャッシュ" という文脈というか目的において、あらためて

  • なぜそれをやるのか?
  • どういうキャッシュを目指すべきか?

を整理してみましょう。


目的としてはおそらく

ビルドの高速化

が一番優先度が高いのではないでしょうか? 他に関連するような微妙に別の観点として

  • ネットワークに負荷をかけたくない
  • (有料のCIサービスの場合)全体として料金面のコストを抑える

など、挙げようと思えばいくつか挙げられる気がしますが、少し脱線するのと、大抵の人は ビルドの高速化 が目的だと思うので、その目的のみ、という前提で話を進めます。


ビルドの高速化 という観点においての 依存ライブラリのキャッシュですが、

まず、キャッシュはあくまでもキャッシュであって、それがないとビルドできない、というものではなく、キャッシュがあったら速くビルドできるが、キャッシュが無くてもビルド自体は(基本的にはいつでも)可能、というものをキャッシュと呼ぶべきでしょう。


ここまでの前提をもとに、どういったキャッシュを目指すべきか?というと、以下にあげる点でしょうか

  • キャッシュが壊れない
    • あるいは壊れたことをできれば自動で検知できる
    • 壊れたことが手動検知だとしても、不正なキャッシュをうまく削除できる
  • 必要なものだけをキャッシュする。当たり前な言葉?で、更に言い換えると
    • 必要なライブラリ(sbtの場合ならjar)は、キャッシュに入っていてほしい
    • 必要ないもの(jar)は、キャッシュに入らないでほしい(必要なくなったら除かれて欲しい)
  • キャッシュの処理自体に時間がかかり過ぎたらキャッシュの意味がなくなるので、必要なときに必要なだけキャッシュを生成、更新、削除などする

"キャッシュが壊れない"件ですが、言及はしましたが、CircleCIの公式ドキュメントにも書いてあるとおり、(相対的に他のツールと比べれば?)基本的にsbtというか、関連するmaven centralリポジトリやcoursierなどは、versionのrangeなどを使っていなければ)、不変であって、原理上簡単に壊れたり、あまりビルドの再現性がなくなったりはしないはずなので、こちらの話は今回は深くしません。*4

versionのrangeなど とは [1.0,)1.0.+ という書き方があるのですが、好みがわかれるというかプロジェクトの方針にもよるのでしょうが、lockファイル的な仕組みがないのにこれをやるのはビルドに再現性がなくなって怖いので、個人的には絶対使いたくない派です。

ant.apache.org

(sbtには独自にlockのpluginなどがあるにはありますが、あまり普及していないのと、sbt pluginの部分のmeta buildのほうも考えるとそれだけでは完璧ではない、など)


"必要なライブラリは、キャッシュに入っていてほしい" だけなら、ある意味簡単に実現できます。ライブラリがダウンロードされて保存されるディレクトリを指定するだけです。

例えば(他にも関連ディレクトリはありますが)これを書いている2020年3月時点の最新安定版であるsbt 1.3.8では、デフォルトでLinuxでは ~/.cache/coursier/v1 に依存ライブラリはダウンロードされて保存されます。

なので、そこを指定すればいいだけです。


逆に言うと、繰り返しになりますが、TravisCIなどはそこを指定したら終わりで、それ以上なにもないですね?

しかしCircleCI特有の話をするので話が続きます。


ビルドツールや言語によるでしょうが、おそらく多くのビルドツールでは

  • 必要ないものは、キャッシュに入らないでほしい(必要なくなったら除かれて欲しい)
  • キャッシュの処理自体に時間がかかり過ぎたらキャッシュの意味がなくなるので、必要なときにだけキャッシュを生成、更新、削除などする

あたりを完璧にやろうとすると、意外と難しいというか、そこまで簡単にはいかないと思っています。

もちろん、単なるキャッシュなので、完璧にやる必要はないのですが、キャッシュについて多少深く考察するエントリーなので・・・。



必要なくなったら除かれて欲しい

という点が、理想的には

現状のprojectで使うライブラリ(jar)が完全にわかる(列挙できる)

という状態を実現できれば、必要がないものを消したあとに残ったものから、キャッシュを再生成すればいいだけです。

しかし、少なくとも自分が知る限り、sbtにはそんな機能は直接的には存在していません。

classpathなどのkeyから頑張って出力すれば原理的には不可能ではないと思いますが、sbt自体が使うものや、sbt plugin関連もあるので、そこも含めるとするなら若干ややこしいです。

それが可能だとしても、その列挙や削除自体に時間がかかったら意味がない、というのもあります。

また、キャッシュのディレクトリに実質直接アクセスのようなことをして、同じライブラリの古いversionの方を問答無用で消す、といった手法も原理上は可能で、だいたいはうまくいくかもしれませんが、稀に同じライブラリの新旧両方のversionを使っているパターンもありえるので、古いものを雑に消すだけでは完璧でもありません。(何かの都合で古い方にあえて下げた、というパターンもあり得るので、それにも対応不可能になってしまう)



そこで、少し妥協して

現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを作り直す

が現実的でしょうか?そうすれば、たいてい最適なキャッシュになるはずです。 現実的、というか、結局あたり前のことを言っているだけですね?ただ、どう作り直すのか?と "変更された可能性がある" の部分の細かい検知の仕方の話があるので、当たり前といっても、よく考えると色々選択肢があります。

さて、上記のように少し妥協するとしたら、このあたりから "キャッシュの作り直し方" の違いによって、微妙なトレードオフになると思うのですが、つまり以下の2択が考えられるはずです

  • 現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを最初から作り直す(全部ダウンロードし直す)
    • キャッシュが常に最適、最小(古いものが消える)メリット。作り直すときに時間はかかるが、一度作れば、最適、最小だと、その後のダウンロードや解凍の時間が短縮される
  • (全部ダウンロードし直す手間が結構大変な場合)少しくらい古いものが混入したままでも、sbtの構造上、使われないだけでバグるわけではなくキャッシュが肥大化するだけなので、少し変わった程度なら最初から作り直すのではなく追加する
    • 少し変更されただけなら、今までキャッシュが大体使えるという観点では効率が良い
    • 多く変更された場合や、変更が積み重なると、古いものが入り混じったままになりキャッシュが肥大化して、ダウンロード、解凍、アップロード、圧縮にかかる時間が増大するデメリット
      • 依存ライブラリは、油断すると簡単にGByte単位になるので、それらの時間は結構無視できない時間になり得る
      • 雑な実体験として、数GByteになると、それらの合計が1分簡単に超えたりする
      • CircleCI公式でもキャッシュは500MBくらいにしておけ、と書いてある



説明がずいぶん長くて、自分で書いていても疲れてきましたが、やっとこのあたりでCircleCIのkeyの話に繋がります。

疲れてきたのでKeyについてのある1つの解答をいきなり示すと、例えばこうなります

sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}

解説すると

  • CircleCIの公式でも示されているように、慣習的にも、あとで変更するためにも、v1とかのprefixつけておく
  • sbtでのビルドで必要なキャッシュは、主に以下の3種類の要素から決定されるはずなので、それらのchecksumを全部keyとして使う*5
    • (なにもpluginを指定していない場合でも) sbt自体が使うjarや、sbt自体のjar。sbtのversionにより決定される。sbtのversionは project/build.properties によるはず
    • sbt pluginが使うjar。 project/任意の名前.sbt による。複数ファイル作成可能だが1つにしておく
    • 普通のプロジェクトの依存ライブラリ トップレベルの任意の.sbtproject/任意の名前.scala のどこにでも書けるが、これをやるならどこか一箇所にまとめる(今回は project/Dependencies.scala。まとめるのと同時に、依存ライブラリ以外の設定を書くと、それを更新した際に意味なくキャッシュ更新がされる可能性があるので、依存ライブラリ関連以外は逆にこのファイルに書かない方が良い)
  • checksumに使っている3つのファイルの順番指定は、意味がある場合もあって多少考えたほうがいいかもしれないが、そこまで最重要でもない?ので、一旦詳細な説明割愛(上記に書いた順序がべつにオススメなわけではない)


公式の説明にもありますが、例えばKeyにBranch名など入れると

必要なときにだけキャッシュを生成、更新、削除

が実現できないため、Branch名やその他は使っていません。Branchが変わったからといって、必ずしも依存ライブラリが変わるとは限りませんよね? 依存ライブラリに変更がないのならばキャッシュを別のKeyで再生成するのは無駄な処理です。

(それをいうなら、上記のchecksum方式も厳密に考えると完璧ではないが、大抵の場合Branch使うだけよりは正確性が増すはず)



ここでやっとCircleCI特有の

どのKeyのキャッシュを使うか?に関して、複数優先順位付きで指定可能

あたりの話になります。

このKeyにおいて

  • 複数指定するべきでしょうか?
  • 指定するとしたら、どういう指定がいいでしょうか?

これが、解答が一意ではないというか、トレードオフの話に戻るのですが

  • 現状のprojectで使うライブラリが変更された可能性があるのならば、キャッシュを最初から作り直す
    • としたいなら、Keyはこの1種類のみ指定するべき
  • 少し変わった程度なら最初から作り直すのではなく、追加する
    • としたいなら、以下のように適当に複数指定するべき
keys:
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}
  - sbt-cache-v1-{{ checksum "project/Dependencies.scala" }}
  - sbt-cache-v1

となるはずです。


なぜそうなるのか?の詳細な解説はしませんが、CircleCIのキャッシュのKeyの概念を正しく理解しているのならば、あとはわかるはずです。 (説明長くなって疲れただけでもある・・・)


複数Keyを指定する場合は、依存ライブラリの更新があるたびに、キャッシュは原理上、徐々に肥大化するはずなので、適切なタイミングで v1 の部分を v2 などにインクリメントするなどして、キャッシュをリフレッシュしたほうがいいです。

あるいは、わざわざconfigファイル変えるのが面倒な場合、(CircleCI側の思想に反する気がするけれど)そこに環境変数を埋め込んでおいて、それ使用してリフレッシュ、という手法もあるようです

engineer.crowdworks.jp

  - sbt-cache-{{ .Environment.CACHE_KEY }}-{{ checksum "project/Dependencies.scala" }}-{{ checksum "project/plugins.sbt" }}-{{ checksum "project/build.properties" }}



タイトルが

最適なキャッシュ方法の考察

とあるように、ここまでの説明だけでは "最適なキャッシュ" にはたどり着いてません。途中で触れたように

現状のprojectで使うライブラリ(jar)が完全にわかる(列挙できる)

が実現できれば、ほぼ最適なものに近づくとは思うのですが、そこまで頑張る気がおきなかったのと、おそらく、一旦実用上は上記のどちらかで十分だと思ったからです。 *6


sbt + CircleCIという組み合わせで、自分がかるくググった限りでは、ここまでしっかり解説というか考察したものを見かけなかったのですが、 間違いや、さらなる改善のテクニックなどの情報がもしあれば教えて下さい。


追記1

追記2

こんなに途中の思考過程の説明書いてはいないけど、だいたい同じ結論に達していたやつは普通にあったようです

*1:CircleCIの1.0系の頃だと色々と違ったらしいですが、2系という前提で話をします。

*2:CircleCIの1.0の古いやつだといろいろ違ったらしいですが、そこは詳しくないし本題ではないので話しません

*3:削除的なことをしたい場合は別のKey名にする

*4:あとは独自の、artifactの削除が簡単にできてしまうようなmaven repoがresolverにあったりしなければ

*5:特殊な設定ファイルが置いてあったり、特殊な起動方法をしたり、sbt起動後に通常とは違う方法で動的にダウンロードしたらこの限りではないが、脱線するしだいぶ例外的なパターンなので、そこには触れない

*6:全部列挙これでいけると思ったら無理だった話 https://twitter.com/xuwei_k/status/1239182522497388544