🔍

GraalVM Native Imageビルド時に渡すリフレクション設定を自動生成!Tracing Agent活用法

に公開

1. はじめに

株式会社ZOZOの計測システム部バックエンドブロック(以降は計測バックエンド)に所属しているでぃーのです。主にバックエンド開発を担当しています。

本稿では、GraalVM Native Imageを使ってビルドする際に、多くのケースで必要であろう実行時に決まる部分の情報を設定ファイルに書き出すプロセスをTracing Agentを使って効率化する方法を紹介します。

2. 課題:リフレクション設定の難しさ

なぜリフレクション設定が必要なのか

GraalVM Native Imageは、事前にネイティブ実行ファイルにコンパイルします。ビルド時にアプリケーションの全てのコードパスを解析し、実行時に必要なコードのみをネイティブ実行ファイルに含めます。

しかし、以下のようなコードはビルド時に解析できません:

// リフレクションによるクラスの動的ロード
val clazz = Class.forName("org.apache.pekko.actor.ActorSystemImpl")
val instance = clazz.getDeclaredConstructor().newInstance()

GraalVM Native Imageのビルドプロセスは静的解析に基づいています。上記のコードが解析できない理由は以下の通りです。

  1. クラス名が文字列として指定されている

    • クラスへの参照が"org.apache.pekko.actor.ActorSystemImpl"という文字列リテラルで表現されている
    • 静的解析ツールは、文字列の内容を「クラス参照」として理解できない
  2. 実行時にしか決まらない動的な処理

    • Class.forName()はJVMの実行時にクラスをロードするメソッド
    • ビルド時には文字列として存在するだけで、どのクラスが使われるかを確定できない

PekkoやTypesafe Configなどのライブラリは、設定ファイルを読み込み、リフレクションでクラスをインスタンス化することが多いです。これらの設定を1つ1つ手動で書くのはとても大変です。

手動設定の問題点

私たちのチームの経験

計測バックエンドでは、これまでもアプリケーションの起動速度が求められるケースでGraalVM Native Imageでビルドしたネイティブ実行ファイルを利用してきました。その度に、リフレクションをはじめとした実行時に決まる部分の情報を書き出す設定ファイルの準備にかなりの時間を費やしていました。

具体的には、以下のような試行錯誤のサイクルを繰り返していました:

  1. Native Imageをビルドする(1回10分くらいかかる)
  2. アプリケーションを実行する
  3. 実行時に出たエラーを確認する
  4. 設定ファイルに必要な設定を追加する
  5. 再度1に戻る

このプロセスは非常に時間がかかり、特に複雑なライブラリを使用している場合は、何度もこのサイクルを回す必要がありました。

設定を手動で書く場合、主に以下の問題があると感じています。

  1. 対象クラスの把握が困難 - ライブラリ内部でどのクラスがリフレクションで呼ばれるか分からない
  2. メンテナンスコストが高い - ライブラリのバージョンアップで設定が変わる可能性
  3. 試行錯誤が多い - ビルド→実行→エラー確認→設定追加 のサイクルを何度も繰り返す

未来の自分含め、計測バックエンドのメンバーに大変な思いはあまりして欲しくないため、このプロセスを効率化できるツールを探していました。そこに一筋の光が差し込みました。

3. 解決策:GraalVM Tracing Agentの活用

GraalVMはTracing Agentというツールを提供しています。このツールはJVMアプリケーションの実行中のリフレクションやリソースアクセスなどの動的な呼び出しを追跡し、Native Image用の設定ファイルを自動出力してくれます。

https://www.graalvm.org/latest/reference-manual/native-image/guides/configure-with-tracing-agent/

Tracing Agentのメリット

  • 自動出力 - 手動で設定を書く必要がない
  • 網羅的 - 実行パスで使用される全てのリフレクションを検出
  • 正確 - 実際に呼び出されるメソッド・コンストラクタを特定

最高ですね。使ってみましょう。

4. Tracing Agentについて

基本的な使い方

Tracing Agentは、-agentlibオプションでJVMに組み込んで使用します:

java -agentlib:native-image-agent=config-output-dir=./native-config \
    -jar your-app.jar

