[Typescript] Lambda SnapStart がコンテナイメージに対応したので試してみた [Rust]

[Typescript] Lambda SnapStart がコンテナイメージに対応したので試してみた [Rust]

[AI要約] AWS Lambda SnapStart がコンテナイメージに対応。Rust・Node.js での実測結果から、Init 2秒以上で 68から90%以上 の cold start 削減を確認
2026.09.04

Introduction

2026年9月、AWS LambdaのSnapStartがコンテナイメージでパッケージした関数に対応しました
いままでSnapStartはJava / Python / .NETのマネージドランタイム専用でしたが、
コンテナイメージであればNode.jsやRust(provided.al2023)の関数でも使えるようになっています。

provided.al2023は、OSとしてAmazon Linux 2023だけを用意し、
言語ランタイムは含まないOS-onlyランタイムです。
RustやGoのようにネイティブバイナリにコンパイルする言語向けで、
Runtime Interface Clientを組み込んだbootstrapという実行ファイルを置いて使います。
コンテナの場合はpublic.ecr.aws/lambda/provided:al2023がそのベースイメージです。

Rustはネイティブバイナリで元々cold startが速いからSnapStart不要では?
と思いましたが、とりあえずやってみます。
また、TypeScriptでもよくLambdaを書くので試します。

  • 最小のHello World関数(Rust / TypeScript)でSnapStart on/offを比較
  • 初期化を意図的に重くした関数(数秒、数十秒)で同じ比較

なお、この記事で「Init」と書いているのは、Lambdaが実行環境を用意した後にランタイムを起動し、
ハンドラの外にある初期化コード(モジュールのロードやmainの前処理)を実行する
Initフェーズのことです。

※AWSはこの初期化をcold startの最大の要因と説明
※今回のコンテナ関数ではInit以外の環境準備にも数百ミリ秒かかってました

通常の関数では、新しい実行環境で最初に処理した呼び出しのREPORT行にInit Durationとして記録されます。
※Initが10秒を超えて再実行された場合を除く

結論:
今回の条件では、SnapStartでcold startが速くなるかどうかは、
Init処理の長さで差が出ました。
Hello WorldレベルのコードではSnapStart版の方が遅いですが、
Initが約2.1秒以上のケースでは、RustでもTypeScriptでも
cold start(client側E2Eのp50)が1秒未満に短縮されました。

About Snap Start

仕組み

通常のLambdaはリクエストが来てから実行環境を作り、ランタイムとコードを初期化します。
SnapStartを有効にすると、この初期化をバージョン発行した時点で実行し、
初期化済みの実行環境(Firecracker microVM)のメモリとディスク状態を
スナップショットとして暗号化・キャッシュします。
以降、初回呼び出しやスケールアウト時には初期化をやり直す代わりにスナップショットから復元します

対応範囲と制約

2026年9月時点の公式ドキュメント(SnapStartの概要コンテナイメージ向けフックのページ)では、次のように整理されています。

デプロイ形式 ランタイム SnapStart
ZIP / コンテナ Java 11+、Python 3.12+、.NET 8+(マネージドランタイムと対応ベースイメージ) 対応。Lambdaがライフサイクルを管理
ZIP Node.js、Rubyなどその他のマネージドランタイム、OS-onlyランタイム 非対応
コンテナ provided.al2023 / Node.js / Rubyのベースイメージ、独自ベースイメージ、独自RIC 対応。ただしコンテナ用のライフサイクル契約に従う必要がある

コンテナ用のライフサイクル契約は、
初期化完了時にAWS_LAMBDA_INITIALIZATION_TYPE環境変数が
snap-startならbefore-snapshotフックを実行してRuntime APIの
GET /runtime/restore/nextを呼び、復元後(この呼び出しがHTTP 200で返った後)に
after-restoreフックを実行してから通常のinvokeループに入るというものです。

フックが不要ならDockerfileに次のラベルを付けるだけでopt-in可能

LABEL com.amazonaws.lambda.feature.snapstart="Allow"

