PerfectScalePerfectScale

PerfectScale

サービスの起動コストが高い原因、どう特定する?

ログ、JFR、async-profilerでJavaサービスの起動が遅い・CPU負荷が高い原因を突き止め、その分析結果をKubernetesのサイジング判断につなげる方法を紹介します。

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

Tania Duggal
By Tania Duggal
Oct 26, 202615 min read

Javaサービスの起動コストが高くなる理由はさまざまです。多数のクラスの読み込み、コードのコンパイル、大量のBeanの構築、データベースの待機、キャッシュの投入などが考えられます。このコストは、Ready到達までの時間の長さ、高いCPU使用量、あるいはその両方として現れます。

原因が異なれば、必要な対処も異なります。推測で対応すると、見当違いのチューニングをしてしまいます。データベースを待っているサービスにCPUを増やしても効果はありません。CPU制限を下げれば、コードのコンパイルで忙しいサービスはさらに遅くなります。

本当の原因は、計測とプロファイルで特定できます。ログ、Java Flight Recorder(JFR)、async-profilerは、それぞれ起動の異なる側面を可視化します。原因がわかれば、サービスにとって安全なCPUとメモリの設定を選べます。

サービスの起動コストが高いとは?

トラフィックを処理できるようになるまでに多くの時間やCPU、あるいはその両方を必要とするサービスは、起動コストが高いと言えます。このコストは3つの指標で表せます。

a. Ready到達までの時間: Ready到達までの時間とは、コンテナの起動からPodがReadyになるまでの時間です。この時間が長いと、ロールアウトが遅れ、スケールアウトが遅くなり、Probe失敗のリスクが高まります。次のコマンドは、appという名前のコンテナについて2つのタイムスタンプを出力します。

kubectl get pod <pod-name> -o jsonpath='{.status.containerStatuses[?(@.name=="app")].state.running.startedAt}{"\n"}{.status.conditions[?(@.type=="Ready")].lastTransitionTime}{"\n"}'

2つの値の差がReady到達までの時間です。Spring Bootのログ行「Started ... in N seconds」はこれより小さい値を示します。計測がアプリケーションの起動時点から始まるため、JVMとコンテナの起動にかかる時間が含まれないからです。

b. Ready到達までのCPU秒: 1 CPU秒とは、1つのCPUが1秒間動作することです。Ready到達までのCPU秒は、起動時のCPU作業量の合計を表します。コンテナは制限値を超えるCPUを使えないため、CPU秒を制限値で割った値が、Ready到達までの理論上の最短時間になります。cgroup v2では、PodがReadyになったらすぐにusage_usecフィールドを読み取ります。

kubectl exec <pod-name> -c app -- cat /sys/fs/cgroup/cpu.stat

usage_usecフィールドは、コンテナが起動してから使用したCPU時間をマイクロ秒単位で示します。1,000,000で割るとCPU秒になります。同じファイルのnr_throttledフィールドとthrottled_usecフィールドは、カーネルがコンテナをどれだけスロットリングしたかを示します。ファイルの読み取りが遅れると、Ready後の作業も値に含まれてしまいます。

c. Ready後のウォームアップ: Readyになったからといって、高速に動作するとは限りません。PodがReadyになった時点でも、JVMはまだ多くのメソッドを低速なインタープリターで実行しており、JITコンパイラはバックグラウンドでコンパイルを続けています。このウォームアップ中、サービスは定常状態より多くのCPUを使用し、最初のリクエストが遅くなることがあります。Readyになった瞬間にリソースを減らすと、ウォームアップが遅くなる可能性があります。Ready到達までだけでなく、Ready後の数分間もCPU使用量を計測すべきです。

media

起動プロファイリングのツール

起動コストが高い原因を特定するツールは3つあります。最も手軽なものから始め、より詳細が必要になった場合にのみ次のツールを使うとよいでしょう。

a. ログとSpring Bootのstartupエンドポイント: Spring Bootのログ行「Started ... in N seconds」が最初の目安になります。より詳しく知るには、Spring Boot Actuatorのstartupエンドポイントを使うと、各起動ステップとその所要時間を一覧で確認できます。アプリケーション側にBufferingApplicationStartupが必要です。

SpringApplication application = new SpringApplication(MyApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(4096));
application.run(args);

4096という数値はバッファの容量で、保持できるステップの最大数です。次にエンドポイントを公開します。Probeが動作し続けるように、リストにはhealthを残してください。

management.endpoints.web.exposure.include=health,startup

/actuator/startupへのGETリクエストはステップのスナップショットを返します。POSTリクエストはステップを返したうえでバッファをクリアします。各ステップには名前、所要時間、Bean名などのタグが付きます。まず最も時間の長いステップを確認しましょう。spring.beans.instantiate、spring.context.config-classes.parse、spring.context.refreshといった名前から、どこに時間がかかっているかがわかります。spring.beans.instantiateでは、タグbeanNameがBean名を示すため、最も遅いBeanを特定できます。なお、ステップ名はSpringのバージョンによって変わる可能性があります。

2つのJVMログオプションでさらにデータを追加できます。-Xlog:gc*オプションはガベージコレクションのイベントを、-Xlog:class+loadオプションは各クラスのロードを記録します。どちらもJAVA_TOOL_OPTIONSで渡せます。

b. Java Flight Recorder(JFR): JFRはJDKに組み込まれています。ガベージコレクション、JITコンパイル、実行サンプルなど、JVMのイベントを記録します。デフォルト設定のオーバーヘッドはごくわずかで、profile設定ではより多くのイベントを記録します。-XX:StartFlightRecordingオプションを使うとJFRはJVMと同時に起動するため、起動の全体をキャプチャできます。ファイルをPodからコピーした後、JDKのjfrツールまたはJDK Mission Controlで読み取ります。

jfr summary startup.jfr
jfr print --events jdk.GarbageCollection,jdk.Compilation startup.jfr
jfr print --events jdk.ExecutionSample --stack-depth 8 startup.jfr

1つ目のコマンドはイベントタイプとその件数を一覧表示します。2つ目のコマンドはガベージコレクションイベントとJITコンパイルを所要時間とともに出力します。3つ目のコマンドは、スタック深度8フレームで、各スレッドが実行しているコードのサンプルを出力します。jdk.Compilationイベントが記録するのは、設定のしきい値より長くかかったコンパイルのみです。

c. async-profiler: async-profilerはJava向けのサンプリングプロファイラで、フレームグラフを出力します。起動に関しては2つのモードが重要です。cpuモードはCPUを使用しているスレッドのみをサンプリングするため、何がCPUを消費しているかがわかります。wallモードは、実行中・スリープ中・待機中を問わず、すべてのスレッドを一定間隔でサンプリングするため、待機を含め、どこに時間がかかっているかがわかります。async-profilerはエージェントとしてJVMと同時に起動できるため、最初の1秒間からキャプチャできます。

Java on K8s: The Startup vs. Steady-State Sizing Dilemma|2026年10月28日 午前11時(米国東部時間)開催のライブハンズオンワークショップ

Kubernetesでの起動プロファイルの取得

記録はJVMと同時に開始し、サービスの実際の稼働条件下で実行する必要があります。重要なポイントは2つです。

a. 本番のCPU制限下でプロファイルを取得する: 本番と同じイメージ、同じJVMフラグ、同じCPU・メモリ設定を使用してください。制限値が異なると起動そのものが変わります。スロットリング、JVMが認識するCPU数、JVMが選択するコレクターが変わるからです。本番の制限を適用せずに取得したプロファイルは、本番とは異なる起動の姿を示します。詳しくはJVM起動時のCPUスロットリングをご覧ください。

次のPodテンプレートは、JVMと同時にJFRを開始し、ファイルをボリュームに書き込みます。

spec:
template:
spec:
containers:
- name: app
image: registry.example.com/orders-service:1.8.0
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:StartFlightRecording=filename=/profiles/startup-%p.jfr,settings=profile,duration=120s,dumponexit=true
resources:
requests:
cpu: 300m
memory: 2Gi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: profiles
mountPath: /profiles
volumes:
- name: profiles
emptyDir:
sizeLimit: 500Mi

各設定の役割は次のとおりです。

  • filename=/profiles/startup-%p.jfrはファイルをボリュームに書き込みます。%pの部分はプロセスIDに置き換わります。
  • settings=profileはデフォルト設定より多くのイベントを記録します。
  • duration=120sは120秒後に記録を停止します。起動と数分間のウォームアップをカバーできる時間を選んでください。
  • dumponexit=trueは、たとえばProbeがコンテナを強制終了した場合など、JVMが早期に終了してもファイルを書き込みます。
  • emptyDirは、ルートファイルシステムが読み取り専用の場合でも書き込み可能なフォルダを提供します。sizeLimitは、巨大なファイルがノードのディスクを埋め尽くすのを防ぎます。
  • CPUとメモリの値は例です。本番の値を使用してください。

コンテナ内ではJVMは通常プロセス1なので、%pは1になり、ファイル名はstartup-1.jfrになります。複数回の実行のファイルを1つのボリュームに残したい場合は、ファイル名に%tを追加してください。タイムスタンプが付加されます。JFRとasync-profilerの両方が%tに対応しています。

async-profilerを使うには、libasyncProfiler.soをイメージに含めるか、async-profilerのリリースアーカイブをinitコンテナで展開したボリュームに配置します。その後、環境変数を変更します。

env:
- name: JAVA_TOOL_OPTIONS
value: >-
-agentpath:/profiler/lib/libasyncProfiler.so=start,event=cpu,threads,timeout=120,file=/profiles/startup-cpu-%p.html

エージェントオプションの動作は次のとおりです。startはJVM起動時にプロファイリングを開始し、event=cpuはCPUモードを選択し、threadsはスレッドごとに個別にプロファイルし、timeout=120は120秒後に停止し、file=...htmlはフレームグラフを書き出します。ウォールクロックのプロファイルを取るには、event=cpuをevent=wall,interval=10msに置き換えます。コンテナ環境ではperfイベントがブロックされることがよくあります。プロファイラが起動に失敗する場合は、perfイベント不要のevent=ctimerを使用してください。

1回の実行で使うプロファイラは1つだけにし、各実行は3回繰り返してください。1回だけの結果は判断を誤らせる可能性があります。

b. Podからプロファイルをコピーする: kubectl cpでフォルダを手元のマシンにコピーします。

kubectl cp <namespace>/<pod-name>:/profiles ./profiles -c app

このコマンドには、コンテナイメージ内にtarプログラムが必要です。イメージにtarがない場合、コマンドは失敗します。その場合は、tarを持つ小さな2つ目のコンテナに同じボリュームをマウントし、そのコンテナ名を-cで指定します。KubernetesはemptyDirボリュームをPodとともに削除するため、Podが削除される前にファイルをコピーしてください。HTMLファイルはブラウザで開きます。

起動フレームグラフの読み方

フレームグラフは、すべてのサンプルのスタックトレースを表示します。各ボックスはメソッドを表し、その幅は、そのメソッドを含むサンプルの割合を示します。async-profilerのグラフでは、mainなどの最も外側のフレームが下部に、実際の処理を行うリーフフレームが上部にあります。幅の広いボックスが、時間を消費している箇所です。threadsオプションにより、各スレッドが独自のタワーを持ち、タワーはスレッド名を示すボックスから始まります。Linuxはスレッド名を15文字に切り詰めるため、長い名前はグラフ上で短く見えます。重要なスレッドグループは3つあります。

a. アプリケーションスレッド: mainを実行するスレッドは、アプリケーションの起動コードを実行します。そのルートボックスの名前はmainの場合もDestroyJavaVMの場合もあるため、名前ではなくSpringApplication.runフレームで見つけてください。幅の広いSpringApplication.runタワーや幅の広いrefreshタワーは、フレームワークの起動が時間の大半を占めていることを意味します。defineClassやloadClassを含む幅の広いボックスはクラスローディングを意味します。自分のパッケージ名を含む幅の広いボックスは、キャッシュロードやデータベースマイグレーションなど、自分自身の起動コードを意味します。

b. JITコンパイラスレッド: C1 CompilerThreおよびC2 CompilerThreで始まるルートはJITコンパイラです。完全なスレッド名はC1 CompilerThread0、C2 CompilerThread0などです。これらのタワーが起動スレッドのタワーと同じくらい広い場合、JIT処理が起動時CPUの大きな割合を占めています。どのフレームがインタープリターで実行されているかを見るには、拡張子.collapsedのファイルにプロファイルを書き出し、annオプションを追加します。このファイルでは、インタープリター実行のフレームは_[0]、C1フレームは_[1]、その他のコンパイル済みフレームは_[j]、インライン化されたフレームは_[i]で終わります。起動スレッドに_[0]フレームが多い場合、コードがまだコンパイルされていないことを意味します。HTMLグラフにはこれらの接尾辞は付かず、フレームタイプは色で表示されます。

c. GCスレッド: G1では、GC Thread#0、G1 Conc#0、G1 Refine#0などのルートがガベージコレクションの作業を行います。SerialコレクターではVM Threadがこれを行います。CPU制限が1 CPU以下、またはメモリ制限が1792 MB未満の場合、JVMはSerialコレクターを選択するため、そのようなPodから取得したグラフにはGCワーカースレッドが存在しません。最初の数秒間に幅の広いガベージコレクションのタワーがある場合、起動時のアロケーションに対してヒープが小さすぎるか、ヒープが何度も拡張されていることを意味します。この点は、JFRのjdk.GarbageCollectionイベント(イベント名がコレクターを示します。たとえばDefNewとSerialOldはSerialコレクターのものです)、またはログオプション-Xlog:gc*で確認してください。

