GraalVM + Javaで作ったバイナリをAWS Lambdaで動かす時にハマった所
仕事でAWS Lambdaを使う機会が増えてきたのですが、やはり書き慣れたJavaでLambdaを書きたいなと思うことが少なくありません。ただAWS LambdaでJavaアプリを動かすと、初回アクセスに十秒近く掛かるし、メモリ消費量も大きいしで、パフォーマンス的にも運用コスト的にも嬉しくありません。そう思ってるところで、JavaのアプリをGraalVMでネイティブビルドをすれば初回起動時間もメモリ消費量も抑えられると聞いたので、これに取り組んでみました。
ただ実際にやってみると、わりとエラーが頻発してしまって、トライ&エラーを繰り返しながら先に進むという感じになりました。せっかく色々と試したので、起きた問題とその解決策・回避策なんかをここにメモしておきます。
やったこととか参考にしたサイトとか
やったこと
まずJava8(1.8.0_202)と、Micronaut(1.1.3)のライブラリを使って、AWS LambdaのJavaランタイム向けのjarを作って動作確認をしました。Micronautはフレームワークとしては利用せず、あくまでMicronautのライブラリとプラグインだけを部分的に使う形で利用しています。
Javaランタイムできちんと動くことが確認できたら、次にGraalVM(19.0.2)を使ってnative-imageコマンドでバイナリを作り、AWS Lambdaのカスタムランタイムとして動作させました。
この辺りまでのやった内容は、GitHubに置いてあります。
https://github.com/cero-t/lambda-graal
さらにこれをベースにして、JavaのImageIOを使った画像のリサイズを行う処理を書こうとしているのですが、そこでハマってしまっているというのが、いまの状況です。
参考サイト
ひとつ目が @kencharos さんのQiita記事。今回GraalVMを使ってみようと思ったきっかけであり、よりどころであり、この記事なしでは何事も進みませんでした。感謝。
もうひとつが、Micronautで作ったファンクションをAWS Lambdaで動かす辺りのことを記述してあるドキュメント。若干、メンテナンス不足だったのでプルリクしたりしてました。
https://micronaut-projects.github.io/micronaut-aws/latest/guide/#customRuntimes
起きた問題と解決策・回避策
繰り返しの説明になりますが、AWS LambdaのJavaランタイムできちんと動作確認を行なってから、それをGraalVMでバイナリ化してAWS Lambdaのカスタムランタイムで動作させました。そこでエラーが起きた問題について、まとめています。
① NullPointerExceptionが発生 / 戻り値がnullになる
現象
リフレクションを伴う処理の戻り値がnullになる。たとえば次のようなコードで問題が起きる。
HttpResponse<MyEntity> response = httpClient.exchange(URI, MyEntity.class); MyEntity entity = response.body();
これで取得した entity
の値がnullになる。他にも、Jacksonを使った処理などでも同じことが起きる。
原因
GraalVMでネイティブイメージを作成すると、リフレクションに必要な情報がなくなってしまうため。上の処理ではリフレクションを使ってMyEntityのインスタンス作成や値の設定を行なうが、リフレクションが使えないためMyEntityのインスタンスが作れない、のだと思う。
対策
リフレクション定義ファイルを作る。
まず src/main/graal
フォルダに reflect.json
を作成する。JSONのサンプルはこんな感じ。
lambda-graal/reflect.json at master · cero-t/lambda-graal · GitHub
次に build.gradle
にannotationProcessorを追加する。
dependencies { annotationProcessor "io.micronaut:micronaut-graal:1.1.3" annotationProcessor "io.micronaut:micronaut-inject-java:1.1.3" }
これでビルドを行うと、できあがったjarにリフレクション定義ファイルが含まれるようになる。リフレクション定義ファイルが正しく含まれてたかどうかの確認は、jarファイルを解凍して META-INF/native-image/io/micronaut/
に reflection-config.json
と native-image.properties
があることを確認すると良い。
所感
リフレクション対象になるクラスの列挙はわりと面倒なので、案件の規模が大きくなるとちょっとしんどいかも。ルールを決めて運用すれば、何とかなるのかな。
② Unsupported method java.lang.ClassLoader.findLoadedClass(String) is reachable が発生
現象
Jacksonを使った処理で次のエラーが発生する。
com.oracle.svm.core.jdk.UnsupportedFeatureError: Unsupported method java.lang.ClassLoader.findLoadedClass(String) is reachable: The declaring class of this element has been substituted, but this element is not present in the substitution class
原因
jackson-module-afterburner というモジュールが、実行時に動的にバイトコードを生成しようとするが、GraalVMで作成したバイナリではバイトコード生成ができないためエラーが発生する。
対策
jackson-module-afterburner を依存から除外する。このモジュールを使っているつもりはなくとも、たとえば aws-serverless-java-container-core などがこのモジュールを利用しているため、次のように記述して依存から除外する。
implementation('com.amazonaws.serverless:aws-serverless-java-container-core:1.3.1') { exclude group: "com.fasterxml.jackson.module", module: "jackson-module-afterburner" }
所感
実行時にバイトコードをエンハンスするようなツールは、軒並み動かなそう。アノテーションプロセッサーで、コンパイル時にエンハンスするようにしなきゃいけないね。
③ java.lang.NoClassDefFoundError: org.apache.commons.logging.LogFactory が発生
現象
commons-logging を使ったライブラリの初期化時にエラーが発生する。たとえば aws-sdk-java などを使おうとするとこの問題が起きる。
Exception in thread "main" java.lang.NoClassDefFoundError: org.apache.commons.logging.LogFactory at org.apache.commons.logging.LogFactory.class$(LogFactory.java:1021) at org.apache.commons.logging.LogFactory.<clinit>(LogFactory.java:1674) at com.oracle.svm.core.hub.ClassInitializationInfo.invokeClassInitializer(ClassInitializationInfo.java:347) at com.oracle.svm.core.hub.ClassInitializationInfo.initialize(ClassInitializationInfo.java:267) at java.lang.Class.ensureInitialized(DynamicHub.java:437) at com.amazonaws.regions.AwsRegionProviderChain.<clinit>(AwsRegionProviderChain.java:33)
原因
きちんと原因を追っていないけど、slf4jやcommons-loggingのような実行時にロギングライブラリを選べるような仕組みは、なんとなくGraalVMと相性が良くなさそう。
回避策
commons-logging を使っているようなライブラリの利用を避ける。aws-sdk-javaの代わりに、micronaut-http-client を使うなど。
所感
--initialize-at-build-time
とか reflect.json
とかをきちんと設定すれば、commons-loggingを使えるようになるかも知れないのだけど、この辺りの挙動がまだ理解できてなくて、ちょっと先延ばしにしてる。
④ IOException: javax.imageio.IIOException: Can't create cache file! が発生
現象
ImageIO.read(in)
メソッドを呼び出して画像をロードしようとすると、エラーが発生する。
IOException: javax.imageio.IIOException: Can't create cache file! javax.imageio.IIOException: Can't create cache file! at javax.imageio.ImageIO.createImageInputStream(ImageIO.java:361) at javax.imageio.ImageIO.read(ImageIO.java:1397)
原因
ImageIOは、staticイニシャライザでキャッシュファイル(一時ファイル)のパスを環境変数から取得している。staticイニシャライザの処理はネイティブイメージのビルド時に行われて、実行時に環境変数が再評価されることはないない。そのため、ビルド時に決定したキャッシュファイルのパスが、実行時には利用できない、という問題が起きてしまう。
回避策
一時ファイルを使わない、つまりImageIOのキャッシュファイルを使わないようにする。自分の作ったFunctionに、次のようなコードを入れておく。
static { ImageIO.setUseCache(false); }
所感
ImageIOを --initialize-at-run-time
の対象にすれば、環境変数の参照を実行時に行えるようにできそうなのだけど、このオプションを指定したところ、他のクラスの初期化をコンパイル時に行うためか、エラーが起きてしまった。
ビルド時評価や、実行時評価の設定を、上手くできるようにならなきゃ。。。
⑤ java.lang.UnsatisfiedLinkError が発生
現象
ImageIOでJPEG画像を読み込もうとすると、次のようなエラーが発生する。
java.lang.UnsatisfiedLinkError: com.sun.imageio.plugins.jpeg.JPEGImageReader.initJPEGImageReader()J [symbol: Java_com_sun_imageio_plugins_jpeg_JPEGImageReader_initJPEGImageReader or Java_com_sun_imageio_plugins_jpeg_JPEGImageReader_initJPEGImageReader__]
原因
JPEGをロードするためのプラグインが、実行時には取得できないため。
回避策
いったん、JPEG画像を扱わないことにした。
所感
JPEGを扱わないわけにはいかないので、あとでまた向き合わなくてはいけない問題。
com.sunパッケージのライブラリはうまく扱えないのか? それとも、プラグイン構造のように、動的にクラスローディングを試みるような処理がいけないのか?
--initialize-at-build-time
とかが上手く使えるようになったら、また戻ってくる。
⑥ java.lang.Error: Could not find class: null が発生
現象
BufferedImage.createGraphics()
メソッドの呼び出し時に次のエラーが発生する。
Exception in thread "main" java.lang.Error: Could not find class: null at java.awt.GraphicsEnvironment.createGE(GraphicsEnvironment.java:117) at java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment(GraphicsEnvironment.java:82) at java.awt.image.BufferedImage.createGraphics(BufferedImage.java:1181)
原因
次のコードがnullを返すため。
AccessController.doPrivileged(new GetPropertyAction("java.awt.graphicsenv", null));
doPrivileged
はnativeメソッドなので詳細までは確認していないけど、動的なクラスローディングができない件と同じだと推測している。この辺はハマったまま、まだ回避策も解決策も見つけられていない。また進展があったらアップデートする。
全体的な所感とか
GraalVMで作ったネイティブイメージは、起動時間が早く、メモリの消費量も少ないので、その点は完全に期待通りです。AWS Lambdaみたいな、いわゆるサーバレスというか、ファンクション実行する環境との相性がとても良いという手応えがあります。
ただその一方で、Javaの世界で古来より用いられているテクニックである、リフレクションや、動的なクラスローディング、動的なバイトコードエンハンスメントなどが、問題を生んでしまっている面があります。この辺りはGraalVM自体の設定やバージョンアップで解決できる部分もあるでしょうけど、いくつかの問題は、ライブラリやフレームワークが実行時解決をするのではなく、コンパイル時解決できるようになっていかないと、根本解決しないのではないかと推測しています。
つまり、既存のライブラリやフレームワークなどが「GraalVM Ready」になっていくというムーブメントが起きるどうかが、GraalVMが使い物になっていくかどうかの分水嶺なのかなぁ、と思って注視しています。SpringもGraalVM対応することをロードマップに入れたりもしていますし、今後もぼちぼちウォッチしていきます。