Android: GitHub Actions で Gradle build daemon disappeared unexpectedly が発生しないようにメモリ使用量を調査する

Andorid Gradle プロジェクトのビルドを Github Actions などの CI で実行したとき、Host Runner のメモリが足りなくなると以下のエラーが発生し Gradle Daemon が強制終了されます。(OOM Kill)

```shell
FAILURE: Build failed with an exception.

* What went wrong:
Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)
```

この問題を解決するには、以下の記事のように Gradle Daemon が使用する最大メモリを指定する必要があります。

https://qiita.com/irgaly/items/4c22d233caf8ec55512e

しかし、Gradle Daemon の最大メモリサイズを小さくしすぎるとビルドに必要なメモリが足りずにビルドが失敗します。

この記事では、Gradle Daemon がどれくらいメモリを必要としているのかを調査する方法を解説します。

# 概要

Gradle Daemon がどの程度のメモリを使用するのかは Android Gradle プロジェクトの規模によります。

今回はローカルマシンでプロジェクトのビルドを実行し、VisualVM を使って Gradle Daemon JVM 実行環境のメモリ使用量を調査します。

## VisualVM セットアップ

VisualVM は以下のサイトからインストールしてください。

https://visualvm.github.io/download.html

VisualGC Plugin も使用します。VisualVM を起動し、`Tools > Plugins > Available Plugins` から VisualGC Plugin をインストールしてください。

![VisualGC Plugin](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/2c447d3c-fa49-2058-17d9-66d54e0b5ef3.png)

## Gradle ビルドの開始

ローカルマシンで、調査対象の Android Gradle Project をビルドします。

キャッシュなしのビルドを計測するために、以下のコマンドを実行しておきます。

```shell
% ./gradlew clean
```

Gradle Daemon のメモリを解放するために Gradle Daemon プロセスを終了しておきます。

```shell
% ./gradlew --stop
```

VisualVM を起動し、Gradle Daemon プロセスが存在しないことを確認してください。

![VisualVM empty](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/b2e4480b-84e0-16c8-1b1a-239cfe21467b.png)

計測したいビルドを開始します。念のため `--rerun-tasks` によりキャッシュの有無に関わらず必ず対象タスクを実行するようにすることをお勧めします。

```shell
% ./gradlew :app:bundleRelease --rerun-tasks
```

## プロセスの監視

ビルドが開始されると、以下のプロセスが表示されます。それぞれをダブルクリックして監視タブを開き、メモリ使用量の監視を開始します。

* Gradle Daemon
* Kotlin Daemon (org.jetbrains.kotlin.daemon.KotlinCompileDaemon)
* Kotlin Daemon (org.jetbrains.kotlin.daemon.KotlinCompileDaemon)

![Processes](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/2376e02a-cf71-5c62-1eae-2e5c0816e6fe.png)

## メモリ使用量の確認

ビルドが終了したら、それぞれのプロセスのメモリ使用量を確認します。

### Gradle Daemon Heap

Gradle Daemon プロセスの Heap 使用量を確認します。右上のグラフが Heap 使用量です。

オレンジの線は Gradle Daemon プロセスが確保した Heap メモリサイズの推移を表しており、ブルーの線は実際に使用している Heap メモリサイズの推移を表しています。

このプロジェクトでは `org.gradle.jvmargs=-Xmx4g` オプションを付けて起動しているため Heap メモリの確保サイズは 4 GB 以上には増えません。

![Daemon Heap](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/e52b3087-d3b5-1a50-852a-87ee65f9a275.png)

このグラフからは、次のことが読み取れます。

* Heap メモリの最大使用量は 3.8 GB (17:23 付近)
* Heap メモリ使用量が高い状態で GC が走ると 2.5 GB 程度まで Heap 使用量が下がる (17:19 ~ 17:22 区間)

このことから、Gradle Daemon の Heap メモリの最大値には 3GB ~ 3.5GB を指定すればビルドは成功するだろうと予測できます。

