Self-hosted runnerにおけるキャッシュ運用【Android】

はじめに

マンガアプリチームでAndroidエンジニアをしている id:matsudamper です。この記事ではSelf-hosted runnerにおけるAndroidのキャッシュの設定/運用方法を共有します。

はてなでは以前よりSelf-hosted runnerを運用しています。
developer.hatenastaff.com

2025年からはAndroidアプリ開発においても試験導入を開始しました。運用が安定してきたため、今回はSelf-hosted runnerにおけるGradleキャッシュの知見についてまとめます。

GitHub Actionsキャッシュの課題

GitHub Actions標準のキャッシュは、リポジトリあたり10GBという制限があります。これまでは削除のライフサイクルが遅かったため、Android開発のようなキャッシュサイズが大きいプロジェクトでもなんとか回っていました。

しかし、キャッシュ削除の頻度が24時間から1時間になるという変更のお知らせがありました。
github.blog

このため、独自にAmazon S3を利用したキャッシュを実装しました。

しかし、S3キャッシュ実装後、GitHub公式から有料でキャッシュ容量を増やせる事が発表されました。
github.blog

キャッシュの変更のお知らせが2025年9月29日、実施が11月。キャッシュの容量が増やせる発表と実施が2025年11月20日。それでも自前のキャッシュを導入するメリットはありました。まとめてお知らせしてくれれば、こういう移行しちゃったけど、お金払って解決するならそれで良かったというプロダクトもあっただろうと思います。

内容

ストレージにはAmazon S3を使用し、Self-hosted runnerと同じリージョンに設定しています。ダウンロード/アップロード速度は400MB/s出ていて、GitHub hosted-runnerとGitHubの標準のキャッシュだと30~100MB/sくらいなのでとても速いです。

キャッシュ戦略

キャッシュActionはruns-on/cacheを使用しています。これはactions/cacheと同じように保存するpathやキャッシュのキーを自分で記述する必要があります。
元々使用していたgradle/actions/setup-gradleはキャッシュもキャッシュのクリーンも導入するだけで行ってくれますが、キャッシュのバックエンドを差し替える機能は現状ありません。殆どのこういったキャッシュは内部的にactions/cacheの仕組みが使用されています。GitHubが公式でバックエンドを差し替える機能を提供してくれれば良いのですが、Self-hosted runnerに対する課金体系の変更の発表を見る限り、self-hosted runnerに力を入れていないし、力を入れる理由も無いだろうと思うので、ここにはあまり期待しないほうが良さそうですね。

保存するパス

以下のディレクトリをキャッシュ対象としています。経験から、キャッシュが大きいと復元が遅くなる可能性があると判断して、4つのpathを別々に保存しています。キャッシュが肥大化した場合に調査しやすいという理由もあります。

  • ~/.gradle/caches/
  • ~/.gradle/wrapper/
  • ~/.m2/
  • ~/.android/

アクセス制御

GitHubのキャッシュアクセス制御

GitHub Actionsの標準キャッシュには、キャッシュポイズニングを防ぐためのアクセス制御が組み込まれています。主なルールは以下のとおりです。

  • ワークフローは現在のブランチベースブランチデフォルトブランチで作成されたキャッシュにアクセスできる
  • Pull Requestで作成されたキャッシュはデフォルトブランチからはアクセスできない
  • ブランチ間でキャッシュがスコープされており、あるブランチで作成されたキャッシュは無関係なブランチからは見えない

これにより、悪意のあるPull Requestがデフォルトブランチのキャッシュを汚染するリスクを軽減しています。

*参考: GitHub Actions のキャッシュアクセス制限
docs.github.com

自前実装でのアクセス制御

runs-on/cacheを使用する場合、GitHubのようなスコープ制御は自動では提供されません。そのため、意図しないキャッシュの破壊を防ぐため、キャッシュキーの設計とワークフロー側の制御で同等の保護を実現しています。
具体的には、キャッシュの保存時にgithub.refがデフォルトブランチかどうかを判定し、デフォルトブランチのキャッシュキーへの書き込みはデフォルトブランチのワークフローからのみ行えるようにしています。PRのブランチからはデフォルトブランチのキャッシュを読み取り専用で参照し、書き込みは自ブランチのキーに対してのみ行います。
GitHubの実装と比較すると、ベースブランチのキャッシュ参照(例: PRのブランチデフォルトブランチ以外 のような複数段のブランチ階層の制御)は省略しており、「デフォルトブランチかそれ以外か」という2段階のシンプルな制御にとどめています。デフォルトブランチ ← 機能をまとめてマージする用のブランチ ← ブランチのように作業する場合、ブランチがmainから遠くなってしまうとキャッシュヒットが低下して問題になりますが、今の所はこれで十分です。

キャッシュキーと復元ロジック

キャッシュのキー設計は以下のとおりです。

保存キー
<<base-name>>-${{ env.CACHE_VERSION }}-${{ github.ref }}-<<hash>>

復元のヒット順番

  1. <<base-name>>-${{ env.CACHE_VERSION }}-${{ github.ref }} (現在のブランチのキャッシュ)
  2. <<base-name>>-${{ env.CACHE_VERSION }}-refs/heads/${{ github.event.repository.default_branch }} (デフォルトブランチのキャッシュ)

説明

  • <<base-name>>: gradle-caches, m2 など用途ごとの名前
  • env.CACHE_VERSION: 手動でキャッシュをクリアしたい場合に更新するバージョン番号(日付などを設定)
  • github.ref: ブランチごとのキャッシュとして分離
  • <<hash>>: 依存関係ファイルのハッシュ値
    • ${{ hashFiles('**/*.gradle.kts', '**/*.gradle', '**/libs.versions.toml', '**/gradle-wrapper.properties') }}

Gradleキャッシュの肥大化対策

何もしないとGradleのキャッシュが肥大化し、復元に2分以上かかるようになってしまいました。 最初は手動で日時を見て消していましたが、キャッシュの不整合が起きました。調べたらGradle自体にキャッシュクリーンの仕組みがあったのでその設定を利用しています。
基準は雑にですが、キャッシュの復元時間が1分を超えないようにコントロールしています。

設定内容

CIの最初の部分で~/.gradle/init.d/cache-settings.gradle.ktsを作成し、以下のように設定しています。

特にdownloadedResourcesはデフォルトが30日でとても肥大化します。

beforeSettings {
    caches {
        // デフォルトのCleanup.DEFAULTだとバックグラウンドで24時間に1回実行されますが、CIだと都合が悪いので環境変数が設定されている場合に削除されるように設定しました。
        if (System.getenv("GRADLE_CACHE_CLEANUP")?.toBooleanStrictOrNull() == true) {
            cleanup = Cleanup.ALWAYS
        }
        // 未使用エントリの保持期間を全て5日に設定
        releasedWrappers { setRemoveUnusedEntriesAfterDays(5) }
        snapshotWrappers { setRemoveUnusedEntriesAfterDays(5) }
        downloadedResources { setRemoveUnusedEntriesAfterDays(5) }
        createdResources { setRemoveUnusedEntriesAfterDays(5) }
        buildCache { setRemoveUnusedEntriesAfterDays(5) }
    }
}

参考: docs.gradle.org

おわりに

最初は制限で仕方なくS3キャッシュに移行しました。その後の発表で追加料金を払えばGitHub標準キャッシュでも問題なく使用できるようにはなりましたが、S3キャッシュの導入によって復元速度も向上し、テストだけではなくBetaビルドの配布も早くなって結果的にとても良い感じになりました。