JVM ベースの言語で AWS Lambda を使うために

# はじめに
最近は組織体制考えたり、技術負債をどう解消していくかを考えるのが仕事になってますが、ちょっと前までバックエンドの開発がメインでした。
特に Java ランタイムの AWS Lambda(以降、Lambda) のコールドスタート改善、性能改善、コスト改善にお熱だったので、その内容を簡単に紹介します。詳細な手順までは説明しませんが、参考になるドキュメントを一緒に載せておくのでよかったら参考にしてください。

# 紹介する内容
* 割り当てメモリの増強
* プロビジョニングされた同時実行の利用
* JAVA_TOOL_OPTIONS の利用
* SnapStartの利用
* カスタムランタイム化

# 割り当てメモリの増強
こちらは説明するまでもないかもしれないですが、性能の観点で一番最初に気にする部分です。
Lambda は割り当てメモリが増強されるとそれに比例して、CPUの性能も上がります。
> Lambda は、設定されたメモリの量に比例して CPU パワーを割り当てます。

引用: [Lambda 関数のメモリを設定](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-memory.html)

理論上は上げればあげるほど、処理速度が上がりますが一定のラインで大体コスト効率が悪くなったりします。また、処理の内容ではなく外部との接続等が実行時間の多くを占める場合はあまり効果がないこともあり、適切なメモリサイズを割り当てることが重要です。
自分で算出するのも良いですが、便利なツールがあるので紹介します。

- [AWS Lambda Power Tuning](https://github.com/alexcasalboni/aws-lambda-power-tuning)

こちらを使うと、複数の指定した割り当てメモリで実際にLambdaを実行し、その実行時間と割り当てメモリの関係を可視化してくれます。
これを使うことで、一番コスト効率のよいメモリサイズがすぐにわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949333/ed174c70-5293-c2ea-8ca7-944f01304e0e.png)
引用: https://github.com/alexcasalboni/aws-lambda-power-tuning

またデプロイも[Serverless Application Repository](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:451282441545:applications~aws-lambda-power-tuning)から簡単に行うことができるので、ぜひ利用してみてください。

# プロビジョニングされた同時実行の利用
こちらも言わずと知れた機能ですね。
あらかじめ、決められた数のLambdaをウォームスタンバイの状態にしておくことで、呼び出しがあったときにコールドスタートのレイテンシーを削減できる機能です。
こちらを利用することで、コールドスタートにおけるレイテンシーの問題はある程度解決できますが、基本的な性能は特に変わりません。
また利用する際には、呼び出しの量を予測もしておかないと、設定している以上の同時呼び出しがあった場合は、コールドスタートが発生してしまいます。
多く確保しすぎても、多くのコストがかかることとなってしまいます。
コールドスタートを避けたい場合は、適切な量を確保して設定するようにしましょう。

参考: [関数に対するプロビジョニングされた同時実行数の設定](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/provisioned-concurrency.html)

# JAVA_TOOL_OPTIONS の利用
こちらは Java に詳しい方なら知っている内容かもしれません。
Lambda の環境変数に以下を設定することで階層化コンパイル設定をカスタマイズすることができます。
`JAVA_TOOL_OPTIONS: -XX:+TieredCompilation -XX:TieredStopAtLevel=1`
Java 実行時のコンパイルのレベルを指定することで処理速度を上げるものとなります。
仕組みを書くと長くなっちゃうので、以下のドキュメントを参照してください。
ちなみに Java17のランタイムではデフォルトで上記のオプションを指定した状態になっています。
参考: [Lambda 関数の Java ランタイムの起動時の動作をカスタマイズする](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-customization.html)

# SnapStartの利用
こちらは何度が処理を実行した状態を保存しておいて、呼び出された時にその状態を復元して実行することでコールドスタートの改善を図ることができるものです。
初期化の際の諸々を終えた状態から復元することで実現されています。
最近、 Python や .NET のランタイムでも使えるようになりましたが、Java ランタイムでは**無料**で利用できます！
（2024年1月6日：誤りを修正しました。"Node" -> ".NET" @nimzo6689さん、ご指摘ありがとうございました！）
ただ、自分は理解が浅くこの機能を利用するだけだと、逆に実行時間が伸びてしまいました。
スナップショットを作成する際に、利用するクラスのロードが完了してなかったことが原因だと考えてます。
実際にスナップショットを作るときにダミーのリクエストとして受け付けて、アプリの処理が走るようにしてみたら、実行時間も改善されていました。

参考: [Lambda SnapStart による起動パフォーマンスの向上](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/snapstart.html)

# カスタムランタイム化
最終行き着いたのがここでした。
カスタムランタイムでは Amazon Linux 2 か Amazon Linux 2023 上で実行可能なファイルを作成しアップロードすることで、どのような言語でも Lambda が利用可能になります。
そして Java でも AOT コンパイラを利用することで、上記の環境で実行可能なファイルを作成することができます。
ただし、マネージドなランタイム（Java, Python, Nodeなど）を利用している場合には Lambda サービス側がやってくれていた処理もランタイム API を使って自前で実装する必要があります。
また Java のコードを AOT コンパイルするのも色々な制約があって中々実装の難易度は高いです。
その代わりに得られる効果としては、絶大なものでした。
自分が別で書いたブログの引用になりますが、 Java ランタイムと比較です。
|指標	|Javaランタイム	|カスタムランタイム|
|---|---|---|
|初回起動時実行時間	|5000ms	|800ms|
|平均実行時間	|140ms	|45ms|
|割り当てメモリ	|1536MB	|256MB|
|消費メモリ	|190MB	|95MB|

割り当てメモリは 1 / 6 になっているのにも関わらず、平均実行時間は　1 / 3 以下という結果でした。
割り当てメモリと実行時間はコストにダイレクトに効いてくる部分となるので、この効果は実装の苦労をしてでもやる価値はあるかなと思いました。

参考: 
[AWS Lambda 用カスタムランタイムの構築](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html)
[カスタムランタイムに Lambda ランタイム API を使用する](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-api.html#runtimes-api-next)
[ネイティブ・イメージ](https://docs.oracle.com/cd/F44923_01/enterprise/21/docs/reference-manual/native-image/)

# 最後に
Lambdaは確かにシンプルで手軽に利用できるサービスですが、Javaランタイムではコールドスタートの影響が大きかったり、必要なメモリ量が多かったりと、中々課題が多かったりします。
しかし、現実には JVM ベースの言語を利用している開発者は多いのではないでしょうか？
そういった状況で、 Lambda を利用する際に別の言語を使うのも一つの選択肢ではありますが、本記事で紹介したように JVM ベースの言語でも意外と性能改善の余地はあります。
JVM 言語でも Lambda が十分に活用できるということを認識してもらえたら幸いです。