LambdaやKubernetes環境でSpring Bootアプリのコールドスタートが遅くて困っていませんか?JVMの起動に数秒かかる問題は、GraalVM Native Imageを使うと劇的に改善できます。Spring Boot 3.0から正式サポートされたので、今が導入のチャンスです。

GraalVM Native Imageとは

GraalVM Native ImageはJavaコードをAOT(Ahead-of-Time)コンパイルしてネイティブバイナリを生成する技術です。JVM起動が不要になるため、FaaSや短命コンテナとの相性が抜群です。

Spring Boot 3.0以前は「Spring Native」という実験的プロジェクトが存在しましたが、設定が複雑でした。3.0以降は標準機能として取り込まれ、かなり使いやすくなっています。Spring Boot 2.xからの移行についてはSpring Boot 2から3への移行ガイドも参考にしてください。

JVMモードとネイティブモードの性能比較

シンプルなSpring Bootアプリ(Hello World相当)での計測例です。依存が増えると数値は変わりますが、傾向は同じです。

項目JVMモードネイティブモード
起動時間3〜5秒0.1〜0.3秒
メモリ使用量200〜400MB50〜100MB

ただし スループットやレイテンシはJVMが有利 なケースも多いです。JITコンパイルで最適化が積み重なるJVMは、長時間稼働する高トラフィックAPIサーバーでは依然として強力です。ネイティブビルドは「起動が速くてメモリを使わない」ことが求められる場面に向いています。

環境セットアップ

GraalVM JDK 21が必要です。SDKMANを使うと簡単にインストールできます。

# 利用可能なバージョンを確認(バージョン番号は執筆時点の例)
sdk list java | grep graal

# Oracle GraalVM(JDK 17以降、GFTCライセンスで無償)
sdk install java 21.0.2-graal
sdk use java 21.0.2-graal

# GraalVM CE(コミュニティ版)を使う場合は graalce サフィックスを使う
# sdk install java 21.0.2-graalce

native-image --version

21.0.2-graal はOracle GraalVM、21.0.2-graalce はコミュニティ版です。バージョン番号は sdk list java | grep graal で最新版を確認してから選択してください。Spring Bootは 3.1以上 を推奨します。

ビルド設定の追加

Mavenの場合

spring-boot-starter-parent を使っていれば、native-maven-plugin を追加するだけです。バージョンはBOMが管理するため指定不要です。

<build>
  <plugins>
    <plugin>
      <groupId>org.graalvm.buildtools</groupId>
      <artifactId>native-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

Gradleの場合

plugins {
    id 'org.springframework.boot' version '3.2.0'
    // io.spring.dependency-management は Spring Boot Gradle プラグイン使用時は任意
    id 'org.graalvm.buildtools.native'
}

Spring Boot BOMがプラグインバージョン(0.10.x系)を管理するため、バージョン指定は不要です。

ネイティブイメージのビルド

# Maven
./mvnw -Pnative native:compile

# Gradle
./gradlew nativeCompile

ビルドには 10〜20分 かかることがあります。成功すると target/<artifactId>(Maven)または build/native/nativeCompile/(Gradle)にバイナリが生成されます。CI環境では専用のビルドステップを用意しましょう。

AOT処理とReflection問題

ネイティブビルドでは、ビルド時にBeanグラフ全体を静的解析します。問題になるのが 動的Reflection です。JacksonやJPAなどはリフレクションで動いているため、設定なしではビルドが通っても実行時にエラーが出ることがあります。Spring Boot自体はほとんどのケースでAOTヒントを自動生成してくれますが、問題が起きるのは主にサードパーティライブラリやカスタムコードです。

Reflectionヒントの追加

アノテーションで指定する(一番シンプル)

@Configuration
@RegisterReflectionForBinding(MyDto.class)
public class AppConfig {
}

reflect-config.jsonを手書きする

