メニコア環境におけるJavaコンテナのパフォーマンス低下


> 2018/04/08追記：
> まとめにJava10に関する記載を追加しました。
> 
> 2017/06/02追記：
> 調査内容をまとめて記載を大幅に更新しました。
>
> 2017/06/01追記：
記事の最後に「JDK8/9以降の本問題の取り組み」を追加しました。OpenJDK8の8u121.b34、8u131.b06で対処済みのようです（本当に修正されているかは別途確認する予定）。
どちらの対処版も2017年以降にリリースされているため、頻繁にJDK/JREを更新してない場合は確認しておくとよいです。

#まとめ (Java10以降)

Java10では、正式にDockerコンテナをサポートするようになりました。Dockerコンテナ上のJavaプログラムはDockerコンテナで設定したCPU、メモリ等のリソース設定を把握できるようになったため、この記事で書いているメニコア問題は解消します。

Java10にDockerサポートについては、下記Qiita記事が動作確認を含めて詳しくまとめてくれています。

https://qiita.com/c9katayama/items/b427a008623f0e228c50

#まとめ (Java8/9)

メニコア環境においてDockerやtaskset等でCPU数を少数に制限した場合、Javaプログラムのパフォーマンスが大幅に低下する場合があります。対処方法は下記の2通りあります。

- dockerコマンドの--cpuset-cpusのようなCPUコアIDを指定するオプションのみを使用する場合は、JDKアップデートで対処できます。（Java8の8u121.b34以降または8u131.b06以降）
- dockerコマンドの--cpusのようなCPUコアIDを指定しないオプションを使用する場合は、Javaプログラム起動時に適切なJVMパラメータ設定のチューニングを行う必要があります。

#発生事象
メニコア環境(大量のCPUコアをもつマシン)において、CPUコア数等のリソース制限を行ったDocker上でJavaプログラムを実行した場合にパフォーマンスが著しく低下します。
またDocker以外でもcgroupsによるリソース制限を利用したコンテナ技術やコマンド(ex. tasksetコマンド)を利用してJavaプログラムを実行した場合でも同様です。

#原因
Javaでは以下のようなJVMパラメータをマシンのハードウェアリソースをもとに設定します。

- GC(ガベージコレクト)スレッド数
- JITコンパイルスレッド数
- ヒープサイズ

これ自体は問題ありません。しかしJavaではDocker等のcgroupによるハードウェアリソース制限を行った場合でも、その制限されたハードウェアリソースではなく、ホストマシンのハードウェアリソースをもとにJVMパラメータを決定します。

このため、例えばCPUコアが20コアあるホストマシンで「CPUコアを1個に制限したDockerコンテナ」上でJavaプログラムを実行した場合、Javaでは20コアをもとにJVMパラメータを決定してしまいます。しかしDockerコンテナではCPUコアを1個しか利用できない事実は変わらないため、Javaプログラムはアプリケーションの処理に加えて大量のGCスレッド、JITコンパイルスレッドのオーバーヘッドに苛まれてパフォーマンスが著しく低下することになります。

以下のような条件に当てはまる人は要注意です。

- ホストマシンがメニコア環境。(CPUコアを数十個搭載)
- Dockerコンテナ上でJavaが動作する。
- 上記DockerコンテナがCPUリソースを制限している。(dockerコマンドで--cpuset-cpus, --cpusなどの
オプションを指定)

#対処方法
対処方法は大きく2つのアプローチがあります。

##対処方法1: JDKアップデート
本記事の「(参考)JDK8/9以降の本問題の取り組み」に記載していますが、JDK8/9の新しいバージョンではcgroup関連の問題は***ある程度対処済み***です。JDK8の場合は下記バージョン以降にアップデートすることにより解消します。

- 8u121.b34以降
- 8u131.b06以降

対処前と対処後のJDKを載せたDockerコンテナでの確認結果を以下に掲載します。論理CPUコア数8の環境で確認していますが、対処後のJDKではちゃんとdockerコマンドの--cpuset-cpusによりリソース制限がJVMパラメータに反映されるようになってますね。

```
#--------------------------------------------------------------
# 対処前JDKでは--cpuset-cpusオプションの設定がJVMパラメータに反映されない
#--------------------------------------------------------------
$ docker run --rm --cpuset-cpus 0-1 openjdk:8u77-b03 java -XX:+PrintFlagsFinal -version | egrep "CICompilerCount |ParallelGCThreads"
     intx CICompilerCount                          := 4                                   {product}
    uintx ParallelGCThreads                         = 8                                   {product}
openjdk version "1.8.0_03-Ubuntu"
OpenJDK Runtime Environment (build 1.8.0_03-Ubuntu-8u77-b03-3ubuntu3-b03)
OpenJDK 64-Bit Server VM (build 25.03-b03, mixed mode)

#--------------------------------------------------------------
# 対処後JDKでは--cpuset-cpusオプションの設定がJVMパラメータに反映される
#--------------------------------------------------------------
$ docker run --rm --cpuset-cpus 0-1 openjdk:8u131-b11 java -XX:+PrintFlagsFinal -version | egrep "CICompilerCount |ParallelGCThreads"
     intx CICompilerCount                          := 2                                   {product}
    uintx ParallelGCThreads                         = 2                                   {product}
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-0ubuntu1.16.04.2-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
```

