Lambdaのメモリ割り当てが増えてもSnapStartのRestore Durationが変わらないことを確認してみた
リテールアプリ共創部@大阪の岩田です。
LambdaのSnapStartはFirecrackerのスナップショット機能を用いて実現されていますが、裏側の挙動としては単にスナップショット機能を利用するだけではなく様々な最適化が施されています。このブログではLambdaのメモリ割り当てを変更しながら、スナップショットのサイズが大きくなってもRestore Durationが大きくならないことを確認してみます。
Firecrackerのスナップショット
Firecrackerのスナップショット機能についてはGitHubのドキュメントに詳細が記載されています。このドキュメントの中ではスナップショットの構成要素として以下の2つのファイルが作成されることが記載されています。
- ゲストOSのメモリ
- 仮想ハードウェアの状態
In order to make restoring possible, Firecracker snapshots save the full state of the following resources:
- the guest memory,
- the emulated HW state (both KVM and Firecracker emulated HW).
firecracker/docs/snapshotting/snapshot-support.md at main · firecracker-microvm/firecracker
メモリのファイルはゲストOSのメモリサイズに比例して大きくなるものなので、メモリ割り当ての大きなMicroVMのスナップショット作成はメモリ割り当ての小さなMicroVMのスナップショット作成よりも遅くなるはずです。実際スナップショットのドキュメントには以下のように記載されており、メモリサイズがスナップショットの作成/再開のパフォーマンスに影響することが示唆されています。
The Firecracker snapshot create/resume performance depends on the memory size, vCPU count and emulated devices count. The Firecracker CI runs snapshot tests on all supported platforms.
firecracker/docs/snapshotting/snapshot-support.md at main · firecracker-microvm/firecracker
SnapStartの最適化
SnapStartの裏側でどのような最適化が施されているかは以下の動画で詳しく解説されています。
要点だけ抜粋すると以下の点がポイントです
- Dedicated Local Cache,Shared Worker-Local Cache, Shared AZ-Local Cacheという3階層のキャッシュを活用し、効率的にスナップショットファイルをMicroVMに展開する
- メモリの状態は512Kのチャンクごとに管理され、MicroVMにはオンデマンドでロードされる
- つまりメモリファイルの中身が全てMicroVM上に展開されるのを待たずしてMicroVMの実行が再開される
このような工夫が施されているため、Lambdaのメモリ割り当てを増やしてスナップショットファイルのサイズを大きくしてもスナップショットのリストアに要する時間はそれほど大きくならないはずです。いくつかのパターンでLambdaを実行して検証してみましょう。
メモリ割当128Mと10GでRestore Durationを比較してみる
まずシンプルに以下のコードをデプロイし、Lambdaのメモリ割当が128Mの場合と10Gの場合でどのような差が出るかを確認してみます。
package helloworld;
import java.util.HashMap;
import java.util.Map;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.crac.Core;
import org.crac.Resource;
public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>, Resource {
public App() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context) throws Exception {
System.out.println("チェックポイント前のフックポイント");
}
@Override
public void afterRestore(org.crac.Context<? extends Resource> context) {
System.out.println("リストア後のフックポイント");
}
public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
headers.put("X-Custom-Header", "application/json");
APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent()
.withHeaders(headers);
return response
.withStatusCode(200)
.withBody("hello");
}
}
LambdaをデプロイしたらAPI GWのバックエンドに設定してabコマンドで適当に並列実行した後、CloudWatch Logs Insightsから以下のクエリでRestore Duracionを集計してみます。
filter @type = "REPORT"
| parse @message /Restore Duration: (?<restoreDuration>.*?) ms/
| filter ispresent(restoreDuration)
| stats
count(*) as invocations,
min(restoreDuration) as min,
max(restoreDuration) as max,
avg(restoreDuration) as avg,
pct(restoreDuration, 50.0) as p50,
pct(restoreDuration, 90.0) as p90
結果は以下のようになりました
メモリ割り当て | コールドスタート回数 | 最小値 | 最大値 | 平均 | 中央値 | 90%タイル |
---|---|---|---|---|---|---|
128M | 93 | 345.5 | 1354.09 | 509.8408 | 487.01 | 600.81 |
10G | 65 | 166.07 | 690.42 | 486.4555 | 475.31 | 543.03 |
メモリ割当を増やしたからといってスナップショットをリストアする時間が長くなったということは無さそうです。
スナップショット取得前に割り当てた値にhandler内でアクセスしてDurationを比較してみる
せっかくなのでもう少し検証してみます。先程のコードは非常にシンプルな内容でしたが、今度はbeforeCheckpoint
のフックポイントでランダムな値を生成してプライベート変数にセットし、handler内の処理でプライベート変数にアクセスしてみます。実装は以下の通りです。
package helloworld;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.crac.Core;
import org.crac.Resource;
public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>, Resource {
private List<byte[]> binaryDataList;
public App() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context) {
System.out.println("チェックポイント前のフックポイント");
this.binaryDataList = new ArrayList<>();
SecureRandom random = new SecureRandom();
//メモリ割当128MBの時の条件
for (int i = 0; i < 10; i++) {
//メモリ割当10GBの時の条件
//for (int i = 0; i < 9000; i++) {
byte[] data = new byte[1024 * 1024];
random.nextBytes(data);
this.binaryDataList.add(data);
}
System.out.println("初期化完了");
}
@Override
public void afterRestore(org.crac.Context<? extends Resource> context) {
System.out.println("リストア後のフックポイント");
}
public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
headers.put("X-Custom-Header", "application/json");
APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent()
.withHeaders(headers);
Random random = new Random();
byte[] selectedData = this.binaryDataList.get(random.nextInt(binaryDataList.size()));
// 選択されたデータの最初の1KBを標準出力に出力
for (int i = 0; i < 1024; i++) {
System.out.print((selectedData[i] & 0xFF) + " ");
}
return response
.withStatusCode(200)
.withBody("hello");
}
}
handler内の処理の冒頭ではプライベート変数の中身はMicroVM内に展開されておらず、プライベート変数にアクセスした時点でLambda実行基盤のキャッシュからオンデマンドでロードされるのでは?という考察です。beforeCheckpoint
フックポイント内で以下のようにプライベート変数の中身を埋めます。
//メモリ割当128MBの時の条件
for (int i = 0; i < 10; i++) {
//メモリ割当10GBの時の条件
//for (int i = 0; i < 9000; i++) {
byte[] data = new byte[1024 * 1024];
random.nextBytes(data);
this.binaryDataList.add(data);
}
後ほどhandler
内の処理でこのプライベート変数にアクセスしますが、スナップショットファイルが管理するメモリの中身は512Kのチャンク単位なので、メモリ割り当てが10Gの場合であってもオンデマンドでロードするメモリの中身は必要最小限になり、メモリ割り当て128Mの時と比べてもhandler内の処理が遅くなることは無いと期待しています。
先ほどと同様にabコマンドで適当に何度かコールドスタートを発生させてから以下のクエリでコールドスタート発生時のDurationを集計してみます
※ウォームスタート時はスナップショットの中身をMicroVM内に読み込み済みのはずなのでコールドスタート時に限定しています
filter @type = "REPORT"
| filter @message like /Restore Duration/
| parse @message /Duration: (?<duration>.*?) ms/
| filter ispresent(duration)
| stats
count(*) as invocations,
min(duration) as min,
max(duration) as max,
avg(duration) as avg,
pct(duration, 50.0) as p50,
pct(duration, 90.0) as p90
結果は以下のようになりました
メモリ割り当て | コールドスタート回数 | 最小値 | 最大値 | 平均 | 中央値 | 90%タイル |
---|---|---|---|---|---|---|
128M | 95 | 992.37 | 1271.12 | 1155.2688 | 1156.56 | 1214.93 |
10G | 75 | 22.73 | 85.15 | 30.2371 | 28.73 | 85.15 |
メモリ128Mの時の方が圧倒的に遅いですね...vCPUのパワー不足の影響を受けていそうなので、vCPU1個分のフルパワーが利用できるメモリ1769MBの設定で計測するパターンも追加してみました。メモリ1769Mの場合は以下のコードでプライベート変数を設定しています。
for (int i = 0; i < 1300; i++) {
byte[] data = new byte[1024 * 1024];
random.nextBytes(data);
this.binaryDataList.add(data);
}
最終的な計測結果は以下の通りになりました
メモリ割り当て | コールドスタート回数 | 最小値 | 最大値 | 平均 | 中央値 | 90%タイル |
---|---|---|---|---|---|---|
128M | 95 | 992.37 | 1271.12 | 1155.2688 | 1156.56 | 1214.93 |
1769M | 70 | 70.96 | 195.93 | 85.8424 | 81.96 | 97.46 |
10G | 75 | 22.73 | 85.15 | 30.2371 | 28.73 | 85.15 |
メモリ割り当て10Gの設定が一番高速という結果に終わりました。メモリ割り当てを増やしたからといってスナップショットファイルをロードする時間が長くなるということは無さそうです。この辺りの動きをうまく最適化してくれているLambdaの基盤に感謝ですね。
まとめ
Lambdaのメモリ割り当てを増やしても...つまりスナップショットのメモリファイルのサイズを大きくしてもRestore Durationが大きくならないことを確認してみました。SnapStartを有効化したLambdaを利用する際のメモリサイズ検討の参考になれば幸いです。