/restore/nextの実装もラベルも無い場合はバージョン発行が失敗します。
またInitとbefore-snapshotフックの合計にはmax(関数タイムアウト, 130秒)制限があります

そのほかの主な制約は以下。

  • 発行済みバージョン(またはそれを指すエイリアス)でのみ有効。$LATESTでは使えない
  • Provisioned Concurrency、Amazon EFS、Amazon S3 Files、512 MBを超えるエフェメラルストレージとは併用できない
  • 初期化時に生成した一意な値(ID、シークレット、疑似乱数生成器の状態など)は、復元された複数の環境で同じになる可能性がある。また、初期化時に確立したネットワーク接続は復元後の状態が保証されないため、必要に応じて再接続する

ログと課金

SnapStart関数ではInitは発行時に行われるため、呼び出し時のREPORTInit Durationは出ません。
代わりに発行時のINIT_REPORTにInit時間が記録され、
新しい実行環境が作られた呼び出しのREPORTには
Restore DurationBilled Restore Durationが付きます。
cold startの時間はRestore Duration + Duration定義されています

料金はJavaマネージドランタイム以外では、
発行済みバージョンごとのスナップショットキャッシュ(バージョンが有効な間、最低3時間)と、
復元のたびに料金がかかります。いずれも関数のメモリ割り当てが基準で、
例えば米国東部リージョン(us-east-1)ではキャッシュが
0.0000015046 USD/GB秒、復元が0.0001397998 USD/GBでした。

※2026/09/04時点
※東京リージョンも同額
※usagetype APN1-Lambda-SnapStart-Cached-GB-S / APN1-Lambda-SnapStart-Restored-GB

なお料金ページにコンテナイメージを名指しした記述はなく、
「Javaマネージドランタイム以外は課金」という文言から、
コンテナのRust / Node.jsも課金対象と思われます。

どんな関数に効果的か

AWSのブログでは

  • 初期化が数百ミリ秒で終わる関数では、SnapStartによる大きな性能改善は見込めない
  • その場合はProvisioned Concurrencyを推奨する

とされています。
また、スナップショットは512KBのチャンクに分割して保存され、
関数が大きいほどチャンク数が増えて復元性能が変わるとも書かれています。

RustとNode.jsの対応状況

AWS公式のRustランタイムlambda_runtime1.3.0SnapStartResourceトレイトによるSnapStart対応が入っています。
今回使った1.4.0でもAWS_LAMBDA_INITIALIZATION_TYPE == "snap-start"
を判定してbefore_snapshot → /restore/next → after_restoreを実行するコードを確認できます。
つまりRustは「フックが必要な場合」の実装をランタイムが肩代わりしてくれるので、ラベルなしで動くはずです。

一方、今回使用したNode.js 22ベースイメージ(public.ecr.aws/lambda/nodejs:22、runtime-release 22.23.2-6a8edcbc)の
Runtime Interface Clientをgrepしてもrestore/nextの実装は見つかりませんでした。
そのためNode.jsはラベル方式でopt-inしました(将来変わる可能性があります)。

Environment

項目
リージョン ap-northeast-1(東京)
Lambda コンテナイメージ / x86_64 / 512 MB
Rust rustc 1.95.0、lambda_runtime 1.4.0、tokio、serde_json。cargo lambda build --release --x86-64(cargo-lambda 1.9.1)でクロスビルド
Rustベースイメージ public.ecr.aws/lambda/provided:al2023(イメージ43.6 MB)
TypeScript Node.js 22ベースイメージpublic.ecr.aws/lambda/nodejs:22(イメージ138.1 MB)、esbuildでバンドル
ツール AWS CLI 2.34.50、boto3 1.43.83
計測クライアント 日本国内からboto3でInvoke。warm呼び出しの往復は約70 ms

SnapStartのoffとonは同じイメージから作った別関数で比較しています。
-offApplyOn=None-onApplyOn=PublishedVersions

Setup

Rust

