QuarkusとGraalVM Native Image:超高速なJavaバイナリのコンパイル手法

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

モダンなクラウドワークロードにおけるJVMウォームアップのコスト

何十年もの間、エンタープライズJava開発者は馴染み深い前提を受け入れてきました。「一度書けばどこでも動く(Write once, run anywhere)」を信条にHotSpot仮想マシンに頼り、JIT(Just-In-Time)コンパイラによって実行時にバイトコードを最適化させるというトレードオフです。このアプローチは、トラフィックが安定している長寿命のモノリスには極めて有効でした。しかし、Kubernetesクラスタやスケール・トゥ・ゼロを採用するサーバーレスプラットフォーム、あるいはエッジノードのようなモダンなクラウド環境では破綻してしまいます。

ベースラインのフットプリントを考えてみましょう。OpenJDK 21上で稼働する標準的なSpring Bootサービスは、最初のリクエストを処理する前に、容易に250MB〜450MBものRSS(Resident Set Size)メモリを消費します。また、コールドスタートには通常3〜8秒を要します。JVMはこの初期フェーズにおいて、クラスパスのスキャン、アノテーションの評価、ランタイムプロキシの生成に時間を費やします。トラフィックの急増によってHorizontal Pod Autoscaler(HPA)がトリガーされた際、新しいPodが起動処理にもたついている間に、受信リクエストがキューに滞留したりタイムアウトしたりする事態に陥ります。

AOT(Ahead-of-Time: 事前)コンパイルはこの課題を根本から解決します。GraalVMとQuarkusを組み合わせることで、負荷の高い解析やコード生成を実行環境から切り離し、CI/CDパイプラインのビルドステップへと前倒しできます。

コア概念:AOT(事前コンパイル)とビルド時メタデータ

動的なバイトコードからコンパイル済みのバイナリへと移行するには、ランタイムの動作に対する捉え方を根本的に変える必要があります。

クローズドワールド仮説(Closed-World Assumption)

標準のHotSpotは「オープンワールド」を前提としています。実行中の任意のタイミングで新しいクラスをロードしたり、動的にバイトコードを生成したりすることが可能です。対照的に、GraalVM Native Imageは「クローズドワールド仮説」に基づいて動作します。

コンパイル時、GraalVMはmain()エントリーポイントを起点として到達可能なコードパスをトレースします。アプリケーションのクラス、サードパーティのJAR、JDK内部を検査し、この静的解析で直接参照されなかったクラス、メソッド、フィールドはすべて削ぎ落とされます。その結果、アプリケーションが実際に呼び出すマシンコードのみを含む、軽量で自己完結型のバイナリが生成されます。

Quarkusがネイティブビルドをシンプルにする理由

かつては、純粋なJavaアプリケーションに対してネイティブイメージビルドを実行するのは極めて困難でした。リフレクションによる参照、動的プロキシ、リソースバンドルを、冗長なreflect-config.jsonファイルに手動でマッピングする必要があったためです。設定が1つでも欠けていれば、コンパイルされたバイナリは本番環境でClassNotFoundExceptionを起こしてクラッシュしてしまいます。

Quarkusはフレームワーク層を再設計し、この障壁を取り除きました。

  • ビルド時メタプログラミング: QuarkusのCDIコンテナであるArcは、MavenまたはGradleのビルドプロセス中に依存関係の注入(DI)を解決し、YAML設定をパースし、JPAエンティティのインデックスを作成します。コードがネイティブコンパイルに渡される段階では、すべてのDI配線がすでにプレーンなJavaコードに変換されています。
  • フラグの自動生成: Quarkusエクステンションがリフレクション用のメタデータやJNIフックを開発者に代わって登録するため、JSON定義を手作業で記述する必要がありません。
  • Substrate VM: 実行可能ファイルには、完全なHotSpotランタイムの代わりにSubstrate VMが組み込まれます。この削ぎ落とされた軽量エンジンは、OSのプリミティブを介してスレッド管理やメモリ割り当てを直接処理し、デフォルトではシンプルなSerial Garbage Collectorを使用します。

ハンズオン:ネイティブバイナリのビルドとベンチマーク測定

本番環境を想定したサービスを構築し、ネイティブバイナリにパッケージングして実際のランタイムメトリクスを計測してみましょう。

1. 軽量なQuarkusサービスの初期設定

Quarkus Mavenプラグインを使用して最小限のプロジェクトをスキャフォールディングします。JSON処理にはJacksonを使用したリアクティブHTTP層を採用します。

mvn io.quarkus.platform:quarkus-maven-plugin:3.15.1:create \
    -DprojectGroupId=com.itfromzero.nativeboot \
    -DprojectArtifactId=quarkus-native-demo \
    -DclassName="com.itfromzero.nativeboot.ProductResource" \
    -Dpath="/api/products" \
    -Dextensions="resteasy-reactive-jackson"

