LambdaのCold Startで本番が死んだ話と2026年時点でやっと落ち着いた対策

キャンペーン直後にAPI GatewayのレイテンシがSLOをぶち抜いた経験、ありませんか?SnapStart・ARM64・Provisioned Concurrencyの選び方、チームで検証した実測値つきで書きます。

本番でCold Startに殺された日のこと

去年の11月、大規模キャンペーンの直後にAPI Gateway + Lambdaの構成で本番が死んだ。正確には「死んだ」というより「応答が5〜8秒かかって、フロントエンドのタイムアウトを全部食いつぶした」という状態だった。原因はシンプルで、トラフィックが急増したタイミングでLambdaのCold Startが連続発生し、そのレイテンシがSLOをぶち抜いた。SLO設計についての反省はSLO設計で2年間失敗し続けた僕が、ようやく運用が回り始めた話にも書いたけど、Cold Startはそれとは別の次元で向き合わないといけないと痛感した件だった。

以来、チームでCold Start対策を本格的に整備してきた。過去の障害ポストモーテムはLambda Cold Startで本番が死んだ話と、2026年時点でやっと落ち着いた対策にまとめてあるので参照してほしいけど、今回はその後も検証を続けてきた結果、2026年5月時点での最新の知見を書き直したい。あの記事を書いたあとも状況が思ったより変わっていたので。

Cold Startって「知ってる」人は多いけど、実際に数値で把握して対策を選んでいるチームはあんまり多くない印象。「Provisioned Concurrencyを入れておけばいいんでしょ?」で終わっているケースが多くて、それはそれで間違っていないんだけど、2026年現在はもっと選択肢がある。


2026年現在のCold Start構造を改めて整理する

LambdaのCold Startは大きく3フェーズに分かれる。

graph LR
    A["環境初期化<br/>〜50ms<br/>(AWSが管理)"] --> B["ランタイム初期化<br/>〜200〜800ms<br/>(言語・ランタイム依存)"] --> C["ハンドラー外初期化<br/>〜100ms〜数秒<br/>(自分たちのコード)"]

ここ2〜3年でAWS側のインフラ改善によって「環境初期化」フェーズは体感でかなり速くなっている。問題は中間のランタイム初期化ハンドラー外初期化で、これは依然としてアプリ側の責任範囲が大きい。正直、「AWSが速くしてくれた部分」よりも「自分たちのコードが遅い部分」のほうがずっと削りがいがある。

2026年時点での主要ランタイム別のCold Start傾向を実測ベースで整理すると:

ランタイム平均Cold Start (128MB)平均Cold Start (1024MB)備考
Python 3.13180〜350ms120〜250ms依存ライブラリ次第で爆増
Node.js 22150〜300ms100〜200msESMよりCJSが若干早い傾向
Java 21(SnapStart無し)2000〜5000ms1500〜4000msJVM起動がネック
Java 21(SnapStart有り)180〜400ms150〜300ms劇的に改善
Go 1.2480〜150ms60〜120msバイナリサイズを絞ると更に早い
.NET 8300〜600ms200〜450msNativeAOTで大幅短縮可能

この数値はうちのチームで同一VPC内のPrivate SubnetにデプロイしたLambdaで計測したものなので、パブリックエンドポイントとはやや異なるかもしれない。ただ傾向としては参考になると思う。Javaのノーケア運用がいかにやばいか、数字で見ると改めて実感する。


現在のアーキテクチャと対策のマッピング

実際にうちのチームが運用している構成を図にするとこんな感じ:

graph TB
    subgraph Internet
        Client[クライアント]
    end

    subgraph AWS_Account[AWS Account]
        CF[CloudFront]
        APIGW[API Gateway v2<br/>HTTP API]

        subgraph VPC[VPC: 10.0.0.0/16]
            subgraph Public_Subnet[Public Subnet]
                ALB[Application Load Balancer]
            end

            subgraph Private_Subnet_AZ1[Private Subnet AZ-1a]
                Lambda_A[Lambda: API Handler<br/>ARM64 / Python 3.13<br/>Provisioned=5]
                Lambda_B[Lambda: Heavy Processor<br/>ARM64 / Java 21<br/>SnapStart ON]
            end

            subgraph Private_Subnet_AZ2[Private Subnet AZ-1c]
                Lambda_A2[Lambda: API Handler<br/>ARM64 / Python 3.13<br/>Provisioned=5]
                Lambda_B2[Lambda: Heavy Processor<br/>ARM64 / Java 21<br/>SnapStart ON]
            end

            subgraph Data_Layer[Data Layer]
                RDS[(Aurora PostgreSQL<br/>Serverless v2)]
                ElastiCache[(ElastiCache<br/>Valkey 8.x)]
                S3[(S3 Bucket)]
            end
        end

        DynamoDB[(DynamoDB Global Tables)]
        SQS[SQS FIFO Queue]
        EventBridge[EventBridge Scheduler<br/>ウォームアップ定期実行]
    end

    Client --> CF
    CF --> APIGW
    APIGW --> ALB
    ALB --> Lambda_A
    ALB --> Lambda_A2
    Lambda_A --> Lambda_B
    Lambda_A2 --> Lambda_B2
    Lambda_A --> DynamoDB
    Lambda_A --> ElastiCache
    Lambda_B --> RDS
    Lambda_B --> S3
    Lambda_A --> SQS
    EventBridge --> Lambda_A
    EventBridge --> Lambda_A2