Cargo.tomlの依存はlambda_runtime = "1.4.0"serde_jsontokioだけです。
SnapStartのライフサイクルが本当に動くことを確認するため、ログを出すだけのフックを登録しています。
※この記事のコードはいずれも説明用に簡略化した抜粋版

use lambda_runtime::{BoxFuture, Error, LambdaEvent, Runtime, SnapStartResource, service_fn};

struct LifecycleLogger;

impl SnapStartResource for LifecycleLogger {
    fn before_snapshot(&self) -> BoxFuture<'_, Result<(), Error>> {
        Box::pin(async { println!("[snapstart] before_snapshot"); Ok(()) })
    }
    fn after_restore(&self) -> BoxFuture<'_, Result<(), Error>> {
        Box::pin(async { println!("[snapstart] after_restore"); Ok(()) })
    }
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    let init_type = std::env::var("AWS_LAMBDA_INITIALIZATION_TYPE").unwrap_or_default();
    println!("[init] AWS_LAMBDA_INITIALIZATION_TYPE={init_type}");

    Runtime::new(service_fn(|event: LambdaEvent<serde_json::Value>| async move {
        Ok::<_, Error>(serde_json::json!({ "request_id": event.context.request_id }))
    }))
    .register_snapstart_resource(std::sync::Arc::new(LifecycleLogger))
    .run()
    .await
}

Dockerfileはビルド済みバイナリを置くだけで、ラベルは付けていません。

FROM public.ecr.aws/lambda/provided:al2023
COPY target/lambda/bootstrap/bootstrap ${LAMBDA_RUNTIME_DIR}/bootstrap
ENTRYPOINT ["/var/runtime/bootstrap"]

TypeScript

モジュールスコープ(Initフェーズ)で初期化種別を記録し、
ハンドラはそれを返すだけです。

import type { Handler } from "aws-lambda";

const initType = process.env.AWS_LAMBDA_INITIALIZATION_TYPE ?? "unknown";
console.log(`[init] AWS_LAMBDA_INITIALIZATION_TYPE=${initType}`);

export const handler: Handler = async (event, context) => ({
  statusCode: 200,
  body: JSON.stringify({ initType, requestId: context.awsRequestId }),
});

esbuildでdist/index.jsにバンドルし、Node.jsベースイメージにラベル付きで載せます。

FROM public.ecr.aws/lambda/nodejs:22
LABEL com.amazonaws.lambda.feature.snapstart="Allow"
COPY dist/index.js ${LAMBDA_TASK_ROOT}/
CMD ["index.handler"]

デプロイ

ECRにpushした後、SnapStart版は--snap-start ApplyOn=PublishedVersionsで作成し、
バージョンを発行してState=Activeになるのを待ちます。

% aws lambda create-function \
  --function-name snapstart-bench-rust-on \
  --package-type Image --code "ImageUri=${ECR_URI}@${DIGEST}" \
  --role "$ROLE_ARN" --architectures x86_64 \
  --memory-size 512 --timeout 10 \
  --snap-start ApplyOn=PublishedVersions

% VERSION=$(aws lambda publish-version --function-name snapstart-bench-rust-on \
  --query Version --output text)

% aws lambda wait function-active-v2 --function-name snapstart-bench-rust-on --qualifier "$VERSION"

% aws lambda get-function-configuration --function-name snapstart-bench-rust-on \
  --qualifier "$VERSION" --query '{State:State,SnapStart:SnapStart}'
# => State: Active, SnapStart.OptimizationStatus: On

% aws lambda create-alias --function-name snapstart-bench-rust-on --name live --function-version "$VERSION"

ラベルなしのRustイメージでもバージョン発行は成功し、
OptimizationStatusOnになりました。
発行からActiveまでの所要時間は、SnapStart offが1〜4秒、
SnapStart onが50〜65秒(初期化が重い関数では最大125秒)でした。
スナップショットはバージョンを発行するたびに作り直されるので、
デプロイのたびに毎回この時間がかかります。(CI/CDなどは要注意)

