Working with Native Image Efficiently

原文はこちら。
The original article was written by Olga Gupalo (Member of Tech Staff for GraalVM at Oracle) and Oleg Šelajev (Developer advocate for GraalVM at Oracle Labs).
https://medium.com/graalvm/working-with-native-image-efficiently-c512ccdcd61b

GraalVM Native Imageは、Javaアプリケーションをバイナリ実行ファイルにコンパイルします。このようにコンパイルされたアプリケーションは瞬時に起動し、実行時のメモリ使用量も少なくて済むため、このパフォーマンス特性が効率的なスケーリングに不可欠な、クラウドサービスでの強力な展開オプションになります。このエントリでは、構成プロセスや、JIT(Just-in-Time)実行モードからAOT(Ahead-of-Time)実行モードへの移行をできるだけスムーズに行うための便利なテクニックを紹介します。

Prepare Your Environment
Configuration Required
Build-time Errors
Run-time Errors
Tooling for Native Image

ビルドプロセスを簡単に説明すると、以下のようです。

  • バイトコードの静的解析
  • アプリケーションで実際に使用されるクラスとメソッドの識別
  • (必要に応じて)クラスの初期化
  • コードのコンパイル
  • 結果のリンク
  • 初期化済みのオブジェクトと共にバイナリに格納

多くのアプリケーションはネイティブイメージとして非常に簡単に動作しますが、シリアライズなどの動的な機能は、静的解析を助けるために明示的な構成が必要になる場合があります。ビルドプロセスは本質的に複雑なので、ビルドを効果的に設定したり調整したりするには、何が起こっているかをよく理解している必要があります。そうすることで、ネイティブイメージから最高の結果を得ることができます。

ここでは、いくつかのヒントをご紹介するほか、よく発生するビルドエラーとその原因、トラブルシューティングの方法についてもご紹介します。最後に、起こりうる問題の防止やパフォーマンスの調査に役立つNative Imageツールの概要をご紹介します。

Prepare Your Environment

Native Imageは、GraalVMのすべてのディストリビューション(JDK 8またはJDK 11ベースのCommunityまたはEnterprise Edition、Linux、macOS、Windowsのプラットフォームに対応)でサポートされています。しかし、JDK 11上のビルドの方がより効果的な傾向がありますので、可能であればそちらをご利用ください。

ネイティブ・イメージ・ビルダーはローカルのツールチェーンに依存しているため、zlibgccなどのCライブラリのヘッダファイルが必要です。お使いのプラットフォームでネイティブイメージを使用するための前提条件を満たしているかどうかを確認してください。

Prerequisites
https://www.graalvm.org/reference-manual/native-image/#prerequisites

ビルド環境やネイティブツールチェーンの情報、イメージのビルド時に適用される設定を確認するには、--native-image-infoオプションを使用します。

native-image --native-image-info -jar App.jar

ネイティブ・イメージのビルドに使われたGraalVMを確認するには、以下のコマンドを実行します。

strings imagename | grep com.oracle.svm.core.VM

Configuration Required

ネイティブ・イメージが失敗する最もよくある理由の一つは、静的解析で取り扱うことができない動的機能の構成が不完全、または完全に欠けているというものです。イメージのビルド時に、ビルダーは静的解析を行い、アプリケーションのメインエントリーポイントから到達可能なすべてのメソッドを見つけます。そして、これらのメソッドだけが、ネイティブ実行ファイルにAOTコンパイルされます。その結果、Javaバイトコードやクラスローディングを扱うインフラが含まれていないため、ランタイムに他のコードをネイティブイメージに追加することはできません。それゆえ、すべてのアプリケーションコードはイメージのビルド時に利用可能である必要があり、実行されるすべてのコードは実行ファイルにAOTコンパイルされます。

この方法は、言語機能の一部が完全に動的なものでない限り、見事に機能します。例えば、アプリケーションがJava Reflection APIを使用している場合、ネイティブイメージビルダはすべての実行パスを静的に把握することはできません。

