Dockerコンテナ化したJavaアプリのヒープのサイズ調整オプションの検証

# はじめに

こんにちは。私は弊社で企画・運営している、Dot to Dotという個人の同意の元に様々なデータを連携することができる分散型データ連携プラットフォームの開発・保守を担当しています。
Dot to Dotではデータ連携をしたい事業者向けに、データ連携用の通信モジュールを、Spring Bootを使用したJavaアプリケーションとして作成したDockerイメージ形式で配布しています。

昨今ではDockerでアプリケーションを実行するのが当たり前の風潮になりつつありますが、実際に本番で適用する際に必要なチューニングの話はあまり聞かないかと思います。
そこで本記事では、JavaアプリケーションをDockerコンテナで運用する場合に必要な、ヒープのチューニングについて説明します。これからJavaアプリケーションをDockerコンテナ化して運用したい人や、すでに運用中でもヒープチューニングしておらずメモリが効率的に利用できていない人には参考になるかと思います。 

# まとめ
* ヒープサイズを固定値で指定する`-Xms`や`-Xmx`は使わない
* Java 8u191およびJava 10から登場した、使用可能なメモリサイズに対する割合指定の
`-XX:InitialRAMPercentage`や`-XX:MaxRAMPercentage`を使う
* メタスペースやカーネルのオーバーヘッドがあるため割合は100％にしない

# 非コンテナ環境でのヒープチューニング

Docker登場以前のJavaアプリケーションではパフォーマンスをチューニングするために、以下のようなオプションをつけてヒープメモリの最小サイズ、最大サイズ、GCアルゴリズム等を事前に調整することがありました。
```
java -jar app.jar -Xms3g -Xmx3g -XX:+UseG1GC
```
こちらの例では、ヒープの初期サイズ、最大サイズをともに3GBに固定し、GCアルゴリズムはG1GCを使用するようにしています。上記はSpring Bootで作成したbootJarを直接実行する場合のイメージですが、tomcatやwildflyなどのアプリケーションサーバーを利用している場合はそれぞれの起動用シェルスクリプトや環境変数などで指定していたかと思います。従来のオンプレミスにサーバーを用意するような、スペックが固定されている場合はこのような指定方式でも問題はありませんでした。

一方、Dot to Dotでは通信モジュールをDockerイメージ化して配布しています。通信モジュールに必要なコンテナ実行環境のスペックはデータ連携の要件に影響され、事業者ごとに異なります。このため通信モジュールのヒープサイズを固定して配布してしまうと、コンテナ実行環境のスペックに対してヒープサイズが過剰になったり、過小になってしまうことがあります。また、昨今のコンテナ実行環境では割り当てるCPUやメモリを容易に変えることができますが、その都度ヒープサイズを適切に調整するのも非常に面倒です。

# ヒープサイズの調整オプション

このような課題に対処するため、Java10から使用可能なメモリ容量に対するパーセンテージでヒープサイズを指定できるオプションが追加されました。
https://bugs.openjdk.org/browse/JDK-8186248
※後にJava 8u191へバックポートされています。

|オプション|説明|デフォルト値|
|--|--|--|
|-XX:InitialRAMPercentage|初期ヒープサイズ。使用可能なメモリに対してパーセンテージで指定する。|1.5625|
|-XX:MaxRAMPercentage|最大ヒープサイズ。使用可能なメモリに対してパーセンテージで指定する。|25|
|-XX:MinRAMPercentage|使用可能なメモリが250MB以下の場合の最大ヒープサイズ。使用可能なメモリに対してパーセンテージで指定する。<br>※オプション名が直感に反するので要注意|50|
 
デフォルトでは、使用可能なメモリが256MB以下の環境では50%、それ以外の環境では25%となっています。Javaはコンテナ環境以外にも、デスクトップ環境やサーバー環境など幅広い環境で使用されるため、Javaだけでメモリを食い尽くさないように控えめな設定になっています。一方、コンテナ化したアプリケーションは、1コンテナあたり1プロセスのみにするのがベストプラクティスとされています。このためコンテナに割り当てられたメモリ容量をJavaだけで使用できるため、デフォルトのヒープサイズ設定では残りのメモリ容量が無駄になってしまいます。

# Javaのコンテナサポート

ヒープサイズが使用可能なメモリに対して割合で指定できるようになったことに関連して、自動コンテナ検出サポートオプションも導入されました。Javaのプロセスが、自身が実行されている環境がコンテナ環境か、そうでない環境なのかを判断することが出来るようになっています。このオプションはデフォルトで有効になっており、`-XX:-UseContainerSupport`で無効化できます。

このオプションが有効になっていないと、Javaプロセスが使用可能なメモリ容量を参照する際に、コンテナに割り当てるメモリの制限値ではなく、ホスト側に実装されたメモリ容量の方が参照されてしまい、予期せず大きなヒープサイズになってしまうことがあります。

# 検証

では実際に検証してみます。

## 事前準備