主要なオプション

オプション 説明
config-output-dir 設定ファイルの出力先ディレクトリ
config-write-period-secs 設定ファイルを定期的に書き出す間隔(秒)
config-write-initial-delay-secs 最初の書き込みまでの遅延(秒)
config-merge-dir 既存の設定とマージする場合のディレクトリ

生成されるファイル

Tracing Agentは以下のファイルを生成します:

ファイル名 内容
reflect-config.json リフレクションで使用されるクラス・メソッド
resource-config.json 読み込まれるリソースファイル
jni-config.json JNI経由でアクセスされるクラス
proxy-config.json 動的プロキシの設定
serialization-config.json シリアライゼーション対象クラス

5. 動作確認

それでは、実際にTracing Agentを使ってPekko HTTPサーバーアプリケーションの設定ファイルを出力し、ネイティブ実行ファイルにビルドしましょう。

サンプルプロジェクトの構成

この記事で使用するサンプルプロジェクトは、Pekko HTTPを使ったシンプルなHTTPサーバーです。

├── build.sbt
├── src/main/scala/
│   └── PekkoHttpServer.scala
├── src/main/resources/
│   └── application.conf
└── Dockerfile

Scalaコード(PekkoHttpServer.scala)

シンプルなヘルスチェックエンドポイントを持つHTTPサーバーです。

import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.server.Directives._
import org.apache.pekko.http.scaladsl.model.{ContentTypes, HttpEntity}

import scala.concurrent.ExecutionContextExecutor

object StartupTimer {
  val startTime: Long = System.currentTimeMillis()
  
  def getElapsedTime: Long = System.currentTimeMillis() - startTime
}

object PekkoHttpServer extends App {
  implicit val system: ActorSystem = ActorSystem("pekko-http-system")
  implicit val executionContext: ExecutionContextExecutor = system.dispatcher

  val route =
    path("health") {
      get {
        val json = s"""{"status":"ok","framework":"Pekko HTTP Native","startupTimeMs":${StartupTimer.getElapsedTime}}"""
        complete(HttpEntity(ContentTypes.`application/json`, json))
      }
    }

  val bindingFuture = Http().newServerAt("0.0.0.0", 8080).bind(route)

  println(s"Server online at http://0.0.0.0:8080/")
  println(s"Startup time: ${StartupTimer.getElapsedTime}ms")
  
  sys.addShutdownHook {
    bindingFuture
      .flatMap(_.unbind())
      .onComplete(_ => system.terminate())
  }
}

build.sbt

以下の設定ファイルには、sbt-assemblyによるfat jar生成設定と、Native Image用の設定が含まれています。Tracing Agentが生成した設定ファイルは、後述のDockerfileでMETA-INF/native-image/配下に配置されます。

name := "pekko-native-server"
version := "1.0"
scalaVersion := "3.3.3"

val PekkoVersion = "1.1.2"
val PekkoHttpVersion = "1.1.0"

libraryDependencies ++= Seq(
  "org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
  "org.apache.pekko" %% "pekko-stream" % PekkoVersion,
  "org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
  "org.slf4j" % "slf4j-simple" % "2.0.9"
)

enablePlugins(NativeImagePlugin)

Compile / mainClass := Some("PekkoHttpServer")

// sbt-assembly設定(トレースエージェント用)
assembly / assemblyMergeStrategy := {
  case PathList("META-INF", "versions", "9", "module-info.class") => MergeStrategy.discard
  case PathList("META-INF", xs @ _*) => MergeStrategy.discard
  case "reference.conf" => MergeStrategy.concat
  case _ => MergeStrategy.first
}
assembly / mainClass := Some("PekkoHttpServer")

// 使用するGraalVMバージョン指定
nativeImageVersion := "22.3.0"
nativeImageJvm := "graalvm-java17"

