GraalVMサーバーの起動時間を50倍高速化できるという話だったので、昔からかなり興味があったので、仕事で触っている gRPC のワーカーをハッカソンで GraalVM で動くまで頑張ってみた。その時の学びを整理しておきたい。
GraalVM とは
GraalVM は、アプリケーションのパフォーマンスと効率性を劇的に改善するランタイムであり、マイクロサービスに理想的である。Serverless が好きな自分としてはかなり興味があるランタイムだ。本体はJavaで書かれているようだ。スタートアップタイムは50倍速いらしい。実際に自分も試してみたが、本当にLinux だと50倍速かった。Windows ではそうでもなかった。3倍程度であった。でも速いのに変わりはない。
Ahead-of-time Compilation
通常のランタイムとしても使えるが、私が興味があるのが、Native image といわれる Java の jar ファイルをバイナリにしてくれる機能だ。バイナリだと、JVMを介さないので Docker に Wrap している場合は、Docker image のサイズを小さくするのに貢献してくれるだろう。しかも、これは単なるバイナリではなく、Ahead-of-time compilation という方式をとっている。次の図がわかりやすいが、通常のVMは、ソースのみコンパイルして、その中間言語をランタイムが動かす形式だ。Native Image を使うと、ソースだけではなく、クラスのロードまでビルド時に行ってバイナリを作る。さらに設定を加えることで、コンフィグファイルの読み込むをもビルド時に実施するということになる。
制約事項
クッソ速いパフォーマンスの代償として、制約事項がある。クラスのロードをビルド時にするために、ビルド時にどんなクラスを使うかがわかる必要がある。だから、リフレクション、クラスローディング、ダイナミックプロキシといったランタイムに実行されるクラスが決定される仕組みの場合、どんなクラスを使う予定なのか?といったコンフィグレーションファイルが必要になってくる。それはこんなイメージだ。ランタイムで解決すべきクラスやメソッドを定義していく感じだ。詳細のドキュメントはここで見つけることができる。
reflect-config.json
[
{
"name":"com.function.Function",
"allPublicMethods":true,
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"com.google.protobuf.Extension"
},
{
"name":"com.google.protobuf.ExtensionRegistry",
"methods":[{"name":"getEmptyRegistry","parameterTypes":[] }]
},
{
"name":"com.microsoft.azure.functions.rpc.messages.BindingInfo$Direction",
"fields":[
{"name":"UNRECOGNIZED"},
{"name":"in"},
{"name":"inout"},
{"name":"out"}
]
:
コンフィグレーションの格納
上記のコンフィグレーションは、jar ファイルに格納される。/resouces/META-INF/native-image/grouopId/artifactId
という感じで格納する。これによって、ネームスペースが重複しないことを保証している。私が触ったところ、左記の5つのコンフィグレーションを設定する。
Graal VM のインストール
Graal VM のインストールはとても簡単で、Downloads から落としてきて、PATH
と、JAVA_HOME
を設定したら終わりだ。Native Image のコマンドを使いたい場合は、インストール後
gu install native-image
コマンドを実行すると、native image で実行が可能になる。事前条件として、いくつかライブラリが必要になるので、詳しくはInstall Native Imageを参照してほしい。
Java8 on Windows のインストール
Java8 を Windows にインストールするときは少々面倒だ。というのも、古い Windows SDK が必要になる。Visual Studio Installer とかからはダウンロードできないものだ。GRMSDX_EN_DVD.isoをダウンロードして、実行して、その中にある、Setup.exe
をクリックすると、Windows SDK 7.1 がインストールされる。それだけでは、動作しなくて
call "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd"
を Developers command line から実行することで、使えるようになる。Windows で使いたい場合は Developer Command Prompt
から GraalVM のコマンドを使う必要がある。
Native Image のビルド
今回のアプリケーションは、gRPC のワーカーで、アノテーションを元に情報を読んだり、クラスローダーを使って、特定のクラスを読んで実行したりと、動的なコードの塊だ。だからきっと、大変なはずだ。いろんな情報がネットに流れているが、大抵の場合は、「コンフィグレーションを適切に実施して、ビルド時に必要なクラスをすべて提供する」とうまくいく。だから、クラスローダーを使って読み込む予定のクラスをビルド時に指定してあげる必要がある。
ただし、簡単に聞こえるがこれが大変だ。本当に大変だ。今回のハッカソンで試しにやってみたのだけど、このアプリをGraal化するだけで7日も使ってしまった(3連休があったのでそれをぶっ潰して何とかこぎつけました)そのコードベースに対する知識と、GraalVMの両方の知識であり、使っているライブラリの知識も、ライブラリが、Graalに対応していなければ知る必要があります。
ビルドのエラーを取り除く
まず普通にビルドすると、ビルド時のエラーがたくさん発生します。同時にオプションを指定しなければ、フォルスポジティブを発生しやすくなります。それ何か?というと、うまくGraalのNativeImage用のビルドがうまくいかない場合、うまくいかない部分は、JVM上で実行するという、フォールバックイメージ
というものができてしまいます。これは、一見うまくいっているようでも、せっかくバイナリなのに、JVMのラインタイムが必要になっていしまいます。ですので、フォルスポジティブを発生させないようにするためいには、オプションを指定してビルドをしてあげる必要があります。
native-image -jar azure-functions-java-worker-1.7.3-SNAPSHOT.jar -H:+ReportExceptionStackTraces --no-fallback -H:+TraceClassInitialization --allow-incomplete-classpath -cp Java-1.0-SNAPSHOT.jar
最初に -jar
のオプションを使うが、その後のオプションを解説していきたい。-H
系のオプションは、GraalVM の VMOption のようで、ここを参照すれば全部見ることができる。
* VM Options Explorer
ビルド時には大量のエラーが発生する。リフレクションなどラインタイム時に決定されるクラスはすべてエラーになる。最近では、ライブラリにすでにGraalの設定ファイルが入っている場合があって、その場合は、そのライブラリは大丈夫だが、それでもあなたのコードベースはたくさんの例外をはくと思う。
-H:+ReportExceptionStackTraces
ビルド時に、Exception が発生したときに、表示する。
-H:+TraceClassInitialization
ビルド時に、Exception が発生したときに、どのクラスで初期化したかを表示する。見落としていたが類似ので、-H:+PrintClassInitialization
というオプションが公式に載っていたのでこちらもいいかも。こちらは、分析によって得られた、すべてのクラスの初期化情報を表示するというオプションがある。これを使ってビルドすべきだったかも。(今度試してみます)
--no-fallback
これは、フォールバックイメージつまり、一見バイナリのように見えて、実は JVM が必要な中途半端なイメージを認めないというオプション。これを指定することで、
--allow-incomplete-classpath
これは、ビルド時にこれをつけるように頻繁に言われるので最終的に追加したもの。これによって、ビルド時ではなく、ランタイム時に問題があったときにエラーを発生させます。もしかすると、このオプションはつけないほうがいいのかもしれません。先ほどの -H:+PrintClassInitialization
を指定すべきだったのかもしれません。
-cp
クラスパス。ランタイム時に必要となるライブラリを追加しておきます。自身のJarに入っているライブラリは指定する必要はありません。
上記の設定で最終的にエラーをゼロにすることができました。そするためのポイントを書いておきます。
Native Image ビルド時のエラーの解決方法
大抵は、ランタイムに必要となるクラスが解決できないというエラーなので、エラーが発生したら、該当のクラスを --initialize-at-run-time=io.netty.buffer.UnpooledUnsafeNoCleanerDirectByteBuf
といった形でオプションとして追加していきます。これは、このクラスはビルド時ではなく、ランタイム時に初期化されますというオプションです。該当のクラスを、指定してもエラーが消えない場合は、それを初期化しているクラスごとこのランタイム指定にしてあげるとうまくいくことが多いようです。私は最終的にどうしても解決できないクラスを--allow-incomplete-classpath
オプションを使って無視しました。(該当のクラスは、Jarファイルに格納されていることを確認したからです)--allow-incomplete-classpath
の追加は最後でよいと思います。ちなみに、追加したオプションは大量になるので、jar 側のコンフィグレーションファイル native-image.properties
として jar に格納しておくと、わざわざオプションを毎回指定しなくてもよいようになります。この設定でエラーが一つも出なくなったらビルド成功です。JVMのランタイム不要で実行できます。参考までに、私の native-image.properties
はこんな感じになりました。
native-image.properties
Args = -H:+ReportExceptionStackTraces --no-fallback -H:+TraceClassInitialization \
--initialize-at-run-time=io.netty.buffer.UnpooledUnsafeNoCleanerDirectByteBuf \
--initialize-at-run-time=io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf \
--initialize-at-run-time=io.netty.buffer.UnpooledByteBufAllocator \
--initialize-at-run-time=io.netty.buffer.Unpooled \
--initialize-at-run-time=io.netty.handler.codec.http.HttpObjectEncoder \
--initialize-at-run-time=io.netty.handler.codec.http.HttpObjectAggregator \
--initialize-at-run-time=io.netty.handler.ssl.ReferenceCountedOpenSslEngine \
--initialize-at-run-time=io.netty.util.internal.logging.Log4JLogger \
--initialize-at-run-time=io.netty.handler.ssl.ConscryptAlpnSslEngine \
--initialize-at-run-time=io.netty.handler.ssl.ReferenceCountedOpenSslClientContext \
--initialize-at-run-time=io.netty.handler.ssl.ReferenceCountedOpenSslServerContext \
--initialize-at-run-time=io.netty.handler.ssl.JdkNpnApplicationProtocolNegotiator \
--initialize-at-run-time=io.netty.handler.ssl.JettyNpnSslEngine \
--initialize-at-run-time=io.netty.handler.ssl.JettyAlpnSslEngine$ClientEngine \
--initialize-at-run-time=io.netty.handler.ssl.JettyAlpnSslEngine$ServerEngine \
--initialize-at-run-time=io.netty.handler.ssl.JdkAlpnApplicationProtocolNegotiator \
--initialize-at-run-time=io.netty.handler.ssl.JdkAlpnApplicationProtocolNegotiator
ランタイム時のエラー解決方法
さて、ビルド時にはどうしてもランタイム時の解決になるクラスを指定しました。実際にランタイムで使うクラスやメソッドをすべて記述する必要があります。これは面倒です。そこで、Graal VM には Agent という仕組みが用意されていて、コンフィグファイルを自動生成できる仕組みが用意されています。
その agent
をつけた状態で、通常の jar ファイルとして、GrralVM の上で、必要なクラスを一通り使うであろうすべてのパスを網羅してあげるようなリクエストを投げます。
Agent の設定
Agent は、おそらく頻繁に実行することになると思います。違う環境で実行するより、全く同じ環境で実行するのが望ましいと思います。このエージェントは、すべてのクラスの初期化と、メソッド呼び出しを観察して、コンフィグファイルを作ります。ですので、おすすめとしては、いつでもこのエージェント付きのJarファイルを実行できるようにしておくのがよいと思います。1つでもかけているシナリオがあって、エージェント付きで実行していなかったパスが流れて、そこでコンフィグレーションファイルにないクラスが実行されると、それがExceptionになってしまうからです。もしくは不思議な挙動になるときがあります。
私は、本番用のコンテナは、マルチステージビルドで、Native Imageを生成して、実行される側のステージはJVMのランタイムを含まない構成にしていますが、それと同じところに、Native Imageのビルドに使っているのとまったく同じ jar ファイルを使って、agent をつけてワーカーを実行する Dockerfile を作っています。これによって、全く同じ jar ファイルを、 agent によって、設定ファイルの自動生成がより堅牢にできるようになりました。
$ /graalvm-ce-java8-20.2.0/bin/java -jar azure-functions-java-worker-1.7.3-SNAPSHOT.jar -agentlib:native-image-agent=config-merge-dir=/graal_config,config-write-period-secs=5
この設定を解説しておくと、-agentlib:native-image-agent
を指定することで Agent が JVM とともに起動します。config-merge-dir=
のオプションは、設定ファイルの追加モードで、例えばリクエストを2回送ったら、1回目のリクエストで、使ったクラスと、2回目のリクエストで使ったクラスをマージしたいと思いますが、その設定です。ほかの設定はイランと思います。ただ、このオプションの場合、jini-config.json
proxy-config.json
reflect-config.json
resource-config.json
が最初から存在しており、正しい JSON フォーマットである必要ありますので、先に空の JSONファイルを 作っておいてください。最後の config-write-period-secs
は読んでのとおりで、サンプリングする時間間隔です。マージの時はある程度短い間隔でいいと思います。例えば10分にすると、シナリオの実行がすべて終わっても、実際にコンフィグファイルができるのは10分後になるからです。
自分で手動でこれらのファイルの内容を追加することはできるのですが、多くの場合、エージェントを実行した状態と、実際に使われる状態の違いによってコンフィグが適切にできてないというケースが大半です。ですので、エージェントをいつでも実行できるようにして、すべてのシナリオを網羅するようにしましょう。
結果
結果として、起動時間はたいして改善しませんでした。これはGraalの問題ではなくて、現在のアーキテクチャが、Javaだけで動いているわけではないのと、インフラの仕組み合わせたアーキテクチャになっているので、起動時間に差が出ない設計になっているためです。また、ボトルネックが別の部分にあるので、そちらを改善したほうが、起動時間という観点ではずっと早くなりそうだからです。
ただし、Graal化すると、元のレスポンスに対して、エンドツーエンドで、平均62%もパフォーマンスが改善しまた。これは恐るべきことです。なぜかというと、今回のアーキテクチャでは、JavaのgRPC ワーカーなので、そのワーカーを読んでいる親玉が違う言語で書かれていて、その親玉へのリクエストが来ると、処理して、Javaのワーカーに投げるという設計なので、Java 部分がすべてではないし、親玉の部分とネットワーク部分は全く同じのはずなので、Java部分の改善だけで、全体のパフォーマンスが62% 向上しました。
また、Docker イメージは、もともとが大きかったので大した効果ではなかったですが、JVMのランタイムと、jar を使ったときに比べると、Docker image のサイズが 80MB 削減されました。ちなみに、jar ファイルと、Native Image のバイナリ比較をすると、比較的差が少なくてびっくりしました。(Native Image が+9MB) ランタイムを足すことを考えるとこれはうれしいところです。
TODO
ハッカソンの限られた時間で調べた内容なので、妥協したところもあります。おそらくまだまだ良い方法があると思います。私のプロジェクトでは、アノテーションの部分が動くのですが、値が返却されると、一部だけ null になるというとても不思議な挙動になやまされている最中です。どうやら内部で、Dynamic Proxy を使っている雰囲気なので、そのあたりだと思うのと、きっとコンフィグファイルで解決できる問題だと思うので継続して試してみたいと思います。
私はGraalVMの初心者なので、アドバイスやコメントがありましたら、ぜひよろしくお願いいたします。