Javaアプリケーションをコンテナ化したものであれば何でもよいですが、今回はSpring Initializrで生成したdemoアプリケーションを使用します。
https://start.spring.io/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/faa60fac-220b-c047-e0f8-f2975eca75cc.png)

サクッと動けば何でも良いので、バージョンは3.2.3、Javaは17、Spring Webの依存関係だけ入れておきます。

作成したプロジェクトを解凍し、以下のようなコマンドを叩いてbootJarを生成しておきます。
```shell-session
./gradlew bootJar
```
続いて、これをDocker化します。

以下のようなDockerfileを用意します。

```dockerfile
FROM eclipse-temurin:17-jdk-jammy

COPY ./demo-*.jar /app.jar
COPY ./entrypoint.sh /entrypoint.sh
RUN chmod 744 /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```

ベースにはOpenJDKの一つであるeclipse-temurinを使用します。
起動に使用するentrypoint.shの中身は概ね以下のような感じです。

```shell
#!/bin/bash
JAVA_OPTS="$JAVA_OPTS -XX:+PrintCommandLineFlags"
exec java $JAVA_OPTS -jar /app.jar
```
起動時に環境変数からJVMオプションを差し込めるようにしてあります。
PrintCommandLineFlagsを指定して、起動後にJavaのエルゴノミクスにしたがって計算されたヒープサイズが出力されるようにしておきます。

コンテナの起動にあたっては、今回はAzureのVMにDockerを入れて試します。VMのサイズはStandard_D2s_v3を使用しているので、vCPUが2core、メモリは8GiBとなります。上記のbootJar, entrypoint.sh, Dockerfileを同一ディレクトリに放り込んだら、以下のようなコマンドでビルドします。
```shell-session
docker build -t testapp .
```

これでテスト用のDockerイメージtestappができました。

## 起動テスト
続いて、docker composeファイルを作って、起動のテストをします。

```yaml
name: testapp
services:
 testapp:
   container_name: testapp
   image: testapp
```
ファイルができたら、起動してみます。

```shell-session
docker compose up
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/48419259-64e5-c088-dc3f-a4623f03d2fc.png)

起動できました。
この設定ではコンテナにCPUやメモリ割り当ての制限をしていないため、VM本来のCPUおよびメモリの値が参照されます。初期ヒープサイズ(`InitialHeapSize`)はデフォルト値が使用可能なメモリ8GBの1.5625%=125MBなので、概ね一致しています。同様に最大ヒープサイズ(`MaxHeapSize`)も、デフォルト値が使用可能なメモリ8GBの25%=2GBなので、概ね一致しています。

## コンテナ割当メモリの制限
続いて、コンテナが使用できるメモリを制限してみます。
docker composeファイルに以下のような変更を加え、割当メモリを4GBにしてみます。

```yaml
name: testapp
services:
 testapp:
   container_name: testapp
   image: testapp
   deploy:
     resources:
       limits:
         cpus: "1.0"
         memory: 4G
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/121ac7e5-2369-2399-87c6-7713e1f9bf1f.png)

割当されるメモリが4GBと最初の半分になったため、初期ヒープサイズ、最大ヒープサイズも共に半分になりました。
このように、Javaはデフォルトで、実行環境で使用可能なメモリからヒープサイズを決定してくれます。

## コンテナサポートのオフ
次に、試しにコンテナにはメモリ4GBまでの制限をつけたまま、Javaのコンテナサポートをオフにしてみます。

```yaml
name: testapp
services:
 testapp:
   container_name: testapp
   image: testapp
   deploy:
     resources:
       limits:
         cpus: "1.0"
         memory: 4G
   environment:
     - JAVA_OPTS=-XX:-UseContainerSupport
``` 
実行結果はこちら。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/2518f694-7818-807c-e958-7206d0788a44.png)

起動テストのパターンと同じヒープサイズになりました。composeに定義したメモリ制限4GBではなく、ホスト側の使用可能なメモリ容量を参照してサイズが決定されたことがわかるかと思います。


## 割当メモリの調整

ここからはJavaのコンテナサポートを有効に戻し、余ったメモリを余すとこなく使えるように調整してみます。

次のようにdocker composeファイルを編集します。
```yaml
name: testapp
services:
 testapp:
   container_name: testapp
   image: testapp
   deploy:
     resources:
       limits:
         cpus: "1.0"
         memory: 4G
   environment:
     - JAVA_OPTS=-XX:InitialRAMPercentage=75 -XX:MaxRAMPercentage=75
```
環境変数からJVMオプションを差し込み、ヒープサイズを割当メモリの75%にしました。

 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/0f6d7445-bb5a-d11b-11f9-c2db8c8071b9.png)
ご覧の通り、初期ヒープサイズと最大ヒープサイズが3GB程度になっています。

コンテナに課された4GB制限のうち、75%の容量を使用しています。


## メリット

さて、ここまで試してきましたが、これの何が嬉しいのでしょうか。

従来のように固定サイズ指定の場合、コンテナに割り当てるリソース制限を見ながら、JVMオプションを調整する必要があります。

