【第3回】AWS Lambda × Python パフォーマンス最適化入門 — SnapStart・プロビジョニング済み同時実行・レイヤー活用で起動を高速化する実践テクニック

AWS

デプロイパッケージが 60MB を超えたあたりから、明らかにコールドスタートが重くなってきて「これ、そろそろちゃんと向き合わないとまずいな」と思ったのが今回のきっかけです。

前回(第2回はこちら)は、コールドスタートがどのフェーズで発生しているのかを計測して、メモリサイズを振りながら傾向を見る、みたいな話を書きました。今回はその続きで、依存関係の軽量化と、AWS 側が用意してくれている SnapStart / プロビジョニング済み同時実行 / レイヤーをどう使い分けるか、というあたりを自分なりに整理してみます。

そもそも Python の Lambda は何が遅いのか

計測してみて一番わかりやすかったのが、遅さの正体はだいたい import だということでした。ハンドラ本体の処理は数十ミリ秒なのに、初期化フェーズで 2〜3 秒持っていかれる。pandas とか LangChain とか、依存の重いライブラリを入れると露骨に効いてきます。

しかもこの INIT フェーズ、以前はオンデマンド実行だと課金対象外だったんですが、2025年8月1日から はオンデマンドでも初期化時間(INIT フェーズ)が課金対象になっています。つまり「重い import は遅いだけじゃなくて地味に高い」わけで、軽量化する動機がひとつ増えた感じです。

で、対策は大きく2方向あります。

  • 初期化そのものを軽くする(=依存関係の軽量化)
  • 初期化を先に済ませておく(=SnapStart / プロビジョニング済み同時実行)

順番としては、まず前者をやってから後者を検討するのがいいと個人的には思ってます。土台が重いまま SnapStart を被せても、スナップショットの復元やネットワークまわりで結局引きずるので。

依存関係の軽量化でやったこと

1. boto3 をパッケージに入れない

Lambda の Python ランタイムには boto3 / botocore が最初から入っています。requirements.txt に何も考えず boto3 を書いていると、それだけで数十MBの無駄です。自分は最初これをやっていて、パッケージサイズを見て「あ……」となりました。

ただし注意点があって、ランタイム同梱の boto3 のバージョンは AWS 側の都合で更新されます。新しめの API(新サービスの新オペレーションとか)を叩くときはバージョン固定して同梱したほうが安全です。バージョン依存がないなら外す、という判断でいいかなと。

2. ビルドはターゲット環境を明示する

Mac で普通に pip install -t すると、当然 macOS 向けのバイナリが混ざって Lambda で動かないやつが出てきます。ホイールを明示的に指定してあげるとだいぶ事故が減りました。

pip install \
  --platform manylinux2014_x86_64 \
  --implementation cp \
  --python-version 3.13 \
  --only-binary=:all: \
  --target ./package \
  -r requirements.txt

--only-binary=:all: を付けておくと、ソースビルドが必要なパッケージがあったときにその場で落ちてくれるので、デプロイしてから気づくより精神衛生にいいです。

3. 不要ファイルを削る

インストールしたディレクトリをそのまま zip すると、テストコードやら型スタブやら __pycache__ やらが全部入ります。ここを削るだけで体感 3〜4割減ることもありました。

cd package
find . -type d -name "__pycache__" -exec rm -rf {} +
find . -type d -name "tests" -exec rm -rf {} +
find . -name "*.pyc" -delete
find . -name "*.so" -exec strip {} \; 2>/dev/null

strip はネイティブ拡張のシンボルを落とすので効果は大きいんですが、稀に動かなくなるライブラリがあるらしく、自分は numpy 系には掛けないようにしています。正直このへんは経験則で、理屈でちゃんと切り分けられてはいないです。

あと botocore を同梱する場合、botocore/data/ の中に全サービスの API 定義 JSON が入っていて、これがかなり容量を食います。使うサービスだけ残す、という荒業もあるんですが、SDK の内部構造に依存するのでおすすめしづらい。やるなら自己責任で。

4. import を遅延させる

全ての呼び出しで使うわけではない重いライブラリは、関数内に import を落とすと初期化が軽くなります。

def handler(event, context):
    if event.get("mode") == "report":
        import pandas as pd
        return build_report(pd, event)
    return quick_path(event)

ただしこれ、SnapStart と組み合わせると話が逆になります。SnapStart は「初期化済みの状態」をスナップショットするので、むしろモジュールレベルで import しておいたほうが得。最適化の方向がツールによって逆転するのは、ちょっと面白いポイントだなと思いました。