Try

計測方法

cold startを測るには、逐次呼び出しでは2回目以降がwarmになってしまうので、
毎回新しい実行環境を作ります。
今回は1関数あたり24 → 48 → 72並列の3ラウンド(計144リクエスト)をboto3で実施し、
LogType=Tailで返るREPORT行から以下を取り出しました。

  • 通常cold: Init Duration
  • SnapStart cold: Restore DurationBilled Restore Duration
  • 併せてclient側の往復時間(E2E)

以下の点に注意。記事の数値はいずれもこれらを考慮したものです。

  1. Init Durationはコンテナ関数のcold start全体を表さない。microVMの準備やイメージ取得は含まれず、
    client E2Eとの差がRustで約480 ms、TypeScriptで約240 msでした。
    一方Restore Durationは「microVM外の処理も含む」と公式に書かれており
    実際に差は約150〜175 msでした。

  2. Init Duration / Restore Durationは事前初期化(proactive initialization)された環境でも出ます。
    Lambdaはon-demandでも呼び出しに先立って環境を初期化することがあるので
    その場合の呼び出しはREPORT上はcoldでもclientからはwarm同然(約90 ms)です。
    client時間がserver時間より短い行は除外し、発行直後の最初のバースト(24並列)のみを
    cold startの数値に使っています。

  3. Initが10秒を超えるとREPORTにInit Durationが出ません。
    on-demandのInitフェーズは10秒で打ち切られ、初回呼び出しの中でやり直されます
    この場合のcoldはDurationに現れるので、Duration > 5秒の行をcoldとして扱いました。

CSVの全行をCloudWatch LogsのREPORT行とRequestIdでチェックし、
計測前に呼び出しが無かったこと&cold判定が一致することを確認しています。

初期化を重くした関数

「Initが数百msなら効かない」という公式の説明を確かめるため、
同じ構成で初期化を重くした関数も用意しました。
イメージに同梱した辞書データ(JSONL、100万行・181 MB、乱数シード固定で生成)をInitで読み込み、
テスト用データを構築します。
状態は読み取り専用で決定的なので、スナップショットに含めて問題ないものです。
ハンドラはリクエストIDから決めた200件をランダムに参照します。

// Init(main の先頭): 辞書を読み込んで 3 つの索引を作る
let limit: usize = std::env::var("DICT_LIMIT").ok().and_then(|v| v.parse().ok()).unwrap_or(500_000);
let dict = load_dictionary("/var/task/dictionary.jsonl", limit)?;

fn load_dictionary(path: &str, limit: usize) -> Result<Dictionary, Error> {
    let reader = BufReader::with_capacity(1 << 20, File::open(path)?);
    let mut entries: Vec<Entry> = Vec::with_capacity(limit);
    for line in reader.lines().take(limit) {
        entries.push(serde_json::from_str(&line?)?);
    }
    let mut by_word = HashMap::with_capacity(entries.len());
    let mut trigrams: HashMap<[u8; 3], Vec<u32>> = HashMap::new();
    for e in &entries {
        by_word.insert(e.word.clone(), e.id);
        for w in e.word.as_bytes().windows(3) {
            trigrams.entry([w[0], w[1], w[2]]).or_default().push(e.id);
        }
    }
    let by_id: HashMap<u32, Entry> = entries.into_iter().map(|e| (e.id, e)).collect();
    Ok(Dictionary { by_id, by_word, trigrams })
}

TypeScriptも同じワークロードをESM + top-level awaitで実装。

const dict = await loadDictionary("/var/task/dictionary.jsonl", limit);

