Android: GitHub Actions での最適な Gradle ビルドキャッシュ戦略

GitHub Actions で Android Gradle プロジェクトをビルドするときの最適な Cache 戦略をまとめます。

Android Gradle の Cache 戦略について解説していますが、Cache の種類 (Build Tool Cache, Dependencies Cache, Build Cache) の考え方は他の種類のプロジェクトにも通用します。

## 前提

以下の構成を前提とします。

* Gradle Kotlin DSL (build.gradle.kts / settings.gradle.kts)
* Gradle Version Catalog 使用
* buildSrc 使用
* Kotlin Multiplatform 使用
    * Kotlin Native Compiler 設定だけに影響します

## GitHub Actions Cache 設定

週次 Cache クリアを採用した場合の Cache 設定はこちらです。

```yml
...
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - id: cache-key
        run: echo "week=$(TZ=Asia/Tokyo date +%W)" >> "$GITHUB_OUTPUT"
      - uses: actions/cache@v2
        with:
          path: ~/.gradle/wrapper
          key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
      - uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches/jars-*
            ~/.gradle/caches/transforms-*
            ~/.gradle/caches/modules-*
          key: gradle-dependencies-${{ steps.cache-key.outputs.week }}-${{ hashFiles('gradle/libs.versions.toml', '**/*.gradle.kts', 'buildSrc/**/*.{kt,kts}') }}
          restore-keys: gradle-dependencies-${{ steps.cache-key.outputs.week }}-
      - uses: actions/cache@v2
        with:
          path: |
            ~/.konan
            ~/.gradle/native
          key: ${{ runner.os }}-kotlin-native-${{ steps.cache-key.outputs.week }}-${{ hashFiles('gradle/libs.versions.toml', '**/*.gradle.kts') }}
          restore-keys: ${{ runner.os }}-kotlin-native-${{ steps.cache-key.outputs.week }}-
      - uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches/build-cache-*
            ~/.gradle/caches/[0-9]*.*
            .gradle
          key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-${{ github.sha }}
          restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-
```

## 解説

### GitHub Actions Cache の仕様

https://docs.github.com/en/actions/advanced-guides/caching-dependencies-to-speed-up-workflows

* Cache の容量
    * 1 つのリポジトリに 10 GB まで Cache を保持できる
    * Cache の個数に制限はなし
* Cache の生存期間
    * 7 日間利用されなかった Cache は削除される
    * Cache 合計量が 10 GB を超えると 10 GB を下回るまで、最終利用日が古い Cache から削除される 
* Cache のスコープ
    * 以下の順で、Cache Key / Cache Restore Keys が検索される
        * Workflow 実行中の branch の Cache
        * Workflow 実行中の branch の親の branch の Cache
        * (さらに親があればさらに遡る)
* Cache Key / Cache Restore Key
    * Cache Key と完全に一致する Cache があればそれが使われる
    * Cache Key と完全一致しなければ Cache Restore Keys と前方一致する Cache Key を探す
        * Cache Restore Keys が複数の Cache Key に一致した場合は、作成日がもっとも新しい Cache が使われる
* Cache のアップロード
    * Cache Key と完全に一致する Cache が使われた Workflow では、Cache アップロードはスキップされる
    * Cache がヒットしなかったり、Cache Restore Keys により Cache が使われている場合は Cache Key として Cache がアップロードされる

Cache Restore Keys により Cache を利用すると、Cache が際限なく肥大化していく可能性があるため、Cache の性質に合わせて気をつけて指定する必要があります。

### Gradle Cache の仕様

https://docs.gradle.org/current/userguide/directory_layout.html

| 対象 | 説明 |
| --- | --- |
| ~/.gradle/wrapper | Gradle Wrapper 本体 |
| ~/.gradle/notifications | Gradle 起動時のアップデート情報の表示フラグが保存されています |
| ~/.gradle/caches/{Gradle Version} | Gradle バージョンごとの cache です。ファイルの変更監視や Kotlin DSL 解析情報が含まれます |
| ~/.gradle/caches/build-cache-\* | Gradle Build Cache です |
| ~/.gradle/caches/jars-*<br/>~/.gradle/caches/transforms-\*<br/>~/.gradle/caches/modules-\* | 外部依存ライブラリやそのメタデータです |
| {Project Directory}/.gradle/{Gradle Version} | Gradle バージョンごとの cache です。ファイルの変更監視や Kotlin DSL 解析情報、Configuration Cache、Version Catalog 情報が含まれます |

~/.gradle ディレクトリは以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/0c083b8a-e01e-4b0b-f287-c2a9eb9b7cd2.png)

### Kotlin Native Compiler の仕様

https://kotlinlang.org/docs/native-improving-compilation-time.html

Kotlin Native Compiler は Gradle 実行時に必要に応じてダウンロードされます。

