エムオーテックス株式会社が運営するテックブログです。

AWS Lambda カスタムランタイムを使って性能改善&コスト削減

AWS Lambda カスタムランタイムを使って性能改善&コスト削減

はじめに

こんにちは、アプリケーションチームの桑名です。

LANSCOPE エンドポイントマネージャー クラウド版のバックエンドは、開発言語として主に Scala を使用しています。
またサーバレスアーキテクチャで構成している部分が多いので、必然的によく AWS Lambda (以下 Lambda) を使います。
Scala はビルドを通じて JAR ファイルに変換できます。それを Lambda の Java ランタイムで動かしています。
今回は月に10億回以上呼ばれる Lambda があり、性能改善兼コスト削減としてカスタムランタイムに移行してみたので、その紹介と効果についてお話しします。

Lambda と Java ランタイムのパフォーマンス問題

Java ランタイムは他のランタイムと比べて、以下のような特徴があります。
・メモリ消費量が多い
・クラスロードが多く時間がかかる
・実行環境の起動時間が長い

これは Lambda という実行環境においては欠点になります。Lambdaはメモリサイズと実行時間に比例してコストが上昇します。
Java ランタイムでパフォーマンスを出すには、Lambda のメモリサイズを上げること(CPUの性能も比例して上がる)や、プロビジョニングされた同時実行を利用することが挙げられますが、どちらもコストが上昇します。
この問題を解決するために、GraalVMでネイティブバイナリ化し、それをカスタムランタイムで実行することで、起動時間の短縮とメモリ使用量の削減を実現しました。

カスタムランタイムとは

Lambda にはAWSが提供する標準のランタイム(Node.js, Java, Pythonなど)とカスタムランタイムがあります。
カスタムランタイムとは、Lambda 関数の実行環境をユーザー側で用意するものです。Lambda 関数が呼び出されると、カスタムランタイムが実行され、そのランタイム内で関数のハンドラが実行されます。

カスタムランタイムには、初期化タスクと処理タスクの2つの主要な役割があります。

初期化タスク

・設定の取得
・関数の初期化
・エラー処理など

処理タスク

・イベントの取得
・トレースヘッダーの伝播
・コンテキストオブジェクトの作成
・関数ハンドラの呼び出し
・レスポンスの処理
・エラー処理
・クリーンアップなど

docs.aws.amazon.com

GraalVMを使ったネイティブコンパイル

GraalVM は、Oracle 社が開発した多言語ランタイムです。GraalVM には、ahead-of-time(AOT)コンパイラが組み込まれており、Java や Scala のコードをネイティブバイナリにすることができます。
ネイティブバイナリは、JVMを介さずに直接実行できるため、起動時間が大幅に短縮されます。また実行中にコンパイルする必要がないため、メモリ使用量も少なくなります。

今回の取り組み

普段は関数ハンドラが呼び出された際に実行したい処理だけを作りますが、カスタムランタイムに必要なタスクも含めて Scala で実装しました。
それを GraalVM を使って一つのネイティブバイナリにコンパイルし、カスタムランタイムとして動くようにしました。実装自体はドキュメントを読みながら進められるので、ここではスムーズに開発を進めるためのポイントを2つ紹介します。

可能な限り標準ライブラリを利用する

GraalVM でのネイティブバイナリの生成において、ビルドの設定は通常の JVM 向けのビルドと比べると複雑です。その上でリフレクションやダイナミックプロキシのような動的な機能を使うとさらに設定は複雑になります。そのため、可能な限り標準ライブラリを使うのが良いでしょう。
とはいえ全くライブラリを使わずに実装とはいかないので、必要なものは使った上で、状況に応じて reflect-config.json や proxy-config.json を設定してください。

HTTPリクエストにタイムアウトを設定しない

ランタイムの処理タスクでは HTTP リクエスト経由で実行するイベントを取得しますが、通信のタイムアウトが頻繁に発生しました。長めの時間を取っても発生するため、ハマってしまいました。
カスタムランタイムのドキュメントをよく読めば解決策が書いてあるのですが、やることとしてはシンプルでタイムアウトを設定しないことです。

docs.aws.amazon.com

応答が遅れる可能性があるため、GET リクエストにタイムアウトを設定しないでください。Lambda がランタイムをブートストラップするときと、返すイベントがランタイムにあるときとの間に、ランタイムプロセスが数秒間停止する可能性があります。

普段AWSに任せているランタイムの処理の実装や、出来上がったコードをネイティブバイナリにコンパイルするためのビルドの設定等、なかなかに学習コストは高かったです。そんな中でも、ドキュメントを読みながら試行錯誤してなんとか実装できました。そして、そんな苦労をしてでも実装したメリットはありました。

実際の効果

Java ランタイムとの比較
指標 Javaランタイム カスタムランタイム
初回起動時実行時間 5000ms 800ms
⭐️平均実行時間 140ms 45ms
⭐️割り当てメモリ 1536MB 256MB
消費メモリ 190MB 95MB

Lambda のコストは主に実行時間と設定しているメモリでの従量課金になります。つまり平均実行時間と割り当てメモリを減らせたことはコスト削減に直結します。
実際に計算してみると、この Lambda だけで年間で$30,000以上のコスト削減ができました。
性能についても、初回起動に至っては約6倍の改善となりました。

おわりに

Lambda のようなライフサイクルが短い動作環境では、起動速度の観点から JVM ベースの言語は避けられがちですが、工夫次第で他と遜色ないコストで性能を発揮できます。
通常の開発と比べると苦労する点は出てきますが、それを踏まえても十分行う価値のある対応かなと思います。

コスト削減や性能向上という観点において Lambda は、JVM ではない言語で実装するというのも選択肢の一つにはなると思います。
とはいえチーム内の学習状況等も鑑みると、それはそれでハードルが高かったりするので、継続してScalaを使う上で一つのいい実績が出てよかったです。今後は、他のLambda関数にも同様のアプローチを適用し、さらなるコスト削減と性能向上をを目指します。