ただし、Heap メモリの最大値を下げすぎると GC の回数が増え、ビルド時間が長引くことになるので Heap メモリサイズと GC 回数のバランスには注意してください。

### Gradle Daemon Metaspace

Metaspace タブを開き、Gradle Daemon プロセスの Metaspace メモリ使用量を確認します。

![Daemon Metaspace](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/446c9c82-49f9-8fa1-b324-99ce986b6d6c.png)

Metaspace メモリ領域は JVM が Gradle の実行に必要なクラスをロードするために使用されます。こちらは基本的には単調増加となり、クラスが読み込まれるとそのまま保持し続けるようです。

Gradle Plugin の依存関係が大きくなってくると Metaspace 領域は圧迫されていきます。

このグラフからは、次のことが読み取れます。

* Metaspace は最大で 410 MB 程度使用される

Metaspace は `org.gradle.jvmargs=-XX:MaxMetaspaceSize={Size}g` オプションにより指定可能です。Gradle では MaxMetaspaceSize=256m がデフォルト値となっているためデフォルトではメモリが足りていません。Metaspace 領域は使う分だけが確保されるため最大値が大きくても無駄にメモリが確保されるということもありません。`org.gradle.jvmargs=-XX:MaxMetaspaceSize=4g` などの充分に大きなサイズを指定することをお勧めします。

### Gradle Daemon GC

Gradle Daemon プロセスの Heap と Metaspace の最適な最大値はそれぞれ判明しましたが、念のため GC の状況も確認しておきます。

VisualGC タブを開くと、GC の実行履歴がグラフで表示されます。

ここで確認する項目は、以下の通りです。

* GC Time
    * GC の実行回数と GC pause が発生した時間
* Eden Space
    * 若いオブジェクトのメモリ確保状況
* Old Space
    * 長期生存オブジェクトのメモリ確保状況

![DaemonGC](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/caaac099-c91f-6434-aac4-42a6935f2fbc.png)

新しく確保されたオブジェクトは最初は Eden Space に配置されます。Eden 領域が不足するようになると Eden 領域を対象とした GC が実行され、不要なオブジェクトが解放されることで Eden 領域に空きを回復させます。Eden 領域の GC が何度か実行されても生存し続けたオブジェクトは寿命の長いオブジェクトと見做されて Old 領域へ移動されます。つまり、Old 領域にはビルド中に長期間にわたって生存しているオブジェクトか、メモリリークしているオブジェクトが確保されることになります。

上記のグラフからは、次のことが読み取れます。

* GC は 234 回実行され、GC pause が 19.247 秒発生している
    * Eden GC も 234 回、19.247 秒であるため、GC はすべて Eden GC である
* Old 領域は時間経過とともに使用量が増加しているが、ビルド完了時点では 2.549 GB 使用している

### Kotlin Daemon Heap

Kotlin ソースコードのビルドは Gradle Daemon とは独立したプロセスの Kotlin Daemon プロセスが担当しています。

今回の調査では Kotlin Daemon プロセスは二つ起動されたようですが、一つ目のプロセスは以下の通りとなりメモリを必要とする処理はあまりないようです。今回の調査では影響は軽微と見做して無視することにしましょう。

![Kotlin1_Heap](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/ae77148d-173e-cff7-4a3e-7ae8758cc333.png)

二つ目の Kotlin Daemon プロセスは以下のグラフとなっていました。

![Kotlin2_Heap](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/3ca3189c-9ba1-1c6c-d413-09ba39f5650e.png)

このグラフからは、次のことが読み取れます。

* Kotlin Daemon の Heap 最大サイズは Gradle Daemon の指定と同じである (この例では `org.gradle.jvmargs=-Xmx4g`)
* Heap メモリの最大使用量は 1.5 GB (17:18 付近)
* Heap メモリ使用量が高い状態で GC が走ると 1.1 GB 程度まで Heap 使用量が下がる (17:18 付近)