// モジュールスコープ = Init
async function loadDictionary(path: string, limit: number): Promise<Dictionary> {
  const entries: Entry[] = [];
  const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
  for await (const line of rl) {
    entries.push(JSON.parse(line));
    if (entries.length >= limit) break;
  }
  const byId = new Map<number, Entry>(), byWord = new Map<string, number>(), trigrams = new Map<string, number[]>();
  for (const e of entries) {
    byId.set(e.id, e); byWord.set(e.word, e.id);
    for (let i = 0; i + 3 <= e.word.length; i++) {
      const k = e.word.slice(i, i + 3);
      (trigrams.get(k) ?? trigrams.set(k, []).get(k)!).push(e.id);
    }
  }
  return { byId, byWord, trigrams };
}

Initの重さは環境変数で変えられるようにし、各言語で3種類を作りました。

変種 内容 Rust TypeScript
A: 数秒 辞書を読み込んでインデックス構築 50万行(使用メモリ327 MB) 15万行(使用メモリ178 MB)※
B: 数十秒級 AをINIT_PASSES回繰り返す(前の結果を消してから再ロードするのでメモリは増えない) 7回(発行時Init 39.5秒) 15回(発行時Init 66.8秒)
C: sleep 30秒 データを読まずsleep(30 s)だけ。時間だけ長くメモリは小さい 使用メモリ2.4 MB 使用メモリ51 MB
  • 使用メモリは、関数のプロセスが実際に使っている物理メモリ量(RSS: Resident Set Size)です。Initの最後に関数自身が読んだ値(Rustは/proc/self/statusVmRSS、Node.jsはprocess.memoryUsage().rss)で、スナップショットに含まれるメモリ量の目安にしています。LambdaのREPORT行にあるMax Memory Used(実行環境全体のピーク値)とは別の値です
  • Node.jsで50万行を読むと使用メモリが約470 MBになり(ローカル計測)512 MBに収まらないため、行数を減らしています
  • RustとTypeScriptで行数が違うので、各言語でのoff / onの差を見てください

BとCのon-demand版はInitが10秒を超えるため、関数タイムアウトを90秒に変更(Aは60秒)。

Verification Results

SnapStartの動作確認

RustのSnapStart版のCloudWatch Logsです。
※ログ形式の確認のため最初にus-east-1で発行した関数のもので、数値は東京の計測とは別

発行時にInitとフック、初回呼び出し時に復元とフックが記録され、
REPORTにはRestore Durationが付いています。

[init] AWS_LAMBDA_INITIALIZATION_TYPE=snap-start at=1788402754940
[snapstart] before_snapshot at=1788402754941
INIT_REPORT Init Duration: 177.09 ms
[snapstart] after_restore at=1788402924927          ← 170 秒後、初回 invoke 時
RESTORE_REPORT Restore Duration: 372.48 ms
START RequestId: de245e74-... Version: 1
REPORT RequestId: de245e74-...	Duration: 1.54 ms	Billed Duration: 2 ms	Memory Size: 512 MB	Max Memory Used: 16 MB	Restore Duration: 372.48 ms	Billed Restore Duration: 0 ms

ハンドラが返すinit_typeもSnapStart版は全リクエストでsnap-start
Init時刻は発行時の値のまま全環境で同一で、スナップショットが複製されていることが分かります。

最小関数

まず「ほぼ何もしない関数」でSnapStartのon/offを比べます。

計測の見方は以下。

  • 発行直後に24回同時に呼び出し、そのうち新しい実行環境が作られた呼び出し(cold start)だけを集計。
    呼び出し前にLambdaが先回りで用意していた環境に当たったものは除く(計測方法参照)。件数はRust offが22件、ほかは24件。
    ※Rust offは2件が同じ環境の使い回しになったため

  • 「server側cold」はLambdaのログ(REPORT行)に出る値で、SnapStart offならInit Duration、onならRestore Durationに、ハンドラの実行時間(Duration)を足したもの。

  • 「client E2E」は呼び出し元から見た往復時間。ネットワークやLambdaが実行環境を用意する時間も含むので、利用者が実際に待つ時間に近い値となる。

  • p50は計測値を小さい順に並べたときの真ん中(中央値)、p90は「遅い方の1割を除いた上限」。24件ならp50は12番目、p90は22番目の値。