同様に、ビルド時に静的に把握できないコードを実行時に生成または使用する動的機能もあります。プロキシ、Java Native Interface (JNI)、メソッドハンドル利用時の一部、クラスパスリソースへのアクセス(Class.getResource)などです。そのため、これらの機能を使用する場合は、最終的なバイナリに含めるクラスやメソッドをネイティブ・イメージ・ビルダーに知らせる必要があります。

イメージのビルド時に必要な構成を渡さずにこれらの機能を使用した場合、ビルダーはデフォルトでは中止しませんが、フォールバックイメージを生成することがあります。このフォールバックイメージは、ネイティブイメージのパフォーマンス特性を持ちません。これを防ぐには、--no-fallback フラグを指定してネイティブイメージをビルドする必要があります。

ネイティブイメージは、ネイティブイメージのビルドプロセスを構成するためのいくつかのオプションをサポートしています。推奨される方法は、プロジェクトのJARファイルにnative-image.propertiesファイルを埋め込むことです。これは通常のプロパティファイルで、ArgsJavaArgsImageNameプロパティをサポートしています。すべての引数は左から右に評価され、ビルダーは、META-INF/native-imageの場所の下のどこかで提供されたすべての構成オプションを自動的にピックアップします。どの設定データをイメージビルド時に適用したかをデバッグするには、詳細出力を有効にするか(--verbose)、--native-image-infoオプションを渡してください。プロパティファイルの埋め込みとそのフォーマットについては、以下のドキュメントをごらんください。

Embedding a Configuration File
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#embedding-a-configuration-file

How to configure dynamic features for native image?

ある動的機能の利用では、例えば、ちょっとしたリフレクションの呼び出しは自動的に処理されますが、その他のリフレクションアクセスの対象は、JSON形式の設定ファイルで提供する必要があります。

Automatic Detection
https://www.graalvm.org/reference-manual/native-image/Reflection/#automatic-detection

このファイルは、設定ファイルnative-image.propertiesの中で、native-imageの引数として渡す必要があります。以下はその例です。

[
{
"name" : "java.lang.String",
"fields" : [
{ "name" : "value", "allowWrite" : true },
{ "name" : "hash" }
],
"methods" : [
{ "name" : "<init>", "parameterTypes" : [] },
{ "name" : "<init>", "parameterTypes" : ["char[]"] },
{ "name" : "charAt" },
{ "name" : "format", "parameterTypes" : ["java.lang.String", "java.lang.Object[]"] }
]
}
]
view raw resources.json hosted with ❤ by GitHub

What’s the most convenient way to create the configuration files?

リフレクション、プロキシ、その他必要な機能のための設定ファイルを手動で書くこともできますが、GraalVMで提供されているTracing Agentを使って設定を生成する方が良いでしょう。

Assisted Configuration of Native Image Builds
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#assisted-configuration-of-native-image-builds

このTracing Agentは、アプリケーションの実行中に動的機能の使用をすべて追跡し、それを設定ファイルに書き出します。Tracing Agentの使用が推奨される方法です。Tracing Agentを有効にするには、-jarオプションの前に-agentlib:native-image-agent=config-output-dir=<Path>を指定するか、コマンドラインでクラス名やアプリケーションのパラメータを指定します。META-INF/native-image/ ディレクトリがまだ存在しない場合は、ディレクトリを作成してから実行します。

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-output-dir=META-INF/native-image -jar App.jar

Tracing AgentはJVMとインターフェースを取って、全てのクラス、メソッド、フィールド、リソース、またはリクエス トプロキシのアクセスをインターセプトします。JVMプロセスが終了すると、Tracing Agentはjni-config.jsonreflect-config.jsonproxy-config.jsonresource-config.jsonなどの構成ファイルを生成し、指定された出力ディレクトリに格納します。つい最近、Native Imageにシリアル化のサポートが追加されましたが、Tracing Agentは、デシリアライゼーションのアクティビティで使用されるクラスのリストを提供し、serialization-config.json ファイルに出力することもできます。

