この記事はウェブクルー 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 |
コードベース量の問題から、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秒当たりの処理数では、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/
-
2025年12月現在 Preview https://docs.cloud.google.com/run/docs/release-notes ↩