ドメインレコードと、商品リストを返すエンドポイントを定義します。

package com.itfromzero.nativeboot;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;

@Path("/api/products")
public class ProductResource {

    public record Product(String sku, String name, double price) {}

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public List<Product> listProducts() {
        return List.of(
            new Product("HW-101", "メカニカルキーボード", 89.99),
            new Product("HW-204", "ワイヤレスエルゴノミクスマウス", 49.50)
        );
    }
}

2. ネイティブ実行可能ファイルの生成

ローカル環境にGraalVM SDKをインストールする必要はありません。Quarkusは隔離されたMandrelコンテナ(Red HatによるOpenJDKフォーカスのGraalVMディストリビューション)を立ち上げてビルドを処理できます。

# Mandrelコンテナ内でビルドを実行
./mvnw package -Dnative -Dquarkus.native.container-build=true

# スタンドアロン実行可能ファイルを確認
ls -lh target/quarkus-native-demo-1.0.0-SNAPSHOT-runner

ビルドが完了すると、約38MBのスタンドアロンLinux ELFバイナリが生成されます。JREやJDKをインストールすることなく、ホスト上で直接実行できます。

3. 死角を作らないリフレクションのハンドリング

Quarkusの管理対象外となるサードパーティ製ライブラリを導入した場合はどうなるでしょうか。そのライブラリが専用のQuarkusエクステンションなしでリフレクションを介してJSONをパースしたりDTOをマーシャリングしたりしていると、GraalVMのデッドコード削除によって必要なフィールドが除外されてしまう可能性があります。その結果、コンパイルは正常に通るものの、ペイロード受信時に実行時エラーでクラッシュしてしまいます。

外部のDTOに@RegisterForReflectionを付与することで、コンパイラに対してアクセサやフィールドを確実に保持するよう指示できます。

package com.itfromzero.nativeboot;

import io.quarkus.runtime.annotations.RegisterForReflection;

@RegisterForReflection
public class ExternalAuditPayload {
    private String trackingId;
    private long timestamp;

    public String getTrackingId() { return trackingId; }
    public void setTrackingId(String trackingId) { this.trackingId = trackingId; }
    public long getTimestamp() { return timestamp; }
    public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}

4. 起動速度とメモリ使用量の測定

コンパイルされたバイナリをシェルから直接起動します。

./target/quarkus-native-demo-1.0.0-SNAPSHOT-runner

コンソール出力には、瞬時に初期化が完了したログが表示されます。

2026-11-02 11:15:02,108 INFO  [io.quarkus] (main) quarkus-native-demo 1.0.0-SNAPSHOT native (powered by Quarkus 3.15.1) 0.018秒で起動しました。リスニング中: http://0.0.0.0:8080

ネイティブモードとJVMモードの運用上の差は歴然としています。

  • コールドスタート: JVMモードの1.8秒に対して、ネイティブでは18ミリ秒(約100倍の高速化)。
  • アイドル時RSSメモリ: 標準的なOpenJDKの約185MBに対し、わずか22MB。
  • 最終イメージサイズ: OpenJDKベースイメージの220MB超に対し、最小限のコンテナレイヤーに配置した場合は48MB。

5. 最小限のKubernetes用コンテナの構築

バイナリをRed Hat Universal Base Image(UBI)のminimalコンテナにパッケージングします。非rootユーザーとして実行することで、厳格なKubernetesセキュリティポリシーに準拠させます。

FROM registry.access.redhat.com/ubi9/ubi-minimal:9.4
WORKDIR /work/
RUN chown 1001 /work \
    && chmod "g+rwX" /work \
    && chown 1001:root /work
COPY --chown=1001:root target/*-runner /work/application

EXPOSE 8080
USER 1001

ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]

トレードオフの評価

ネイティブコンパイルは万能の特効薬ではありません。ビルドステップはリソースを大量に消費し、中規模のサービスであっても6GB〜8GBのRAMを消費し、4コアのCIランナーで3〜5分かかることがあります。また、ネイティブコードを対象にコアダンプを調査したりスレッド診断を実行したりする必要がある場合、デバッグの難易度も高くなります。

スループット特性にも違いがあります。標準のHotSpotはランタイムプロファイリングを活用して頻出コードパス(ホットスポット)を動的に再コンパイルするため、安定稼働時のCPU集約型ワークロードにおいてはSubstrate VMと同等か、それを上回る性能を発揮することが少なくありません。それでもなお、1秒未満のオートスケーリング、極小のコンテナフットプリント、あるいはAWS FargateやKnativeにおけるクラウドアカウントのコスト削減を求める環境では、ネイティブイメージの優位性は圧倒的です。

Share: