blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2022.12.12 イベントレポート

JJUG CCC 2022 fallに登壇してきました

by yutaro.kanagawa

#java #ecs

はじめに

こんにちは。ソリューション事業本部ゲームアライアンス事業部プラットフォーム開発部の金川( @orekyuu )です。
先日 JJUG CCC 2022 fall で「Fargate上のJVMからCPUを認識するまで 〜正しく認識されないCPUの謎を追え〜」というタイトルで、JVMがコンテナ内からどのようにCPU数を認識しているのかを話しました。

この記事では発表の内容のまとめと、2022年12月現在のコードでの違いについて紹介します。

直面した課題

JavaアプリケーションをECS on Fargateで運用を始めるに当たり1タスクでどれだけの負荷を捌けるかのテストを行うために JavaFlightRecorder を使って詳細なメトリクスを取っていたところ、G1GCになっていることを期待していましたが意図せずSerial GCが有効になっていることに気付きました。

Serial GCは単一のスレッドでGCを行うアルゴリズムで、CPU負荷を比較的小さくすることができますがGC停止時間は長くなってしまいます。
G1GCは並列型のアルゴリズムで、Serial GCと比べてスループットは落ちますがGC時間を短くすることができます。ユーザーのリクエストを裁くようなアプリケーションではG1GCなどの並列型のGCを利用します。

テストを行ったECS Taskではcpuの値を2048に設定しており、今回のようなマルチプロセッサ環境ではデフォルトでG1GCが選択されるはずですが意図せずSerial GCが選択されてしまうことがわかりました。

原因と解決策

JVMのデフォルトのGCは、動く環境のメモリとCPU数によって変わります。
JDK17かつLinux環境ではCPU数が2以上、メモリが2GB以上であればG1GCが選択されます。この条件を満たさない場合はSerial GCが選択されます。

ECS on Fargateで動かしているDockerコンテナ内のJVMからはCPU数が1つに見えており、Serial GCが選択されていました。
CPU数が1に見えていた原因は、ECSのTask Definitionに タスク全体のCPU コンテナごとのCPU がありコンテナ側の設定を適切に指定しないとJVMからCPU数が1に見えてしまうことがわかりました。

処理の流れ

ここからはデフォルトのGCアルゴリズム決定の流れについてJVMの実装に基づいて紹介します。
ただし、ここで参照している実装は2022年春頃のものとなっており、2022年12月時点ではガラッと変わっています。
最後にその点について補足します。

JVMのデフォルトGC決定の流れ

GCを起動オプションで明示的に指定していない場合はまずGCConfig::select_gc_ergonomicallyが呼び出されます。
ここではサーバークラスマシンであればG1GC、そうでない場合はSerialGCが選択されます。

https://github.com/openjdk/jdk/blob/b6b0317f832985470ccf4bc1e2abf9015ce5bd54/src/hotspot/share/gc/shared/gcConfig.cpp#L98-L112

ここで書かれているサーバークラスマシンとは、CPU数が2以上メモリを2GB以上のマシンのことを指します(一部のOSでは変わりますが)

https://github.com/openjdk/jdk/blob/b6b0317f832985470ccf4bc1e2abf9015ce5bd54/src/hotspot/share/runtime/os.cpp#L1623-L1635

2022年春頃のJVMのCPU数検出の流れ

CPU数はos::active_processor_countが返しています(ちなみにJavaの世界からは Runtime.availableProcessors で取ることができます)。osごとに実装が違うので、今回はlinuxの実装を読んでいます。
Docker内とホストで直接動かす場合とでは動きが変わり、Docker内で動かす場合はOSContainer::active_processor_count()の値を使っています。
https://github.com/openjdk/jdk/blob/b6b0317f832985470ccf4bc1e2abf9015ce5bd54/src/hotspot/os/linux/os_linux.cpp#L4761-L4780

最終的にたどり着くのがこのコードで、ここでCPU数を取得しています。
https://github.com/openjdk/jdk/blob/b6b0317f832985470ccf4bc1e2abf9015ce5bd54/src/hotspot/os/linux/cgroupSubsystem_linux.cpp#L486-L544

Dockerはcgroupsという仕組みを使っており、これによってプロセスごとのメモリやCPUを制限しています。
JVMはcgroupsが吐き出すcpu.sharesなどの値を元にCPU数を決定しているようです。

ログによる確認

JVMのログを確認するためには Unified Logging を使います。
起動時に -Xlog:os+container=trace のようなオプションをつけて起動すると次のようなログを見ることができます。
ログと実際のコードを突き合わせて確認するとCPU Sharesが2になっていて、それが原因でCPU数が1と認識されているようです。

img

CPU Sharesが2になる原因

詳解: Amazon ECS による CPU とメモリのリソース管理 を読むと次のような注意が書かれています。

注: コンテナに CPU ユニットを指定しない場合、ECS は内部的に cgroup に対して 2 (Linux カーネルが許容する最小値) という値の Linux CPU 配分 (cpu.shares) を設定します。「CPU ユニット」は、ECS の構成要素であり、Linux カーネルの世界には存在しないことに注意してください。CPU ユニットが (Linux CPU 配分と cgroup 設定を介して) Linux ホストで強制される方法については、実装の詳細でありここでは触れません。また、Linux は CPU の数や、定義する配分の絶対値を気にしないことに注意してください。Linux は、CPU 競合時に特定の cgroup が利用できる CPU リソースの割合を決定する際に、階層内の特定の場所で定義されたすべての CPU 配分の合計を分母として使用します。

ECSのTask Definitionには タスクサイズに渡すCPU コンテナに渡すCPU があり、タスクサイズのCPUのみを指定した場合はcgroupに2が渡されているのでJVMから見たときにCPUが1に見えていたようです。
正しくCPU数を指定するためにはコンテナに渡すCPUも指定する必要があります。

2022年12月時点でのJVMのCPU数検出の流れ

質疑応答で、現時点ではどのように変わっているか軽くお話したのでここでも紹介します。
2022年12月現在ではCPU Sharesを使わずに quota/period もしくは、ホストのCPUで小さい方を利用しているようです。

https://github.com/openjdk/jdk/blob/f5ad515db0b8f5545137c47200e81d78f89aa09c/src/hotspot/os/linux/cgroupSubsystem_linux.cpp#L476-L513

理由は こちらのチケット で説明されており、cpu.sharesは相対的な値であるにも関わらずJDKの実装では1024で割った絶対的な値として扱っているのが間違っていたようです。
この変更が含まれるJDKを使っている場合、ホストのCPU数を参照するためコンテナに渡すCPUが指定されていない場合でもデフォルトでG1GCが指定されるようになります。

まとめ

  • GCは明示的に指定しない場合、意図しないGCが指定される場合もあるので以下のどちらかの対応を行うことをおすすめします
    • 今動いているGCアルゴリズムを確認して、意図通りに設定されているか確認する
    • 明示的にGCを指定する
  • JVMの実装と付き合わせて調査をする場合-Xlogオプションが非常に便利です
  • Unified Loggingとコードを突き合わせていけばJVMのコードを読むのは意外と怖くない。ぜひ読んでみてください。

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。