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 を活用する方針で分け始めてます。すべての関数に必要なわけじゃないってのが、実装してわかったホントのところですね。

正直、まだ本番環境で新しいケースに出会うたびに「あ、こういう罠があるのか」って学んでるので、完全に最適化できてるわけじゃないんですよ。でも、ここに書いた失敗パターンを避けるだけで、かなり安定した運用ができるようになるはずです。

U

Untanbaby

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

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

関連記事