GitHub Actionsにおけるsbtでの依存ライブラリの最適なcache方法

5年半前(!?)にCircleCIで書いたことがあるのですが、それのGitHub Actions版として書き直す的なもの。

xuwei-k.hatenablog.com

タイトルにありますが、今回は「依存ライブラリ」のcacheの話だけで、他の話はしません。

色々な前提やポイントとしては

  • GitHub Actions独自の仕様や制約に注意
    • 普通、pull reqとpushなどのeventごとにやることを変える
    • cacheの合計のsize制限に注意
  • 頑張るなら公式の https://github.com/actions/cache のsaveとrestoreだけを使い、それらを分けて使う
  • GitHub Actions独自の仕様や制約を超えてさらに頑張りたいなら、独自のストレージに保存することも検討
  • cacheに余計なものが残って肥大化し続けないように注意
  • cacheのkeyの設計をしっかり考える
    • 頑張るならrestore-keysも設定
  • 効率的なcacheを追求するならbuild fileの書き方も工夫する
  • OS毎にcoursierのcache場所が異なるので注意
    • sbtの起動時の引数などで変更することも可能
    • 最近のsbtを前提としてcoursierとしているが、古いと $HOME/.ivy2/ だったりするとか、最近のsbtで $HOME/.ivy2/ 書いても意味ない

などでしょうか。

最近のsbt、あるいはgithub actionsのネットワーク環境は、調子が良い時はまぁまぁ優れているので、すごく雑にダメ過ぎるcacheを設定するなら、cache設定一切しない方がマシ、という可能性すらありえます。

GitHub Actions独自の仕様や制約に注意」ですが、これはsbtに限らずみんなハマって、ググるといくらでも出てくるので詳細は割愛しますが、大雑把に書いておくと

  • pull reqでcache保存しても、他のpull reqなどからはそのcacheは参照不可能
  • default branchで保存したcacheは他のpull reqその他のjobで参照可能

といった制約があります。これは確かセキュリティのためらしいです。OSSで悪意があるかもしれない人からpull reqが送られてくる環境を想定するなら確かに安心安全な仕様ですが、private repoで開発してる場合には、徹底的に効率的なcacheを求めると割と邪魔な制約です。

また、GitHub Actionsにはデフォルトでは10GBの制約があります。

つい最近?お金で増やせるようになったらしい?ですが

github.blog

それらを考慮すると、それらのデフォルトの制約の範囲内でやるなら、まず

「cache保存はpull reqでは行わない。default branchのみ、または特定のbranchへのpush時のみ」

を検討した方がいいです。pull req時に雑に保存しても、割と無駄に終わるというか、10GBの制約を超えてしまって必要なcacheが揮発してしまうデメリットすらありえます。

あくまで保存の話であって、restoreは別です。

ギリギリまで効率を追い求めるためにpull reqで保存もしたい場合は、少なくともそれを念頭に入れて、必要なcacheが揮発しないような範囲で頑張るようにしましょう。

monorepoっぽくなっていて、sbt以外の他のcacheも存在する場合は、特に10GBの制約超過は注意しましょう。

そして、最初の方に書きましたが、それらを超えて頑張るなら「独自のストレージに保存することも検討」しましょう。(つまりAWSのS3など)

そうすれば10GBの制限を気にする必要がなくなったり、pull reqで保存しても、それをmerge後のpush eventでも再利用出来ます。 (セキュリティの心配がない環境という前提は必要)

独自のストレージ保存の場合の話は脱線するので、今回はこれ以上詳細は触れません。

また、すでにこれも述べてますが、公式の範囲内で頑張る場合

「saveとrestoreだけを使い、それらを分けて使う」

が重要です。

つまり、特定のjobでは、あるいはpull reqの場合は

「 restoreはするがsaveはしない」

という設定にするべきだからです。

github actions公式の actions/cache において、saveとrestoreが分離して利用可能になったのは、おそらく以下の

github.com

2022年の最後の頃、versionでいうとv3からなので、逆にそれより大昔から使っている人ほど知らない可能性もありますが。

では具体的にどうするか?の話は最後にするとして、次の話題に進みましょう。

「cacheに余計なものが残って肥大化し続けないように注意」

「cacheのkeyの設計をしっかり考える」

と直結するので同時に話しますが、cacheのkeyは、理想的には日時やbranch名などを入れるのではなく、あくまで

「依存ライブラリについて変更の可能性があった時だけkeyが変わり、ライブラリの変更がなければkeyが変わらない」

という設計にするべきです。通常は依存ライブラリのversionなどが書いてあるファイルを列挙して、それに対してgithub actions公式の hashFiles に渡す、となるでしょう。

  • cacheのkeyが(hashFilesも何も使わずに)ほぼ固定
  • saveとrestoreを正しく使い分けてない

などのミスが重なると、もう使ってない依存ライブラリの古いversionがいつまで経ってもcacheに残り続けてcacheがずっと肥大化してしまう、ということになりかねません。

さて次に

「効率的なcacheを追求するならbuild fileの書き方も工夫する」

ですが、sbtのbuildファイルは、良くも悪くもScalaでなんでも書けて便利ですが、依存ライブラリに関連する部分と、全く関連しない部分の全部が入り混じって書けてしまうので、ある程度依存ライブラリの記述はファイルを分けておくことをオススメします。 例えば(そんなにすごく標準的な慣習でもないけど) project/Dependencies.scala に書いて、それ以外には書かないようにする、というパターンはよく見かけますね。