このことから、Kotlin Daemon の Heap メモリの最大値には 1.5GB 程度を指定すればビルドは成功するだろうと予測できます。

Kotlin Daemon プロセスの Heap メモリ最大サイズは `org.gradle.jvmargs=-Dkotlin.daemon.jvm.options=-Xmx{Size}g` で指定することができます。`-Dkotlin.daemon.jvm.options` を指定しない場合には Gradle Daemon 向けの `org.gradle.jvmargs=-Xmx{Size}g` が Kotlin Daemon に対しても適用されてしまうので注意してください。

### Kotlin Daemon Metaspace

Kotlin Daemon プロセスの Metaspace 領域を確認します。

![Kotlin2_Metaspace](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/49c1e398-4935-9a7b-385a-209da6024eff.png)

このグラフからは、次のことが読み取れます。

* Metaspace は最大で 220 MB 程度使用される

こちらも、Gradle Daemon と同様に充分に大きい最大サイズを指定しておけば問題ありません。すでに Gradle Daemon 向けに `org.gradle.jvmargs=-XX:MaxMetaspaceSize=4g` のように設定していればその値が Kotlin Daemon にも適用されるため、別途指定する必要はありません。

### Kotlin Daemon GC

念のため、Kotlin Daemon の GC の状況も確認しておきます。

![Kotlin2_GC](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17931/1392b3fb-3378-58d8-f1db-f5669e9a6132.png)

このグラフからは、次のことが読み取れます。

* GC は 79 回実行され、GC pause が 3.946 秒発生している
    * Eden GC も 79 回、3.946 秒であるため、GC はすべて Eden GC である
* Old 領域はあまり変化せず、おおよそ 980 MB を使用している

## Gradle Daemon と Kotlin Daemon の設定

GitHub Actions Linux Host runner のメモリは 7 GB です。

https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners

調査結果を踏まえ、以下の設定とすると合計でおおよそ 5.6 GB を使用する設定となり GitHub Actions でメモリ不足は起きない可能性は高くなります。厳密には Kotlin Daemon が 2 プロセスあるなどの細かなオーバーヘッドがあるため、それも考慮して 7 GB に対して余裕のある設定を指定しましょう。

* Gradle Daemon
    * Heap Max: 3~3.5 GB
    * Metaspace Max: 410 MB 以上
* Kotlin Daemon
    * Heap Max: 1.5 GB
    * Metaspace Max: 220 MB 以上

また、今回はビルド 1 回だけを計測しましたが、CI 環境で複数の Gradle コマンドを実行するとその間は Gradle Daemon が生存することでメモリリークなどでメモリ使用量が増え続けてしまうこともあります。CI のメモリに余裕がなければ Gradle コマンドを実行するごとに Gradle Daemon を終了させる `--no-daemon` オプション (もしくは `org.gradle.daemon=false` プロパティ) の使用を検討してください。

Gradle のオプションで表すと以下の設定となります。GitHub Actions などの CI 環境であれば ~/.gradle/gradle.properties などを設置して以下の内容を指定しましょう。

`~/.gradle/gradle.properties` (GitHub Actions CI 環境の Gradle のグローバル設定)

```properties
org.gradle.jvmargs=-Xmx3500m -XX:MaxMetaspaceSize=4g -Dkotlin.daemon.jvm.options=-Xmx1500m
```

もしくは、GitHub Actions Workflow で以下の環境変数を設定しておくと同じオプションを設定することが可能です。

`.github/workflows/sample.yml`

```yml
    env:
      GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3500m -XX:MaxMetaspaceSize=4g -Dkotlin.daemon.jvm.options=-Xmx1500m"
```

# 参考

以下の記事を参考にしました。以下の記事では Gradle Daemon の fork 設定なども説明があります。

https://medium.com/traveloka-engineering/identify-handle-android-builds-memory-issues-66bf9299549