言語 指標 SnapStart off SnapStart on 差(on − off)
Rust server側cold(InitまたはRestore + Duration) 19 ms 377 ms +358 ms
Rust client E2E p50 / p90 497 / 507 ms 563 / 630 ms +66 / +123 ms
TypeScript server側cold 274 ms 418 ms +144 ms
TypeScript client E2E p50 / p90 530 / 656 ms 568 / 671 ms +38 / +15 ms

最小関数のcold start(SnapStart off / on、server側とclient E2E)

図は上の表と同じ呼び出しを描いたものです。棒がp50、縦線がp90です。

結果:Hello WorldではSnapStartは速くなりません

SnapStartのRestoreには約380〜400 msの固定の時間がかかるためです。
Hello Worldの初期化はRustで18 ms、TypeScriptで270 msしかないので、
初期化を省いて速くなる時間より、Restoreにかかる時間の方が長くなります。

呼び出し元から見た往復時間(client E2E)では、差は15〜125 msまで縮まりますが、
それでもSnapStart版の方が遅い結果は変わりません。
「初期化が数百ミリ秒で終わる関数では改善は見込めない」という公式の説明どおりです。

なお、Restoreにかかった時間のうち課金対象になるBilled Restore Durationは0〜2 msでした。
(バージョンごとのスナップショットキャッシュ課金は別途発生)

Initを重くしたRust関数

種類 指標 SnapStart off SnapStart on
A: 辞書ロード(Init約4.6秒) server側Init / Restore p50 (p90) 4,579 (4,759) ms 673 (792) ms
A client E2E p50 (p90) 4,894 (5,045) ms 884 (1,000) ms
B: 数十秒級 初回呼び出しのDuration p50 15,690 ms(再初期化分) 5 ms(Restore 693 ms)
B client E2E p50 (p90) 25,985 (27,816) ms 871 (924) ms
C: sleep 30秒 初回呼び出しのDuration p50 30,027 ms(再初期化分) 1.3 ms(Restore 356 ms)
C client E2E p50 (p90) 40,333 (40,361) ms 514 (568) ms

Rust: 初期化を重くした関数のcold start(SnapStart off / on)

図はclient E2Eのp50(棒)とp90(縦線)で、単位は秒です。
灰色がSnapStart off、青がonで、同じ目盛に並べています。
Aはcold start(client E2Eのp50)が約4.9秒から0.88秒に、Bは約26秒から0.87秒に、
Cは約40秒から0.51秒に短縮されました。

Initを重くしたTypeScript関数

変種 指標 SnapStart off SnapStart on
A: 辞書ロード(Init約2.1秒) server側Init / Restore p50 (p90) 2,115 (2,417) ms 546 (650) ms
A client E2E p50 (p90) 2,412 (2,700) ms 746 (861) ms
B: 約60秒※ 初回呼び出しのDuration p50 59,213 ms(再初期化分) 27 ms(Restore 565 ms)
B client E2E p50 (p90) 69,483 (71,907) ms 762 (854) ms
C: sleep 30秒 初回呼び出しのDuration p50 30,137 ms(再初期化分) 18 ms(Restore 395 ms)
C client E2E p50 (p90) 40,409 (40,417) ms 577 (715) ms

※ Bは30秒を狙って15回繰り返しにしましたが、on-demandの再初期化では1回あたりの時間が伸びて実際は約60秒

TypeScript: 初期化を重くした関数のcold start(SnapStart off / on)

図はclient E2Eのp50(棒)とp90(縦線)で、単位は秒です。
灰色がSnapStart off、青がonで、同じ目盛に並べています。

TypeScriptも同じ傾向で、client E2Eのp50はAが約2.4秒から0.75秒、Bが約69秒から0.76秒、
Cが約40秒から0.58秒になりました。

Initが10秒を超えたときの挙動

BとCのon-demand版のログには、次のようにInitの打ち切りと再実行が記録されていました。