GraalVM 21.0 Release Note (Native Image)
https://www.graalvm.org/release-notes/21_0/#native-image
SerializationSupport.java (デシリアライズ時に使われるクラスのリスト生成に関与する部分のソースコード)
https://github.com/oracle/graal/blob/master/substratevm/src/com.oracle.svm.reflect/src/com/oracle/svm/reflect/serialize/SerializationSupport.java

前述の通り、native-imageは、デフォルトでは、クラスパス上のMETA-INF/native-imageディレクトリからすべての構成ファイルを拾い上げます。

異なる実行パス用の設定を作成するために、ターゲットアプリケーションを異なる入力で複数回実行する必要があるかもしれません。これは、Tracing Agentが実行をトレースするのであって、アプリケーションを分析しないためです。config-merge-dir オプションを指定してTracing Agentを実行すると、トレースされた設定を既存の設定ファイル群にマージできます。

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-merge-dir=/path/to/config-directory/ -jar App.jar

構成を生成する方法の一つとして、Tracing Agentを使ってテストを実行し、生成された設定ファイルを確認または修正する方法があります。Tracing Agentには他にも高度な機能がありますので、あなたのケースで使える機能があるかもしれません。

Agent Advanced Usage
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#agent-advanced-usage

Build-time Errors

Classes Initialized at Build Time instead of Run Time

典型的なビルド時のエラーは、“Classes that should be initialized at run time got initialized during image building”(実行時に初期化されるべきクラスがイメージビルド時に初期化されました)です。例えば以下のような例です。

Error: Classes that should be initialized at run time got initialized during image building: org.example.library.Klass the class was requested to be initialized at build time (from the command line). org.example.library.Klass has been initialized without the native-image initialization instrumentation and the stack trace can't be tracked.
...
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

クラスの不適切な初期化プロセスに起因する問題を解決する方法を説明する前に、一般的になぜそれが起こるのかを簡単に説明します。

アプリケーション内のクラスは、使用前に初期化が必要です。この処理では、例えば、クラスの静的フィールドを初期化します。ネイティブイメージのライフサイクルは、ビルド時(コードをコンパイル、データをイメージヒープに格納)と実行時(出来上がった実行ファイルを実行)の2つの部分に分けられます。この2つの間には明らかに時間的な違いがあり、またそれぞれ異なる環境で行われていると思われますが、アプリケーションの一部のコードは両方で実行されるため、これらをアプリケーションランタイムと考えることができます。デフォルトでは、アプリケーション・クラスを実行時に初期化しますが、--initialize-at-build-time または --initialize-at-run-time オプションでこのデフォルト設定を変更できます。

クラスの初期化タイミングの設定は、さまざまな場所で指定できます。ライブラリのJARファイルに埋め込んだり、フレームワークが一部のクラスの初期化をビルド時に設定していたり、使用しているビルドスクリプトがコマンドラインオプションを使用して一部のクラスに設定していたり、といった具合です。

大事なことを言い忘れていましたが、ビルド時の初期化は静的フィールドを伝播します。これは、今まで考えたことがないと混乱するかもしれません。クラス初期化時に、静的フィールドを初期化します。例えば、あるクラスのオブジェクトをそこに割り当てます。そのクラスは、インスタンス化される前に初期化される必要があり、その後、ビルド時にも初期化されます。

これは、クラスがビルド時に初期化される場合、常に明確な理由があることを意味します。デフォルトのエラーメッセージでは、実行可能な情報をもたらしませんが(データを収集するには、事前にオプションを有効にする必要があるため)、状況を確認するためにできることが示唆されます。

上のエラーメッセージを見てみましょう。

Error: Classes that should be initialized at run time got initialized during image building: org.example.library.Klass the class was requested to be initialized at build time (from the command line).