それでは以下のような設定ミスをしてしまうとどうなるでしょうか。

```yaml
name: testapp
services:
 testapp:
   container_name: testapp
   image: testapp
   deploy:
     resources:
       limits:
         cpus: "1.0"
         memory: 2G
   environment:
     - JAVA_OPTS=-XX:InitialHeapSize=3g -XX:MaxHeapSize=3g
```
コンテナに割り当てたメモリ制限よりも、ヒープサイズのほうが大きくなっています。
この場合、実行中にOutOfMemoryエラーでコンテナが異常終了してしまう場合があり、サービスの可用性に影響を与えてしまいます。
このような些細な設定ミスで可用性を落としてしまうこともあります。

逆に、以下のように設定してしてしまうと、

```yaml
name: testapp
services:
testapp:
  container_name: testapp
  image: testapp
  deploy:
    resources:
      limits:
        cpus: "2.0"
        memory: 8G
  environment:
    - JAVA_OPTS=-XX:InitialHeapSize=3g -XX:MaxHeapSize=3g
```
割当メモリが8GBなのにもかかわらず、ヒープサイズが3GBとなっており5GB分無駄になっています。
このような事故や無駄を減らすためにも、コンテナに割り当てるメモリ容量とヒープサイズは自動的に連動して調整してくれる方が安全かもしれません。

 

また、ここまではVMに直接Dockerを入れて試してみましたが、PaaS環境ではどうでしょうか。
以下の画面は、Azure Container Appsのコンテナ設定画面です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/803735e1-b1e6-a080-c593-e37c0432ceea.png)

コンテナリソース割り当ての入力欄と環境変数の両方を調整する必要があります。
 

ではAWSはどうでしょうか。
こちらはFargateのタスク設定の画面です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3758807/0afb0e10-9032-704e-788e-85d8d10a7911.png)

タスクサイズのところにコンテナに割り当てるリソースの制限の設定があります。
環境変数の設定も同ページの下部にありますが、スクロールしないと出てきません。

どうでしょう。思いの外、変更ミスが発生しそうではないでしょうか。

 

私が担当しているDot to Dotでは通信モジュールを事業者に配布しています。コンテナ実行環境で必要となるスペックは事業者のデータ連携の要件によって異なるため、それぞれの事業者に価格と見合うものを選択してもらっています。また、事業者によってはVMにDockerを入れて起動したり、Azure Container Appsを使ったり、AWSのFargateを使ったりと実行環境は様々です。さらに、担当者がJavaやDocker等に精通しているとも限りません。それぞれの事業者が間違いなく設定できるようにガイドは整備していますが、できるだけミスなく設定できるようにする必要があります。そこでDot to Dotでは、担当者がJavaのオプションを意識しなくて済むように、entrypoint.shにあらかじめデフォルトのヒープサイズのパーセンテージを指定しています。

```shell
#!/bin/bash
if ! echo "$JAVA_OPTS" | grep -q -- "-XX:InitialRAMPercentage"; then
 JAVA_OPTS="$JAVA_OPTS -XX:InitialRAMPercentage=75"
fi

if ! echo "$JAVA_OPTS" | grep -q -- "-XX:MaxRAMPercentage"; then
 JAVA_OPTS="$JAVA_OPTS -XX:MaxRAMPercentage=75"
fi

JAVA_OPTS="$JAVA_OPTS -Duser.language=ja -Duser.country=JP -Duser.timezone=Asia/Tokyo -Djava.security.egd=file:/dev/urandom"
exec java $JAVA_OPTS -jar /app.jar
```
このようにすれば事業者のユーザーは特にJAVA_OPTSを気にすることなく、コンテナに割り当てるリソースの制限だけ調整すれば適切にヒープサイズが調整されるようになりますし、JavaやDockerに詳しい担当者がいれば細かく調整することも出来ます。

## パーセント指定の注意事項
さて、ここまでヒープサイズのパーセント指定について記事にしてきました。

ここまで読んだ人の中には、「コンテナは1コンテナ1プロセスがベストプラクティスなんだから、100%にすれば綺麗にメモリを使い切れていいのではないか？」と思う人がいるかも知れません。

結論から言えば、100%にしてしまうとメモリ不足になります。

コンテナは実行時にカーネルが使用するオーバーヘッドが必要なのと、Javaはヒープ以外にもMetaspaceでメモリを使用します。
このあたりの余裕を見積もって、Dot to Dotの通信モジュールでは75%くらいがちょうどいいと判断しています。

# おわりに
いかがだったでしょうか。
本記事ではコンテナ化したJavaアプリケーションのヒープサイズについて記事にしました。昨今ではアプリケーションをコンテナ化して運用することが当たり前になってきていますので、ぜひ参考にしてみてください。

# We Are Hiring!

弊社では一緒に働く仲間を募集しています！
ご興味がある方はこちらをご参照ください！
https://www.biprogy.com/recruit/recruiting/