こんにちは。まちやです!
皆さんはLambdaのコールドスタート時の処理速度に悩まされたことは
ありますでしょうか?
Webアプリを開発していたのですが、画面のレンダリングに平均11秒かかるという深刻なパフォーマンス問題に直面しました。
調査の結果、原因はLambdaの処理の遅さにあることが判明。
改善対応を実施し、以下の結果を得ることができました。
| コールドスタート | ウォームスタート | |
|---|---|---|
| 改善前 | 平均 11.1 秒 | 平均 0.4 秒 |
| 改善後 | 平均 3.9 秒 | 平均 0.3 秒 |
本記事では、改善の過程で得られた知見を共有します!
動作環境
- 言語:Python 3.13
- ランタイム:AWS Lambda(Python 3.13)
前提知識
本記事はLambdaのライフサイクル(Init / Invoke フェーズ等)を理解していると読みやすいです。
参考:Lambdaのhandler外(メソッド以外)のコードは「コールドスタート時の1回だけ実行される」という話 - DevelopersIO
Webアプリのアーキテクチャ概要
本アプリは以下のような構成です。
- DNS:Route 53 でドメイン管理
- セキュリティ:WAF でWebアプリへの不正アクセスを防御
- フロントエンド:VPC内のWeb Applicationサーバーでホスティング
- バックエンド:複数のLambda(API群)で各種APIを処理
- データベース:RDS Proxy経由でAurora Serverless v2に接続
- 認証情報管理:Secrets Manager でDB接続情報等を管理
クライアントからのリクエストはInternet → Route 53 → WAF → Web Application
→ 各Lambda(API群)→ RDS Proxy → Aurora Serverless v2 という流れで処理されます。
【調査】X-Rayでボトルネックを特定する
まず、API GatewayとLambdaに AWS X-Ray を有効化し、各APIのトレースを取得しました。
AWS X-Rayの使い方:AWS公式ドキュメント - X-Ray
X-Rayトレース結果(改善前)
| No | API | メモリ(MB) | Cold全体(秒) | Init(秒) | 実行(秒) | Warm全体(秒) | Warm実行(秒) |
|---|---|---|---|---|---|---|---|
| 1 | API1 | 512 | 8.2 | 4.5 | 3.0 | 1.2 | 1.2 |
| 2 | API2 | 256 | 11.3 | 2.2 | 8.2 | 0.3 | 0.3 |
| 3 | API3 | 512 | 12.5 | 4.6 | 7.3 | 0.2 | 0.2 |
| 4 | API4 | 512 | 8.5 | 4.4 | 3.5 | 0.3 | 0.3 |
| 5 | API5 | 256 | 11.6 | 2.3 | 8.0 | 0.3 | 0.3 |
| 6 | API6 | 512 | 14.2 | 4.4 | 9.1 | 1.3 | 1.3 |
| 7 | API7 | 512 | 12.6 | 4.5 | 7.4 | 0.3 | 0.3 |
| 8 | API8 | 512 | 9.1 | 4.7 | 3.7 | 0.1 | 0.1 |
| 9 | API9 | 512 | 12.4 | 4.4 | 7.3 | 0.4 | 0.3 |
| 10 | API10 | 256 | 11.0 | 2.1 | 8.1 | 0.2 | 0.2 |
| 11 | API11 | 256 | 10.6 | 2.1 | 7.7 | 0.3 | 0.2 |
| 12 | API12 | 256 | 11.4 | 2.5 | 8.2 | 0.2 | 0.2 |
考察
トレース結果から、2つの問題が浮かび上がりました。
① Initフェーズに時間がかかりすぎている
コールドスタート時のInitフェーズだけで平均 3.6秒 を要していました。
Webアプリとして初期化だけでこれだけの時間がかかるのは致命的です。
② コールドスタート時のInvokeフェーズが異常に遅い
- コールドスタート時の実行時間(Init除く):平均 6.8秒
- ウォームスタート時の実行時間:平均 0.4秒
この差は「毎回実行する必要のない処理が、コールドスタート時に毎回走っているのでは?」と推察しました。
【改善策1】Lambda SnapStartでInitフェーズを高速化
SnapStartとは?
コールドスタートを改善する主な方法は2つあります。
① Lambda SnapStart
InitフェーズのサーバーState(メモリ・ディスク)をスナップショットとしてS3に保存しておき、起動時にそれをリストアすることで、通常のInitよりも高速に起動できる機能です。
② Provisioned Concurrency
初期化済みのLambda実行環境を指定した同時実行数分だけ事前にプールしておく機能です。設定した数のインスタンスを常にWarm状態に保ちます。
今回の選択:SnapStart
Provisioned Concurrencyは「Warm状態のサーバーを常時起動し続けるコスト」が発生し、「実行した分だけ課金」というLambdaのメリットが薄れます。
そのため、今回は SnapStart を採用しました。
また、参照系APIのみに絞って適用しています。
SnapStart適用後のX-Rayトレース結果
SnapStart適用後はInitフェーズがなくなり、代わりにRestoreフェーズ(スナップショットのリストア)が追加されます。
| No | API | メモリ(MB) | Cold全体(秒) | Restore(秒) | Invocation(秒) | Warm全体(秒) | Warm Invocation(秒) |
|---|---|---|---|---|---|---|---|
| 1 | API1 | 512 | 5.7 | 0.9 | 4.6 | 1.2 | 1.1 |
| 2 | API2 | 256 | 6.9 | 0.8 | 5.9 | 0.2 | 0.1 |
| 3 | API3 | 512 | 6.5 | 1.4 | 5.0 | 0.2 | 0.2 |
| 4 | API4 | 512 | 6.1 | 0.9 | 5.0 | 0.2 | 0.2 |
| 5 | API5 | 256 | 7.1 | 0.9 | 6.1 | 0.1 | 0.1 |
| 6 | API6 | 512 | 8.0 | 0.8 | 7.0 | 1.0 | 1.0 |
| 7 | API7 | 512 | 6.9 | 1.0 | 5.8 | 0.2 | 0.2 |
| 8 | API8 | 512 | 5.5 | 0.8 | 4.6 | 0.1 | 0.1 |
| 9 | API9 | 512 | 6.6 | 0.9 | 5.5 | 0.3 | 0.2 |
| 10 | API10 | 256 | 6.7 | 0.8 | 5.7 | 0.1 | 0.1 |
| 11 | API11 | 256 | 6.4 | 0.8 | 5.5 | 0.1 | 0.1 |
| 12 | API12 | 256 | 6.7 | 0.8 | 5.7 | 0.1 | 0.1 |
SnapStart適用結果
| コールドスタート | ウォームスタート | |
|---|---|---|
| 適用前 | 平均 11.1 秒 | 平均 0.4 秒 |
| 適用後 | 平均 6.6 秒 | 平均 0.3 秒 |
【改善策2】Secrets Managerの取得情報をグローバル変数にキャッシュする
問題
今回のアプリはDBが複数スキーマ構成のため、スキーマごとに異なる接続情報をSecrets Managerから取得する必要がありました。
改善前のコード(イメージ)
def get_password(schema):
# Secrets Manager APIを呼ぶ
return secrets_manager.get_secret(f"{schema}/password")
def get_schema_name(schema):
# Secrets Manager APIを呼ぶ
return secrets_manager.get_secret(f"{schema}/name")
def lambda_handler(event, context):
for schema in ["schema_a", "schema_b", "schema_c"]:
password = get_password(schema) # 3回呼ばれる
name = get_schema_name(schema) # 3回呼ばれる
# → 合計6回 Secrets Manager APIを呼ぶ
アプリ仕様の関係で3つのスキーマへの接続が必要なのですが、
メソッドが呼ばれるたびにSecrets Manager APIを叩いていたため、合計6回のAPI呼び出しが発生していました。
改善後のコード(イメージ)
# グローバルスコープでキャッシュ
_secrets_cache = {}
def get_secret(schema):
if schema not in _secrets_cache:
# 初回のみ Secrets Manager APIを呼ぶ
secret = secrets_manager.get_secret(f"{schema}/credentials")
_secrets_cache[schema] = {
"password": secret["password"],
"name": secret["name"]
}
return _secrets_cache[schema]
def lambda_handler(event, context):
for schema in ["schema_a", "schema_b", "schema_c"]:
credentials = get_secret(schema)
# → 合計3回に削減(初回のみ、以降はキャッシュを利用)
パスワードとスキーマ名をセットで取得・キャッシュすることで、API呼び出しを6回→3回に削減しました。
AWSも推奨しています
AWS公式動画でも、Secrets Managerの認証情報取得はグローバルスコープでキャッシュすることが推奨されています。
適用結果
| コールドスタート | ウォームスタート | |
|---|---|---|
| 改善前 | 平均 6.6 秒 | 平均 0.3 秒 |
| 改善後 | 平均 5.8 秒 | 平均 0.3 秒 |
このアプリは複数スキーマのDB設計にしてますが
1つのスキーマにデータを集約することができれば
RDSProxyのコネクションプーリングが使えるし、
Secrets Managerへの問合せも一回で済むんで
もっと早くなるんだろうとは思います・・・
【改善策3】boto3クライアントをグローバルスコープで初期化する
問題
AWSのLambda ベストプラクティスでは、boto3クライアントはグローバルスコープで宣言することが推奨されています。
しかし、改善前のコードでは lambda_handler 内でクライアントを初期化しているケースが多数ありました。
改善前のコード(イメージ)
def lambda_handler(event, context):
# ハンドラー内で毎回初期化 → 呼び出しのたびに100〜300msのコストが発生
s3_client = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
...
改善後のコード(イメージ)
import boto3
# グローバルスコープで初期化 → 再利用されるためコスト発生は初回のみ
s3_client = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
def lambda_handler(event, context):
# 初期化済みのクライアントをそのまま利用
s3_client.put_object(...)
...
boto3.client() の初期化には 100〜300ms 程度かかってました。
グローバルスコープで宣言することで、Lambdaの実行環境が再利用される限りクライアントも再利用されるため、2回目以降の呼び出しでは初期化コストがかかりません。
適用結果
| コールドスタート | ウォームスタート | |
|---|---|---|
| 改善前 | 平均 5.8 秒 | 平均 0.3 秒 |
| 改善後 | 平均 3.9 秒 | 平均 0.3 秒 |
まとめ
3つの改善策を適用した結果、コールドスタートを 11.1秒 → 3.9秒 に短縮できました。
| 改善策 | コールドスタート | 効果 |
|---|---|---|
| 改善前(ベースライン) | 11.1 秒 | - |
| ① Lambda SnapStart適用 | 6.6 秒 | ▲ 4.5秒短縮 |
| ② Secrets Managerキャッシュ化 | 5.8 秒 | ▲ 0.8秒短縮 |
| ③ boto3クライアントのグローバル化 | 3.9 秒 | ▲ 1.9秒短縮 |
| 合計 | 3.9 秒 | ▲ 7.2秒短縮(約65%改善) |
今回の学び
1. まずX-Rayで計測する
「なんとなく遅い」の状態から改善を始めても効果は出にくいので、X-Rayでトレースを取り、どのフェーズに時間がかかっているかを定量的に把握することが改善の第一歩です。
2. グローバルスコープを有効活用する
Lambdaはウォームスタート時に実行環境が再利用されます。この特性を活かし、以下のリソースはグローバルスコープで初期化・キャッシュするのがベストプラクティスです。
- boto3クライアント
- Secrets Managerから取得した認証情報
- DBコネクション など
3. パフォーマンス改善はコスト削減にもつながる
Lambdaは実行時間に応じて課金されます。処理時間を短縮することは、ユーザー体験の向上だけでなく、Lambda実行コストの削減にも直結します。まさに一石二鳥です。
以上、Lambda のパフォーマンスチューニングの実例でした。
同じような問題で悩んでいる方の参考になれば幸いです!