[init] AWS_LAMBDA_INITIALIZATION_TYPE=on-demand ...
INIT_REPORT Init Duration: 9999.36 ms	Phase: init	Status: timeout
[init] AWS_LAMBDA_INITIALIZATION_TYPE=on-demand ...            ← プロセスが再起動して Init をやり直す
[init] slept 30000 ms
REPORT RequestId: afba3be3-...	Duration: 30027.70 ms	Billed Duration: 30028 ms	Memory Size: 512 MB	...

公式ドキュメント
「Initフェーズは10秒以内で、超えた場合は初回呼び出し時に関数タイムアウトの範囲でInitを再試行」
という記述どおりの動きです。やり直したInitはREPORT行のBilled Durationに含まれており、
通常の実行時間として課金されていました。
clientから見たcold startは10秒 + Init時間になり、sleep 30秒の関数では約40秒でした。

SnapStart版はInitの上限がmax(関数タイムアウト, 130秒)緩和されるため、今回のB/Cでは10秒での打ち切りがなくなります。

Restore時間と使用メモリ

関数 スナップショット作成時の使用メモリ Restore Duration p50
Rust sleep 30秒 2.4 MB 356 ms
Rust最小 使用メモリ未取得(REPORTのMax Memory Usedは16 MB) 376 ms
TypeScript sleep 30秒 51 MB 395 ms
TypeScript辞書15万行 178〜188 MB 546〜565 ms
Rust辞書50万行 327 MB 673〜693 ms

今回の条件では、Restore時間はInitの長さには依存せず、
スナップショット作成時の使用メモリが大きいケースほど長くなる傾向が見られました。
Init 30秒級のBと数秒のAでRestoreがほぼ同じである一方、メモリの小さいCは速い、という結果です。
「関数が大きいほどチャンク数が増えて復元性能が変わる」という
公式ブログの説明と整合しますが、
ランタイム・イメージ・キャッシュ階層の影響は切り分けていません。

また、復元直後の初回ハンドラは、同じSnapStart版のwarm呼び出し(全ラウンド)より遅くなりました。
(Duration p50: Rust 3.4 → 4.9 ms、TypeScript 3.8 → 33.7 ms)
復元されたメモリがオンデマンドでページインされる影響と考えていますが、
Node.jsで差が大きい理由も含めて原因の調査はしていません。

コスト

課金のされ方の違いは以下。

  • SnapStart off: cold startのたびに、Initの時間がそのまま実行時間として課金されます(Lambdaは2025年8月からInitフェーズを課金対象に統一しています)。今回のREPORT行のBilled Durationでも、Rust辞書ロードで4,581 ms、TypeScript約60秒で59,213 msが課金されていました。
  • SnapStart on: cold startのたびの実行時間課金は数十msだけです(Billed Duration 3〜34 ms + Billed Restore Duration 0〜1 ms)。代わりに、復元のたびの復元料金と、SnapStartを有効にした発行済みバージョンごとのキャッシュ料金がかかります

東京リージョンの単価(AWS Price List APIで確認)は次の通りです。

項目 単価
実行時間(x86_64) 0.0000166667 USD/GB秒
SnapStart復元(1回あたり) 0.0001397998 USD/GB
SnapStartキャッシュ 0.0000015046 USD/GB秒(バージョンごと、最低3時間)

512 MBの関数のcold start 1回あたりで比べると次の通りです。onの復元料金は0.5 GB × 0.0001397998 = 約0.00007 USDで、どの関数でもほぼ同じです。

関数(offのInit課金時間) off on
Rust最小(20 ms) 0.00000017 USD 0.00007 USD
TypeScript最小(275 ms) 0.0000023 USD 0.00007 USD
Rust辞書ロード(4.6秒) 0.000038 USD 0.00007 USD
TypeScript辞書ロード(2.1秒) 0.000018 USD 0.00007 USD
Rust数十秒級(15.7秒) 0.00013 USD 0.00007 USD
TypeScript約60秒(59.2秒) 0.00049 USD 0.00007 USD