この問題を解決するには、Klassクラスを初期化するタイミングの設定の不一致を解消が必要です。解決策は、イメージのビルド時にKlassを初期化する一連のクラスを特定し、--initialize-at-run-timeオプションを使って実行時に初期化するようにすることです。

1. まず、どのクラスが原因で、ビルド時に他のクラスを意図せずに初期化しているかを追跡する必要があります。そのためには、ネイティブのイメージビルドコマンドに--trace-class-initializationというパラメータを追加します。

native-image -jar my-app.jar 
-H:IncludeResources=resources.json
--trace-class-initialization
--verbose

2. 実行すると、以下のようなエラーが発生するはずです。

Error: Classes that should be initialized at run time got initialized during image building:
org.example.library.Klass was unintentionally initialized at build time. some.other.klass.AnotherKlass caused initialization of this class with the following trace:
at org.example.library.Klass.<clinit>(Klass.java)
at org.example.library.KlassFactory.<init>(KlassFactory.java:90)
at org.example.library.SomeOtherKlass.<clinit>(SomeOtherKlass.java:86)

3. スタックトレースの最初のクラスが犯人です。実行時に初期化するようネイティブイメージを再ビルドします。

native-image -jar my-app.jar
--initialize-at-build-time=org.example.library.SomeOtherKlass
-H:IncludeResources=resources.json
--trace-class-initialization
--verbose

より多くのエラーが表示される場合は、上記の手順を繰り返す必要があります。初期化のためにさらにクラスを追加する必要がある場合は、--initialize-at-run-time(およびビルド時のオプション)で、クラスとパッケージ名、またはパッケージの接頭辞をカンマで区切ったリストを受け取ります。

一般的には、実行時にできるだけ多くのクラスを初期化することをお勧めします。ビルド時にクラスを初期化すると、意図しない副作用が生じる可能性があるためです。クラスの初期化が安全かどうかをチェックするのは開発者の義務です。

Missing Type Error

次のエラーはよく報告されるもので、ビルド時に型がない(Missing type)、というものです。

com.oracle.graal.pointsto.constraints.UnresolvedElementException: Discovered unresolved type during parsing: NNN

このエラーは、イメージのビルド時にクラスが見つからないことが原因です。ネイティブイメージのランタイムには、新しいクラスをロードする機能がないため、すべてのコードはビルド時にコンパイルされ利用可能である必要があります。そのため、参照されているのに見つからないクラスは、実行時に問題となる可能性があります。最良のアドバイスは、すべての依存関係をビルドプロセスに提供することです。そのクラスが 100% オプションであり、実行時に使用されることがないと確信している場合は、native-image--allow-incomplete-classpath オプションを付けて、見つからないクラスがあったときにビルドプロセスを失敗させるデフォルトの動作をオーバーライドできます。

Out-of-memory

ネイティブイメージ生成時のもう一つの問題は、メモリ不足やデッドロックです。

Image generator watchdog detected no activity. This can be a sign of a deadlock during image building and watchdog is aborting image generation.

ネイティブイメージのビルドは計算量の多いプロセスであり、多少のRAMを消費します。実行時にどのクラスやメソッドが使用されるかがわかるようプログラム全体の表現を作成するためです。ネイティブ・イメージ・ビルダーはJavaプログラムで、Java HotSpot VM上で動作し、基盤となるJDKのメモリ管理を使用します。メモリ不足のエラーを防ぐには、ネイティブ・イメージ・ビルダに -J<JVMのメモリ指定オプション> を渡して、イメージ・ビルド時に使用する最大ヒープ・サイズを明示的に設定します。例えば以下のような感じです。

native-image -J-Xmx14g

Memory Configuration for Native Image Build
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#memory-configuration-for-native-image-build

Run-time Errors

