PerfectScalePerfectScale

PerfectScale

JVMサービスに必要なのは、最初の30秒だけ2 CPU、その後はずっと0.3 CPU

JVMは起動直後の数秒間に最も多くのCPUを必要とします。KubernetesのCPUリミットがJVM起動をスロットリングする仕組み、その計測方法、Podの適切なサイジングを解説します。

このページはEnglish、Deutsch、Español、Français、Italiano、Portuguêsでもご覧いただけます。

Tania Duggal
By Tania Duggal
Oct 14, 202618 min read

Javaサービスは、起動直後の数秒間に最も多くのCPUを必要とします。JVMはクラスをロードし、コードをコンパイルし、アプリケーションを構築します。その後、サービスに必要なCPUは大幅に減ります。ほとんどのクラスタでは、Podはライフサイクル全体を通じて同じCPUリクエストと同じCPUリミットを保持します。そのため、Podはこのパターンに追従できません。

このミスマッチが、JVM起動時のKubernetes CPUスロットリングを引き起こします。リミットが低いと、JVMは起動処理を素早く終えられず、PodがReadyになるのが遅れます。リクエストが高いと、ノードは起動後にサービスが使わないCPUを予約し続けます。どちらの設定も両方のフェーズには適合しません。

タイトルの数字はあくまで一例です。サービスごとに固有の数字があり、本記事では自分のサービスの挙動を確認する方法を紹介します。起動時により多くのCPUが必要な理由、KubernetesがCPU設定を強制する仕組み、JVMがその設定から何を決定するのかを解説します。最後に、現時点で取れる選択肢を紹介します。

Javaの起動時に定常状態より多くのCPUが必要な理由

JVMが最初のリクエストを処理する前には、大量の処理を終える必要があります。この処理は主に4つの部分からなります。

クラスのロードとリンク

JVMは、コードが最初に必要としたタイミングでクラスをロードします。クラスファイルを読み込み、検証し、リンクします。Springアプリケーションでは、フレームワークがアプリケーションをスキャンしてBeanを生成するため、最初の数秒間に大量のクラスが必要になります。

C1とC2によるJITコンパイル

JVMはまずバイトコードをインタープリタで実行しますが、これは低速です。JVMは各メソッドの実行回数をカウントし、HotSpotは頻繁に実行されるメソッドを2つのコンパイラでマシンコードにコンパイルします。C1は高速にコンパイルし、シンプルなコードを生成します。C2はより時間をかけてコンパイルし、最も頻繁に実行されるメソッド向けに高速なコードを生成します。この設計は階層型コンパイル(tiered compilation)と呼ばれます。コンパイラは専用のスレッドで動作し、JVMはこれらのスレッドのデフォルト数をCPU数から決定します。

ガベージコレクションスレッドとヒープの拡大

起動時には多くのオブジェクトが割り当てられます。ヒープが拡大し、メモリを解放するためにコレクタが動作します。JVMは、CPU数とメモリサイズからコレクタとそのスレッド数を選択します。

フレームワークの初期化

フレームワークはBeanを構築し、プロキシを生成し、設定を読み込み、接続を開きます。Spring Boot Actuatorは、BufferingApplicationStartupを使用すると、これらの各ステップをstartupエンドポイントに一覧表示できるため、どこに時間がかかっているかを把握できます。

これらの処理には、いずれも終わりがあります。クラスがロードされ、頻出メソッドがコンパイルされ、Beanが生成されると、CPU需要は低下します。実トラフィックが到着してもJITコンパイラはメソッドのコンパイルを続けるため、低下は瞬時ではありません。それでも、定常状態に必要なCPUは起動時よりはるかに少なくなります。この2つのフェーズの差こそが、1つのCPU設定では両方に適合できない理由です。

KubernetesがCPUリクエストとリミットを適用する仕組み

Kubernetesは、2つの設定でコンテナのCPUを制御します。

CPUリクエストとスケジューリング

CPUリクエストは、Kubernetesがコンテナのために予約するCPU量です。スケジューラは各ノード上のPodのリクエストを合計し、未予約のCPUが十分にあるノードにのみ新しいPodを配置します。リクエストは、負荷の高いノードでのCPUシェアも決定します。コンテナ同士がCPUを奪い合うとき、カーネルはリクエストの大きいコンテナにより多くのCPU時間を与えます。cgroup v2では、コンテナランタイムはリクエストをcpu.weightとして書き込みます。cgroup v1ではcpu.sharesを使用します。