// Native Image 設定
// トレースエージェントで生成された設定ファイルを使用
nativeImageOptions ++= Seq(
  "--no-fallback",
  "--report-unsupported-elements-at-runtime",
  "--install-exit-handlers",
  "-H:+ReportExceptionStackTraces",
  // ビルド時に初期化するパッケージ
  "--initialize-at-build-time=scala",
  "--initialize-at-build-time=org.apache.pekko",
  "--initialize-at-build-time=com.typesafe.config",
  "--initialize-at-build-time=org.slf4j",
  "--initialize-at-build-time=org.parboiled2",
  "--initialize-at-build-time=org.reactivestreams",
  // 実行時に初期化(Randomを使用するクラス)
  "--initialize-at-run-time=scala.util.Random$",
  "--initialize-at-run-time=org.apache.pekko.routing.TailChoppingRoutees",
  "--initialize-at-run-time=org.apache.pekko.routing.RandomRoutees"
)

ポイント

  • sbt-assemblyでfat jarを作成(Tracing Agent実行用)
  • Native Image Pluginで最終的なネイティブバイナリをビルド
  • --initialize-at-build-time / --initialize-at-run-time でクラスの初期化タイミングを制御

ビルドステップ

Trace Agentが出力した設定ファイルをそのままビルド時に読み込ませるため、Dockerfile内で完結する形で実装しました。

Dockerfile

# マルチステージビルド
# Stage 1: sbtでfat jarをビルド
FROM eclipse-temurin:17-jdk AS jar-builder

WORKDIR /build

# sbtのインストール
RUN curl -L https://github.com/sbt/sbt/releases/download/v1.9.7/sbt-1.9.7.tgz | tar xz -C /usr/local
ENV PATH="/usr/local/sbt/bin:${PATH}"

COPY build.sbt .
COPY project project/
COPY src src/

# fat jarをビルド
RUN sbt assembly

# Stage 2: トレースエージェントでメタデータを収集
FROM ghcr.io/graalvm/graalvm-community:17 AS trace-collector

WORKDIR /build

# curlとprocpsをインストール
RUN microdnf install -y curl procps-ng && microdnf clean all

# JARをコピー
COPY --from=jar-builder /build/target/scala-3.3.3/pekko-native-server-assembly-1.0.jar app.jar

# トレースエージェントでアプリを実行してメタデータを収集
# config-write-period-secs で定期的にファイルを書き込む
# わかりやすさのためにechoで文字列を出力しています。
RUN mkdir -p /build/native-config && \
    java -agentlib:native-image-agent=config-output-dir=/build/native-config,config-write-period-secs=5,config-write-initial-delay-secs=3 \
    -jar app.jar & \
    APP_PID=$! && \
    sleep 10 && \
    echo "=== Testing health endpoint ===" && \
    curl -s http://localhost:8080/health && \
    echo "" && \
    sleep 8 && \
    echo "=== Stopping application ===" && \
    kill $APP_PID || true && \
    sleep 3 && \
    echo "=== Generated config files ===" && \
    ls -la /build/native-config/ && \
    echo "=== reflect-config.json (first 100 lines) ===" && \
    head -100 /build/native-config/reflect-config.json

# Stage 3: GraalVM Native Imageでビルド
FROM ghcr.io/graalvm/native-image:ol9-java17-22 AS native-builder

WORKDIR /build

# sbtのインストール
RUN microdnf install -y findutils && \
    curl -L https://github.com/sbt/sbt/releases/download/v1.9.7/sbt-1.9.7.tgz | tar xz -C /usr/local
ENV PATH="/usr/local/sbt/bin:${PATH}"

# ビルドファイルをコピー
COPY build.sbt .
COPY project project/
COPY src src/

# 自動収集したメタデータをコピー
COPY --from=trace-collector /build/native-config/ /build/src/main/resources/META-INF/native-image/

# Native Imageをビルド
RUN sbt nativeImage

# Stage 4: 実行用の軽量イメージ
FROM oraclelinux:9-slim

WORKDIR /app

# Native Imageバイナリをコピー
COPY --from=native-builder /build/target/native-image/pekko-native-server .

EXPOSE 8080

CMD ["./pekko-native-server"]

ビルドしたイメージをコンテナとして起動

docker run -d --name startup-test \
            -p "8081:8080" \
            "pekko-native:latest" > /dev/null 2>&1