同じ起動のcpuグラフとwallグラフを比較しましょう。wallグラフにはWebサーバースレッドなど多くのアイドルスレッドも表示されます。それらは無視し、起動スレッドに注目してください。

media

代表的な起動コストのカテゴリー

起動コストが高いケースの大半は、5つのカテゴリーのいずれか、または複数に当てはまります。カテゴリーごとにプロファイルには明確な兆候があり、サイジングの判断も異なります。

a. フレームワーク処理が重いアプリケーション: 兆候は、起動スレッドのSpringApplication.run配下の幅広いタワー、多数のspring.beans.instantiateステップ、大量のクラスローディングです。この処理はCPUバウンドなので、CPU制限が低いと処理が直接遅くなります。Spring Bootはspring.main.lazy-initialization=trueで初回使用時にBeanを作成できます。Class Data SharingとAOTキャッシュはクラスローディング処理の一部を削減します。AOTキャッシュはJDK 24(JEP 483)で登場しました。

b. JIT負荷の高いワークロード: 兆候は、幅の広いC1 CompilerThreおよびC2 CompilerThreタワーと、PodがReadyになった後も高いままのCPU使用量です。JDK 25のAOTキャッシュは、トレーニング実行から取得したメソッドプロファイルを保存できます(JEP 515)。これによりJVMのプロファイリング時間が短縮され、JITはより早く作業を開始できます。ただしJITコンパイラは引き続き動作するため、ウォームアップがなくなるわけではありません。

c. GCとヒープの圧迫: 兆候は、最初の数秒間に発生する多数のガベージコレクションイベントと、幅の広いGCタワーです。デフォルトでは、JVMは小さなヒープで起動し、アプリケーションがオブジェクトをアロケートするにつれてヒープを拡張します。拡張のたびにCPU作業が追加されます。-XX:InitialRAMPercentageで初期ヒープを大きく設定できます。初期ヒープを大きくすると最初からより多くのメモリを使用するため、まずメモリ制限を確認してください。初期ヒープは最大ヒープ未満に保ちます。最大ヒープはデフォルトでコンテナメモリの25%です(-XX:MaxRAMPercentage)。

d. I/Oと依存先の待機: 兆候は、CPU使用量が低く、Ready到達までの時間が長いことです。wallグラフでは、起動スレッドがソケット読み取り、スリープ、park呼び出しの中で時間を費やしている様子がわかります。典型的な原因は、データベース接続、スキーママイグレーション、リモート設定、DNSルックアップ、TLSハンドシェイクです。

e. 積極的なキャッシュウォーミング: 兆候は、自作のキャッシュロード処理を実行しているアプリケーションスレッドと、キャッシュのロード中に上昇するCPUおよびメモリ使用量です。これはPodがReadyになった後に起こることもあります。ウォーミングをReady前に完了させる必要があるかを判断してください。必要な場合は、そのCPUとメモリを起動時のサイズに含めます。不要な場合は、ウォーミングをReady後に移動し、CPU使用量を監視します。

分析結果をサイジング判断につなげる

次の2つの問いに答えることで、分析結果はサイジング判断に変わります。その処理はCPUバウンドなのか、それとも待機しているのか?その処理は起動後に止まるのか?判断は次のとおりです。

a. CPUバウンドかつ短時間(フレームワーク処理が重い場合とJIT負荷が高い場合): 起動に十分なCPUを与えます。CPU制限を起動時の必要量より高く設定するか、起動時ブーストまたはインプレースリサイズを使用し、リクエストは定常状態に近い値に保ちます。制限が低すぎると、PodのReady到達が遅れ、startup probeが失敗する可能性があります。

b. 依存先の待機: CPUを増やしてはいけません。待機そのものを解消するか、処理を切り出します。たとえば、マイグレーションを別のJobとして実行します。startup probeのウィンドウ(failureThreshold × periodSeconds)は、計測した最も遅いReady到達時間にマージンを加えた値以上に設定します。次の例では、ウィンドウは24 × 5 = 120秒です。

startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 24

ウィンドウが短すぎると、kubeletがコンテナを再起動し、新しいコンテナが同じ待機を繰り返すことになります。

c. GCとヒープの圧迫: 初期ヒープまたはメモリ制限を引き上げます。JVMコンテナでは、メモリリクエストをメモリ制限と同じ値に保ちます。JVMは最大ヒープを起動時に決定するため、起動後にメモリ制限を下げてはいけません。