ポイントはいくつかあって、まずARM64(Graviton3)への移行、SnapStartの適用範囲の整理、Provisioned Concurrencyをどの関数に当てるかの判断基準、そしてEventBridgeを使ったウォームアップ戦略。順番に実装の詳細を説明していく。


ARM64(Graviton3)移行:地味だが効果は確実

最初は「アーキテクチャ変えたらバグ出ないか不安」という理由でずっと後回しにしていたんだけど、実際に移行してみると思ったより何も起きなかった。Python・Node.jsは基本的にそのまま動く。問題が出るとしたらバイナリが入ったzipをビルドした環境がx86だった場合くらい。

効果としては、Cold Start時間が平均15〜25%短縮(ランタイムによる)、実行コストが約20%削減(同等性能比)、メモリ効率の改善で同じ処理を低メモリ設定で回せるようになった、という3点が主なところ。個人的には、コードを1行も変えずにこれだけ改善するのはかなり費用対効果が高いと思っている。

CDKでの設定はシンプル:

import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';

const apiHandler = new lambda.Function(this, 'ApiHandler', {
  runtime: lambda.Runtime.PYTHON_3_13,
  architecture: lambda.Architecture.ARM_64, // ここだけ変える
  handler: 'handler.lambda_handler',
  code: lambda.Code.fromAsset(path.join(__dirname, '../src/api')),
  memorySize: 512,
  timeout: cdk.Duration.seconds(30),
  environment: {
    POWERTOOLS_SERVICE_NAME: 'api-handler',
    LOG_LEVEL: 'INFO',
  },
});

CI/CDのビルド環境も合わせる必要があって、GitHub ActionsでDockerを使ってビルドしているならこれを追加:

- name: Build Lambda layer (ARM64)
  run: |
    docker run --platform linux/arm64 \
      -v $(pwd)/src:/var/task \
      public.ecr.aws/lambda/python:3.13-arm64 \
      pip install -r requirements.txt -t /var/task/libs

プラットフォームを明示的に指定するのを忘れて最初ハマった。x86のMacでビルドするとlibを混在させてしまって、Lambdaでexec format errorが出る。これ、エラーメッセージだけ見ると原因がわかりにくくて結構時間を溶かすので注意。


SnapStartの適切な活用:Java以外でも使えるようになった

Lambda SnapStart 2026年実装ガイドに詳しくまとめたけど、2025年後半からPython・Node.jsへのSnapStart適用が一般化して、状況がかなり変わった。

簡単に言うと、SnapStartはLambda関数の「初期化後スナップショット」を保存しておいて、Cold Startが発生するたびにJVMや依存関係の初期化をスキップする仕組みだ。Javaの場合は2〜5秒のCold Startが200〜400ms台になるので、効果は絶大だった。

ただし注意点がある。スナップショット復元時に「ユニークな状態」が必要なものはリセットされない問題がある。具体的にはランダムシード、タイムスタンプ、UUIDなどが初期化時にキャッシュされてしまうと、復元後も同じ値が使われ続ける可能性がある。これを怠ると、セキュリティ的にまずい状態になりかねないので要注意。最初にSnapStartを有効にした直後、ログのリクエストIDが同じになる謎の現象が起きて冷や汗をかいた経験がある。

これを防ぐためのフックが用意されている:

import com.amazonaws.services.lambda.runtime.Context;
import org.crac.Core;
import org.crac.Resource;

public class Handler implements RequestHandler<Map<String,String>, String>, Resource {

    private static SecureRandom secureRandom;
    private static String instanceId;

    static {
        Core.getGlobalContext().register(new Handler());
        // 初期化時(スナップショット前)に実行
        initializeConnections();
    }