curl http://localhost:8081/health
{"status":"ok","framework":"Pekko HTTP Native","startupTimeMs":13797}

無事、ビルドが成功し、Pekko HTTPサーバーアプリケーションをネイティブ実行バイナリで起動することを確認しました。

生成された reflect-config.json の例

最後に、Tracing Agentが自動生成した設定ファイルを確認しましょう。

trace-agent-output/
├── reflect-config.json      # リフレクション設定
├── resource-config.json     # リソースファイル設定
├── jni-config.json          # JNI設定
├── proxy-config.json        # 動的プロキシ設定
├── serialization-config.json # シリアライゼーション設定
├── predefined-classes-config.json
└── agent-extracted-predefined-classes/

reflect-config.json

reflect-config.jsonファイルの一部を記載します。

[
~~~
{
  "name":"org.apache.pekko.actor.LocalActorRefProvider",
  "fields":[{"name":"defaultMailbox$lzy1"}, {"name":"guardian$lzy1"}, {"name":"rootGuardian$lzy1"}, {"name":"systemGuardian$lzy1"}, {"name":"tempContainer$lzy1"}],
  "methods":[{"name":"<init>","parameterTypes":["java.lang.String","org.apache.pekko.actor.ActorSystem$Settings","org.apache.pekko.event.EventStream","org.apache.pekko.actor.DynamicAccess"] }]
},
~~~
{
  "name":"org.apache.pekko.event.slf4j.Slf4jLogger",
  "fields":[{"name":"log$lzy1"}],
  "methods":[{"name":"<init>","parameterTypes":[] }]
}
~~~ 
]

今回のような簡単なPekko HTTPサーバーアプリケーションでも、260程のリフレクション設定が検出されていました。手動で設定することを考えると大変ですね。

6. まとめ

本稿では、GraalVM Native Imageでネイティブ実行ファイルを作成する際の障壁の1つだったリフレクションをはじめとする実行時の動的アクセス要素の設定ファイルへの書き出しを、Tracing Agentで効率化する方法を紹介しました。

Tracing Agentは、リフレクションだけでなく、リソースファイル、JNI、動的プロキシ、シリアライゼーションなど、様々な動的アクセスの設定を自動出力できます。

従来の手動設定では、時間のかかる確認サイクルを何度も繰り返す必要がありましたが、

Tracing Agentを使うことで:

  • ✅ アプリケーションを実行するだけで設定を自動生成
  • ✅ 実際に使用される全てのリフレクションを網羅的に検出
  • ✅ 試行錯誤の時間を大幅に削減

今回のサンプルでも、簡単なPekko HTTPサーバーで260ものリフレクション設定が必要でした。これを手動で書くのは現実的ではありません。

GraalVM Native Imageの採用を検討されている方は、ぜひTracing Agentを活用して、開発の効率化を図ってみてください。

本稿で紹介したサンプルコードは、GitHubで公開しています。GraalVMでビルドしたHTTPサーバーのパフォーマンス比較をしたリポジトリの一部ですが参考にはなるはずです。
https://github.com/shaw-papadino/scala-startup-performance/tree/main/pekko-native-server

本稿が、Native Image導入の一助となれば幸いです。

運用上の注意点

Tracing Agentを使う際は、以下の点に注意が必要です:

  1. 実行パスの網羅性

    • Tracing Agentは実際に実行されたコードパスのみを記録します
    • 本番で使用される全ての機能を実行してメタデータを収集する必要があります
  2. 定期的な更新

    • ライブラリのバージョンアップ時には、設定を再生成することを推奨します
    • CI/CDパイプラインに組み込むことで、自動化も可能です
  3. 過剰な設定の可能性

    • 開発時のみ使用するクラスも含まれる可能性があります
    • 必要に応じて設定ファイルを精査・最適化することも検討してください

一緒に働きませんか?

私たち計測バックエンドチームと密に働く機会の多い計測システム部SREチームでは、ZOZOの計測システムを支えるSREを募集しています。

https://hrmos.co/pages/zozo/jobs/1809846973241688303

カジュアル面談も歓迎です。お気軽にご応募ください!

GitHubで編集を提案
株式会社ZOZO

Discussion