d. Ready後のウォームアップ: CPU使用量が定常状態まで下がるまで、起動時のリソースを維持します。

e. 変更ごとに検証する: 変更は1つだけにし、再度プロファイルを取得して、Ready到達までの時間とReady到達までのCPU秒を比較します。両方の指標が改善した場合にのみ、その変更は有効です。

media

多数のサービスにわたる起動プロファイリング

1つのサービスなら手作業でプロファイルできますが、すべてのサービスを手作業でプロファイルすることはできません。クラスタのメトリクスを使って起動コストが最も高いサービスを見つけ、それらだけをプロファイルしましょう。次の3つのステップが役立ちます。

a. Ready到達時間でPodをランク付けする: このクエリはkube-state-metricsの2つのメトリクスを使用します。appをお使いのコンテナ名に置き換えてください。

topk(20,
max by (namespace, pod) (
kube_pod_status_ready_time
- on (namespace, pod, uid)
kube_pod_container_state_started{container="app"}
))

このクエリは、メトリクスがまだ残っているPodのみを返します。Kubernetesがかなり前に削除したPodは表示されません。

b. 最初の10分間のCPUでコンテナをランク付けする: CPUカウンターはコンテナ起動時にゼロから始まるため、10分後の値がそのまま最初の10分間のCPU秒になります。

topk(20,
max by (namespace, container) (
max_over_time(
(
container_cpu_usage_seconds_total{container!~"POD|"}
and on (namespace, pod) (time() - kube_pod_start_time < 600)
)[7d:1m]
)
)
)

600の部分は、お使いのサービスの起動時間に変更してください。このクエリはPodの経過時間が10分を超えてから実行してください。それより新しいPodはCPUの一部しか示さないためです。結果は各コンテナのPod群の中で最も高い値になるため、このクエリは個々のPodではなくコンテナをランク付けします。

c. 上位のサービスをプロファイルする: 両方のクエリで上位にランクされたサービスを対象にします。それぞれについて本番の制限下で起動プロファイルを取得し、各サービスを5つのカテゴリーのいずれかに分類します。

PerfectScaleは、クラスタ全体の主要なJVMメトリクスを表示します。Javaコンテナについては、ヒープおよび非ヒープのメモリ使用量に加え、GC時間、アクティブなプロセッサ数、GCタイプといったガベージコレクション(GC)メトリクスを収集します。JVMメトリクスの収集はPerfectScaleエージェントのインストール時に有効化でき、追加の設定は不要です。この機能はLinuxノードでのみサポートされています。

JVMデータの収集を有効にすると、PerfectScaleはアプリケーションの挙動を細かく可視化し、起動時のリソース要件が高いサービスの特定を支援します。PerfectScaleはこうしたサービスを自動的に検出し、各ワークロードに対して起動時と定常稼働時の2つの推奨値を提示します。

PerfectScaleは、Podの作成時に起動時の推奨値を適用し、Podが定常状態に達すると、インプレースでリソースを調整して稼働時の推奨値に合わせます。これにより、アプリケーションのパフォーマンスを損なうことなく、両フェーズのリソース割り当てを最適化できます。PerfectScaleはJVMのヒープサイズ制限も尊重するため、推奨値が設定済みの最大ヒープサイズを超えたり、設定済みの最小値を下回ったりすることはありません。Podの再起動も手動の介入も不要です。

インプレースリサイズにはKubernetes 1.33以降と、コンテナのリサイズポリシーrestartPolicy: NotRequiredが必要です。Deployment、StatefulSet、DaemonSet、Argo Rolloutsをサポートしており、Windows Podはサポートしていません。

Java Podのサイジングを正しく行うことは、アプリケーションのパフォーマンスとリソース効率のバランスを取るうえで欠かせません。本ガイドでは必ず押さえるべき基本を取り上げましたが、さらに高度なアプローチを実際に見てみたい方は、2026年10月28日 午前11時(米国東部時間)開催のハンズオンワークショップ「Java on K8s: The Startup vs. Steady-State Sizing Dilemma」にぜひご参加ください。DoiTのVikram Seshadriが、JVM Podを起動フェーズに合わせてサイジングし、再起動なしで定常状態にリサイズする方法を紹介します。今すぐ席を確保する

Java on K8s: The Startup vs. Steady-State Sizing Dilemma|2026年10月28日 午前11時(米国東部時間)開催のライブハンズオンワークショップ

PerfectScaleがJavaワークロードをどのようにライトサイジングするか、実際にご覧になりませんか?無料で始める、または技術セッションを予約するからどうぞ。