ポイントは2つです。

  • 復元料金1回分は、Initの実行時間約8.4秒分に相当します(0.0001397998 ÷ 0.0000166667)。cold start 1回あたりでSnapStartの方が安くなるのは、Initの課金時間が約8.4秒を超える場合だけです。どちらもメモリ量に比例するので、この閾値はメモリ設定を変えても同じです。
  • キャッシュ料金は、512 MBのバージョン1つにつき1日約0.065 USD、30日で約1.95 USDです。

例として、512 MBで月1万回cold startする関数を考えます。

  • Init 4.6秒: off約0.38 USD、on約0.70 + 1.95 = 約2.65 USD。SnapStartの方が高い
  • Init 15.7秒: off約1.31 USD、on約2.65 USD。まだ高い
  • Init 15.7秒でcold startが月10万回: off約13.1 USD、on約8.95 USD。SnapStartの方が安くなった

SnapStartはコスト削減の機能ではなく、レイテンシを買う機能です。
Initが8秒を超えてcold startが多い関数であれば、コストでも元が取れます。

※10秒で打ち切られた最初のInit試行が課金されるかは、公式ブログにも記述がなく未確認
※SnapStartでもハンドラ外の初期化コードやフックの実行時間、Lambdaがランタイム更新のために初期化を再実行した分は課金対象

発行時のInitはon-demandより遅い

スナップショット作成時のINIT_REPORTはRust Aで8.5秒、TypeScript Aで5.6秒と、
on-demandのInitのp50(4.6秒、2.1秒)より大幅に長い値でした。
on-demand側も一様ではなく、Rust Aのround 1では24件中17件が約4.6秒、
7件が1.6〜1.9秒という二峰性でした。
Lambda基盤側のイメージ層やファイルキャッシュ状態の違いが候補と推測していますが、
今回のログからは切り分けできていません。
SnapStartはこのコストを発行時に払い切ってしまうので、
呼び出し側には現れません。

Summary

  • コンテナイメージなら、Node.jsやRustでもSnapStartを使えます。Rustはlambda_runtime 1.3.0以降がライフサイクルを実装しているので、ラベルなしで動きました。Node.jsは今回のベースイメージのRICに実装がなかったので、ラベルでopt-inしました。
  • 今回の条件では、SnapStartが効くかどうかは言語の違いよりInitの長さに左右されました。
    • Hello World級(Init数百ms以下)では、復元の固定費(約350〜400 ms)の方が大きく、逆に遅くなりました。
    • Initが約2.1秒以上のケースでは、RustでもTypeScriptでもcold start(client側E2Eのp50)が1秒未満になりました。
    • Initが270 ms〜2.1秒の範囲は測っていません。
  • Initが10秒を超えると、通常のLambdaは10秒で打ち切り、初回呼び出しの中でInitをやり直します。cold startは「約10秒 + Init時間」になり、やり直した分は課金されます(打ち切られた最初の10秒分の課金は未確認)。この規模のInitで低レイテンシが必要なら、SnapStartは有力です。
  • Restoreの時間はInitの長さとは関係なく、スナップショット作成時の使用メモリが大きいほど長くなる傾向でした。復元直後の初回呼び出しもwarmより少し遅くなります。
  • 料金は基本的にSnapStartの方が高くなります。復元1回の料金はInit約8.4秒分の実行時間に相当し、バージョンごとにキャッシュ料金(512 MBで月約1.95 USD)もかかります。Initが8秒を超えてcold startが多い関数以外は、レイテンシを買うための追加コストです。
  • 計測するときの注意は3つです。Init Durationだけでなくclient側の時間も見る。事前に初期化されていた環境の呼び出しは除く。10秒を超えたInitはDurationに現れる。

判断の目安は「Initが1秒未満なら入れない、数秒なら測ってから、10秒超で低レイテンシが必要なら有力」です。
キャッシュが温まった後の復元時間、キャッシュ階層の影響、arm64、メモリサイズ違いは今回未検証です。

References

この記事をシェアする

関連記事