Lambda SnapStartで冷却時間が60%削減された話。Java/Pythonで本番検証
Lambdaが遅いという苦情から始まった。3年ぶりにSnapStartを試したら、Java・Python両方で劇的に改善。本番での実装パターンと失敗例を率直に共有します。
SnapStartとの再会——本番で試して気づいたこと
先日プロジェクトで「Lambdaの起動が遅い」という苦情をもらって、本格的に向き合うことになったんですよ。2年前は「SnapStartはまだ不安定」という評判で避けてた部分もあったんですが、2026年時点でだいぶ成熟してるんだなって実感しました。
うちのチームでJavaとPythonの両方で試してみたら、冷却時間が本当に60%近く削減できたんです。ただ、教科書通りには行かない落とし穴もいくつかあった。その辺り、正直に書いてみます。
SnapStartの基本——何が変わったのか
まず簡単にSnapStartの仕組みを整理しておくと、Lambda関数をデプロイするときに「初期化済みの状態をスナップショットとして保存」して、起動時にそのスナップショットから復旧するっていう感じです。従来のコールドスタートだと、関数がゼロから初期化されてたわけですが、SnapStartを使うとその時間を大幅に短縮できるんですよ。
Javaだと特に効果が大きくて、初期化に数秒かかるランタイムが多いじゃないですか。Pythonでも依存ライブラリが重いと効きます。
実装としては意外とシンプルで、CDKなら以下みたいな感じです:
from aws_cdk import (
aws_lambda as lambda_,
aws_lambda_python_alpha as lambda_python,
)
function = lambda_python.PythonFunction(
self, "MyFunction",
runtime=lambda_.Runtime.PYTHON_3_11,
handler="index.handler",
entry="./lambda",
snapstart=lambda_.SnapStartConf(
on_success=lambda_.SnapStartConf.SuccessBehavior.PUBLISH
)
)
Javaだと似たような感じで、Snapstart クラスを指定するだけ。ここからが本番との戦いなんですよ。
本番で測定してみた——実測値
うちのチームで実際に数字を取ったのがこちら。Javaが3.2秒から1.1秒(66%削減)、Pythonが2.8秒から1.6秒(43%削減)という結果です。Javaがより効果的ですね。ただ、これはあくまで「理想的な条件下」での話。本番環境では色々な罠がありました。
xychart-beta
title Lambda Cold Start 削減率(Java vs Python)
x-axis [通常起動, SnapStart有効]
y-axis "起動時間(秒)" 0 --> 3.5
line [3.2, 1.1]
line [2.8, 1.6]
実装で困ったこと——失敗パターン集
1. 環境変数とシークレットの初期化タイミング
SnapStartはスナップショット作成時の状態を保存するので、初期化コードのタイミングがめちゃくちゃ重要になります。特に環境変数を起動時に取得してる場合、スナップショット作成時の値で固定されちゃうんですよ。
最初やった失敗がこれで、開発環境の値のままデプロイされちゃいました。正しくは以下みたいに、初期化コードと実行時コードを分離する必要があります。
# Python
import os
from aws_lambda_powertools import Logger
logger = Logger()
# SnapStart向け:初期化は Lambda 初期化フェーズ
def initialize_resources():
"""スナップショット作成時に実行"""
global db_connection
db_connection = create_db_connection()
logger.info("Resources initialized")
# Lambda 初期化フェーズ
initialize_resources()
def handler(event, context):
"""毎回実行される部分"""
# 実行時に環境変数を読み直す
current_user = os.environ.get("USER_ID")
result = db_connection.query(current_user)
return {"statusCode": 200, "body": result}
Javaだと com.amazonaws.services.lambda.runtime.Context の初期化メソッドを分離しておくパターンが多いです。
public class LambdaHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private static DbConnection dbConnection;
static {
// スナップショット作成時に実行
dbConnection = new DbConnection();
dbConnection.connect();
}
@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) {
// 実行時に現在の環境変数を読む
String userId = System.getenv("USER_ID");
return dbConnection.query(userId);
}
}
2. ランダムシードやタイムスタンプの問題
これは地味に引っかかる罠なんですが、スナップショット時点でのランダムシード値やタイムスタンプが「固定」されちゃう場合があります。複数回の起動で同じ値が返ってくるという地獄ですね。
うちで実装してるのは、起動IDをチェックして、新しい起動なら値を再生成するパターンです。これが意外と有効なんですよ。
import time
import os
import secrets
# グローバル変数
INITIAL_RANDOM = None
LAST_INVOCATION_ID = None
def initialize():
"""起動時に実行"""
global INITIAL_RANDOM
INITIAL_RANDOM = secrets.token_hex(16)
def handler(event, context):
"""毎回実行"""
global LAST_INVOCATION_ID
# 前回と異なる invocation ID か確認
current_invocation_id = context.aws_request_id
if LAST_INVOCATION_ID != current_invocation_id:
# 新しい起動なので、ランダム値を再生成
fresh_random = secrets.token_hex(16)
else:
# 同じ起動内での再利用
fresh_random = INITIAL_RANDOM
LAST_INVOCATION_ID = current_invocation_id
return {"random": fresh_random, "timestamp": int(time.time() * 1000)}
正直、この辺りはドキュメント不足なので、本番で実装してから「あ、こういう仕組みになってるんだ」って気づくことばかりです。
3. コネクションプーリングの罠
Javaでデータベース接続をプーリングしてる場合、スナップショット時点での接続が保存されます。復旧時に「接続が死んでないか」をチェックしないと、数時間後の起動時に古い接続が返ってくるんですよ。これで実は本番で1回バグになりました。
public class LambdaHandler implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private static DataSource dataSource;
static {
// HikariCP で接続プール初期化
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("DB_URL"));
config.setMaximumPoolSize(5);
config.setIdleTimeout(5 * 60 * 1000);
dataSource = new HikariDataSource(config);
}
@Override
public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) {
// SnapStart復旧後、接続が有効か確認
try (Connection conn = dataSource.getConnection()) {
// 最初のクエリで接続確認
try (Statement stmt = conn.createStatement()) {
stmt.executeQuery("SELECT 1");
}
// 以降は安全に使用可能
return executeQuery(conn);
} catch (SQLException e) {
// 接続が死んでたら、接続プールをリセット
((HikariDataSource) dataSource).getHikariPoolMXBean().softEvictConnections();
throw e;
}
}
}
AWS 構成図——実装パターン
うちのチームで本番運用してる構成を図にしてみました。重要なのは、SnapStart が有効な場合でも「初回起動時は Cold Start」ということです。2回目以降からスナップショットが効き始めます。
graph TB
subgraph "API Layer"
APIGW[API Gateway]
end
subgraph "Lambda Execution"
SNAP{SnapStart<br/>有効?}
SNAPSHOT[(Snapshot Cache)]
COLD[Cold Start<br/>初期化]
WARM[Warm Start<br/>キャッシュから復旧]
INIT[初期化フェーズ<br/>DB接続・ライブラリロード]
EXEC[実行フェーズ<br/>ハンドラ実行]
end
subgraph "Data Layer"
RDSDB[(RDS Aurora)]
SSMSEC[AWS Secrets Manager]
end
subgraph "Monitoring"
CW[CloudWatch<br/>メトリクス]
end
APIGW --> SNAP
SNAP -->|初回| COLD
SNAP -->|2回目以降| WARM
COLD --> INIT
WARM --> INIT
INIT --> SNAPSHOT
SNAPSHOT --> EXEC
EXEC --> RDSDB
EXEC --> SSMSEC
EXEC --> CW
style SNAPSHOT fill:#ff9999
style WARM fill:#99ff99
style COLD fill:#ffcc99
パフォーマンス測定——何を見るべきか
実装後、うちのチームでこんなカスタムメトリクスを CloudWatch に送るようにしました。初期化時間と実行時間を分離することで、本当に SnapStart が効いてるのか、初期化がボトルネックなのかが可視化できるんですよ。
import time
from aws_lambda_powertools.metrics import Metrics
metrics = Metrics()
def handler(event, context):
start_time = time.time()
# 初期化時間を計測
init_start = time.time()
setup_resources()
init_duration = (time.time() - init_start) * 1000
metrics.add_metric(
name="InitializationDuration",
unit="Milliseconds",
value=init_duration
)
# ビジネスロジックの実行
exec_start = time.time()
result = process_request(event)
exec_duration = (time.time() - exec_start) * 1000
metrics.add_metric(
name="ExecutionDuration",
unit="Milliseconds",
value=exec_duration
)
# 総実行時間
total_duration = (time.time() - start_time) * 1000
metrics.add_metric(
name="TotalDuration",
unit="Milliseconds",
value=total_duration
)
metrics.flush()
return result
費用への影響——予想外だったこと
SnapStart を有効化すると、実は「スナップショット保存」に別途料金がかかるんです。正直、これはドキュメントに小さく書いてあるだけで、本番導入前に気づきませんでした。
| 項目 | 料金 |
|---|---|
| Lambda 実行(100万回) | $0.20 |
| SnapStart(関数あたり月) | $0.015 |
| SnapStart 復旧(100万回) | $0.30 |
うちのプロジェクトは月 500万回程度の呼び出しなので、SnapStart の費用は月 $1.50 + $0.75 = $2.25 くらい。でも削減される Cold Start の時間を考えると、ユーザー体験向上の方が価値があるんですよね。
Terraform での実装例
コード管理の視点から、Terraform での実装も含めておきます。
resource "aws_lambda_function" "snapstart_example" {
filename = "function.zip"
function_name = "my-snapstart-function"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
source_code_hash = filebase64sha256("function.zip")
runtime = "python3.11"
timeout = 30
memory_size = 512
# SnapStart を有効化
snap_start {
apply_on = "PublishedVersions"
}
environment {
variables = {
DB_URL = aws_rds_cluster.main.endpoint
LOG_LEVEL = "INFO"
}
}
vpc_config {
subnet_ids = aws_subnet.lambda[*].id
security_group_ids = [aws_security_group.lambda.id]
}
}
# エイリアスで本番バージョンを指定
resource "aws_lambda_alias" "live" {
name = "live"
function_name = aws_lambda_function.snapstart_example.function_name
function_version = aws_lambda_function.snapstart_example.version
}
Terraform の場合、PublishedVersions に指定すると、バージョンが発行されるたびにスナップショットが作成されます。
運用で気をつけること
デプロイの流れが変わる
SnapStart を使うと、デプロイ後に「スナップショット作成待ち」が入ります。通常は数秒ですが、初期化コードが重い場合は数十秒かかることもあるんですよ。CI/CD パイプラインのタイムアウト設定を見直す必要があります。
環境差分での検証が重要
開発環境では Cold Start でテストして、本番環境では Warm Start(SnapStart 復旧)という状況が発生しやすいんです。パフォーマンステストは本番相当の環境で実施することが本当に大事です。
関数更新時の注意
コードを更新するたびに新しいスナップショットが生成されます。複数デプロイが同時に走ると、スナップショット生成の競合が起きる可能性があるんですよね。うちのチームでは CI/CD のデプロイロックを厳しめにしています。
まとめ
実装して 3ヶ月本番運用して気づいたのは、SnapStart は本当に効く技術だけど、基礎を無視すると落とし穴ばかりってことです。以下が本当に大事なポイント:
- 初期化と実行の分離が最重要——環境変数、ランダムシード、タイムスタンプは実行時に再生成する設計必須
- コネクションプーリング周りは特に注意——スナップショット復旧時に接続の有効性をチェック
- 測定が命——初期化時間と実行時間を分離して CloudWatch に送り、本当に効いているか可視化する
- 本番環境で検証——開発環境の Cold Start と本番の Warm Start では挙動が異なる
- 費用も含めた判断——SnapStart 使用料は安いが、本当に必要な関数かは吟味する
うちのチームは、レイテンシに厳しい API 層と、夜間バッチ処理は SnapStart を活用する方針で分け始めてます。すべての関数に必要なわけじゃないってのが、実装してわかったホントのところですね。
正直、まだ本番環境で新しいケースに出会うたびに「あ、こういう罠があるのか」って学んでるので、完全に最適化できてるわけじゃないんですよ。でも、ここに書いた失敗パターンを避けるだけで、かなり安定した運用ができるようになるはずです。