| 対象 | 説明 |
| --- | --- |
| ~/.konan/kotlin-native-* | Kotlin Native Compiler 本体 |
| ~/.konan/dependencies | Kotlin Native Compiler が使用する外部ツール |
| ~/.konan/cache | 詳細不明 |
| ~/.gradle/native | 詳細不明、*.dylib などネイティブバイナリ生成に必要そうなファイルが設置されています |

~/.konan ディレクトリは以下のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/3d90e0a2-2cf0-708f-f2be-ada42026665f.png)

### Cache の種類

Cache には以下の種類があります。それぞれの性質に合わせて Cache を設計します。

* Build Tool Cache
* Dependencies Cache
* Build Cache

#### Build Tool Cache

アプリをビルドするために必要な外部ツールです。

* Gradle Wrapper, Kotlin Native Compiler
    * ビルドするために必要なビルドツールです
    * 初回にダウンロードしてしまえばずっと使えます
    * Gradle バージョン、Kotlin バージョンが変われば、既存の Cache は使えず、新しくダウンロードする必要があります

#### Dependencies Cache (依存ライブラリ)

アプリが依存する外部ライブラリです。

* 依存ライブラリ
    * アプリが依存しているライブラリのメタデータやライブラリ本体です
    * アプリの依存設定が変わらない限りは使い回せますが、依存関係が変化するとライブラリの再ダウンロードが必要になります
    * Cache Restore Keys を使うことで、差分となるライブラリだけをダウンロードすることができますが、使われないライブラリが残り、Cache が肥大化する問題があるため、月次クリアや週次クリアの仕組みが必要です

#### Build Cache

https://docs.gradle.org/current/userguide/build_cache.html

Gradle Task の Output Cache です。ビルドの成果物や Kapt の処理結果などが保存されます。
Gradle Task の Input が同一であれば Task がスキップされ、ビルド時間が短縮されます。

* Gradle Task の Input は多岐に渡るため、適切な Cache Key は存在しません
* Cache Restore Keys を使って、ビルドごとに前回のビルド結果の Cache を引き継いでいく必要があります
    * Cache の引き継ぎにより、Cache が肥大化していくため、月次クリアや週次クリアの仕組みが必要です

Gradle の Build Cache は以下のオプションを指定することで有効となります。

`gradle.properties`

```properties
org.gradle.caching=true
```

https://docs.gradle.org/current/userguide/build_cache.html

### 設定の解説: Gradle Wrapper Cache

```yml
      - uses: actions/cache@v2
        with:
          path: ~/.gradle/wrapper
          key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}
```

Gradle Wrapper はバージョンごとに Cache が一意に決まるため、restore-keys の設定は不要です。
また、Gradle Wrapper はすべての OS で同一バイナリであるため runner.os で分ける必要はありません。

### 設定の解説:  Dependencies Cache

```yml
      - uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches/jars-*
            ~/.gradle/caches/transforms-*
            ~/.gradle/caches/modules-*
          key: gradle-dependencies-${{ steps.cache-key.outputs.week }}-${{ hashFiles('gradle/libs.versions.toml', '**/*.gradle.kts', 'buildSrc/**/*.{kt,kts}') }}
          restore-keys: gradle-dependencies-${{ steps.cache-key.outputs.week }}-
```

外部依存ライブラリは Version Catalog, settings.gradle.kts, build.gradle.kts, buildSrc によって決まるため、そのすべてを hash キーとして使用します。

依存設定が変化すると、差分となるライブラリのダウンロードが必要となるため restore-keys により以前の Cache を再利用します。このままでは使われなくなったライブラリも Cache 内に残されることで Cache が肥大化してしまうため、週次 Cache クリアを設定しています。

ダウンロードされるライブラリは JVM *.jar であり、ビルドホスト OS によって違いはないため runner.os で分ける必要はありません。

### 設定の解説: Kotlin Native Cache

```yml
      - uses: actions/cache@v2
        with:
          path: |
            ~/.konan
            ~/.gradle/native
          key: ${{ runner.os }}-kotlin-native-${{ steps.cache-key.outputs.week }}-${{ hashFiles('gradle/libs.versions.toml', '**/*.gradle.kts') }}
          restore-keys: ${{ runner.os }}-kotlin-native-${{ steps.cache-key.outputs.week }}-
```

Kotlin Multiplatform を使用している場合は Kotlin Native Compiler を Cache します。

Kotlin Native Compiler は Kotlin のバージョンに依存します。Kotlin バージョンは Version Catalog と settings.gradle.kts や build.gradle.kts によって決まるため、それらを hash キーとして使用します。

Kotlin のバージョンが変わらなくても、Version Catalog, settings.gradle.kts, build.gradle.kts が変化してしまうと Cache が使えなくなってしまうため、restore-keys により冗長性を持たせます。その代わり、以前のバージョンの Kotlin Native Compiler が Cache 内に残されてることで Cache が肥大化してしまうため、週次 Cache クリアを設定しています。

Kotlin Native Compiler はビルドホスト OS によってバイナリが異なるため runner.os で分けています。

