【第2回】AWS Lambda × Python パフォーマンス最適化入門 — 実測で学ぶコールドスタート vs ウォームスタートの違いと計測手法

AWS

前回(第1回)は、このシリーズの入口として「Lambda のパフォーマンスはどこで決まるのか」を、実行環境のライフサイクルとメモリ設定あたりを軸にざっくり整理しました。で、書き終わったあとに自分でこう思ったんです。「結局、自分の関数のコールドスタートって何ミリ秒なの?」と。

感覚で「Lambda 遅いなあ」と言っているだけだと改善したかどうかも分からないので、今回は測る方に振り切ります。数字が出ると急に楽しくなるタイプなので、ちょっと長くなるかもしれません。

この記事でわかること

  • コールドスタートとウォームスタートの違いと INIT Duration の見方
  • CloudWatch Logs Insights でコールドスタートの発生率を調べる方法
  • 初期化処理の内訳を計測して重い import を特定する手法
  • 遅延 import とSnapStart による改善パターン
  • 計測時によくある失敗と対策

コールドスタートとウォームスタートは「INIT を通るかどうか」の差

言葉の定義から。コールドスタートは、新しい実行環境が用意されてランタイムが起動し、ハンドラの外側(トップレベル)のコードが走る一連の流れです。逆にウォームスタートは、すでに温まっている実行環境が再利用されて、ハンドラ関数の中だけが実行される状態。Lambda は一度呼ばれたあとは基本的に実行環境を再利用しようとするので普段はウォームが多く、需要増でスケールするときにコールドが混ざるというのが一般的な理解です。

ここで大事なのは、コールドスタートの中身がさらに分解できるということ。ざっくり言うと

  • 実行環境の作成・ランタイム起動(自分ではほぼ触れない領域)
  • コードのダウンロードと展開(デプロイパッケージのサイズが効く)
  • 初期化コードの実行(import、クライアント生成、設定読み込み)

で、自分たちが手を出せるのは主に3つ目、たまに2つ目。CloudWatch の REPORT 行に出てくる Init Duration がこのあたりをまとめた数字なので、まずはこれを見ます。

まずは発生率を知る|Logs Insights のクエリ

いきなり短縮に走る前に、「そもそも自分の関数はどれくらいの割合でコールドスタートしているのか」を知らないと優先度を決められません。CloudWatch Logs Insights で REPORT 行から @initDuration を抽出し、有無で数える方法が便利です。

filter @type = "REPORT"
| stats count(*) as invocations,
        count(@initDuration) as cold_starts,
        avg(@initDuration) as init_avg_ms,
        pct(@initDuration, 95) as init_p95_ms,
        max(@initDuration) as init_max_ms

@initDuration はコールドスタートのときに出力されることが多いフィールドなので、これがある行を数えると「だいたいの」コールドスタート回数の目安になります。自分の趣味プロジェクト(1時間おきに叩かれる程度の関数)で回してみたら、コールドスタート率が3割超えていて「そんなに冷えてたのか…」となりました。逆にAPI Gateway 経由でそれなりに叩かれている関数は数%。この差を見ずに全部同じ対策を打とうとしていたのが、そもそもの間違いだったっぽいです。

平均だけ見るのはあまり意味がなくて、p95 と max を一緒に出すのがおすすめです。コールドスタートは「たまに刺さる」タイプの遅延なので、平均に埋もれます。

初期化の内側を測る|どの import が重いのか

Init Duration が800msだと分かっても、その内訳が分からないと手が出せません。自分がやっているのは、トップレベルに計測を仕込んで各ステップの時間を測る方法です。

import time, os, json

_t0 = time.perf_counter()
import boto3
_t_boto3 = time.perf_counter()
import pandas as pd
_t_pandas = time.perf_counter()

ddb = boto3.resource("dynamodb").Table(os.environ["TABLE"])
_t_client = time.perf_counter()

INIT_PROFILE = {
    "boto3_ms": round((_t_boto3 - _t0) * 1000, 1),
    "pandas_ms": round((_t_pandas - _t_boto3) * 1000, 1),
    "client_ms": round((_t_client - _t_pandas) * 1000, 1),
}
IS_COLD = True

def lambda_handler(event, context):
    global IS_COLD
    print(json.dumps({"cold": IS_COLD, "init": INIT_PROFILE}))
    IS_COLD = False  # ここ注意。2回目以降は False になる
    ...

