Java 17:OpenJDKのコンテナ対応における新機能

Red Hat で Java Platform Advocate として OpenJDK を担当している伊藤ちひろ(@chiroito)です。

この記事は、Red Hat Developerのブログ記事、Java 17: What’s new in OpenJDK's container awareness | Red Hat Developer の翻訳記事です。


https://developers.redhat.com/sites/default/files/styles/article_feature/public/ST-java1_1x%20%282%29.png?itok=dY1JgjGN

OpenJDKは、以前からLinuxコンテナ(DockerやPodman、またKubernetesのようなコンテナオーケストレーションフレームワークなど)を意識してきました。コンテナ対応とは、OpenJDKがコンテナ内で動作していることを検出することを意味します。この記事では、コンテナ対応がなぜ有用なのか、OpenJDKのその領域で最近何が変わったのか、そして、開発者がJVMの設定決定方法について理解を深めるために利用できる診断オプションは何かについて学びます。

OpenJDKのコンテナの検出は、Linuxのコントロールグループ(cgroup)ファイルシステムを使用して、強制されたリソース割当てを検出します。この記事を書いている時点では、Java 17 と 11.0.16+ が、cgroups v1 と cgroups v2 の両方の構成をサポートする唯一の長期サポートリリースです。

OpenJDKは、コンテナで実行されているときに、特定のリソース割り当てがあるかどうかを検出し、ある場合は、その操作のためにそれらの上限値を使用します。これらのリソース制限は、例えば、JVMによって選択されたガベージコレクション(GC)アルゴリズム、ヒープのデフォルトサイズ、スレッドプールのサイズ、およびForkJoinPoolのデフォルト並列性がどのように決定されるかに影響を及ぼします。

OpenJDKのコンテナ対応機能は、Java 17とJava 11ではそれぞれの一般提供版(GA)リリースから、Java 8uではアップデート8u202から利用できます。

コンテナへの配慮が重要な理由

Kubernetesやその他多くの一般的なクラウドオーケストレーションシステムでは、デプロイ時にCPUや メモリの割り当てを行い、コンテナリソースを制限できます。これらの制限は、コンテナのデプロイ時にコンテナエンジンに渡されるオプションに変換されます。コンテナエンジンのオプションは、Linux の cgroup 疑似ファイルシステムを通じて、リソースの制限を設定します。Linux カーネルは、cgroup を介してリソース制限が設定されている場合、どのプロセスもその制限を超えないようにします(少なくとも、長時間は超えない)。

このような環境でJavaプロセスをデプロイすると、デプロイされたプロセスに対してcgroup limitが設定されることがあります。Java仮想マシンが設定されたcgroupの制限を考慮しない場合、オペレーティングシステムが提供しようとする以上のリソースを消費する危険性があります。その結果、Java プロセスが予期せず終了する恐れがあります。

OpenJDKのコンテナ対応コードに関する最近の変更点

Java 11とJava 17の間では、cgroups v2のサポートとOperatingSystemMXBeanにおけるコンテナの対応という、最も顕著な2つの追加事項があります。

cgroups v2 サポート

Java 15 以降、OpenJDK は使用中の cgroup バージョンを検出し、cgroup バージョン固有の設定にしたがって制限を検出します。Java 15 以降では、OpenJDK は cgroups v1 に加えて cgroups v2 または単一階層構造(Unified Hierarchy)をサポートしています (これについては JDK-8230305 を参照してください)。

cgroups v2 のみを持つシステムで Java 11 または Java 8 を実行すると、コンテナの検出が行われず、代わりにホストの値が使用されます。先に説明したように、これはコンテナ化されたデプロイメントで予期しないアプリケーションの動作をもたらす可能性があります。

システムで使用されているcgroupのバージョンを表示する簡単な方法の1つは、JVMオプションの-XshowSettings:systemオプションです。(このオプションは Linux 固有のものです。) 以下はその例です。