ただしdockerコマンドの--cpusのように具体的なCPUコアIDを指定しないオプションの場合は、対処後のJavaであっても問題は回避できないようです。試していませんが、--cpu-quotaおよび--cpu-periodのオプションについても同様に本問題は回避できない可能性が高そうです。この場合はもう一つの対処方法であるJVMパラメータ設定のチューニングが必要となります。

```
#--------------------------------------------------------------
# 対処後JDKでも--cpusオプションの設定だと効果なし
#--------------------------------------------------------------
docker run --rm --cpus 2.0 openjdk:8u131-b11 java -XX:+PrintFlagsFinal -version | egrep "CICompilerCount |ParallelGCThreads"
     intx CICompilerCount                          := 4                                   {product}
    uintx ParallelGCThreads                         = 8                                   {product}
openjdk version "1.8.0_131"
OpenJDK Runtime Environment (build 1.8.0_131-8u131-b11-0ubuntu1.16.04.2-b11)
OpenJDK 64-Bit Server VM (build 25.131-b11, mixed mode)
```


##対処方法2: JVMパラメータ設定
Dockerコンテナ上のJavaプログラムを実行するときにJVMパラメータを設定することによりチューニングを行って解消します。JDKアップデートよりも手間がかかりますが一番確実です。

###GCをシリアルGCに変更する
以下の条件に当てはまる場合は、GCをシリアルGCに設定してもよいかもしれません。

- CPUリソースをCPUコア1個、またはそれ以下に制限している
- 主な処理内容がバッチ処理 (多少のStop the Worldを気にしない)

```bash
# GCをシリアルGCに変更
java -XX:+UseSerialGC  ...
```

ただしシリアルGCは将来的には廃止されるという話もあるので、Javaのアップデートを行う予定の場合は注意が必要です。
※ちなみにJDK9で廃止されるという話は聞きません

###GCスレッド数を変更する
Java8のデフォルトGCであるパラレルGCを使用する場合は、Parallel GCのスレッド数を減らすことにより対応します。適切なスレッド数はdockerコマンドで指定したCPUリソース制限に依存するため、チューニングが必要となります。

```bash
# ParallelGCのスレッド数を1に設定
java XX:ParallelGCThreads=1  ...
```

CSM、G1等の設定は行ったことがないため記載しませんが、基本的にCPUリソースに見合ったスレッド数設定を行えばよいです。

###JITコンパイルスレッド数を減らす。
JITコンパイルも複数スレッドで動作しており、メニコア環境ではそれなりのスレッド数が生成されてしまいます。このためこちらもCPUリソース制限に合わせてチューニングが必要となります。

```bash
# JITコンパイルスレッド数を1に設定
java -XX:CICompilerCount=1 ...
```

#(参考)仮想マシンの場合
仮想マシン上のJavaプログラム、仮想マシンのハードウェアリソースをもとにJVMパラメータを決定します。このためホストマシンがメニコア環境であっても、仮想マシンに割り当てられたのハードウェアリソースにもとづいてJVMパラメータが設定されます。


#(参考)問題発生時のバージョン情報
- Ubuntu16.04
- OpenJDK 8u77-b03
- Docker v1.12

#(参考)JDK8/9以降の本問題の取り組み
JDK9以降でこの問題は認知されており、Issueの対応状況を見る限りはJDK8にもIssueがバックポートされているようです。そしてJDK8「8u121.b34」と「8u131.b06」では対処済みとなっています。


1. [JDK-8140793 - getAvailableProcessors may incorrectly report the number of cpus in Docker container](https://bugs.openjdk.java.net/browse/JDK-8140793)
  - 本記事で取り上げてるCPU問題に関するIssue。
  - 別件JDK-6515172の対処により、本問題は対処済み。
  - 関連Issueを見ると「8u121.b34」と「8u131.b06」は対処済み済み。

2. [JDK-8172318 - [linux] Experimental support for cgroup memory limits in container (ie Docker) environments](https://bugs.openjdk.java.net/browse/JDK-8170888)
  - メモリでも本記事と同様の問題が起きるため、JVM側でcgroups制御されているDockerコンテナのメモリサイズを把握できるよう対処するためのIssue。
  - JDK9で「試験的」に導入される。
  - JDK8にもバックポート依頼済み。
  - JDK-6515172/JDK-8140793の問題と同様に、関連Issueを見ると「8u121.b34」と「8u131.b06」は対処済み。