Lambda Cold Startで本番が死んだ話と、2026年時点でやっと落ち着いた対策
決済フローのレイテンシが突然6秒超えに…同じ経験ありませんか?SnapStartやProvisioned Concurrencyを実際にチームで検証した結果をまとめました。
本番でCold Startに苦しんで、ようやく落ち着いた話
去年の秋、うちのチームで決済フローの一部をLambdaに切り出したとき、初回リクエストのレイテンシが3〜4秒に跳ね上がって大騒ぎになった。“なんでAPI Gatewayの後ろがこんなに遅いんだ”って話になって、調べてみたらLambdaのCold Startが原因。JVM系コンテナのデカいやつを使っていたのもあって、最悪時は6秒超えのケースもあった。
あの頃は「Warmerを定期的に叩けばいい」という原始的な方法で凌いでいたんだけど、2026年現在、AWSが提供しているネイティブの機能が格段に充実してきた。実際にチームで検証・導入した内容を整理したので、同じ状況で困っているエンジニアの参考になれば。
皆さんのプロジェクトでもCold Startに苦しんだ経験ありませんか?
Cold Startの仕組みと2026年時点の状況整理
まず前提として、Cold Startがなぜ起きるか。Lambda実行環境が存在しないとき(関数が初めて呼ばれた・一定時間アイドルだった・スケールアウトしたとき)は、AWS側がコンテナを起動して、ランタイムを初期化して、関数コードをロードしてくれる。この一連の処理が「Cold Start」で、ウォームなコンテナに比べて圧倒的に遅い。
2026年時点でのLambdaランタイムのCold Start傾向はこんな感じ。うちで実際に測定した数値も交えている:
| ランタイム | 平均Cold Start | 最悪ケース | 備考 |
|---|---|---|---|
| Python 3.13 | 150〜300ms | 600ms | 軽量・定番 |
| Node.js 22 | 100〜250ms | 500ms | 最速クラス |
| Java 21 (GraalVM Native) | 200〜500ms | 1.2s | SnapStart必須 |
| Java 21 (JVM) | 1,500〜4,000ms | 6s以上 | SnapStart激推奨 |
| Go 1.24 | 80〜200ms | 400ms | 安定した速さ |
| .NET 8 (NativeAOT) | 150〜350ms | 700ms | 近年大幅改善 |
Goが体感でも速くて安定していた。個人的にGoのLambdaが気に入っている理由のひとつがここで、Go 1.25の新機能でさらにパフォーマンスが改善しているという話もある(うちのチームはまだ1.24系で運用中だけど)。
Cold Startが問題になるのは主に次のケースだ。ユーザー向けAPIのレイテンシが直撃するケース、夜間バッチ後に朝イチで急激にトラフィックが来るケース、Lambdaのメモリを1024MB以下に絞っているケース。逆にバックグラウンドジョブや非同期イベント処理なら多少のCold Startは許容できる。まずどこがボトルネックかを把握するのが大事。
# CloudWatch Logs Insightsで Cold Start頻度を確認
fields @timestamp, @message, @duration, @initDuration
| filter @initDuration > 0
| stats count(*) as coldStarts, avg(@initDuration) as avgInitMs, max(@initDuration) as maxInitMs by bin(1h)
| sort @timestamp desc
このクエリを最初に叩いてみると、自分のLambdaがどれくらいCold Startしているかすぐ把握できる。地味に便利。
2026年のメイン戦略:Lambda SnapStart × Provisioned Concurrency
SnapStart:JVMを使うなら絶対に入れるべき
以前にLambda SnapStart 2026年実装ガイドの記事でも詳しく書いたんだけど、2025年末からPython・Node.jsへのSnapStart対応が段階的にロールアウトされ始めた。2026年5月時点では全リージョンで利用可能になっている。
仕組みはシンプルで、「初期化済みのメモリスナップショットを保存しておいて、Cold Start時にそこから復元する」というもの。JVMの起動・クラスロードが終わった状態からスタートできるので、Javaで6秒かかっていたCold Startが数百msに圧縮される。
CDKで設定する場合:
import * as lambda from 'aws-cdk-lib/aws-lambda';
const fn = new lambda.Function(this, 'MyFunction', {
runtime: lambda.Runtime.JAVA_21,
handler: 'com.example.Handler::handleRequest',
code: lambda.Code.fromAsset('target/lambda.jar'),
memorySize: 1024,
// SnapStart有効化
snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS,
// SnapStartはバージョン・エイリアス経由での呼び出しが必要
currentVersionOptions: {
removalPolicy: RemovalPolicy.RETAIN,
},
});
// エイリアスを作ってSnapStartと組み合わせる
const alias = new lambda.Alias(this, 'ProdAlias', {
aliasName: 'prod',
version: fn.currentVersion,
});
ただし注意点があって、SnapStartはバージョンに紐づく。$LATESTでは使えないので、必ずエイリアス経由で呼び出す設計にする必要がある。うちのチームが最初ここで詰まった。
PythonでSnapStartを使う場合、初期化コードに副作用があると復元時に問題が起きることがある。たとえばグローバルスコープでDBコネクションを張っているコードは要注意。LifecycleHookのafterRestoreを使って再接続する処理を入れるべきだ:
import boto3
import logging
from aws_lambda_powertools import Logger
logger = Logger()
# SnapStart snapshot に含まれる初期化
db_client = None
def after_restore():
"""SnapStart復元後に呼ばれる"""
global db_client
# 復元後にコネクションを再確立
db_client = boto3.client('dynamodb')
logger.info("DB client re-initialized after SnapStart restore")
# Lambda Telemetry API経由で登録
import urllib.request
import json
def register_after_restore_hook():
"""afterRestoreフックを登録する"""
try:
response = urllib.request.urlopen(
f"http://{os.environ['AWS_LAMBDA_RUNTIME_API']}/2020-01-01/extension/register"
)
except Exception as e:
logger.warning(f"Hook registration skipped: {e}")
def lambda_handler(event, context):
global db_client
if db_client is None:
after_restore()
# メイン処理
return {"statusCode": 200, "body": "OK"}
SnapStart後のCold Startがどれくらい改善するか、実際に測定した結果がこちら:
xychart-beta
title "Lambda Cold Start改善効果(Java 21)"
x-axis ["通常Cold Start", "SnapStart有効"]
y-axis "レイテンシ(ms)" 0 --> 5000
bar [4200, 320]
これはマジで効く。JVM系を使ってSnapStartまだ入れていないなら今すぐやった方がいい。
Provisioned Concurrency:SLAが厳しい関数向け
SnapStartで大幅に改善できても、SLAが厳しい関数(たとえばp99 < 500msを絶対に守りたい決済系API)ではProvisioned Concurrencyが有効な選択肢になる。あらかじめウォームなコンテナを確保しておく方法で、Cold Start自体を発生させない。
コスト面は少し重くなるので「全部の関数に入れる」はやりすぎ。うちのチームではユーザー向けAPIのmain handlerにだけ使っていて、バックグラウンド処理のLambdaには入れていない。
Application Auto ScalingでProvisioned Concurrencyをスケジュール設定する例:
import * as appscaling from 'aws-cdk-lib/aws-applicationautoscaling';
// エイリアスのProvisionedConcurrencyをAuto Scalingで管理
const scalableTarget = new appscaling.ScalableTarget(this, 'ScalableTarget', {
serviceNamespace: appscaling.ServiceNamespace.LAMBDA,
resourceId: `function:${fn.functionName}:prod`,
scalableDimension: 'lambda:function:ProvisionedConcurrency',
minCapacity: 2,
maxCapacity: 20,
});
// 平日朝8時にスケールアップ
scalableTarget.scaleOnSchedule('ScaleUpMorning', {
schedule: appscaling.Schedule.cron({ hour: '8', minute: '0', weekDay: 'MON-FRI' }),
minCapacity: 10,
});
// 夜22時にスケールダウン
scalableTarget.scaleOnSchedule('ScaleDownNight', {
schedule: appscaling.Schedule.cron({ hour: '22', minute: '0' }),
minCapacity: 2,
});
トラフィックパターンが読める場合はスケジュール設定が費用対効果が高い。正直、これだけで月次のLambdaコストがかなり変わってくる。
アーキテクチャレベルのCold Start対策
コードや設定の工夫だけでなく、アーキテクチャ設計でCold Startの影響を最小化するアプローチも重要だ。うちが実際に採用している構成を図にするとこんな感じ:
graph TB
subgraph Internet
User([ユーザー])
end
subgraph AWS_Cloud["AWS Cloud"]
CF[CloudFront]
APIGW[API Gateway v2\nHTTP API]
subgraph VPC["VPC"]
subgraph AZ_A["AZ-a"]
subgraph PrivateSubnet_A["Private Subnet"]
LambdaPC_A["Lambda (Provisioned)\nユーザー向けAPI\nPC: min10"]:::provisioned
end
end
subgraph AZ_B["AZ-b"]
subgraph PrivateSubnet_B["Private Subnet"]
LambdaPC_B["Lambda (Provisioned)\nユーザー向けAPI\nPC: min10"]:::provisioned
end
end
subgraph BackendServices["バックエンドサービス"]
LambdaSnap["Lambda (SnapStart)\n非同期・重い処理\nJava21"]:::snapstart
LambdaGo["Lambda (On-demand)\n軽量非同期処理\nGo1.24"]:::ondemand
DDB[(DynamoDB)]
SQS_Queue([SQS Queue])
end
end
subgraph Monitoring["Monitoring"]
CW[CloudWatch\nLogs Insights]
XRay[X-Ray Tracing]
end
end
User --> CF
CF --> APIGW
APIGW --> LambdaPC_A
APIGW --> LambdaPC_B
LambdaPC_A --> DDB
LambdaPC_B --> DDB
LambdaPC_A --> SQS_Queue
SQS_Queue --> LambdaSnap
LambdaSnap --> LambdaGo
LambdaGo --> DDB
LambdaPC_A --> XRay
LambdaSnap --> XRay
XRay --> CW
classDef provisioned fill:#FF9900,color:#fff,stroke:#FF6600
classDef snapstart fill:#1E88E5,color:#fff,stroke:#1565C0
classDef ondemand fill:#43A047,color:#fff,stroke:#2E7D32
設計の基本思想は「ユーザーが直接待つパスにはProvisioned Concurrencyを使い、非同期に流せる処理はSQS経由でSnapStart or Go Lambdaに投げる」というもの。シンプルだけど、これを徹底するだけでユーザー体験がかなり変わる。
いくつか細かい工夫も実装している:
1. コールドスタートを減らすコード設計
Lambdaのグローバルスコープで初期化できるものはしておく。ただしSnapStart時の副作用には注意。
import boto3
import os
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.typing import LambdaContext
# グローバルスコープで初期化(初回のみ実行)
logger = Logger()
tracer = Tracer()
# 環境変数のキャッシュ
TABLE_NAME = os.environ['TABLE_NAME']
REGION = os.environ['AWS_REGION']
# クライアントの再利用
ddb_resource = boto3.resource('dynamodb', region_name=REGION)
table = ddb_resource.Table(TABLE_NAME)
@logger.inject_lambda_context(log_event=True)
@tracer.capture_lambda_handler
def lambda_handler(event: dict, context: LambdaContext) -> dict:
# ウォームコンテナではここから実行される
user_id = event.get('pathParameters', {}).get('userId')
response = table.get_item(Key={'userId': user_id})
item = response.get('Item')
if not item:
return {'statusCode': 404, 'body': 'Not Found'}
return {
'statusCode': 200,
'body': json.dumps(item)
}
2. メモリサイズを適切に設定する
「コストを下げたい」でLambdaのメモリを256MBとかに絞ると、Cold Start時間が長くなる。LambdaはメモリとCPU性能が比例するので、メモリを上げるとCold Startも早くなる。AWS Lambda Power Tuningツールを使うと最適なメモリ設定が見つかる。
# Lambda Power Tuning(SAMでデプロイ後)の実行例
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:ap-northeast-1:XXXX:stateMachine:powerTuningMachine \
--input '{
"lambdaARN": "arn:aws:lambda:ap-northeast-1:XXXX:function:my-function",
"powerValues": [128, 256, 512, 1024, 2048, 3008],
"num": 20,
"payload": {"key": "test"},
"parallelInvocation": true,
"strategy": "balanced"
}'
うちの場合、Python関数で256MBから1024MBに変えたら、Cold Start時間が480msから190msに短縮されつつ、実行コスト自体はほぼ変わらなかった。「メモリを増やしたら当然コストが上がる」と思い込んでいたのでこれは正直意外だった。コストと速度のトレードオフを数字で見て判断できるのが良い。
3. Function URLとAPI Gatewayの使い分け
API Gatewayを経由するとそれ自体のレイテンシが加算される(数十ms〜100ms程度)。内部サービス間の呼び出しやシンプルなWebhookレシーバーならFunction URLを直接使う方がシンプルで速い場合もある。
ただしFunction URLはWAFと組み合わせにくいというデメリットがあって、ここは好みが分かれるかもしれない。パブリックなAPIにはAPI Gateway + WAFを使う方が安心。
X-Rayとaws-lambda-powertoolsで観測する
Cold Start対策は「やった、終わり」じゃなくて継続的に観測し続けるのが大事。インシデント対応をしたことがある人ならわかると思うけど、突然Cold Startが増えるタイミングって必ずある(デプロイ直後・大規模スパイク時・ランタイムのローリングアップデートなど)。
インシデント対応のベストプラクティスでも触れているけど、問題が起きてから調べるより、常時観測していた方が断然楽。
aws-lambda-powertoolsとX-Rayを組み合わせると、Cold Startかどうかのフラグをトレースに含めて記録できる:
from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.metrics import MetricUnit
logger = Logger()
tracer = Tracer()
metrics = Metrics(namespace="MyApp", service="UserAPI")
is_cold_start = True # グローバルスコープ = 初回だけTrue
@logger.inject_lambda_context
@tracer.capture_lambda_handler
@metrics.log_metrics(capture_cold_start_metric=True) # Cold Startを自動でメトリクス化
def lambda_handler(event, context):
global is_cold_start
if is_cold_start:
logger.info("Cold start detected", extra={
"initDuration": context.get_remaining_time_in_millis(),
"functionVersion": context.function_version
})
# カスタムメトリクスとして記録
metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1)
is_cold_start = False
# 通常処理...
return {"statusCode": 200}
capture_cold_start_metric=Trueを指定するだけでColdStartのカスタムメトリクスが自動でCloudWatchに飛んでいく。これを使ってダッシュボードを作り、Cold Start率が急増したらアラームが飛ぶようにしている。設定自体は5分もかからないのに、地味に効いてくる仕組みだ。
SLI/SLOの設計と組み合わせると、“Cold Start率をSLIの一つとして定義してSLOを設定する”という運用が実現できて、チームで数値ベースで議論できるようになった。「なんか遅い気がする」を卒業できるのが大事。
まとめ
2026年時点のLambda Cold Start対策、チームで実際にやったことを整理するとこうなる:
-
まずCloudWatch Logs Insightsで現状把握。
@initDurationで検索してCold Startの頻度・深刻度を数字で把握する。感覚ではなくデータで判断する。 -
JVM系(Java/Kotlin)は迷わずSnapStartを有効にする。2026年現在、PythonやNode.jsにも展開済みで、Cold Start時間が60〜90%削減できる事例も多い。コードの副作用に注意しつつ、
afterRestoreフックで対応する。 -
ユーザー向けSLAが厳しいAPIにはProvisioned Concurrencyを組み合わせる。Application Auto Scalingでスケジュールするとコストもコントロールできる。全関数に入れると費用が爆発するので注意。
-
アーキテクチャで非同期分離。ユーザーが待つパスをできるだけ短くして、重い処理はSQS経由で別Lambdaに流す設計を心がける。
-
aws-lambda-powertoolsでCold Startを継続観測する。対策を入れて終わりではなく、デプロイ・スパイク時にも異常検知できる体制を作る。
正直まだ完全に解決したわけじゃなくて、大規模スケールアウト時のバーストはまだ課題として残っている。Lambda Reserved Concurrencyと組み合わせた上限設計も継続で改善中。皆さんのチームではどんな工夫していますか?コメントで教えてもらえると嬉しい。