$ java -XshowSettings:system -version
Operating System Metrics:
    Provider: cgroupv2
    Effective CPU Count: 2
    CPU Period: 100000us
    CPU Quota: 200000us
    CPU Shares: 1024us
    List of Processors: N/A
    List of Effective Processors, 4 total:
    0 1 2 3
    List of Memory Nodes: N/A
    List of Available Memory Nodes, 1 total:
    0
    Memory Limit: 1.00G
    Memory Soft Limit: 800.00M
    Memory & Swap Limit: 1.00G

openjdk version "17.0.2" 2022-01-18
OpenJDK Runtime Environment 21.9 (build 17.0.2+8)
OpenJDK 64-Bit Server VM 21.9 (build 17.0.2+8, mixed mode, sharing)

使用中のcgroupの構成を把握する他の方法としては、 VM.info jcmd ユーティリティ (in section "container (cgroup) information") や -Xlog:os+container=debug JVMオプションがあります。

cgroup v2がサポートされていない場合、例えばJava 11を使用しているのであれば、-XshowSettings:systemの出力は次のようなものになります。

$ java -XshowSettings:system -version
Operating System Metrics:
    No metrics available for this platform
openjdk version "11.0.14" 2022-01-18
OpenJDK Runtime Environment 18.9 (build 11.0.14+9)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.14+9, mixed mode, sharing)

システム・メトリクスが検出されない場合、JVMプロセスはホストOSの設定を使用します。

OperatingSystemMXBean コンテナ対応

Java 14 以降、OperatingSystemMXBean は、JDK の内部で Linux 固有の Metrics Java API を使用してシステム情報を通知するようになりました。つまり、cgroup 制限が設定されている場合、OperatingSystemMXBean はコンテナ・ホストのシステム・リソース上でそれらの制限を(必要に応じて)報告します。この機能は、Java 8(8u272 以降)および Java 11(11.0.9 以降)にもバックポートされています。

注意 : OpenJDKでは、-XX:-UseContainerSupport JVMオプションで、コンテナ対応機能を無効化できます。これにより、OperatingSystemMXBean のコンテナ対応を無効にします。

次のファイル CheckOperatingSystemMXBean.java は、実行中のシステムに関する情報を表示します。コンテナに対応した OperatingSystemMXBean を使用しているので、実行する環境に応じて物理ホストまたはコンテナのリソースの情報を表示します。

import com.sun.management.OperatingSystemMXBean;
import java.lang.management.ManagementFactory;

public class CheckOperatingSystemMXBean {

    public static void main(String[] args) {
        System.out.println("Checking OperatingSystemMXBean");

        OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        System.out.println(String.format("Runtime.availableProcessors: %d", Runtime.getRuntime().availableProcessors()));
        System.out.println(String.format("OperatingSystemMXBean.getAvailableProcessors: %d", osBean.getAvailableProcessors()));
        System.out.println(String.format("OperatingSystemMXBean.getTotalPhysicalMemorySize: %d", osBean.getTotalPhysicalMemorySize()));
        System.out.println(String.format("OperatingSystemMXBean.getFreePhysicalMemorySize: %d", osBean.getFreePhysicalMemorySize()));
        System.out.println(String.format("OperatingSystemMXBean.getTotalSwapSpaceSize: %d", osBean.getTotalSwapSpaceSize()));
        System.out.println(String.format("OperatingSystemMXBean.getFreeSwapSpaceSize: %d", osBean.getFreeSwapSpaceSize()));
        System.out.println(String.format("OperatingSystemMXBean.getSystemCpuLoad: %f", osBean.getSystemCpuLoad()));
    }

}

プログラムをコンパイルして実行すると、そのプログラムが利用できるリソースが表示されます。