[
  {
    "name": "com.example.MyDto",
    "allDeclaredFields": true,
    "allDeclaredMethods": true,
    "allDeclaredConstructors": true
  }
]

src/main/resources/META-INF/native-image/ に配置します。RuntimeHintsRegistrar インターフェースを使ったプログラム的な登録も可能で、条件付きでヒントを追加したいときに便利です。

native-image-agentで設定を自動生成する

手動でヒントを書くのは大変ですよね。native-image-agent を使えば、JVMモードで実行しながらReflection使用状況を記録できます。

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
  -jar target/myapp.jar

アプリを起動して代表的なエンドポイントを一通り叩いてから、Ctrl+C(SIGINT)で正常終了 させてください。強制終了(SIGKILL)では設定ファイルが書き出されません。終了後に reflect-config.json などが生成されます。実際に実行されたコードパスしか記録されない 点に注意してください。複数回実行して設定をマージしたい場合は config-merge-dir オプションが便利です。

Testcontainersでの動作確認

<!-- Maven -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>

Gradleの場合は testImplementation 'org.springframework.boot:spring-boot-testcontainers' を追加します。@ServiceConnection アノテーションを使うと、データベース等のコンテナがテスト実行時に自動起動します。ネイティブモードでのテストは次のコマンドで実行します。

./mvnw -PnativeTest test

ネイティブテストはJVMテストより数倍時間がかかるため、CI/CDでは専用ステージに分けるのが現実的です。

詰まりやすいビルドエラーと解決策

ClassNotFoundException / Missing reflection configuration

実行時にクラスが見つからないエラーはReflectionヒントの設定漏れです。native-image-agent で再生成するか、@RegisterReflectionForBinding で対象クラスを追加してください。

ビルドメモリ不足

JAVA_TOOL_OPTIONS=-Xmx8g ./mvnw -Pnative native:compile

-J-Xmx8g をmvnwコマンドに直接渡す方法はMaven自身のJVMヒープを制御するだけで、native-imageコンパイラが起動するサブプロセスのヒープには影響しません。環境変数 JAVA_TOOL_OPTIONS を使うか、native-maven-plugin<buildArgs><buildArg>-J-Xmx8g</buildArg> を追加する方法が正しいです。大規模プロジェクトでは8GB程度必要になることもあります。

ライブラリがネイティブ非対応

一部のライブラリはネイティブビルドに対応していません。GraalVM Reachability Metadata Repository で対応状況を確認し、代替ライブラリへの置き換えか除外を検討しましょう。

Lambda・Kubernetesへのデプロイ

マルチステージDockerfileでビルドと実行を分離します。

FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests

FROM ubuntu:22.04
COPY --from=builder /app/target/myapp /myapp
ENTRYPOINT ["/myapp"]

ランタイムに ubuntu:22.04 を使うのは、デフォルトのネイティブバイナリがglibc動的リンクになるためです。--static オプションで静的リンクビルドにすれば gcr.io/distroless/static も利用できますが、追加設定が必要です。詳細はSpring BootのDockerコンテナ化ガイドKubernetesデプロイガイドを参考にしてください。

まとめ

ネイティブビルドが向いているのは、Lambda・短命コンテナ・CLIツールなど 起動速度とメモリ効率が重要 な場面です。一方で、長期稼働の高スループットAPIサーバーはJITコンパイルが効くJVMモードの方が有利なことが多いです。ビルド時間が長くなりデバッグもやや難しくなるため、用途を見極めて導入しましょう。ライブラリの対応状況も事前に確認しておくと安心です。

  • Reflectionエラーには @RegisterReflectionForBindingnative-image-agent で対処
  • ビルドメモリ不足は JAVA_TOOL_OPTIONS=-Xmx8g で解決(-J-Xmx はMaven自身のヒープ制御なので注意)
  • スループット重視の場面ではVirtual Threads(Java 21)との組み合わせも検討してみてください

まずは新規プロジェクトや小さなサービスから試してみましょう。