    @Override
    public void beforeCheckpoint(org.crac.Context<? extends Resource> context) {
        // スナップショット取得前にクリアしておく
        secureRandom = null;
        instanceId = null;
        System.out.println("Before checkpoint: clearing non-serializable state");
    }

    @Override
    public void afterRestore(org.crac.Context<? extends Resource> context) {
        // 復元後に再初期化
        secureRandom = new SecureRandom();
        instanceId = UUID.randomUUID().toString();
        System.out.println("After restore: re-initialized unique state");
    }

    public String handleRequest(Map<String,String> event, Context context) {
        // 処理
    }
}

CDKでSnapStartを有効にする設定:

const javaHandler = new lambda.Function(this, 'HeavyProcessor', {
  runtime: lambda.Runtime.JAVA_21,
  architecture: lambda.Architecture.ARM_64,
  handler: 'com.example.Handler::handleRequest',
  code: lambda.Code.fromAsset('target/function.jar'),
  memorySize: 1024,
  snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS, // これだけ
  currentVersionOptions: {
    provisionedConcurrentExecutions: 3, // SnapStartはバージョン単位で設定
  },
});

Provisioned Concurrencyとウォームアップ戦略:費用対効果の計算

Provisioned Concurrency(PC)は万能薬じゃない。常時ウォームなインスタンスを確保しているわけだから、実行していなくても課金される。費用対効果が合うかどうかを計算してから入れるべきだ。

うちのチームが使っている判断フレームワークをグラフにするとこんな感じ:

xychart-beta
    title "Provisioned Concurrency コスト vs Cold Start 影響(月次試算)"
    x-axis ["PC=0\n(ウォームアップのみ)", "PC=3", "PC=5", "PC=10", "PC=20"]
    y-axis "月次コスト(ドル)" 0 --> 800
    bar [45, 180, 280, 520, 980]
    line [200, 80, 40, 20, 10]

棒グラフがPC費用、折れ線がCold Startによる機会損失の推計値で、交差点がコスト最適点になる。これを見ると、うちのワークロードではPC=5あたりが損益分岐点だとわかる。ただしこれは「Cold Startが発生するたびに一定の離脱率が生じる」という仮定の下なので、ユーザー向けAPIと社内バッチでは全然違う結論になる。社内バッチにPC突っ込んでいたらただの無駄遣いなので、ちゃんと区別したい。

費用を抑えつつウォームを維持したい場合のウォームアップ戦略がこれ:

// EventBridge Schedulerで定期ウォームアップ
const warmupRule = new events.Rule(this, 'WarmupRule', {
  schedule: events.Schedule.rate(cdk.Duration.minutes(4)), // 5分おきに実行
  description: 'Lambda warmup to prevent cold starts',
});

warmupRule.addTarget(new targets.LambdaFunction(apiHandler, {
  event: events.RuleTargetInput.fromObject({
    source: 'warmup',
    warmupCount: 5, // 同時に5インスタンスウォームアップ
  }),
}));

Lambda側でウォームアップイベントを識別して早期リターンする:

import json
import os
import time

def lambda_handler(event, context):
    # ウォームアップリクエストの早期リターン
    if event.get('source') == 'warmup':
        warmup_count = event.get('warmupCount', 1)
        # 並列ウォームアップのために少しスリープしてインスタンスを分散させる
        if warmup_count > 1:
            import random
            time.sleep(random.uniform(0, 0.5))
        return {'statusCode': 200, 'body': 'warmed'}
    
    # 通常のビジネスロジック
    return handle_business_logic(event, context)

def handle_business_logic(event, context):
    # ...
    pass

この方法は完全に無料じゃなくて実行回数の課金はかかるけど、PC課金と比べると圧倒的に安い。正直まだトラフィックパターンによっては不十分なケースもあって、PC=2〜3と組み合わせて使うのがうちのベストプラクティスになっている。


依存関係の最適化:コードサイズと初期化コストを下げる

Cold Startを語るとき、ランタイムの話ばかりになりがちだけど、実はハンドラー外の初期化コストが一番コントロールしやすい部分だ。具体的にはモジュールのインポートとグローバル変数の初期化。地味なところだけど、ここが一番手っ取り早く効果が出る。

Python(Lambda Powertoolsを使った場合):

import os
from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.utilities.typing import LambdaContext
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEventV2

# ← ここがCold Start時に実行される(ウォーム時はスキップ)
logger = Logger(service="api-handler")
tracer = Tracer(service="api-handler")
metrics = Metrics(namespace="MyApp", service="api-handler")

# DB接続もここで初期化(ウォーム時は再利用される)
_db_client = None

def get_db_client():
    global _db_client
    if _db_client is None:
        import boto3
        _db_client = boto3.resource('dynamodb')
    return _db_client

