AWS Lambda SnapStartがどの程度効果があるのか、実際のプロダクションで利用できる性能なのか、気になっていたのでAWS Lambda SnapStart をためしてみました。

AWS Lambda SnapStart は、Java で開発が進められている CRaC を AWS Lambda ランタイムに転用したモノです。
CRaC は、Javaのプロセスイメージをスナップショットとして取得し、再起動時にスナップショットからプロセスを復元することで、Java特有の起動の遅さを解決することを企図したプロジェクトです。

まずはSpring Cloud Function を使って AWS Lambda Function上で動作させる簡単なRest APIを用意します。

このようなパッケージ構成で、パラメータで受け取った値を大文字にして返却するRest APIを作りました。

├── java
│   └── com
│       └── example
│           └── sample
│               ├── SampleApplication.java
│               └── functions
│                   └── Uppercase.java
└── resources
    └── application.yaml

このコードをデプロイし、SnapStartを使わずに、このRest APIをcurlコマンドを使って速度を測ってみました。起動時間などを含めたパフォーマンスを計測するため、 -w オプションを付けています。

curl https://***.lambda-url.ap-northeast-1.on.aws/ -H "Content-Type: text/plain" -d "hello, spring cloud function!" \
  -w " - http_code: %{http_code}, time_total: %{time_total}\n"

上記結果は下記のようになりました。

"HELLO, SPRING CLOUD FUNCTION!" - http_code: 200, time_total: 5.306902

AWS Lambda 関数としてただしく動作していることがわかります。 トータル時間が 5秒 となっているのは、いわゆる Java のコールドスタート問題で、Spring の起動に時間がかかっているためです。 この直後にもういちど同じコマンドを実行すると、以下の様になります。

"HELLO, SPRING CLOUD FUNCTION!" - http_code: 200, time_total: 0.069804

すでに起動しているので、実行時間は 0.07秒 となっており、想定していた通りのレスポンス速度でした。では、次にSnapStartで動作させる設定をしてデプロイ後、実際に測定してみましょう。

AWS Lambda の設定

SnapStart を有効にするのは簡単で、コンソールでは関数の一般設定で、SnapStart の項目を None から PublishedVersions に変更することで有効となります。 AWS CLI では、以下の様なコマンドで有効にできます。

aws lambda update-function-configuration --function-name SpringCloudFunctionSample \
  --snap-start ApplyOn=PublishedVersions

「PublishedVersions」なので、公開されたバージョンに対して実行されることになります。 この設定をして以降は、バージョンを作成する度に、スナップショットが取得されるようになります。 バージョンを発行するには、コンソールの「バージョン」タブで新規に作成するか、AWS CLI では、以下の様なコマンドで実行できます。

aws lambda publish-version --function-name SpringCloudFunctionSample

バージョンを作成すると、スナップショットをとるのに数分を要します。 CloudWatchでログを見ると、INIT START して Spring が起動しているログが確認できる場合があります。 (ときどき出力されないときがありそこは今後調べたい)

INIT_START Runtime Version: java:11.v19    Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:*****
(中略)
:: Spring Boot ::               (v2.7.10)
(中略)

このバージョンに対して AWS Lambda Function URL を作成して、下記 curl コマンドを投げてみます。

curl https://*****.lambda-url.ap-northeast-1.on.aws/ -H "Content-Type: text/plain" -d "hello, spring cloud function!" \
  -w " - http_code: %{http_code}, time_total: %{time_total}\n"

結果は以下の様になりました。

"HELLO, SPRING CLOUD FUNCTION!" - http_code: 200, time_total: 1.452490

Snapstartを使わない時がtime_total: 5.306902 だったので、70% 程度高速化していることがわかります。 なお、CloudWatch のログを確認すると、以下の様に出力されています。

RESTORE_START Runtime Version: java:11.v19    Runtime Version ARN: arn:aws:lambda:ap-northeast-1::runtime:***
RESTORE_REPORT Restore Duration: 315.26 ms

スナップショットから 315.26 ms かけて復元したことが確認できます。 設定をちょっと変えただけで高速化したのスゴイですね!

ランタイムフックの設定