レイヤーは軽量化になるのか、という話

レイヤーを使えばコールドスタートが速くなる、みたいな説明を見かけることがあるんですが、自分が計測した範囲だとそこは疑わしいです。レイヤーは /opt に展開されるだけで、Python から見れば結局同じサイズのコードを読み込んでいます。250MB(解凍後)の上限もレイヤー込みでカウントされるので、「サイズ制限の回避策」でもない。

じゃあ何が嬉しいかというと、

  • 複数関数で共通の依存を使い回せる
  • アプリコードの zip が小さくなるのでデプロイが速い(これは体感かなり違う)
  • AWS 管理レイヤー(SDK for pandas など)をそのまま使える

このあたり。起動高速化というより開発体験の改善、という理解でいます。逆に依存が 250MB を超えるなら、素直にコンテナイメージ(最大10GB)に切り替えたほうが早いです。

SnapStart を Python で使ってみる

SnapStart は Java 専用というイメージがあったんですが、Python と .NET でも GA しています(Python は 3.12 以降のランタイム)。仕組みとしては、初期化済み実行環境のメモリとディスク状態をスナップショットとして取っておいて、新しい環境はそれを復元して起動する、というもののようです。数秒かかっていた初期化がサブ秒になるケースがあるとアナウンスされていて、実際に重い import を抱えた関数ほど効きます。

有効化自体は簡単で、SAM ならこれだけ。バージョンを発行して、そのバージョン(またはエイリアス)に対して有効になります。

Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.13
      AutoPublishAlias: live
      SnapStart:
        ApplyOn: PublishedVersions

気をつけたいのが「スナップショットは使い回される」という点です。初期化時に生成した乱数やユニークID、開きっぱなしのDBコネクションは、復元後の全環境で同じものが復活します。乱数を鍵に使っているようなコードは普通に事故ります。

そこで用意されているのがランタイムフックで、Python では snapshot-restore-py を使う、とされています。

from snapshot_restore_py import register_before_snapshot, register_after_restore

@register_before_snapshot
def close_connections():
    db.close()

@register_after_restore
def reconnect():
    db.connect()
    reseed_random()

制約もそれなりにあって、プロビジョニング済み同時実行との併用不可、EFS 不可、512MB を超えるエフェメラルストレージ不可、アーキテクチャの制限あたりは事前に確認しておいたほうがいいです。あと料金。Java は追加料金なしですが、Python / .NET はスナップショットのキャッシュ保管と復元にそれぞれ課金が発生します。呼び出し頻度が極端に低くてバージョンをたくさん抱えている構成だと、思ったより効いてくるかもしれません。

プロビジョニング済み同時実行との使い分け

プロビジョニング済み同時実行は、要するに「初期化済みの環境を常時いくつか温めておく」機能です。コールドスタートを実質ゼロにできる代わりに、待機中もお金がかかります。Application Auto Scaling でスケジュール設定できるので、「平日9〜19時だけ5個」みたいな運用が現実的な落としどころかなと。

aws application-autoscaling put-scheduled-action \
  --service-namespace lambda \
  --resource-id function:my-func:live \
  --scalable-dimension lambda:function:ProvisionedConcurrency \
  --scheduled-action-name weekday-morning \
  --schedule "cron(0 0 ? * MON-FRI *)" \
  --scalable-target-action MinCapacity=5,MaxCapacity=5

自分の中の判断基準はこんな感じです。トラフィックのピークが読めて、レイテンシ要件が厳しい(ユーザー向けAPIなど)ならプロビジョニング済み同時実行。スパイクが読めなくて、それなりに速ければいい、かつ初期化が重いなら SnapStart。バッチや非同期処理なら、そもそもどちらも要らなくて依存の軽量化だけで十分、というパターンも多いです。

で、結局どうしたか

自分の関数(Claude API を叩くやつ)では、まず boto3 を外して不要ファイルを削って、パッケージを 58MB → 19MB まで落としました。この時点でコールドスタートが 2.4秒 → 1.1秒 くらい。そこから SnapStart を入れて 0.4秒前後まで来たので、今のところこれで満足しています。プロビジョニング済み同時実行は、月額を計算した瞬間に「今はいいかな」となりました。財布と相談した結果です。

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

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

📚 シリーズ「AWS Lambda × Python パフォーマンス最適化入門」(第3回 / 全4回)

← 前回の記事: 前回の記事はこちら

→ 次回の記事: 【第4回】AWS Lambda × Python パフォーマンス最適化入門 — やりがちな最適化ミスと本番運用で使えるチューニングのまとめ

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