@logger.inject_lambda_context(log_event=True)
@tracer.capture_lambda_handler
@metrics.log_metrics(capture_cold_start_metric=True) # Cold StartをMetricsに記録
def lambda_handler(event: APIGatewayProxyEventV2, context: LambdaContext):
    db = get_db_client()  # 遅延初期化
    # ...

capture_cold_start_metric=Trueが地味に便利で、CloudWatchにCold Startフラグを自動で送ってくれる。これがないと「今の遅さはCold Startか?通常の処理か?」の切り分けがダッシュボード上でできない。入れておいて損はない設定だと思う。

Lambdaのデプロイパッケージのサイズも影響する。Python 3.13の場合、Lambda Layerを使って依存ライブラリを分離すると、関数コードの変更時のデプロイが速くなる(Cold Start自体への影響は限定的だが、デプロイの頻度が高い開発環境では体感が変わる)。

# 不要なものを除外してパッケージサイズを最小化
pip install \
  --platform manylinux2014_aarch64 \
  --target ./layer/python \
  --implementation cp \
  --python-version 3.13 \
  --only-binary=:all: \
  --no-cache-dir \
  -r requirements.txt \
  --exclude '*.dist-info' \
  --exclude '__pycache__'

# サイズ確認
du -sh ./layer/python/
# 目標: 50MB以下に抑えたい

うちのチームでは不要なboto3サブモジュール(boto3はLambda環境に含まれている)をrequirementsから除いたら、Layerのサイズが120MB→38MBになった。これでCold Startが200msほど改善した。地道な作業だけど、こういう積み重ねが効いてくる。

インシデント対応の観点からいうと、Cold Startの発生頻度やレイテンシ分布を常時モニタリングしていないと、何かあったときに原因特定が遅れる。インシデント対応の最新ベストプラクティス2026でも触れているけど、可観測性の整備はCold Start対策と並行でやっておいたほうがいい。

CloudWatchのメトリクスフィルタで「Cold Startだった実行の割合」を継続監視する設定:

const coldStartAlarm = new cloudwatch.Alarm(this, 'ColdStartRateAlarm', {
  metric: new cloudwatch.Metric({
    namespace: 'MyApp',
    metricName: 'ColdStart',
    dimensionsMap: { service: 'api-handler' },
    statistic: 'Sum',
    period: cdk.Duration.minutes(5),
  }),
  threshold: 10, // 5分間に10回以上のCold Startでアラート
  evaluationPeriods: 2,
  alarmDescription: 'Lambda Cold Start rate is high',
  comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
});

なお、バッチ処理や非同期ワークロードにLambdaを使っている場合は、Cold Startの影響度が全然違う。エンドユーザーへのレイテンシに直結しないなら、PCのコストをかけるよりバッチ処理設計の記事のアプローチで設計したほうが合理的なケースも多い。


まとめ

2026年5月時点でのLambda Cold Start対策を実務ベースで整理すると、優先順位としてはこういう順番になる:

  1. ARM64(Graviton3)への移行は最初にやること。コードほぼ無変更で15〜25%のCold Start短縮+コスト削減が得られる。x86のままでいる理由はほぼない

  2. Javaを使っているならSnapStartは必須。2〜5秒が200〜400msになる。Python・Node.jsでも2025年後半から適用可能になったので検討の価値あり。ただしbeforeCheckpoint/afterRestoreフックの実装を忘れずに

  3. Provisioned Concurrencyは万能薬じゃない。費用対効果を計算してから入れる。EventBridgeによる定期ウォームアップと組み合わせると、PC=0〜3の少ない設定でもかなりカバーできる

  4. ハンドラー外の初期化コストの削減が一番コントロールしやすい。遅延初期化パターンの徹底と依存パッケージのスリム化から手をつける

  5. Cold Startを可観測化するcapture_cold_start_metric=True(Powertools)とCloudWatchアラームで、問題が顕在化したときに即対応できる体制を作っておく

次のアクションとしては:まずARM64移行から始めて、その後Cold Startの発生頻度をCloudWatchで計測する。数値を見てからProvisioned ConcurrencyかSnapStartかを判断するのが一番遠回りしない順序だと思う。「対策を入れたら何ミリ秒改善したか」を数値で追えるようになってから、次の施策を選ぶのが個人的にはおすすめだ。

皆さんのチームではどのランタイムが一番Cold Startで苦労してますか?JavaのSnapStart移行は想像以上にスムーズだったので、まだ試してない人はぜひ検証してみてほしい。

U

Untanbaby

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

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

関連記事