この IS_COLD のパターン、実行環境が再利用されるとグローバル変数が生き残るという性質をそのまま利用したものです。Powertools for AWS Lambda(Python)の Metrics を使えば ColdStart メトリクスを勝手に出してくれるので、本番向けならそちらのほうが素直かもしれません。自分は「まず内訳が見たい」段階だったので手書きしました。

自分の環境(Python 3.13 / 512MB / ap-northeast-1)でやってみた結果はこんな感じでした。あくまで1環境の数字なので参考程度に。

  • boto3 の import: 250〜320ms
  • DynamoDB のリソース生成: 90ms前後
  • pandas の import: 1.2〜1.6秒(!)
  • ウォーム時のハンドラ実行: 4〜8ms

pandas が桁違いでした。この関数、実は月1回のバッチ処理でしか集計を使っていなくて、それ以外の呼び出しでは完全に無駄。なので必要になった時点で読み込む形(いわゆる遅延 import)に変えました。

def _summarize(rows):
    import pandas as pd
    return pd.DataFrame(rows).groupby("kind")["amount"].sum().to_dict()

これだけで Init Duration が1.9秒台から500ms前後まで落ちました。「重いライブラリを使うパスと使わないパスを分ける」というだけの話ですが、効果は一番大きかったです。参考までに python -X importtime をローカルで走らせて import ツリーを眺めるのも有効で、boto3 が内部で何を読んでいるかを見ると勉強になります。

SnapStart で「初期化そのものを飛ばす」選択肢

ここまでは初期化を軽くする話でしたが、初期化済みの状態をスナップショットとして持っておく、という方向もあります。Lambda SnapStart です。もともと Java だけでしたが、Python と .NET にも対応し、対応リージョンも拡張されています。Python は 3.12 以降が対象です。

re:Invent 2024 のセッションでは、LangChain 利用の LLM アプリで3.5秒→500ms、DuckDB でのデータ分析で8秒→1秒という改善例が紹介されていたようです。重い初期化を抱えている関数ほど効くという理解でよさそうです。

使うときは、関数バージョンを publish して SnapStart を有効化する形になります。スナップショットの前後にフックを挟めるのも面白いところで、Python だと専用のライブラリが用意されています。

from snapshot_restore_py import register_before_snapshot, register_after_restore

@register_before_snapshot
def before_snapshot():
    ...  # スナップショット前に閉じたいものを閉じる

@register_after_restore
def after_restore():
    global conn
    conn = create_connection()  # 復元後に張り直す

注意点として、スナップショットは「ある瞬間の状態のコピー」なので、初期化時に生成した乱数やタイムスタンプ、張ったままのコネクションがそのまま複製されます。一意性が必要なものは after_restore 側に寄せるのが定石です。あと料金がキャッシュとリストアで別途かかるので、無条件に有効化するものでもないかなと。正直まだ「どのくらいのトラフィックなら元が取れるのか」の感覚が掴めていません。

計測でやりがちだった失敗

最後に、自分がハマったところを3つ。

ひとつは、デプロイ直後に1回叩いて「速くなった!」と喜んでしまうこと。コールドスタートは環境によってブレるので、少なくとも十数回はコールドを引いて分布で見ないと判断できません。自分は関数バージョンを更新して強制的にコールドを作り、それを何度か繰り返す雑スクリプトを回しています。

ふたつめは、Duration と Init Duration を混ぜて見てしまうこと。請求対象時間の扱いも含めてこの2つは別物なので、グラフも分けたほうが分かりやすいです。

みっつめは、ローカルの体感で判断すること。Docker で試すと import は速いのに Lambda 上では遅い、ということが普通に起きます。ストレージやCPUの割り当てが違うので当然ですが、最初は本当に混乱しました。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

AWS Lambda のパフォーマンス改善は、やみくもに対策を打つのではなく、まず「自分の関数は今どうなっているのか」を計測して把握することが大事です。

  • CloudWatch Logs Insights で Init Duration を集計し、コールドスタートの発生率と分布を見る
  • トップレベルに計測を挟んで各初期化ステップの時間を特定する
  • 重いライブラリは遅延 import で対応、または SnapStart で初期化をスキップする
  • 計測は1回ではなく複数回、分布で見ることで正確な判断ができる

次回は、この計測結果を使ってもう少し踏み込んだメモリ最適化や、ランタイムの選択肢について見ていこうと考えています。

タイトルとURLをコピーしました