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 を作る技術の候補として選択して良いかも知れません。