13
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Java 25(JEP483)で、Cloud Runのコールドスタートは早くなったのか

13
Last updated at Posted at 2025-12-08

この記事はウェブクルー Advent Calendar 2025 の6日目の記事です。 でした。
遅刻して申し訳ございません。

昨日は@shoshi-maruyamaさんの、SvelteKitを個人で触ってみたので、感想を共有して布教してみるでした!

結論

  • Cloud RunでJavaを使うなら、Java 25のAOTコンパイル一択

経緯

1年半ほど前、弊社で使っているScalaプロジェクトを、コスト削減のためにServerlessのCloud Runで運用できないかという検証をしていました。
単にコンテナをCloud Runにデプロイしただけでは起動に5~6秒以上はかかってしまい、ToC向けの本番環境としては使えないと判断しました。

GraalVMに可能性を感じて検証もしましたが、得られる速度メリットに対して、ライブラリの互換性、CI/CD時間の増大など、制約条件も多くなってしまったため、本番環境での利用を断念しています(´・ω・`)

しかし!

今秋リリースされたJava 25の目玉機能の一つとして、事前キャッシュを用いたパフォーマンス更新が含まれました。(JEP483, JEP514, JEP515
また、Cloud Run側も 10月末にJava 25へ対応しています1
以前は起動速度の遅さに悩まされたJavaですが、これなら本番運用に耐えられるかもしれない…! と思い、今回検証を考えた次第です。

Java 25の起動速度だけ検証するのも面白くないため、せっかくなのでJava 8からのLTSバージョンで、速度を一斉に計ってみることにしました。
秋と言えばJavaンカップですね。

検証バージョン

OS

  • Windows 11 + WSL2 (Ubuntu 24.04.3 LTS)

アプリケーション
JavaのLTS世代に合わせて、動作する Spring Boot のバージョンを切り替えています。

  • Spring Boot 2.7(Java8/11)
  • Spring Boot 3.4(Java17/21)
  • Spring Boot 3.5.5(Java 25)

Cloud Run

  • 実行環境:デフォルト
  • CPU:1000m(1つ)
  • メモリ:512MiB
  • 起動時のCPUブースト:あり

検証詳細

1. ファイル作成

詳細

やることは以下の2つです。

  • ルートディレクトリにSpring Boot設定用のpom.xmlを配置。
  • 起動用のJavaファイルの作成

1.1. pom.xmlの作成

1つのフォルダを使いまわして検証する際には、以下2点を変更します。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version> <relativePath/> // 1. Javaのバージョンによって変える(バージョン項を参照)
    </parent>
    <groupId>com.example</groupId>
    <artifactId>startup-bench</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>17</java.version> // 2. Javaのバージョンによって変える
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

1.2. 起動用Javaファイルの作成

Maven の標準的なディレクトリ構造を作成します。

mkdir -p src/main/java/com/example/startupbench/

移動後のフォルダで、起動検証用のJavaファイルを作る。

備考:Java 25ではmain メソッドの簡略化が可能になりました
(参考:Javaがパブリックスタティックヴォイドメインの呪文から解放される
しかし、今回は差異をなくすために余計な呪文を付けています。

package com.example.startupbench;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class StartupApplication {

    public static void main(String[] args) {
        SpringApplication.run(StartupApplication.class, args);
    }

    @GetMapping("/")
    public String hello() {
        return "OK";
    }
}

2. ローカル環境で起動(任意)

詳細

Cloud Run へデプロイする前に、ローカル(WSL2/Ubuntu)で各世代の Java が正しく動作するか確認できます。

Javaのバージョン管理のためにSDKMAN!をインストールする。

curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

sdkmanでJavaのバージョンを切り替えることができます。

## Javaでインストールできるバージョンを確認
sdk list java
## java 8の場合
sdk install java 8.0.472-tem
## 設定されたことの確認
java -version

今回の検証で使ったLTSのバージョンは以下です。

## Java 11
sdk install java 11.0.29-tem  && sdk use java 11.0.29-tem

## Java 17
sdk install java 17.0.17-tem && sdk use java 17.0.17-tem

## Java 21
sdk install java 21.0.9-tem && sdk use java 21.0.9-tem

## Java 25
sdk install java 25.0.1-tem && sdk use java 25.0.1-tem

Maven で JAR を作成し、Spring Boot を起動します。
参考:Maven を Ubuntu にインストールして Hello World する

# Mavenのインストール
sudo apt install maven

# ビルド
mvn clean package

# 起動
java -jar target/startup-bench-0.0.1-SNAPSHOT.jar

別のターミナルから curl localhost:8080 を実行し、OK が返ってくればローカル環境での準備は完了です。

3. GCP環境へのデプロイ

詳細

Cloud Run用にDockerfileを作成します。

3.1. 標準版 Dockerfile (Java 8/11/17/21/25)

世代ごとにベースイメージを差し替えてビルドします。

# --- ビルドステージ ---
# 検証対象のJDKに合わせて 8 / 11 / 17 / 21 / 25 を選択
# 25の場合は、FROM maven:3.9.11-eclipse-temurin-25
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# --- 実行ステージ ---
# 検証対象に合わせてベースイメージを切り替え
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

3.2. Java 25 AOT 最適化版 Dockerfile

Java 25 の新機能(AOT Cache)を有効にするため、ビルドプロセスで一度アプリケーションを起動(トレーニング)させます。

# ビルドステージ
FROM maven:3.9.11-eclipse-temurin-25 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# AOT生成 & 実行ステージ
FROM eclipse-temurin:25-jdk-jammy 
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar

# 1. AOTキャッシュのトレーニング
# Spring Bootは自動終了しないため、timeout コマンドで20秒後に強制終了させる。
# 終了コード 124 (timeout) を正常終了として扱う。
RUN timeout 20s java -XX:AOTCacheOutput=app.aot -jar app.jar || [ $? -eq 124 ]

# 2. 本番起動:生成された app.aot を読み込んで起動
ENTRYPOINT ["java", "-XX:AOTCache=app.aot", "-jar", "app.jar"]

3.3. デプロイ手順

Artifact Registry をリポジトリとして使用します。

# gcloud コマンドを入れる(割愛)

# ログイン
gcloud auth login

# プロジェクトIDとリージョンを変数設定
export PROJECT_ID=$(gcloud config get-value project)
export REGION="asia-northeast1"
export REPO_NAME="java-benchmark"

gcloud config set project $PROJECT_ID
gcloud config list

# Artifact Registry リポジトリの作成(初回のみ)
gcloud artifacts repositories create $REPO_NAME \
    --repository-format=docker \
    --location=$REGION \
    --description="Java LTS Startup Benchmark"

# ビルド & プッシュ (例: Java 17)
# 25-aotコンパイルのときのみ、タグをjava25-aotとした。
gcloud builds submit --tag $REGION-docker.pkg.dev/$PROJECT_ID/$REPO_NAME/java17:latest .
    
# Artifact RegistryからCloud Runへのデプロイ
# 上記プッシュ時とタグを合わせる
gcloud run deploy java17-bench \
    --image $REGION-docker.pkg.dev/$PROJECT_ID/$REPO_NAME/java17:latest \
    --region $REGION \
    --allow-unauthenticated \
    --min-instances 0 \
    --max-instances 10 \
    --memory 512Mi

4. 起動速度の計測

heyコマンドを用いて、リクエストを投げてからレスポンス(OK)が返ってくるまでの時間を計測します。

sudo apt update && sudo apt install -y hey

# 対象のURLを取得(例:java25-aot-bench)
URL=$(gcloud run services describe java8-bench --region $REGION --format='value(status.url)')

# 1リクエストを送信
# -n 1: 1リクエスト
# -c 1: 1リクエストを並列で送信
hey -n 1 -c 1 $URL

Cloud Run インスタンスがアイドル状態から破棄(ゼロスケール)されるまでには、通常15分程度の待機が必要です。各 LTS バージョンのコールドスタートを正確に、かつ効率的に収集するため、以下の Bash スクリプトで 余裕をもって18 分おきの自動計測を行いました。

#!/bin/bash

# Cloud Run のサービス名リスト
SERVICES=("java8-bench" "java11-bench" "java17-bench" "java21-bench" "java25-bench" "java25-aot-bench")
REGION="asia-northeast1"

echo "測定を開始します。結果は results.log に保存されます。"

while true; do
  echo "--- $(date) 計測開始 ---" >> results.log
  
  for SVC in "${SERVICES[@]}"; do
    # URLの取得
    URL=$(gcloud run services describe $SVC --region $REGION --format='value(status.url)' 2>/dev/null)
    
    if [ -n "$URL" ]; then
      echo "Testing $SVC..."
      # hey で1回リクエストを送り、レスポンスタイム(Average)を抽出して保存
      RESULT=$(hey -n 1 -c 1 $URL | grep "Average" | awk '{print $2}')
      echo "$SVC: $RESULT seconds" >> results.log
    fi
  done
  
  echo "18分待機します(インスタンスを0にするため)"
  sleep 1080
done

余談:全てが終わった後に、コールドスタートの時間設定を変更すればいいことに気づきました。

検証結果

それぞれのCloud Runインスタンスに5回アクセスしたところ、以下のような結果になりました。

バージョン/回数(秒) 1回目 2回目 3回目 4回目 5回目 中央値
java8-bench 4.1547 4.3361 4.0248 4.1763 4.3718 4.1763
java11-bench 5.1917 5.9195 5.708 5.377 5.4253 5.4253
java17-bench 4.9911 4.408 4.6318 4.7523 4.3838 4.6318
java21-bench 5.1823 5.2538 5.1878 4.8879 5.0193 5.1823
java25-bench 5.2427 4.9071 5.2389 5.3375 5.5455 5.2427
java25-aot-bench 5.0277 3.5764 4.5661 4.091 3.6713 4.091

Cold Start起動時間(秒).png

コードベース量の問題から、Java 8が最も早く、java25に向かって線形に増えていくだろうと思っていたのですが、意外にも一番遅かったのはJava 11でした。Gemini曰く、Project Jigsaw(packageの上にmodule層を追加)等によるオーバーヘッドとのことでしたが、真相は分からず…。

Java 25でも、21に比べて起動時間は遅くなっていますが、aotコンパイルをすることでAOTなしの状態から 約1.15秒(約22%)もの高速化 に成功しています。
肥大化した現代のSpring Bootが、Java 8時代の速度に匹敵するというのは素晴らしいですね!

起動速度だけでなく、処理速度も気になったため、heyコマンドで多数のリクエストをしてみました。

hey -n 200 -c 50 $URL(全200リクエスト、並行50リクエスト)
バージョン/種類(秒) 最遅 最速 平均 リクエスト処理時間(req/秒)
java8 0.3863 0.0127 0.075 435.0192
java11 0.1584 0.0167 0.0541 847.0969
java17 0.191 0.0135 0.053 810.7287
java21 0.1601 0.0119 0.0494 750.0962
java25 0.2526 0.012 0.0585 450.6977
java25-aot 0.1518 0.0117 0.0499 842.0699

1秒あたりのリクエスト処理時間(req_秒).png

1秒当たりの処理数では、Java 11がトップ(847 req/sec)という意外な結果に。
「起動は遅いが、処理速度はJITコンパイルで最適化されている」というJavaのイメージにピッタリです。
「処理速度なんて、最新世代一択だろう」と全ツッパしなくて正解でした。

通常ビルドのJava 25で急激に処理速度が落ちているのが気になりますが、これから改善でしょうか。
AOTコンパイルではJava 21並みの処理能力を発揮しているので、ひと手間かけた方がいいかもしれません。
特筆すべきは『最遅レスポンス(Tail Latency)』でしょう。全世代で最も優秀な 0.1518s を記録しました。起動直後の高負荷時にも、レスポンスの跳ね上がりが少ない安定性も AOT キャッシュの隠れた恩恵かもしれません。

結論

Java25-aotコンパイルは、Java 8並みの身軽さを取り戻しながら、Java21世代並みの処理性能でした。
実際プロダクト使えるかと言われたら…ですが、今後Cloud RunでJavaを使うなら、ぜひJava 25のaotコンパイルで試してみたいところです。

筆者の大学時代は、ハッカソンでコスト削減といえばHerokuの無料サーバーでしたが、今は何を使っているんでしょうねー。

7日目は@kouki_kubotaさんの「ZIOを使った適切なリソース管理について」です。
ZIOの知見記事が増えるのは有難いです。お楽しみに!!

ウェブクルーでは一緒に働いていただける方を随時募集しております。
お気軽にエントリーくださいませ。
https://www.webcrew.co.jp/recruit/

  1. 2025年12月現在 Preview https://docs.cloud.google.com/run/docs/release-notes

13
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?