### 設定の解説: Build Cache

```yml
      - uses: actions/cache@v2
        with:
          path: |
            ~/.gradle/caches/build-cache-*
            ~/.gradle/caches/[0-9]*.*
            .gradle
          key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-${{ github.sha }}
          restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-
```

Gradle Build Cache はビルドのたびに、前回のビルド結果を引き継いで更新していく必要があります。git commit hash を表す github.sha により、コミットごとの Cache Key を使用します。

restore-keys により前回の Cache を引き継ぎますが、その結果 Cache は肥大化していくため、週次 Cache クリアを設定しています。

Gradle Task は多岐に渡るため、runner.os でビルド環境ごとに Cache を分けています。

実行される　Gradle Task は GitHub Actions Workflow ごとに異なるため、github.workflow で Workflow ごとに Cache を分けています。

## 月次 Cache クリア / 週次 Cache クリア

date コマンドにより月番号や週番号を用いることで月初や週の初めに Cache をクリアできます。
GitHub Actions Host runner はタイムゾーンが設定されておらず UTC 時刻になっています。Cache クリアのタイミングを日本時間に揃えるためにタイムゾーンを Asia/Tokyo に指定しています。

| コマンド | 説明 |
| --- | --- |
| `TZ=Asia/Tokyo date +%m` | 01 ~ 12 の月番号 |
| `TZ=Asia/Tokyo date +%W` | 00 ~ 53 の週番号(月曜日はじまり) |
| `TZ=Asia/Tokyo date +%V` | 01 ~ 53 の [ISO 8601 方式](https://ja.wikipedia.org/wiki/ISO_8601) 週番号(月曜日はじまり) |
| `TZ=Asia/Tokyo date +%U` | 00 ~ 53 の週番号(日曜日はじまり) |

date コマンドの結果を Cache step から利用するために `$GITHUB_OUTPUT` を利用します。

```yml
      - id: cache-key
        run: echo "week=$(TZ=Asia/Tokyo date +%W)" >> "$GITHUB_OUTPUT"
```

https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter

この結果は、以下のように使用できます。

```yml
      - uses: actions/cache@v2
        with:
          path: ...
          key: ...-${{ steps.cache-key.outputs.week }}-...
          restore-keys: ...-${{ steps.cache-key.outputs.week }}-
```

月次クリア・週次クリアを設定すると、月初・週初めのビルドは Cache が存在しないためビルドに時間がかかるようになります。Cache クリア後の最初のビルドは時間がかかるものと受け入れるか、main branch などの root となる branch で Cache クリア直後に Monthly ビルドや Weekly ビルドを走らせるように GitHub Actions trigger を設定することで最初の Cache を生成するようにします。

## 代替手段: Gradle Build Action の Cache 機能について

この記事で解説したような actions/cache の細かな設定を定義しなくても、Gradle Build Action に cache を管理させることも可能です。

https://github.com/marketplace/actions/gradle-build-action

Gradle Build Action は Gradle が扱う Cache を細かく分類して適切な Cache Store / Restore を実行してくれます。

ただし、Gradle Build Action の Cache 戦略は前回のビルド結果の Cache を引き継ぎ続けるものとなっているため、Cache の肥大化に対処できていません。また [Kotlin Multiplatform の .konan ディレクトリの Cache にも対応していません](https://github.com/gradle/gradle-build-action/issues/125)。

Gradle Build Action を使う場合は [CACHE_PREFIX_VAR 環境変数を設定](https://github.com/gradle/gradle-build-action/blob/c349fa5b1f789c6d45979b48e470ffdb61e2f4a5/src/cache-utils.ts#L34)に週次 Cache Key を設定して、Cache クリアできるようにする必要があります。

## Gradle Cache クリーンアップ機能について

Gradle 4.10 以降では ~/.gradle も {Project Directory}/.gradle も、Gradle Daemon が終了するタイミングでクリーンアップが実行されます。

クリーンアップの詳細は以下のドキュメントを参照してください。

https://docs.gradle.org/current/userguide/directory_layout.html#dir:gradle_user_home:cache_cleanup

このクリーンアップ工程により、7 日以上や 30 日以上使われていない Gradle Wrapper や依存ライブラリがあれば Gradle により自動で削除されることになっています。

Gradle Cache クリーンアップが CI 上でも正しく動作するのであれば月次クリアや週次クリア制御は不要なのですが、GitHub Actions 上でこの機能が正しく動作するかは不明です。

また、[7 日以上や 30 日以上といったクリーンアップの閾値は外部から設定するオプションが存在しないため](https://github.com/gradle/gradle/issues/7018)、どの程度のあいだ不要なファイルを残すか制御することができません。いまのところ、Gradle Cache クリーンアップは CI 環境ではなくローカル PC 環境向けに用意されているものなので、CI 上ではあてにできないと思います。CI 環境でも使えるように調整されるまでは、GitHub Actions Cache 側で Cache クリアを制御するのが安心です。