GraalVM(native-image)×micronaut reflection周りを2週間くらい触ってみた備忘(手探りの記録)
GraalVMは事前コンパイルだからReflectionが苦手
javaでサーバレスをしたい!そのためには最高速度でなくても初速を速くしたい!と言う動機からGraalVMでnative-imageをビルドしてLambdaにDeployしています。Reflection周り触ってみたので、備忘。(具体的にはRDS Aurora(Postgres)にDB接続してデータ読んだり更新したりという普通の業務ロジックのjavaの関数をデプロイした時の内容)
対象はあくまでReflectionのあるjavaのコードについて言及しています。
native-image をカンタンに説明すると「Java プログラムを事前コンパイル (AOT、Ahead-Of-Time) して、単独実行可能なネイティブバイナリを生成するツール」です。
この事前コンパイルことAOTの際に問題となるのが主にreflectionのコード。
graalVMはこれらの問題を解消するために、AOTに必要な情報を設定ファイルとして読み込み、native-imageをビルドするような構造となっている。
反対に言うと、Reflectionを利用しているコードである場合、この設定ファイルが正しく読み込まれてビルドされていない場合には、Classが見つからないとしてランタイムでエラーとなってしまう。(NoClassDefFoundExceptionみたいなエラー)。
え、それをReflectionの数だけ用意するとか無理じゃね?
自分が書いたコードならいざ知らず、ライブラリ使ってるよ?
各種設定ファイルを自動生成する仕組み
この弱点は中の人もわかっているのでそれらを解決するために、これらをdetectするツールが用意してあります。但し、これらは何もコードを静的解析して生成しているわけではなく、testやrunなど実際にnative-imageを動かして、該当のファイルを取得するような仕組みとなっています。
解説記事
-agentlib と記載されているところの処理を蹴ることで、reflect-config.jsonやproxy-config.jsonといったファイルを書き出してくれます。
これらのファイルをビルド時に指定することでそれに則ってビルドしてくれるわけですね。
めでたしめでたし
テストを書こう
めでたしではなく、問題がこちら。
実際にnative-imageを動かして、該当のファイルを取得するような仕組み
つまり実際にテスト等で全て動かした状態でないと全ての必要なファイルを取得することができない、と言うことになります。反対にそれら無しで動いた場合は端にラッキーというか、、、
要するにこれ本当にコンパイルする言語か?みたいな気持ちになったりします。
だから実際に全ケースをrunさせるまたは、ユニットテストを書いてテストで通ったところのreflect-config.json等をちゃんと同梱してビルドする、が必要になります。
つまり、ユニットテストによって保証されたもののみが正しく動くと言う世界観になっております。
Micronaut 3.3.1 GraalVM 22 で使ってみたんだけど
Micronautにいい感じにリファクタし、Junitを通りいっぺん書き、さて実際のnative-imageでテスト(gradle nativeTest --info)しようとすると通らない通らない。(ここからはMicronautとGraalVMの話)
reflectionの通りいっぺんが全部起きた後はBean登録時にこんな感じのエラーにぶち当たる
Multiple possible bean candidates found:
とりまメモがてら回避方法を書いてみる。(2022/03/08 更新)
-
agentを利用していたときに起きました。
1-1. 明示的に利用していたとき
https://github.com/micronaut-projects/micronaut-core/issues/6579#issuecomment-9817354661-2. 暗黙的に利用していたとき
native-imageのビルドオプションに--trace-class-initializationを指定していた場合も勝手にnative-imageの際にagent(-agentlib)が入ることを確認しました。ご注意を。 -
エージェントで自動生成したresource-config.jsonを同梱したとき
深く調べられていないが、resouce-config.jsonの中で自分の意図しない内容(テスト関連のリソースとmicronautのリソース)を削除することで回避できた。完全に推測だが、ランタイムに各種設定を取得する際に、起動時には本来不要(というか別の箇所で既に設定されている)ような内容が記載されちゃうんだろうな。。という気持ち。
まあどうにかこうにか業務ロジックのいくつかをnative-imageでDeployできました。
感想
とまあ、制約が厳しく、トラブルシューティングも難しいのですが、やっぱ初速が早いっちゃ早いので、
k8sのスケーリングが追いつかないほどのリクエストが飛ぶ(*)とかの話題に疲れたのでjavaでもサーバレスを検討している人にとっては一考の検討の余地があるかなと思います。反対にサーバレスにするなら避けてはほぼ通れない道じゃないかと。
それでもやっぱ、native-imageのビルドに4分くらいかかってはしまうので、ビルドのオプションを試行錯誤するたびにこれがかかるとかってのは慣れるまで結構ストレス。。。一回固まっちゃうと大丈夫だと思うけど。
ただやっぱこのままだと大規模開発とかに載せるのは実際厳しそう。もう少しこの辺りのソリューションが改善されるときっとあちこちで利用されるんじゃないかなぁと思うのでOracleさん及び各種FWの皆さまに期待。
*AutoScalingの速度も上がってると思うので最近はこんなこともないのかもしれませんが、私がアプリケーションエンジニアなのでk8sとか触らなくていいなら触りたくないと言う変なバイアスが入っています。
備忘 nativeTest用設定
こんな感じの作業ステップ
1.各種設定データ取得のため、テスト工程で取得
以下の設定でmicronaut gradle plugin よりnativeTestを実施。
build.gradleより抜粋
graalvmNative {
binaries {
main{
// CustomeRuntimeのクラスを指定
buildArgs.add('-H:Class=com.example.CustomeRuntime')
}
test{
// agent利用時のMultiple possible bean candidates found回避用
buildArgs.add("-H:-UseServiceLoaderFeature")
agent {
enabled = true
// agentのフィルタ指定。ファイル内容は後述
options.add("access-filter-file=${projectDir}/src/test/resources/access-filter.json")
}
}
}
}
access-filter.json
{ "rules": [
{"excludeClasses": "sun.**"},
{"excludeClasses": "com.sun.**"},
{"excludeClasses": "java.**"},
{"includeClasses": "java.util.**"},
{"excludeClasses": "javax.**"},
{"excludeClasses": "jdk.**"},
{"excludeClasses": "io.micronaut.**"},
{"includeClasses": "io.micronaut.context.condition.**"},
{"includeClasses": "io.micronaut.context.event.**"},
{"excludeClasses": "io.netty.**"},
{"excludeClasses": "org.junit.**"},
{"excludeClasses": "org.gradle.**"},
{"excludeClasses": "kotlin.**"},
{"excludeClasses": "org.apache.http.**"},
{"excludeClasses": "apple.**"},
{"excludeClasses": "org.eclipse.**"},
{"excludeClasses": "com.example.test.**"}
]
}
{"includeClasses": "io.micronaut.context.condition."},
{"includeClasses": "io.micronaut.context.event."},
テスト実行時は起動のためにこやつら自体を読み込み対象にしておかないと、そもそもテストが起動できないので必要。
フィルタ内容は適当ですが、junit関連やIDE内部のものなどを省いています。
というのもフィルタしすぎるとどうせnativeTestのJunitで落ちると思います。
反対に多い分にはそんなに大きな問題にはならないはず。
(尚、クラスパスに通っていない設定内容があると、ビルド時に下のようなログが出ますが)
=========================================
GraalVM Native Image: Generating 'exampleFunc-tests'...
=========================================
[1/7] Initializing... (17.4s @ 0.35GB)
Version info: 'GraalVM 22.0.0.2 Java 11 CE'
Warning: Could not resolve java.util.Calendar for reflection configuration. Reason: java.lang.ClassNotFoundException: java.util.Calendar.
(ちなみにこのログは実はreflect-config.jsonの中で設定する際に、java.util.Calendarの手前にスペースを書いてしまったことにより起きているという残念なログです。深く考えないでください)
-
ファイルのコピー
build/native/agent-output/test配下にreflect-config等の設定ファイルが出力されます。
reflect-config.jsonとproxy-config.jsonを内容を確認して、/main/resources/META-INF/native-image/{groupName}/{artifact}/配下に配置。 -
作った設定でちゃんとテスト通るか
今度は build.gradleのagent設定をオフにして再テスト。
agent設定があると、そもそも設定ファイルなしでも読み込んでくれているので、この設定ファイルがいい加減でも動いてしまうため(つまり、テスト用のnative-imageでは動くけど、実際のnative-imageでは動かないということが起こり得る)。
graalvmNative {
binaries {
main{
// CustomeRuntimeのクラスを指定
buildArgs.add('-H:Class=com.example.CustomeRuntime')
}
test{
/*
buildArgs.add("-H:-UseServiceLoaderFeature")
agent {
enabled = true
options.add("access-filter-file=${projectDir}/src/test/resources/access-filter.json")
}
*/
}
}
}
備忘その2 フォルダ構造と戦略(というかTips)
0. (前提)Lambdaを常時待機させ、その経費削減のために関数がライブラリベースで集約している
そのため、
GraphQLの関数(N) - Lamda関数 (1)
なのでGraphQL10個分くらいをこの1つのLambda関数が捌いています
1. 1つのソースでnative-imageと通常のDeployの口を作れるようにしておく
一部GraphQLに関しては達成済みだが、それ以外は未達という状況があり得る。
この場合にリリースできないという状況は運用設計として厳しい。
そこでこの状況でも開発PJ自体を遅延させないためにnative-imageで動作するLambdaと通常動作するLambdaを分ける。(Lambdaのランタイムを分ける必要があるため、関数のversionで分割する方法は使えない)
-
exampleFunc
開発はこっちでやる
自前で必ず gradle nativeTestをローカルで通しておく運用
Linuxの場合はテストをスキップするようにしている
Runtimeはjava11 -
exampleFuncV2
gradleマルチプロジェクトの構成によりソース自体はほぼすべてexampleFuncを参照。
厳密にはjavaソースで言うとcustomeRuntimeだけこちらにあるような構造。
build.gradleでnative-imageをビルドするように設定。
Linuxの場合はテストをスキップするようにしている
Runtimeはprovided
上記のように分割し、GraphQL内でどちらの関数を呼ぶかを決定した。
これにより、Performance向上(=native-image)を別工程として一旦切り離せる構造とした。
もしどこかの層でリトライができるなら、native-imageでエラーとなった場合に再度通常verで再実行という構造にするのも悪くはない、、かも(コスト面が許せば)。
2. reflect-confg.json等設定ファイルは2フォルダに分けている
取得した各種ファイルは main/resources/META-INF/native-image/{group}/{artifact} 配下に格納しているのですが、更にフォルダを分けています。該当verのmicronautやgraalVMだと問題なく読み込んでビルドしてくれているようです。
-
auto
上記のJunitで取得した内容を保存 -
manual
上記のJunitでうまくとれなくて、nativeTest等でエラーとなり自身で設定したものをこちらに配置(ちょっと上記のaccess-filterがキツすぎたよねなんてケースもありました、、)
理由としては、autoのものを常に自動生成したもので更新したいから、ということにつきます。通常実行で直接保存フォルダを指定しても、追記の形になり、上書きはされなさそうなのですが、如何せん自分で何か書いたものを別にしておいた方が取り回し、メンテが楽、、、ということでこの構造にしています。
実施結果
評価環境適用後Metricsを見てみたので内容を追記。
青が旧関数、オレンジがnative-imageに移行した新関数の所要時間です。
青とオレンジが混在する期間があるのはAppSyncで各関数の繋ぎかえを順次行っていったので、
一部GraphQLは引き続き旧Lambda関数を起動していたため、このような両方の関数が起動しているという一時状態が発生しました。