$ ./jdk-17.0.1+12/bin/javac CheckOperatingSystemMXBean.java
$ sudo podman run -ti --rm --memory 300m --memory-swap 300m --cpu-period 100000 --cpu-quota 200000 -v $(pwd):/opt:z fedora:35
[root@7a0de39d8430 opt]# /opt/jdk-17.0.1+12/bin/java CheckOperatingSystemMXBean
Checking OperatingSystemMXBean
Runtime.availableProcessors: 2
OperatingSystemMXBean.getAvailableProcessors: 2
OperatingSystemMXBean.getTotalPhysicalMemorySize: 314572800
OperatingSystemMXBean.getFreePhysicalMemorySize: 291680256
OperatingSystemMXBean.getTotalSwapSpaceSize: 0
OperatingSystemMXBean.getFreeSwapSpaceSize: 0
OperatingSystemMXBean.getSystemCpuLoad: 0.050386
[root@7a0de39d8430 opt]# /opt/jdk-17.0.1+12/bin/java -XX:-UseContainerSupport CheckOperatingSystemMXBean
Checking OperatingSystemMXBean
Runtime.availableProcessors: 4
OperatingSystemMXBean.getAvailableProcessors: 4
OperatingSystemMXBean.getTotalPhysicalMemorySize: 5028548608
OperatingSystemMXBean.getFreePhysicalMemorySize: 3474866176
OperatingSystemMXBean.getTotalSwapSpaceSize: 5027917824
OperatingSystemMXBean.getFreeSwapSpaceSize: 5027917824
OperatingSystemMXBean.getSystemCpuLoad: 0.000000

コンテナのデフォルトをチューニング

場合によっては、メモリとCPU使用率に関するOpenJDKのデフォルト設定が、コンテナで実行されるアプリケーションにとって望ましい設定ではない可能性があります。OpenJDKは、マルチユーザのデスクトップとサーバシステム、およびコンテナの使用ケースなどを考慮する必要があります。コンテナはデスクトップや サーバとは異なり、そのコンテナでは Java プロセスだけが実行されることがかなり多いからです。たとえば、Kubernetesコンテナで、800MB RAMのメモリ制限を持つ場合、デフォルトの -XX:MaxRAMPercentage=25 は、おそらく、マルチユーザのデスクトップシステムでのそれほどの意味を持ちません。なぜなら、最大ヒープサイズは、その800MBコンテナの200MB RAM(800MBの4分の1)により、上限に達してしまうからです。

OpenJDK を典型的なコンテナ用途に合わせるために、JDK-8186248 (および OpenJDK 8u では JDK-8146115) で導入されたオプションで、特にこの特定の用途に合うように、ヒープサイズを利用可能(コンテナ)メモリに対するパーセントで設定できます。これらのオプションは、 -XX:InitialRAMPercentage, -XX:MaxRAMPercentage, そして -XX:MinRAMPercentage です。

アプリケーションがコンテナ内で動作している場合、これらのパーセントオプションを設定することは、 -Xmx-Xms を使用してアプリケーションの最大ヒープサイズと最小ヒープサイズを設定するよりも望ましいことです。これらの-XXのオプションは、コンテナのメモリ制限に対してヒープサイズを設定し、デプロイメント設定でメモリ制限が変更されると、再デプロイメント時に自動的に更新されます。両方の設定がある場合、-Xmx-Xms が優先されます。

コンテナ内で実行するときにCPU設定を上書きするためのもう一つの重要なオプションは、-XX:ActiveProcessorCountです。このオプションは、コンテナ検出機構に関係なく、JVMが使用すべきCPUコアの数を正確に指定できます。

コンテナ対応のサポートは -XX:-UseContainerSupport オプションで完全に無効にすることもできます。

表1は、コンテナで実行する際にJVMの設定を調整するための便利なオプションをまとめたものです。

表1:チューニングのオプション