リミットのみを設定しリクエストを設定しない場合、Kubernetesはリミットをリクエストとして使用します。リクエストとリミットは、PodのQoSクラスも決定します。すべてのコンテナでCPUリクエストとCPUリミットが同じ値、かつメモリリクエストとメモリリミットが同じ値の場合、PodはGuaranteedになります。リクエストまたはリミットはあるもののGuaranteedの条件を満たさない場合はBurstable、どちらもない場合はBestEffortになります。

CPUリミットとスロットリング

CPUリミットはハードキャップであり、カーネルはスロットリングによってこれを強制します。カーネルは時間を期間(period)に分割し、kubeletはデフォルトで100msの期間を使用します。各期間内で、コンテナはCPU時間のクォータを使用できます。リミットがクォータのサイズを決めます。ランタイムはcgroup v2でリミットをcpu.maxとして書き込みます。500mのリミットは50000 100000となり、これは100,000マイクロ秒の各期間につき50,000マイクロ秒のクォータを意味します。

resources:
requests:
cpu: 300m # CPUを予約し、負荷の高いノードでのCPUシェアを決める
memory: 2Gi
limits:
cpu: "1" # ハードキャップ:cpu.maxに100000 100000として書き込まれる
memory: 2Gi

KubernetesのCPUスロットリングとは

スロットリングは、カーネルがCPUリミットを強制する方法です。コンテナのスレッドがある期間のクォータをすべて使い切ると、カーネルは次の期間が始まるまでそれらすべてを一時停止します。

スレッドは1つのクォータを共有しており、この点がJVMにとって重要です。500mのリミットを例にすると、各期間に50msのCPU時間が与えられます。ビジーなスレッドが1本なら、クォータを使い切るのに50msかかるため、期間の半分は動作できます。ビジーなスレッドが8本なら、同じクォータを約6msで使い切り、カーネルは期間の残りの間、8本すべてを一時停止します。起動時のJVMは多くのビジーなスレッドを同時に実行するため、毎期間の早い段階でクォータに達します。

Kubernetesは、CPUを使いすぎたコンテナを停止するのではなく、減速させます。Kubernetesはスロットリングをイベントとして報告せず、コンテナログにもエラーは表示されません。その影響は遅延として現れ、計測して初めて気づくのです。

JVM起動時のCPUスロットリング

JVMの起動処理の多くはCPUバウンドな作業です。JVMには、アプリケーションがトラフィックを処理できるようになるまでに終えるべき一定量の作業があり、リミットはコンテナがその作業をこなす速度を決めます。リミットが高ければ作業は早く終わります。リミットが低いと、同じ作業がより多くの期間に引き伸ばされ、コンテナはクォータを使い切った後、毎期間待たされます。起動時のスロットリングは2つの問題を引き起こします。

起動の遅延とReady状態の遅れ

スロットリングされたJVMは起動に時間がかかり、PodがReadyになるのが遅れます。この遅延はPodの外にもコストをもたらします。ローリングアップデートは新しい各PodがReadyになるのを待つため、すべてのロールアウトに時間がかかります。新しいレプリカはReadyになって初めて役立つため、トラフィック増加時のスケールアウトも遅くなります。ノード障害時には、代替のPodにも同じ余分な時間が必要になります。

プローブの失敗と再起動

スタートアッププローブは、アプリケーションが起動したことをkubeletに伝えます。スタートアッププローブの実行中、Kubernetesはlivenessプローブとreadinessプローブを実行しません。スタートアッププローブがfailureThreshold × periodSeconds以内に成功しない場合、kubeletはコンテナを再起動します。起動に長い時間を要するコンテナにはスタートアッププローブを使用し、livenessプローブと同じエンドポイントを指すようにしてください。

スタートアッププローブがないと、起動の遅さが再起動ループを招くことがあります。JVMの起動中にlivenessプローブが失敗し、kubeletがコンテナを再起動し、新しいコンテナが同じCPU処理を繰り返すのです。スロットリングされたJVMは起動に時間がかかるため、このループに陥る可能性が高まります。

待機時間に余裕を持たせたスタートアッププローブなら、このループを防げます:

startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 24 # 最大24 x 5 = 120秒待機

待機時間を広く取れば再起動は止まりますが、起動が速くなるわけではありません。PodがReadyになるのは依然として遅いままです。

スロットリングの計測方法

2つのcAdvisorカウンタがスロットリングを示します。カウンタcontainer_cpu_cfs_periods_totalはすべての期間を数えます。カウンタcontainer_cpu_cfs_throttled_periods_totalは、コンテナがクォータをすべて使い切った期間を数えます。後者を前者で割ると、スロットリングされた期間の割合が得られます。

sum(rate(container_cpu_cfs_throttled_periods_total{container="app"}[5m]))
/
sum(rate(container_cpu_cfs_periods_total{container="app"}[5m]))

5分間のレートでは、数秒しか続かない起動時の状況が隠れてしまいます。起動時については、PodがReadyになった時点で生のカウンタを読み取り、自分で割り算してください。cgroup v2では、コンテナは同じデータをcpu.statでも公開しています。nr_periods、nr_throttled、throttled_usecの各フィールドは、期間数、スロットリングされた期間数、スロットリングされた合計時間を示します。

Terminal window
kubectl exec <pod-name> -- cat /sys/fs/cgroup/cpu.stat

kubeletからcAdvisorの値を読むこともできます:

Terminal window
kubectl get --raw /api/v1/nodes/<node-name>/proxy/metrics/cadvisor | grep container_cpu_cfs

サンプリング間隔に注意してください。メトリクスシステムは一定間隔でしか値を更新しません。この間隔より短い起動時のピークは、実際より小さく見えるか、まったく現れません。短いピークについては、生のカウンタをより高頻度でサンプリングしてください。

CPUリミットがJVMの挙動を変える仕組み

CPUリミットはJVMを遅くするだけではありません。LinuxではJVMはデフォルトでコンテナをサポートし(-XX:+UseContainerSupport)、起動時にリミットを読み取ります。この値がJVMの動作をどう変えるか、3つのポイントを紹介します。

CPUリミットから決まるアクティブプロセッサ数

JVMはCPUクォータを期間で割り、結果を次の整数に切り上げます。したがって、1001mのリミットは2 CPUになります。JDK 19以降およびJDK 17.0.5以降では、CPU数のカウントにCPUリクエスト(cpu.shares)を使用しません。JITコンパイラスレッド数やGCスレッド数をはじめ、多くのJVM設定がこの数に依存します。

ガベージコレクタの選択(Serial vs. G1)

JVMは、プロセッサが2個以上かつメモリが1792MB以上のマシンをサーバークラスとみなします。サーバークラスのマシンではG1を選択し、それ以外ではSerialコレクタを選択します。そのため、リミットの小さな変更がコレクタを変えることがあります。1 CPUのリミットはプロセッサ1個となり、JVMはSerialを選択します。1100mのリミットはプロセッサ2個となり、メモリが1792MB以上であればG1を選択します。2つのコレクタは起動時の挙動が異なります。G1は並列に動作する複数のワーカースレッドを起動するため、一度により多くのCPUを使用できます。Serialは1本のスレッドでコレクションを行います。

CPUリミットのないPod

リミットがない場合、JVMはノードのCPUを数えます。大きなノードでは多数のCPUを検出し、多数のコンパイラスレッドとGCスレッドを起動します。このPodにスロットリングは発生しませんが、合計で大量のCPUを使用する可能性があり、リクエストに比例して近隣のPodと競合します。リクエストが小さいと、ノードがビジーなときにこのPodの取り分は小さくなります。

自分のJVMがどう選択しているかは確認できます。コンテナログを有効にし、実行中のJVMのフラグを読み取ってください:

env:
- name: JAVA_TOOL_OPTIONS
value: "-Xlog:os+container=trace -Xlog:gc*"
Terminal window
kubectl exec <pod-name> -- jcmd 1 VM.flags -all \
| grep -E 'UseSerialGC|UseG1GC|ParallelGCThreads|CICompilerCount'

注:このコマンドはイメージ内にjcmdが必要で、JVMがプロセス1として動作していることを前提としています。シェルがJVMを起動している場合は、JVMの実際のプロセスIDを使用してください。JREのみのイメージにはjcmdは含まれていません。

フラグ-XX:ActiveProcessorCount=<n>は、検出されたCPU数を上書きします。すべての環境で同じJVMの挙動にしたい場合に使用してください。

安全な単一のCPUリクエスト設定が存在しない理由

リクエストの設定方法にはそれぞれコストがあります。選択肢は次の4つです。

起動時に合わせたサイジング

高いリクエストとリミットは、JVMに高速起動のための十分なCPUを与えます。しかし、ノードはPodのライフサイクル全体にわたって高いリクエスト分を予約し続け、他のPodはそのCPUを使用できません。起動後、サービスは予約分のごく一部しか使いません。多数のレプリカとノードにまたがると、この未使用CPUは大きなコストになります。

定常状態に合わせたサイジング

小さなリクエストとリミットは、起動後のサービスには適合します。しかし、JVMの起動処理にはCPUが足りなくなります。カーネルがスロットリングし、PodのReadyが遅れ、プローブが失敗する可能性があり、ロールアウトに時間がかかります。サービスは安価になりますが、脆弱になります。

CPUリミットを設定しない

リミットがなければ、カーネルはコンテナをスロットリングせず、起動時にノードの空きCPUを自由に使えます。この選択にはトレードオフがあります。JVMはノードの全CPUに合わせてスレッドをサイジングします。ビジーなノードでは、コンテナはリクエストに比例したCPU時間しか得られないため、リクエストが小さいと起動は依然として遅くなり得ます。また、リミットがないと、1つのPodが近隣のPodに必要なCPUを奪ってしまう可能性があります。

HPAとCPU初期化期間

Horizontal Pod Autoscaler(HPA)は、CPU使用量をリクエストに対する割合として計測します。リクエストが小さいと起動時のピークが非常に高い負荷に見え、HPAがサービスに不要なレプリカを追加する可能性があります。HPAは新しいPodのCPUサンプルをスキップすることがあるため、Kubernetesはこのリスクを軽減しています。デフォルトでは、readiness遅延は30秒(--horizontal-pod-autoscaler-initial-readiness-delay)、CPU初期化期間は5分(--horizontal-pod-autoscaler-cpu-initialization-period)です。どちらのフラグもkube-controller-managerに属するため、マネージドクラスタでは変更できない場合があります。

起動時CPUへの現時点での対処法

起動時CPUに対処する方法は次のとおりです。

起動時の処理を減らす

処理が少なければ、どのリミットでも起動は短くなります。Spring Bootはspring.main.lazy-initialization=trueで、初回使用時にBeanを生成できます。Class Data SharingとAOTキャッシュは、クラスロード処理の一部を省けます。AOTキャッシュはJDK 24(JEP 483)で登場しました。Spring FrameworkはCDSとAOTキャッシュの両方をサポートしています。プロファイリングを行えば、自分の起動時間がどこに費やされているかがわかります。

CPUリミットを起動時の必要量より高く設定する

リクエストは定常状態の水準に近く保ち、リミットは起動に十分な高さに設定します。この場合、PodはBurstable QoSクラスになります。スケジューラはリクエスト分だけを予約し、ノードに空きCPUがあればPodはリミットまでCPUを使用できます。起動時にノードに空きCPUがない可能性もあるため、この方法は余裕のあるノードで最も効果を発揮します。リミットを高くすると、JVMが認識するCPU数も変わります。

In-place Podリサイズ

In-place Podリサイズは、実行中のPodのCPUとメモリを再起動なしで変更します。この機能はKubernetes 1.33でベータになり、Kubernetes 1.35で安定版になりました。この機能を使えば、Podを高いCPUリミットで起動させ、起動後に低い値へ移行できます。コンテナはリサイズポリシーで動作を制御します。CPUのデフォルトポリシーはNotRequiredで、マニフェストに明示することもできます:

containers:
- name: app
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired

リソースの変更はresizeサブリソースを通じて行います。このコマンドにはkubectl 1.32以降が必要です:

Terminal window
kubectl patch pod <pod-name> --subresource resize --patch \
'{"spec":{"containers":[{"name":"app","resources":{"requests":{"cpu":"300m"},"limits":{"cpu":"500m"}}}]}}'

status.containerStatuses[].resourcesフィールドは、コンテナが現在使用しているリソースを示します。In-placeリサイズを使用できないPodもあります。Windows Podや、static CPUマネージャまたはMemoryマネージャポリシー下のPodがその例です。また、リサイズでPodのQoSクラスを変更することはできません。さらに、誰かがリサイズをトリガーする必要があります。手動でパッチを実行することもできますが、多数のPodに対してはコントローラによる実行が必要です。

リサイズはcgroupのリミットを変更しますが、JVMは一部の設定を一度しか決定しません。コレクタは起動時に選択され、その後維持されます。GCスレッドの最大数など、CPU数に依存する設定も起動時に決まります。Runtime.availableProcessors()はJVMの実行中に変化し得るため、実行時にCPU数を読み取るコードは新しい値を参照できます。本番で頼る前に、使用中のJDKでリサイズをテストしてください。

JVMメモリに関する注意点

JVMコンテナのメモリリミットは下げないでください。JVMは起動時に最大ヒープを設定します。デフォルトでは、最大ヒープはコンテナメモリの25%です(-XX:MaxRAMPercentage)。JVMは、メタスペース、スレッドスタック、コードキャッシュなど、ヒープ外のメモリも使用します。プロセスの実使用量を下回るメモリリミットはOOM killを引き起こします。このため、起動後にリサイズするのはCPUとし、メモリリミットは必要な場合にのみ引き上げてください。

VPAとマネージドの起動ブーストオプション

Vertical Pod Autoscaler(VPA)にはInPlaceOrRecreate更新モードがあります。このモードはまずin-placeリサイズを試み、それが不可能な場合はPodを再作成します。GKEでは、CPUスタートアップブーストがVPAと連携して動作します。Pod作成時にCPUリクエストを引き上げ、PodがReadyになってからdurationSecondsの間、高いリクエストを維持します。その後、GKEはリクエストをin-placeでベースライン値に戻します。ブーストが適用されるのはPod作成時のみで、同じPod内でのコンテナ再起動にはブーストは適用されません。

この機能は執筆時点でプレビュー段階であり、GKE 1.36.0-gke.4447000以降が必要です。

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: example-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: example
updatePolicy:
updateMode: "Off"
startupBoost:
cpu:
type: "Factor"
factor: 2
durationSeconds: 10

この例では、ブーストはCPUリクエストを2倍にし(factor: 2)、PodがReadyになってから10秒間維持します。updateModeをOffに設定すると、VPAはブーストのみを適用し、他の変更は行いません。HPAをブーストと併用する場合は、readinessプローブを定義し、durationSecondsを0に設定してください。これにより、起動時のCPUピークを理由にHPAがレプリカを追加するのを防げます。

PerfectScaleのデュアルフェーズ自動化

JVMサービスには2つのリソースニーズがあります。起動時には、JVMがクラスのロード、コードのコンパイル、アプリケーションの構築を行うため、多くのCPUが必要です。その後の必要量は大幅に減ります。1つの平均的な推奨値では、両方のフェーズに対応できません。PerfectScaleは、各workloadに対して起動時用と定常状態のランタイム用という2つの推奨値を提供します。PerfectScaleは起動時推奨値を2つのフェーズのうち高いほうのニーズに合わせてサイジングするため、ノードは起動中に十分なキャパシティを確保できます。

PerfectScaleは、Podの作成時に起動時推奨値を適用します。Podが安定稼働に入った後、PerfectScaleはランタイム推奨値を再起動なしでin-place適用します。PerfectScaleは各Podのフェーズを追跡し、workloadがランタイムフェーズへクリーンに移行できない場合には安全チェックを追加します。

In-placeリサイズにはKubernetes 1.33以降と、コンテナのリサイズポリシーrestartPolicy: NotRequiredが必要です。Deployment、StatefulSet、DaemonSet、Argo Rolloutsをサポートし、Windows Podはサポートしません。クラスタがin-placeリサイズをサポートしていない場合、PerfectScaleは標準の安全なアプローチにフォールバックします。

PerfectScaleはJVMヒープを保護します。最大または最小ヒープサイズを設定している場合、PerfectScaleはその範囲外となる推奨値を適用しません。In-placeリサイズでは、メモリリミットは増加のみ可能です。Javaコンテナについては、PerfectScaleはヒープ、非ヒープ、ガベージコレクションのデータも収集します。GC時間、アクティブプロセッサ数、GCタイプを表示します。PerfectScaleエージェントのインストール時にJVMメトリクス収集を有効化でき、サポート対象はLinuxノードのみです。PerfectScaleは起動時と定常状態のリソースを分けて表示するため、コストと無駄の数値は各フェーズでworkloadが実際に必要としたものを反映します。

ワークショップにぜひご参加ください。DoiTのVikram Seshadriが、2026年10月28日 午前11時(米国東部時間)開催のハンズオンワークショップ「Java on K8s: The Startup vs. Steady-State Sizing Dilemma」で、起動時に合わせたJVM Podのサイジングと、再起動なしで定常状態へリサイズする方法を解説します。お申し込みはお早めに。

PerfectScaleがJavaのworkloadsをどのようにライトサイジングするか、ぜひお確かめください。無料で始める、またはデモを予約するからどうぞ。