そうすればcacheのkey指定の際に全部を指定する必要はなく

  key: ${{ hashFiles('project/Dependencies.scala') }}

のようなもので済むからです。

それ以上徹底的に頑張りたいならば、独自にlockfileのような

「実際に使われる依存ライブラリ一覧を列挙」

したファイルを作り、それをコミットする決まりにする、その独自lockfileの整合性チェックも実装する、 などをすればより完璧になりますが、普通はそこまでする必要はないと思います。

そろそろもう少し実例にそった説明に移りますが、そこそこシンプルにしておく方法として例えば

  • default branchにpushした時だけcache保存
  • 他のpull reqではrestoreだけ
  • restore-keysは使わない
  • keyはsaveもrestoreも1つに固定

という感じです。

保存は以下のようになるはずです。

- uses: "actions/cache/save@v4"
  if: "${{ github.ref == 'refs/heads/main' }}" # default branchでのみcache保存
  with:
     path: |
       # linuxの場合ここ。OS毎に異なるので注意
       # 他も保存するべきか?は要議論?詳細割愛
       ~/.cache/coursier/v1
     key: scala-cache-${{ hashFiles('project/Dependencies.scala', 'project/plugins.sbt', 'project/build.properties', '.scalafmt.conf') }}

前述のように普通の依存ライブラリは「project/Dependencies.scala」にまとめれば良いですが、

  • sbt pluginの記述
  • sbt自体のversion
  • scalafmtのversion

などは普通はそこには書けないので、他のファイルも指定してます。

hashFiles は複数の引数を取れるので、一気に全部渡しています。

restore-keysを使ってさらに工夫する場合は、hashFiles に全部一気に渡すのではなく

key: scala-cache-${{ hashFiles('project/Dependencies.scala') }}-${{ hashFiles('project/plugins.sbt')  }}

という感じで、分けて呼び出した方がいい場合もありえます。

使う方では

- uses: "actions/cache/restore@v4"
  with:
    path: |
      ~/.cache/coursier/v1
    key: scala-cache-${{ hashFiles('project/Dependencies.scala', 'project/plugins.sbt', 'project/build.properties', '.scalafmt.conf') }}

という感じになります。こちらは if で条件をつけないのがポイントです。

この方法だと、少しでも依存ライブラリが変わっていたら、全てのjobで最初から全部ダウンロードやり直しになるはずです。

これより頑張るなら、すでに多少言及しましたが

  • hashFilesは分けて渡す
  • restore keyも指定する

をすると、一部の依存だけ変わっていた場合に、一部だけ再利用出来る。という工夫はありえます。しかし、繰り返しになりますが正しく設定しないと

「 cacheに余計なものが残って肥大化し続ける」

という危険があるので、しっかり理解して設定しましょう。よく理解せずにrestore key設定すると長期的には逆効果になり得ます。

restore keyに限らず

「最適なものとは微妙に異なるcacheをrestoreしたならば、それをそのまま単純にsaveしたら余計な古いものが残る」

と、なり得るので、単純にやるなら

「saveするjobにおいてはrestore keyを使ったrestoreをしない」

が一番わかりやすいはずです。

(究極的な理想としては、sbtが使ったjarファイルを列挙できれば最も効率が良いが、それはおそらく現状では簡単には不可能なので)

しかしそのために

  • このjobで、かつmain branchならsaveするのでrestore key使わない
  • このjobで、でもpull reqだからrestore key使う

となると、結局restore keyを使うかどうか?をなど条件分岐しないといけない可能性が出てきて、それをしっかり理解してyaml書ける、メンテし続ける覚悟がある人やチームではない限り、restore key使わないで、最初に指定した

「keyが完全に1つで、少しでも変わったら全部ダウンロードやり直し」

程度の頑張りでいいのではないかなぁ、と思います。

他の発展的な話題としては、例えば

  • saveされるものが同じになるなら、複数のjobでsaveする設定書いても意味ないはず?なので、どこか1箇所だけに書く?それはどこがいいか?
  • scalafixやscalafmtその他の実行方法によっては、compileタイミングとは別に動的にdownloadされるものがあり得るので、それのcacheを正しくやれているか?
  • jobに依存関係があるなら、saveするタイミングを先に実行して、後半のjobですぐ利用出来るようにする
  • 実際にcacheのものだけが使われてるのか?を確かめる工夫
    • cacheのrestore直後に、coursierのディレクトリのjarファイル一覧を適当なtempファイルに列挙しておき、jobの最後に再び同じことをしてそれのdiff取ってみる(やったことある)
    • 徹底的にやるなら、hostsいじって、maven centralなどへのアクセスを強制的に遮断してもbuild出来るか?を確かめてみる
  • そもそもcacheが頻繁に変わると効率が微妙になるので、依存ライブラリの更新タイミングまで工夫する?
  • $HOME/.sbt はcacheするべきなのか?cacheするとしてもどこをcacheするべきか?除くファイルは?
  • sbtのinstall方法によっては $HOME/.sbt/launchers/ も保存した方がいい可能性?
  • cacheがhitしたかどうか?のoutputも取得可能なので、すごく頑張るならそれを使った分岐を書く
  • あるいは lookup-only という、存在確認だけも実行できるが、それを使った工夫

などもありますが、全部詳細に書いたらキリがないのと、一番重要なポイントは書いたつもりなので、今回はこれで終わりにします。