SnapStart によって AWS Lambda の起動が高速化されることが確認できました。 しかし、SnapStart はプロセスのスナップショットをとるという仕組み上、プロセスに状態を保持すると、その状態が使い回されてしまいます。例えば、以下の様な場合に考慮の必要がありそうです。

  • RDSとの接続状態
  • 認証情報
  • 処理で利用する一時データ
  • 一意性が必要なデータ

こうした問題に対処するために、ランタイムフックを利用して、復元時に実行する動作を設定出来るようになっています。 ランタイムフックの実装は、以下のように行います。

CRaCの依存性を build.gradle 追加

ext {
    set('springCloudVersion', "2021.0.6")
    set('awsLambdaCoreVersion', '1.2.2')
    set('awsLambdaEventsVersion', '3.11.1')
    set('cracVersion', '0.1.3') // 追加
}

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-function-webflux'
    implementation 'org.springframework.cloud:spring-cloud-function-adapter-aws'
    compileOnly "com.amazonaws:aws-lambda-java-core:${awsLambdaCoreVersion}"
    compileOnly "com.amazonaws:aws-lambda-java-events:${awsLambdaEventsVersion}"
    implementation "io.github.crac:org-crac:${cracVersion}" // 追加
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

関数クラスに、CRaC の Resource インターフェースを実装し、メソッドをオーバーライドしコンストラクタで関数クラスをコンテキストに登録

public class Uppercase implements Function<String, String>, Resource {

    private static final Logger logger = LoggerFactory.getLogger(Uppercase.class);

    public Uppercase() {
        Core.getGlobalContext().register(this); // CRaC のグローバルコンテキストに登録
    }

    @Override
    public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
        logger.info("Before checkpoint");
        // チェックポイント作成前の操作をここに書く
    }

    @Override
    public void afterRestore(Context<? extends Resource> context) throws Exception {
        logger.info("After restore");
        // スナップショットからの復元時の操作をここに書く
    }

    @Override
    public String apply(String s) {
        return s.toUpperCase();
    }
}

上記変更を行ってデプロイし、確認用の curl コマンドを投げると以下の様な結果となりました。

"HELLO, SPRING CLOUD FUNCTION!" - http_code: 200, time_total: 1.295677

コンテキストに追加する処理を書いたせいか、実行時間が短くなっていますね。誤差かもしれませんが。

なお、CloudWatchLogs でログを見ると、以下の様なログが出力されています。

2023-04-21 02:47:55.708  INFO 8 --- [           main] com.example.sample.functions.Uppercase   : After restore

ランタイムフックが正しく動作していることがわかります。

同時接続での速度確認

Java で AWS Lambda を実装する場合の問題は、同時接続が発生したときにコールドスタートが多発して速度が遅くなってしまうことでした。 そこで、SnapStart でその状況が改善されるかを確認するために、 Apache Bench を利用して、同時接続が発生した場合のパフォーマンスを測定してみました。

ab -n 100 -c 10

上記コマンドで、リクエスト 100 件を、並行数 10 で行ってくれます。イメージとしては、10ユーザーが同時に10リクエストを要求している感じです。 結果は、以下の通り。

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       27   71  33.4     68     156
Processing:    23  160 304.9     57    1286
Waiting:       23  156 305.6     52    1285
Total:         53  230 308.2    127    1362

Percentage of the requests served within a certain time (ms)
  50%    127
  66%    171
  75%    183
  80%    211
  90%    916
  95%   1118
  98%   1285
  99%   1362
 100%   1362 (longest request)

トータルでみると、最大が 1362 ミリ秒、最小が 53 ミリ秒。 この最大値が許容できれば、REST API として利用するのもアリかもしれません。

まとめ

SnapStart を利用することで、Java で実装した AWS Lambda が劇的に高速化することがわかりました。 SpringBoot アプリケーションでも問題無く利用できることがスゴイですね!

気をつけなければいけない点として現時点では、AWSのドキュメントに記載があるように以下のような制限があります。

  • ランタイムとして Java11とJava17を利用可能
  • Graviton2 が使えない
  • Amazon EFSが使えない
  • エフェメラルストレージが512MB固定

これらを許容できて、かつ、スナップショットからの復元時間が許容できるものであれば、REST API を作る技術の候補として選択して良いかも知れません。