JVMオプション JVMオプションの置き換え 説明 デフォルト値
-XX:InitialRAMPercentage -XX:InitialRAMFraction 初期ヒープサイズに使用される実メモリのパーセンテージ 1.5625
-XX:MaxRAMPercentage -XX:MaxRAMFraction 最大ヒープサイズに対する実メモリの最大使用率 25
-XX:MinRAMPercentage -XX:MinRAMFraction 物理メモリが小さいシステムで、最大ヒープサイズに使用される実メモリの最小パーセンテージ 50
-XX:ActiveProcessorCount n/a 有効として報告すべきVM が使えるCPU 数 n/a
-XX:±UseContainerSupport n/a コンテナ検出と実行時設定のサポートを有効にする true

意固地なオプション

CPUリソース制限のためのデフォルトのコンテナ検出法(ヒューリスティック)は、一般的なコンテナオーケストレーションフレームワーク(特にKubernetesとMesos)がコンテナを起動する方法を主に手本にしている。たとえば、Kubernetesのセットアップでは、CPUリソースの制限がある場合に考慮すべき4つの(主要な)ケースがあります。実際には、クラスタのデフォルトが設定されている場合もあるため、さらに多くの可能性がありますが、それらもこれらのケースでほぼカバーされます。

  1. spec.containers[].resources.limits.cpuspec.containers[].resources.requests.cpu の両方が明示的に設定されています。
  2. spec.containers[].resources.limits.cpu のみが明示的に設定されている。Kubernetes は spec.containers[].resources.requests.cpuspec.containers[].resources.limits.cpu と同じ値に設定します。
  3. spec.containers[].resources.requests.cpu のみが明示的に設定されている。Kubernetes は spec.containers[].resources.limits.cpuspec.containers[].resources.requests.cpu 以上に設定します。
  4. spec.containers[].resources.limits.cpuspec.containers[].resources.requests.cpu のどちらも設定されていない。Kubernetes は spec.containers[].resources.limits.cpu を未設定のままにし、他にデフォルトがない場合はコンテナの CpuShares 値を 2 に設定します。

コンテナ・オーケストレーション・フレームワークは通常、 spec.containers[].resources.requests.cpu のミリコア値を1024倍して、DockerやPodmanの --cpu-shares コマンドラインオプションで設定した値に直接変換しています。したがって、JVMはこの知識に基づいて、CPUシェアの値を計算します。

さらに、JVMはCPUコアの値の下限を1に設定します。つまり、spec.containers[].resources.requests.cpu=500mの設定を持つコンテナは、JVMがシングルCPUコアを使用するようになります(0.5 * 1024 = 512--cpu-shares=512のオプションを生成しますが、 cpu-shares < 1024では1コアとなります)。spec.containers[].resources.requests.cpu=2` と設定すると、JVMは2つのCPUコアを使用するようになり、以下同様です。

これらのルールは、先ほどの一覧の最後のケースで、どちらのオプションも明示的に設定されていない場合、JVMが1つのCPUコアのみを使用できると考えるようになることに注意してください。 このような場合は、-XX:ActiveProcessorCountで希望のCPUコアの値をオーバーライドすることをお勧めします。

注意: バージョン 18.0.2+, 17.0.5+, 11.0.17+ において、OpenJDK は利用可能な CPU コアの計算に CPU シェアの設定を考慮しなくなりました。詳しくは、JDK-8281181 を参照してください。

spec.containers[].resources.limits.cpu(L) millicore値は、DockerとPodmanの--cpu-quota(Q) と--cpu-period(P) 値に直接変換します。JVMは、(L)で設定された制限をceil(Q/P)に基づいて計算します。もし、spec.containers[].resources.limits.cpuspec.containers[].resources.requests.cpuの両方が指定された場合、limitsの値が優先されることに注意しましょう。これにより、最初の3つのケースでは、JVMはCPUコアに合理的な値を使用するようになります。CPU割り当てよりもsharesを優先したい場合は、-XX:-PreferContainerQuotaForCPUCount`オプションを指定します(これについてはJDK-8197867を参照してください)。