エージェントを使って、期待通り、多くのクラスが検出され、設定ファイルに追加でき、ネイティブ・イメージのビルドに成功しました。イメージを実行したところ、クラスがfat JARに存在するにもかかわらず、ClassNotFoundエラーが発生しました。あなたならどうしますか?このエラーは、コンソールに出力される名前のクラスがクラスパスに見つからない(設定ファイルで利用できない)ことを意味します。このエラーを修正するには、reflect-config.jsonにクラス名を追加してください。

{
{
"name": "java.package.ClassName"
}
}

もう一つの手っ取り早い方法は、--allow-incomplete-classpathオプションを付けてネイティブイメージを再構築し、リンクエラーの可能性をビルド時からランタイムに移すことです。

次のリストは、その他の最も典型的なランタイムエラーです。

java.lang.ClassNotFoundException: xxx
java.io.FileNotFoundException: Could not locate xxx on classpath
java.lang.IllegalArgumentException: Class xxx is instantiated reflectively but was never registered
java.lang.IllegalArgumentException: java.lang.IllegalArgumentException: Class xxx cannot be instantiated reflectively
java.lang.InstantiationException: Type xxx can not be instantiated reflectively as it does not have a no-parameter constructor or the no-parameter constructor has not been added explicitly to the native image
java.lang.IllegalStateException: input must not be null

多くの場合、これらのエラーは、構成不足が原因です。ネイティブ・イメージ・ビルダーは、実行時にロードされる反射的な呼び出しやリソースのことは知りません。また、サードパーティのライブラリに、AOTコンパイルができないコードが含まれていることも考えられます。

ほとんどの場合、問題が発生したときには、エラーメッセージが何をすべきかを的確に示してくれます。例えば、以下のようになります。

Caused by: java.util.MissingResourceException: Resource bundle not found javax.servlet.http.LocalStrings. Register the resource bundle using the option -H:IncludeResourceBundles=javax.servlet.http.LocalStrings.

そこで、-H:IncludeResourceBundles=javax.servlet.http.LocalStrings をコマンドラインもしくはnative-image.propertiesファイルに指定し、再度イメージをビルドしてください。

コンソールに明示的な提案が表示されない場合は、イメージのビルド時にクラスの初期化を手動でトレース(--trace-class-initialization)するか、Tracing Agentツールを適用して必要な構成を作成してください(推奨は後者)。

Assisted Configuration of Native Image Builds
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#assisted-configuration-of-native-image-builds

