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.13150〜300ms600ms軽量・定番
Node.js 22100〜250ms500ms最速クラス
Java 21 (GraalVM Native)200〜500ms1.2sSnapStart必須
Java 21 (JVM)1,500〜4,000ms6s以上SnapStart激推奨
Go 1.2480〜200ms400ms安定した速さ
.NET 8 (NativeAOT)150〜350ms700ms近年大幅改善

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対策、チームで実際にやったことを整理するとこうなる:

  1. まずCloudWatch Logs Insightsで現状把握@initDurationで検索してCold Startの頻度・深刻度を数字で把握する。感覚ではなくデータで判断する。

  2. JVM系(Java/Kotlin)は迷わずSnapStartを有効にする。2026年現在、PythonやNode.jsにも展開済みで、Cold Start時間が60〜90%削減できる事例も多い。コードの副作用に注意しつつ、afterRestoreフックで対応する。

  3. ユーザー向けSLAが厳しいAPIにはProvisioned Concurrencyを組み合わせる。Application Auto Scalingでスケジュールするとコストもコントロールできる。全関数に入れると費用が爆発するので注意。

  4. アーキテクチャで非同期分離。ユーザーが待つパスをできるだけ短くして、重い処理はSQS経由で別Lambdaに流す設計を心がける。

  5. aws-lambda-powertoolsでCold Startを継続観測する。対策を入れて終わりではなく、デプロイ・スパイク時にも異常検知できる体制を作る。

正直まだ完全に解決したわけじゃなくて、大規模スケールアウト時のバーストはまだ課題として残っている。Lambda Reserved Concurrencyと組み合わせた上限設計も継続で改善中。皆さんのチームではどんな工夫していますか?コメントで教えてもらえると嬉しい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事