結論から言うと、Micronaut アプリをAWS Lambdaで動かす場合には、GraalVM Native Image を用いてネイティブコンパイルしてカスタムランタイムで動かすのが、処理速度とコストの両方の面でおすすめです。
但し、 Native Image 化には制約(※後述)があるため、依存ライブラリが多い場合は特に注意が必要。ライブラリ1つ1つについて利用可否の確認や置き換えを検討する必要があるので、動作しているアプリケーションをあとから Native Image するのはそれなりに大変と思われます。もちろん規模に依ります。
Micronaut やその周辺のこと
Micronaut
Micronaut は JVM で動作するマイクロサービス向けのフレームワークです。2018年5月にOSSとして公開され、2020年6月にバージョン2.0がリリースされました。
マイクロサービス向けのフレームワークというと、Spring Boot がまず挙げられると思いますが、Micronaut は Spring Boot よりも軽量です。同様に軽量なマイクロサービスフレームワークとしては、 quarkus や Helidon がありますね。機会があれば quarkus や Helidon も触ってみようと思います。
GraalVM Native Image
Micronaut は GraalVM Native Image を用いたネイティブコンパイルをサポートしています。これにより、超高速起動するフットプリントの小さいアプリケーションを開発することが出来ます。
GraalVM Native Imageは良い点ばかり悪い点もあります。
まず、 GraalVM の Native Image 化には、制約があります。
https://github.com/oracle/graal/blob/master/substratevm/Limitations.md
- 動的なクラスロードが出来ない
- リフレクションを使えない
など。
また、ネイティブコンパイルには時間がかかります。私が Micronaut で試した時は5分以上かかりました。
その上、いざ実行してみるとランタイムエラーということも多々発生するので、アプリケーションのベースが出来てきて安定するまでは試行錯誤する可能性があります。
AWS Lambda
(AWS Lambda 自体の説明は割愛します。公式等を確認してください。)
Micronaut は Lambda もサポートしています。
API Gateway 経由でHTTPリクエストを処理する場合と、ALB (Application Load Balancer) 経由でHTTPリクエストを処理する場合の両方に対応しています。
Azure Functions や Google Cloud Functions にも Micronaut 2.0 から対応しているはずですが未調査です。
検証内容
Lambda のランタイムとして、Java11 (Corretto), Java8, カスタムランタイム (AmazonLinux2) を選択して動作させた場合の、それぞれの処理時間や使用メモリを確認します。
ALB からHTTPリクエストを受け取る想定で、Lambdaを実行 (Invoke) します。
検証に利用するツールやフレームワークのバージョンは以下の通りです。
Micronaut | 2.3.1 |
GraalVM | CE (Community Edition) 21.0.0 |
Java11 / Java8 ランタイム用Jarは、以下の公式ガイドを参考に行いました。
カスタムランタイム用Native Imageの作成は、以下の公式ガイドを参考に行いました。
検証結果
コールドスタート時の処理時間とメモリ使用量
処理時間
メモリ上限 | Java 11 (Corretto) | Java 8 | カスタムランタイム |
---|---|---|---|
128m | (Out of Memory) | (Out of Memory) | 846 ms |
256m | (Out of Memory) | (Out of Memory) | 510 ms |
512m | 5,488 ms | 5,518 ms | 436 ms |
1024m | 4,795 ms | 4,806 ms | 400 ms |
初回起動時に関して、カスタムランタイムのほうが圧倒的に処理時間が短いようです。
また、Lambdaのメモリ上限を増やすとvCPUが増えるという話があり、メモリが多いほうが処理時間は短くなっています。
メモリ使用量
メモリ上限 | Java 11 (Corretto) | Java 8 | カスタムランタイム |
---|---|---|---|
128m | (Out of Memory) | (Out of Memory) | 105 MB |
256m | (Out of Memory) | (Out of Memory) | 104 MB |
512m | 179 MB | 173 MB | 102 MB |
1024m | 182 MB | 174 MB | 103 MB |
Native Imageをカスタムランタイムで動かしたほうが、メモリ使用量も少ないようです。
Javaで実行した場合ですが、メモリ使用量が 180 MB程度のところ 256 MB分割り当てていたのですが、何故か Out Of Memory になりました。
ウォームスタート時の処理時間とメモリ使用量
処理時間
メモリ上限 | Java 11 (Corretto) | Java 8 | カスタムランタイム |
---|---|---|---|
128m | (確認不可) | (確認不可) | 41 ms |
256m | (確認不可) | (確認不可) | 9 ms |
512m | 7 ms | 6 ms | 5 ms |
1024m | 7 ms | 6 ms | 4 ms |
2回目以降については、配備済のコンテナを再利用するので処理時間が総じて短くなります。
JavaはJITコンパイルによって実行環境に最適化されるので、Native Image より高速に処理できているのだと思われます。
メモリ使用量
メモリ上限 | Java 11 (Corretto) | Java 8 | カスタムランタイム |
---|---|---|---|
128m | (確認不可) | (確認不可) | 105 MB |
256m | (確認不可) | (確認不可) | 104 MB |
512m | 180 MB | 173 MB | 105 MB |
1024m | 182 MB | 174 MB | 105 MB |
メモリ使用量については、初回起動時とほぼ一緒ですね。
まとめ
リアルタイム性が求められるWebアプリケーション等で、LambdaのランタイムとしてJavaを選択する場合は、コールドスタート対策が必須になると思われます。
コールドスタート対策としては、2019年末に発表された Provisioned Concurrency や 昔からある定期実行する方法が考えられます。Provisioned Concurrencyはそこそこ高いのと、どちらの方法も急なスパイクには弱いです。
例えば既存のコード資産がJavaであるといった理由等でJavaで開発したいのであれば、Native Image化してカスタムランタイムで動かしたほうが良いと思います。
ただ、JavaがNative Imageに対して必ず遅いのかというとそういうことではなくて、重いループ処理を行うケースでは実行環境に最適化して実行できるJVMのほうが早い場合もあると思います。