リソースが見つからない原因には、構成ファイルがクラスパスからアクセスできない場合もあります(-agentlib:native-image-agent=config-output-dir=/path/to/config-dir/を使い、Agentに対してカスタムの出力ディレクトリを指定している場合などが該当)。構成ファイルをビルダープロセスに認識させるにはいくつかの方法があります。以下はその例です。

  • 生成されたファイルを、デフォルトでクラスパスからアクセス可能なMETA-INF/native-image/ディレクトリに移動する(例えば、src/main/resourcesディレクトリの下)。
  • 生成されたファイルをクラスパスディレクトリに移動し、引数で指定する( -H:ConfigurationResourceRoots=path/to/resources/)。
  • 任意のディレクトリに配置し、引数で指定する(-H:ConfigurationFileDirectories=/path/to/config-dir/

Tooling for Native Image

GraalVMではNative Image用のツールを用意しています。

Tracing Agent

Tracing Agentは、ネイティブイメージビルダーに必要な設定を提供するのに欠かせないものとして、すでにこの記事で紹介しました。このツールは、JVM上でアプリケーションを実行する際の動的機能の使用をすべて追跡し、それらをJSONファイルに書き出して、後でnative-imageが読み取ります。

$JAVA_HOME/bin/java -agentlib:native-image-agent=config-output-dir=/path/to/config-dir/ -jar App.jar

Tracing Agentは、個々のアクセスを含んだJSON形式のトレースファイル(trace-file.json)を書き込むこともできます。詳しくは、ガイド「Assisted Configuration of Native Image Builds」をご覧ください。

Assisted Configuration of Native Image Builds
https://www.graalvm.org/reference-manual/native-image/BuildConfiguration/#assisted-configuration-of-native-image-builds

GraalVM Dashboard

ネイティブ実行ファイルのサイズが大きすぎると感じたり、どのクラス、パッケージ、初期化済みオブジェクトがイメージを満たし、ヒープの大半を占めているのか気になる場合は、ウェブベースの可視化ツールであるGraalVMダッシュボードを使って調査できます。

GraalVM Dashboard
https://www.graalvm.org/docs/tools/dashboard/?ojr=dashboard
Making sense of Native Image contents
https://medium.com/graalvm/making-sense-of-native-image-contents-741a688dab4d
https://logico-jp.io/2021/02/21/making-sense-of-native-image-contents-what-code-ends-up-in-the-executable-and-whos-to-blame/

このツールを使用するには、イメージの構築中に診断データをダンプファイルに集め、ダッシュボードを開いてダンプをアップロードします。下のスクリーンショットは、パッケージサイズの内訳表示を示しています。

GraalVM Dashboard in action

Debugging

JDKには、伝統的にターゲットJVMとのデバッグ・セッションを確立するためのコネクタが同梱されています。生成されたネイティブ・イメージは、最小限のシンボル情報を持つ高度に最適化されたコードであり、そのデバッグは困難です。しかしGraalVMでは、Dwarf形式のデバッグ情報を含むネイティブ・イメージを構築し、デバッグ・クライアントが接続できるようにネットワーク・ソケットを開くことができます。デバッグシンボルを含むイメージをビルドするには、-gを渡します。

ネイティブ・イメージを構築するプロセスにデバッガをアタッチすることもできますが、これは実のところJavaアプリケーションです。--debug-attach[=< port >] オプションを使用すると、ビルダーをデバッグモードで起動し、お気に入りの IDE デバッガをアタッチして、ブレークポイントを設定できます。例えば、クラスのイニシャライザにブレークポイントを設定すると、そのクラスがどこで初期化されているかのスタックトレースを確認できます。詳細は以下のドキュメントをご覧ください。また、デバッグに役立つ、生成されたイメージの追加チェックを有効にする方法もご確認ください。

Debug Info Feature
https://www.graalvm.org/reference-manual/native-image/DebugInfo/
Debug Options
https://www.graalvm.org/reference-manual/native-image/HostedvsRuntimeOptions/#debug-options

Tracking Memory

ネイティブイメージとして実行されるアプリケーションのメモリ構成を調整することで、アプリケーションを最適化することができると考えられる場合、実行時にいくつかのデバッグ出力を有効にできます。例えば、次のオプションはGCログを表示します。

XX:+PrintGC -XX:+VerboseGC

ネイティブイメージでは、Java のヒープを管理するために、さまざまなガベージコレクタ(GC)の実装が用意されています。詳細は以下のドキュメントをご覧ください。

Memory Management at Image Run Time
https://www.graalvm.org/reference-manual/native-image/MemoryManagement/

Conclusions

この記事では、Native Imageに関するよくある誤解と、設定の不備や不完全さによってどのような問題が発生するかを見てきました。このブログ記事が、ネイティブイメージビルダー利用時に問題に遭遇した際に必要なツールとなることを願っています。

この記事でのアドバイスがNative Imageの使用を成功させるための近道にならないようであれば、オープンSlackチャンネルの#native-imageで助けを求めるか、GitHubのIssue立てることができますので、覚えておいてください。また、native-imageの主要なオプションをまとめた1ページも、便利なコマンドを思い出す上で役立つかもしれません。

GraalVM Slack invitation
https://www.graalvm.org/slack-invitation/
Issues
https://github.com/oracle/graal/issues
GraalVM Native Image Quick Reference
https://www.graalvm.org/uploads/quick-references/native-image-quick-reference-v2_A4.pdf

GraalVM Native Imageは、技術的な能力と開発者の使い勝手の両面で継続的に技術を向上させています。あらゆるフィードバック、質問、報告をお待ちしています。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中