コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

Spring × KotlinのAPIで、起動直後に負荷が高騰する理由を調べてみました

こちらは「コドモン Advent Calendar 2025」の7日目の記事です。

こんにちは!コドモンでエンジニアをしている藤村です!

今回は実務で遭遇したSpring × KotlinのAPIをデプロイする際のメトリクス悪化について、何が起きているのか深掘りして調べてみました!

課題:タスク起動時のメトリクス悪化

  • ECSで動かしているSpringとKotlinを使ったAPIサーバーについて、新しいタスクが起動するタイミングでヘルスチェックに失敗していました。
  • その時のメトリクスを見ると、CPU使用率の高騰やレイテンシの悪化も見られました。
    • 可用性を大きく損なうものではなく問題ない範囲ではあるのですが、何が起きているのかの解像度が低かったので調べてみることにしました。
      デプロイ直後に悪化するメトリクス

まず試してみたこと

ヘルスチェック失敗の原因については、一つ思い当たるものがありました。

JVMのアプリケーションでは、JIT(Just-In-Time)コンパイルといって実行時にバイトコードを機械語に変換する処理を行います。

アプリケーションの起動直後はまだコンパイルされていないコードが多く、コンパイルが多く走るためCPU負荷が高まりレスポンスが遅れ、ヘルスチェックがタイムアウトしているのではないかと考えました。

起動直後には少ないトラフィックでJITコンパイルを進め、温まってからより多くのトラフィックを受け付けられるので、レイテンシ悪化の影響を受けるリクエストを減らせるのではないかと考えていました。

なので、新しく登録されたターゲットへのトラフィックの切り替えを時間をかけて徐々に100%にするスロースタート設定を入れたのですが、思ったより改善しませんでした。

何が起きているかを調べるため、ローカルで再現してみる

SpringとKotlinを使った簡単なAPIサーバーを用意しました。

また、JITコンパイルの様子をログ出力するために、環境変数でJAVA_TOOL_OPTIONS-XX:+PrintCompilationを設定しました。コンテナのログにJITコンパイルの様子が出力されます。

services:
  app:
    # buildpackでビルドしたイメージを指定(./gradlew bootBuildImage で作成)
    image: jit-experiment:0.0.1-SNAPSHOT
    container_name: jit-experiment-app
    ports:
      - "8080:8080"
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 1G
    # JITコンパイルの様子をログ出力する
    environment:
      JAVA_TOOL_OPTIONS: -XX:+PrintCompilation

アプリケーション起動時

docker compose up -dでコンテナ起動後、docker logs -f jit-experiment-appでJITコンパイルのログを確認しました。

アプリケーションの起動が始まるくらいからJITコンパイルが活発になり、 CPU使用率が200%(コンテナに割り当てた2コア分)に張り付く様子が見られました。

$ docker stats
CONTAINER ID   NAME                 CPU %     MEM %
edeb8e92a5d5   jit-experiment-app   203.38%   27.20%

その後アプリケーションの起動が完了する頃には、JITコンパイルのログも少なくなり、CPU使用率も下がりました。

$ docker stats
CONTAINER ID   NAME                 CPU %     MEM %
edeb8e92a5d5   jit-experiment-app   0.79%     28.78%

リクエスト処理時

アプリケーション起動後、固定の文字列を返すシンプルなエンドポイントにリクエストを投げると処理に必要なコードがコンパイルされ、CPU使用率も再び上がりました。

ただ、思っていたよりCPU使用率は上がっていませんでした。自分の中ではリクエストを処理する時のコンパイルがECSのメトリクスを跳ねさせている要因だと予想していたのですが、予想外の結果となりました。

$ docker stats
CONTAINER ID   NAME                 CPU %      MEM %
edeb8e92a5d5   jit-experiment-app   20.66%     28.96%

気付き

アプリケーション起動時のコンパイルが活発

  • 自分の中ではリクエストを処理する時のコンパイルだけをイメージしていたのですが、実際にはアプリケーションの起動時にもコンパイルが走っていました
    • アプリケーション起動のタイミングでのコンパイルが特に激しく、CPU使用率を引き上げていました
    • CPU使用率などは起動時のコンパイルによる悪化が大きそうですが、スロースタートはここに影響する打ち手ではなかったので思ったほど改善しなかったのだなと気付きました
    • 同時にCPU使用率が張り付いてしまうのが起動時だけでトラフィックを受け付ける前までで収まっている限り、ユーザーには影響しなさそうだなという気付きもありました

リクエストを受けた時に走るコンパイルが思ったより活発じゃなかった

  • リクエストを受けた際にも必要に応じてコンパイルが走るためCPU使用率が上がりますが、今回調べた限りでは起動時ほど激しくない様子でした
  • とはいえ、実運用されるAPIではより複雑な処理を行うエンドポイントが複数あるケースも考えられるので、実践的にはリクエストに応じたコンパイルによる負荷の影響はもう少し大きくなりそうです

ヘルスチェックについて調べる

またヘルスチェックの失敗についても、タイムアウトを設けているとはいえ、そこまで厳しい設定値ではなかったので、どういう挙動になっているのか気になりました。

ALBに届いたトラフィックをECSに転送する構成の場合、2種類のヘルスチェックがあるかと思います。

それぞれ調べたところ、以下の気付きがありました

  • 最初のヘルスチェックについて、まだアプリケーションが起動していない時点で行われてしまうことがあり得る
    • そのため、起動に時間がかかって失敗することがあるのだと理解しました。
  • どちらのヘルスチェックでも起動直後の一定期間は失敗を無視する設定がある
  • 対策できることがわかりました
ヘルスチェックの対象 失敗が続くと 最初のヘルスチェック 猶予設定
コンテナ コンテナを再起動 タスク起動後すぐ startPeriod
ターゲットグループ 該当TGへのトラフィック停止 ターゲット登録直後 healthCheckGracePeriodSeconds

まとめ

今回調べてみると、以下の整理がつきました。

起動時に起きること

  • アプリケーションの起動に時間がかかるので、起動が完了する前にヘルスチェックが走ると失敗する
  • コンパイルでCPU使用率が上がるためレイテンシが悪化し、ヘルスチェックがタイムアウトしやすくなる
    • アプリケーション起動時は特にコンパイルが活発で、CPU使用率が急激に上がる
    • 起動後もリクエストに応じてコンパイルが走り、ここでもCPU使用率が上がる

学び

  • JITコンパイルを行うアプリケーションでは、起動直後のウォームアップ期間を考慮できると良い
    • ヘルスチェックに対しては猶予期間を設定できる
    • ウォームアップが済んでからトラフィックを受け付けられると、レイテンシの改善が見込める
      • トラフィックを受け付け始める前に、いくつかリクエストを投げてコンパイルを進める「暖機運転」という手法があるようです
  • CPU使用率の上昇について、トラフィックを受け付ける前の起動時のコンパイルによるものも含まれている
  • 起動時間やウォームアップ期間を考慮して初期のヘルスチェックに失敗することを許容しているので、ヘルスチェックに失敗したからといってそこまで怖がらなくて大丈夫