Lambda SnapStart のコンテナイメージ対応を Next.js + Lambda Web Adapter で試してみた
はじめに
2026年9月2日、AWS Lambda の SnapStart がコンテナイメージ関数に対応しました。
これまで SnapStart を使えたのは .zip ファイルアーカイブの関数だけでした。Node.js・Ruby・カスタムベースイメージでは、Dockerfile のラベルまたはライフサイクルフックの実装で有効化します。
Next.js を Lambda Web Adapter のコンテナイメージで動かしている構成に SnapStart を有効化し、同じイメージで SnapStart なしの関数と比べました。対象としたのは、スケールアウトで新しい実行環境が必要になったときのコールドスタート応答時間です。
検証内容
同じコンテナイメージと関数設定を使い、SnapStart の有無だけを変えて計測しました。
検証環境
性能を測るため、ランタイムのバージョンとメモリ・アーキテクチャを揃えました。
| 項目 | 値 |
|---|---|
| リージョン | us-east-1 |
| アーキテクチャ | arm64 |
| メモリ | 512 MB |
| タイムアウト | 30秒 |
| Node.js | 22.14.0 |
| Next.js | 14.2.35 |
| Lambda Web Adapter | 1.0.1 |
| イメージダイジェスト | sha256:7fa4665f72fd30a518a16be56b3f79b4e812924b37f8828ee0403a83ded7564d |
| イメージサイズ | 300,656,702 バイト(約287 MiB) |
Baseline と SnapStart は同じイメージダイジェストを使い、SnapStart 側にだけ ApplyOn=PublishedVersions を指定しました。呼び出しはどちらも公開バージョン 1 とエイリアス経由です。
イメージ作成
計測対象は、Node.js のベースイメージで Next.js を動かすイメージです。Lambda Web Adapter はエクステンションとして同梱しました。計測に使う /api/ping は固定の JSON を返します。それ以外のパスは Next.js のリクエストハンドラーへ渡します。
server.js
const http = require('http');
const { performance } = require('perf_hooks');
const next = require('next');
const port = Number(process.env.PORT || 8080);
const hostname = process.env.HOSTNAME || '0.0.0.0';
const buildId = process.env.NEXT_PUBLIC_BUILD_ID || 'unknown';
const initStartedAt = performance.now();
function log(event, extra = {}) {
console.log(JSON.stringify({
event,
buildId,
utc: new Date().toISOString(),
...extra,
}));
}
log('app_init_start');
const app = next({ dev: false, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || '/', `http://${hostname}:${port}`);
if (requestUrl.pathname === '/api/ping') {
const body = JSON.stringify({
ok: true,
buildId,
service: 'nextjs-lambda-web-adapter',
});
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('content-length', Buffer.byteLength(body));
res.end(body);
return;
}
handle(req, res);
});
server.listen(port, hostname, () => {
log('app_ready', {
port,
initMs: Number((performance.now() - initStartedAt).toFixed(3)),
});
});
}).catch((error) => {
log('app_init_error', { message: error.message });
process.exitCode = 1;
});
package.json
{
"name": "nextjs-lambda-web-adapter-snapstart-verification",
"private": true,
"version": "1.0.0",
"scripts": {
"build": "next build"
},
"dependencies": {
"next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
}
}
pages/index.js
export default function Home() {
return (
<main>
<h1>Next.js Lambda Web Adapter SnapStart verification</h1>
<p>Build: nextjs-snapstart-arm64-v1</p>
</main>
);
}
Dockerfile では、SnapStart を許可するため com.amazonaws.lambda.feature.snapstart ラベルに Allow を指定しました。
FROM public.ecr.aws/docker/library/node:22.14.0-bookworm-slim
LABEL com.amazonaws.lambda.feature.snapstart="Allow"
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_BUILD_ID=nextjs-snapstart-arm64-v1 \
AWS_LWA_PORT=8080 \
AWS_LWA_READINESS_CHECK_PATH=/api/ping \
AWS_LWA_READINESS_CHECK_HEALTHY_STATUS=200 \
PORT=8080
WORKDIR /var/task
COPY next-app/package.json next-app/package-lock.json ./
RUN npm ci --omit=dev
COPY next-app/ ./
RUN npm run build
CMD ["node", "server.js"]
ラベルも /restore/next の実装も持たないイメージでは、バージョンの発行に失敗します(コンテナイメージのSnapStartフックのドキュメント)。
依存関係の解決と next build は Docker build の中で完了するため、Lambda の実行時にパッケージマネージャーは動きません。
ビルドは次のコマンドで実行しました。
docker run --rm --platform linux/arm64 \
-v "$ROOT/artifacts/next-app:/app" -w /app \
public.ecr.aws/docker/library/node:22.14.0-bookworm-slim \
npm install --package-lock-only --ignore-scripts
docker build --platform linux/arm64 --provenance=false -f "$ROOT/artifacts/Dockerfile" \
-t "$IMAGE_TAG" "$ROOT/artifacts"
lockfile の生成も ARM64 コンテナの中で実行し、ホストで npm は動かしていません。
関数作成とSnapStart有効化
まず、Lambda の実行ロールを作成し、基本実行ロールの管理ポリシーを割り当てました。
信頼ポリシー
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role \
--role-name "$ROLE_NAME" \
--assume-role-policy-document "file://$ROOT/artifacts/lambda-trust-policy.json" \
--description "Temporary role for Next.js Lambda Web Adapter SnapStart verification"
aws iam attach-role-policy \
--role-name "$ROLE_NAME" --policy-arn "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
ロールの伝播を待つため、ここで10秒空けてから関数を作成しました。
次に ECR リポジトリを作成し、ビルドしたイメージを push しました。
aws ecr create-repository \
--region "$REGION" \
--repository-name "$REPOSITORY" \
--image-scanning-configuration scanOnPush=false \
--image-tag-mutability IMMUTABLE
aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$REGISTRY"
docker tag "$LOCAL_IMAGE" "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
docker push "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
push したイメージのダイジェストを指定して、Baseline と SnapStart の2つの関数を作成しました。違いは --snap-start ApplyOn=PublishedVersions の有無だけです。
aws lambda create-function \
--region "$REGION" --function-name "$BASELINE_FUNCTION" --package-type Image \
--code "ImageUri=$IMAGE_URI_DIGEST" --role "$ROLE_ARN" --architectures arm64 \
--memory-size 512 --timeout 30 --environment "$ENV_VARS"
aws lambda create-function \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" --package-type Image \
--code "ImageUri=$IMAGE_URI_DIGEST" --role "$ROLE_ARN" --architectures arm64 \
--memory-size 512 --timeout 30 --snap-start ApplyOn=PublishedVersions \
--environment "$ENV_VARS"
環境変数は両関数で同じ値を渡しました。
Variables={AWS_LWA_PORT=8080,AWS_LWA_READINESS_CHECK_PATH=/api/ping,AWS_LWA_READINESS_CHECK_HEALTHY_STATUS=200,NEXT_TELEMETRY_DISABLED=1,NEXT_PUBLIC_BUILD_ID=nextjs-snapstart-arm64-v1}
SnapStart を使えるのは公開済みバージョンと、そのバージョンを指すエイリアスだけで、$LATEST では使えません(SnapStart のドキュメント)。そのため Baseline 側にも公開バージョンとエイリアスを作り、呼び出し経路を揃えました。SnapStart 側では、スナップショットの作成が終わるまで待ってからエイリアスを作成しました。
aws lambda publish-version --region "$REGION" --function-name "$SNAPSTART_FUNCTION"
aws lambda get-function-configuration \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" --qualifier "$SNAPSTART_VERSION"
# State が Active、SnapStart.OptimizationStatus が On になるまで5秒間隔でポーリング
aws lambda create-alias \
--region "$REGION" --function-name "$SNAPSTART_FUNCTION" \
--name "$SNAPSTART_ALIAS" --function-version "$SNAPSTART_VERSION"
構築で待った区間は3つでした。
| 区間 | 実測 |
|---|---|
| ECRへのイメージpush | 18.6秒 |
publish-version の応答 |
1.2秒 |
公開バージョンが OptimizationStatus: On になるまで |
55.6秒 |
最後の区間は、publish-version が応答を返した 2026-09-02T23:07:39.292Z から、On を確認した 2026-09-02T23:08:34.905Z までです。
計測用の入口として、各エイリアスに Function URL を作成しました。
aws lambda create-function-url-config \
--region "$AWS_REGION" --function-name "$function_name" --qualifier "$alias" \
--auth-type NONE
aws lambda add-permission \
--region "$AWS_REGION" --function-name "$function_name" --qualifier "$alias" \
--statement-id "${RESOURCE_PREFIX}-${label}-url" --action lambda:InvokeFunctionUrl \
--principal '*' --function-url-auth-type NONE
この Function URL は認証なしです。固定の本文を返す /api/ping に加えて Next.js のページも公開されるため、検証用の内容だけを置き、計測後に削除しました。
計測を始める前に、両エイリアスの疎通を確認しました。/api/ping はどちらも同じ本文を返しました。
{"ok":true,"buildId":"nextjs-snapstart-arm64-v1","service":"nextjs-lambda-web-adapter"}
/ に対するレスポンスの SHA-256 も、Baseline と SnapStart で同じでした。値は 54872fb3f8dfdc2aa3ae7db6be14d295f5daf2bd737cd1e2364d72c6ca906721 です。
検証で作成したリソースは、次のコマンドで削除できます。
撤去コマンド
aws lambda delete-function-url-config --region "$AWS_REGION" --function-name "$FUNCTION" --qualifier "$ALIAS"
aws lambda delete-alias --region "$AWS_REGION" --function-name "$FUNCTION" --name "$ALIAS"
aws lambda delete-function --region "$AWS_REGION" --function-name "$FUNCTION"
aws logs delete-log-group --region "$AWS_REGION" --log-group-name "/aws/lambda/$FUNCTION"
aws ecr delete-repository --region "$AWS_REGION" --repository-name "$ECR_REPOSITORY" --force
aws iam detach-role-policy --role-name "$ROLE_NAME" --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name "$ROLE_NAME"
コールドスタート計測
各条件へ40並列のバーストを2ラウンド送り、ラウンド間は120秒空けました。1条件あたり80件、合計160件です。ラウンド1は、スモークテスト後の最初のバーストにあたります。
各リクエストではレスポンスヘッダーの x-amzn-Requestid を保存し、CloudWatch Logs の REPORT 行とリクエストIDで突合しました。突合できたのは、Baseline と SnapStart のどちらも80件中80件でした。これにより、初期化や復元を待ったリクエストと、既存の実行環境が処理したリクエストを、推測なしに分けられます。
応答時間の取得と並列実行は、計測スクリプトの次の部分です。
curl -sS --max-time 120 -o "$body" -D "$headers" \
-w '%{http_code} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}' \
"${url%/}/api/ping"
seq 1 "$size" | xargs -P "$size" -I{} bash -c \
'"$0" --one "$1" "$2" "$3" "$4"' "$ROOT/artifacts/measure-burst.sh" "$label" "$url" "$round" "{}"
ラウンド1で初期化または復元を待ったリクエストの応答時間は次のとおりです。
| 条件 | 件数 | 最小 | 中央値 | 最大 |
|---|---|---|---|---|
| Baseline | 12 | 1.1923秒 | 1.4646秒 | 1.6241秒 |
| SnapStart | 34 | 0.9799秒 | 1.0754秒 | 1.3015秒 |
最小・中央値・最大のいずれも、SnapStart のほうが短くなりました。どちらも40並列のバーストですが、新しい実行環境が必要になった件数は Baseline が12件、SnapStart が34件と異なりました。残りのリクエストは既存の実行環境が処理しました。この応答時間が比較の基準になります。
| 条件 | 件数 | 最小 | 中央値 | 最大 |
|---|---|---|---|---|
| Baseline | 28 | 0.5332秒 | 0.6402秒 | 0.7530秒 |
| SnapStart | 6 | 0.6130秒 | 0.6556秒 | 0.6940秒 |
既存環境での応答時間を基準にすると、コールドスタートの上乗せは Baseline が中央値で0.8244秒、SnapStart が0.4198秒でした。
外形の差がどこから来たのかは、Lambda が記録した内部の値で確認できます。
| 条件 | 記録された値 | 件数 | 最小 | 中央値 | 最大 |
|---|---|---|---|---|---|
| Baseline | Init Duration | 12 | 468.63 ms | 739.085 ms | 899.08 ms |
| SnapStart | Restore Duration | 34 | 343.13 ms | 415.22 ms | 649.13 ms |
中央値では、スナップショットからの復元のほうが初期化より短くなりました。
Baseline の REPORT 行です。
REPORT RequestId: 95af3325-b0d2-464e-9cce-70fe1d930fdd Duration: 3.82 ms Billed Duration: 755 ms Memory Size: 512 MB Max Memory Used: 113 MB Init Duration: 750.93 ms
SnapStart では、RESTORE_REPORT と REPORT の2行が記録されました。
RESTORE_REPORT Restore Duration: 410.92 ms
REPORT RequestId: 9c6d29cd-0866-4502-9fed-dfc1e9267d6a Duration: 26.22 ms Billed Duration: 27 ms Memory Size: 512 MB Max Memory Used: 113 MB Restore Duration: 410.92 ms Billed Restore Duration: 0 ms
実際に突合した1件を、条件ごとに示します。
突合した計測レコード
Baseline の1件です。
{
"round": 1,
"lambdaRequestId": "95af3325-b0d2-464e-9cce-70fe1d930fdd",
"startedAtUtc": "2026-09-02T23:08:48.081Z",
"httpStatus": 200,
"timeToFirstByteSeconds": 1.475638,
"timeTotalSeconds": 1.475952,
"requestKind": "init",
"initDurationMs": 750.93,
"restoreDurationMs": null,
"handlerDurationMs": 3.82
}
SnapStart の1件です。
{
"round": 1,
"lambdaRequestId": "9c6d29cd-0866-4502-9fed-dfc1e9267d6a",
"startedAtUtc": "2026-09-02T23:08:49.793Z",
"httpStatus": 200,
"timeToFirstByteSeconds": 1.075861,
"timeTotalSeconds": 1.076103,
"requestKind": "restore",
"initDurationMs": null,
"restoreDurationMs": 410.92,
"handlerDurationMs": 26.22
}
ラウンド2では、Baseline の13件それぞれで、外形の応答時間が同一リクエストの Init Duration を下回りました。この13件の応答時間は最小0.6483秒、中央値0.7627秒、最大0.8278秒で、同ラウンドの Init Duration の中央値は769.61 ms(約0.77秒)です。応答時間が初期化時間より短いため、この初期化はリクエストが届く前に終わっていたことになります。
同じラウンドで、SnapStart 側の復元がリクエスト到着前に終わっていたケースは0件でした。
アイドル時間を空けた単発リクエストだけで測ると、Init Duration や Restore Duration がログに記録されていても、外形の応答時間には現れません。40並列のバーストで測ったのはこのためです。
Baseline と SnapStart の /api/ping 各80件は、すべて HTTP 200 でした。レスポンス本文の SHA-256 も、両条件とも 46f03818aa4d0f40cb4997c41864ce3adbe125d89ec20de01442ab2ea0aaff38 の1種類でした。SnapStart 側では復元のログが40件記録され、そのうち37件が計測対象リクエストの REPORT と同じリクエストIDでした。
今回のアプリは初期化時に一意な値(ID・シークレット・乱数シード)を作らないため、スナップショットを複数の実行環境で再開しても本文は変わりません。初期化で一意な値を作るアプリでは、ハンドラー側で作り直す必要があります(SnapStart のドキュメントの互換性に関する考慮事項)。
まとめ
Next.js を Lambda Web Adapter で動かすコンテナイメージで SnapStart を使えることが確認できました。今回のイメージは、AWS が起動に数秒かかるとしている規模には届いていません。それでも初期化より復元のほうが短く済んだので、より大きなイメージや初期化に時間のかかるワークロードなら、短縮の幅はさらに大きくなると考えられます。
Dockerfile にラベルを持たせ、公開バージョンとエイリアス経由で呼び出し、初期化時に一意な値を作らないアプリなら、コードと依存関係はそのままでコールドスタートの所要時間の短縮が期待できます。$LATEST を直接呼んでいる場合は、バージョンの発行とエイリアス経由の呼び出しへ移す必要があります。
スケールアウト時の Lambda のコールドスタートが課題になっているなら、今回のアップデートをお試しください。








