[レポート] AWS LambdaとJavaのベストプラクティス #SVS403 #reinvent

この記事はSVS403 Best practices for AWS Lambda and Javaのセッションレポートです。 AWS Lambda上でのJavaプログラムのコールドスタート問題をJVMやLambdaの詳細な仕様に基づいて高速化していきます。
2019.12.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

SVS403 : Best practices for AWS Lambda and Java

はじめに

この記事はSVS403 Best practices for AWS Lambda and Javaのセッションレポートです。 スライドと動画はそれぞれ下記で公開されています。

セッション概要

In this session, we follow a customer’s journey as they optimize an AWS Lambda function written in Java to meet their cold start time requirements. We start from a simple yet slow PoC and walk through all of the changes, tricks, and trade-offs we made to reduce the cold start time by over 70%. Finally, we explore new technologies such as Quarkus and GraalVM that can make Java even faster in Lambda.

スピーカー

  • Stefano Buliani - Principal Business Development Manager, Amazon Web Services

シチュエーション

Java 8 で記述されたLambda関数のコールドスタートは遅い可能性がある。 しかし...Javaには多くのデベロッパーがいるので、Lambda上でもうまく動かしたい。

  • LambdaバックエンドのAPI
  • 応答時間のp99が24秒、p100は30秒以上
  • API Gateway はタイムアウトしてしまう

取り急ぎの対応

  • メモリ割り当てを増やすとタイムアウトしなくなる(256MB->512MB)
  • メモリ割り当てを増やすとCPUもスケールアップする
  • しかしコストも増加するので、別の方法を模索したい

目標

  • タイムアウトしないことを目標とする(実行時間30秒以内)
  • 10秒以下はボーナスチャレンジ
  • 正しい解決方法は何か?

計測

  • Gatlingで計測してパフォーマンスのベースラインを探ってみる
    • 応答時間の最小とp99は矛盾がない
    • コールドスタートを除いた標準偏差はσ101
    • しかしコールドスタートの場合はあまりにも遅い
  • XRayでプロファイルしてみる
    • ハンドラーとJavaランタイムの初期化の間に7秒間の空白時間がある
    • 二つのパッケージを作って比較
    • HelloWorldだけの関数: 87.4ms
    • HelloWorld + AWS SDK + DynamoDBクライアントの生成 : 6.4 sec

クラス読み込み

  • クラス読み込みが過分に行われているという仮説が立てられる
  • パッケージサイズは実行時間に影響しない
  • JVMはクラスを遅延読み込みし、大量のI/Oが発生する
  • 計測してみると4,130クラスが読み込まれている

Intermission コールドスタート時の挙動

次の3ステップ

  1. JVMの起動
  2. ハンドラーの読み込みと初期化: ここはホストのCPU上で実行される
  3. ハンドラーの実行: ここはスロットリングされたCPU上で実行される

AWS SDK for Java 2へ変更してみる

  • v2はフットプリントが小さく、モジュール性がある
  • HTTPクライアントを選択できる => 外部ライブラリをやめてURLConnectionへ変更してみる
    • 読み込み時間はHelloWorldは6.4 sec -> 4.7secへ改善
  • さらにAHCとNettyを依存から除外する 4.7 sec -> 4 secへ改善
  • 元のアプリでは23.6 sec -> 17.2 secへ改善

ハンドラ初期化時のCPUブーストを利用する

  • ハンドラーの時間の大部分がDynamoDB ClientをDIするGuiceの処理に費やしている
  • コンポーネントの事前設定をすることで、DI処理の時間を初期化処理へ移動したい
  • 次の3点の修正によって23 sec -> 12 secに改善
    • AWS SDK for Java 2.0へ変更
    • DIされるフィールドをstatic クラスメンバーへ変更する
    • AWS SDKの設定を明示的に行うことで自動設定のオーバーヘッドをなくす(リージョン、エンドポイント、クライアント、Credential provider)

リフレクションのオーバーヘッド

  • 再度プロファイルするとGuiceによるコンストラクタの検索が1,7k ms程度かかっている
  • リフレクションは次の2点のためにオーバーヘッドがあるのではないかという仮説
    • リフレクションによる動的な実行はJVMによる最適化がされない
    • クラスパスのスキャンはCPUとI/Oが制約された環境では良い選択肢ではない
  • Guiceからコンパイル時DIが行えるDaggerへ変更する
    • ハンドラの初期化処理は400msへ短縮
    • 予想外にDynamoDB PutItemの実行が8.9secかかっている
  • JacksonのMarshallerは初回のマーシャル時にリフレクションを使って遅延生成され、キャッシュされる
    • このMarhallerの生成がCPUがスロットリングされた状態で実行されている
    • static初期化処理内で失敗するDynamoDBへの書き込み処理をすることでプリウォームする
    • ハンドラーは9.3 sec -> 1 sec、全体では12 sec -> 5 secへ改善

ここまでまとめ: 高速化のためのtips

  • 必要なクラスを全て初期化処理内のでロードする
  • リフレクションを避ける
  • クラスロードではバイトコードのI/Oが発生するのでクラス数を少なくする
  • 依存ライブラリを整理する
  • 本当に必要な場合を除いて次の方法を避ける
    • コンストラクタパラメータ1つのクラスのためのビルダー
    • DIのためだけの依存性注入
    • 具象クラスが一つしかないインターフェースの定義

さらなるアプローチ

  • コールドスタートの問題はサーバーレスだけでなくコンテナにもある
  • コンテナ向けにいくつかの解決策がある
    • GaalVM: Java プログラムをネイティブバイナリへコンパイする
    • Quarkus/Micronaut: GaalVMでのネイティブコンパイルや、OpenJDKのHotSpotに最適化されたフルスタックフレームワーク
  • Quarkus、MicronautのどちらもSpringのアノテーションと互換性がある
  • Micronautで書き換えて、ネイティブへコンパイするするとコールドスタートで652msとなる サンプル
  • SpringはSpringBootのGaalVMでのネイティブイメージ化に取り組んでいる

最後に

JVMの挙動やLambdaの起動処理の詳細に基づいた高速化は非常に興味深い内容でした。 最後に紹介されたGraalVMやフレームワークも日々改善されているようなのでサーバーレスでの用途に限らず、新たにプロジェクトを始めるときに選択肢としてみる価値はあるかと思います。