同様に、RAMのリソース制限もコンテナ・オーケストレーション・フレームワークによって存在します。spec.containers[].resources.limits.memory は、コンテナエンジンの -memory-memory-swap コマンドラインオプションに変換されます。spec.containers[].resources.requests.memory は通常、生成されたコンテナに影響を与えません。cgroup v2 を使用しているノードでは、memory.min または memory.low が適切に設定されるかもしれません。メモリ要求の設定は、診断のためにそれらの値を報告する以外、JVM側には何の影響も与えません。

デバッグのための診断オプション

トレースログは、OpenJDK のコンテナ対応のロジックが何を行っているかをより理解するのに非常に役立ちます。そのロジックには、2つの異なる実装があることに注意してください。ひとつはJVM (libjvm.so) 用で、もうひとつはコア・ライブラリーを使用するためにJavaで実装されています。

JVMのコンテナ検出ロジックは、Unified Loggingフレームワークと統合されており、例えば -Xlog:os+container=trace によって追跡できます。OpenJDK 8u JVMでは、-XX:+UnlockDiagnosticVMOptions -XX:+PrintContainerInfo がおおよその同等のものです。これらの追跡は、デプロイされたアプリケーションのcgroup疑似ファイルシステムを検査することによって、コンテナ対応が実際に機能しているかどうか、そして、JVMがどのような値を決定しているかを出力します。

Java 11+では、どのGCが使用されているかを知ることも有用で、-Xlog:gc=infoでこの情報を表示できます。例えば、コンテナの制限で1つのCPUしか使用できない場合、Serial GCが選択されます。複数の CPU が有効で、十分なメモリ(少なくとも 2GB)がコンテナに割り当てられている場合、 Java 11 およびそれ以降のバージョンでは G1 GC が選択されます。

$ java -XX:ActiveProcessorCount=1 -Xlog:gc=info -version
[0.048s][info][gc] Using Serial
openjdk version "17.0.1" 2021-10-19
OpenJDK Runtime Environment 21.9 (build 17.0.1+12)
OpenJDK 64-Bit Server VM 21.9 (build 17.0.1+12, mixed mode, sharing)

$ java -Xlog:gc=info -version
[0.006s][info][gc] Using G1
openjdk version "17.0.1" 2021-10-19
OpenJDK Runtime Environment 21.9 (build 17.0.1+12)
OpenJDK 64-Bit Server VM 21.9 (build 17.0.1+12, mixed mode, sharing)

その他のオプションとして、 VM.info jcmd ユーティリティがあります。これは、すでに実行されている Java プロセスのコンテナに関する設定を確認するために便利です。VM.info で表示される cgroup 情報は、JVM がクラッシュしたときに hs_err*.log ファイルに記録される情報と同じものです。

注意: これらの情報のほとんどは、問題を診断するための強力なツールであるJDK Flight Recorder(JFR)を介して入手することもできます。コンテナでは、JFRの情報をCryostat経由でアクセスできます。

まとめ

Kubernetesのデプロイメント設定が、cgroups v2システム上でOpenJDKがGCアルゴリズムを選択する際にどのような影響を及ぼすかについての詳細は、このトピックに関する私の動画をご覧ください。

youtu.be

この動画は、コンテナへのアプリケーションのデプロイメントによって、OpenJDKの動作が若干異なる可能性があることを実証しています。この記事の助けを借りて、その理由をより理解することができます。あなたのアプリケーションのコンテナ設定を自由に調整し、クラウドへのデプロイメントを最大限に活用できるようにしましょう。cgroup v2 のサポートは、Java 17+ および OpenJDK 11.0.16+ でのみ利用可能であることに留意してください。

コンテナベース環境での Java の微調整についてもっと知りたい方は、このシリーズの最初の記事「シングルコアのコンテナにおけるJavaのベストプラクティス - 赤帽エンジニアブログ」を読むか、赤帽エンジニアブログの最近の Cryostatに関するシリーズをチェックしてください。また、, DevNation Tech TalkでChristine FloodとEdson Yanagaの「Java and containers: What's there to think about?」もごらん下さい

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。