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

AWS

前回はコールドスタート周りの話を中心に、初期化処理の置き場所やパッケージサイズの削り方あたりをまとめました。今回はシリーズ最終回ということで、テーマを「お金」に寄せます。具体的には Lambda のメモリ設定とコスト最適化、そして自分が実際にやってしまった(あるいは危うくやりかけた)最適化ミスの話です。

ちなみに前回記事を書いたあと、個人プロジェクトの Lambda を全部 128MB に落として悦に入っていたら、翌月の請求がむしろ増えていました。今回の記事の半分くらいはその反省文です。

この記事でわかること

  • Lambda の課金構造(メモリ × 実行時間)の基本
  • やりがちなコスト最適化ミス5つ
  • 本番運用で実際に使っているチューニングの流れ
  • Power Tuning と Compute Optimizer の使い方
  • arm64(Graviton)への移行のコツ

Lambda の料金は「メモリ × 実行時間」で決まる

ここを誤解していると最適化の方向を丸ごと間違えるので、先に整理しておきます。Lambda の課金要素は大きく3つです。

  • リクエスト数(100万リクエストあたり 0.20 USD)
  • 実行時間 = GB-秒(割り当てメモリ GB × 実行時間 秒)
  • おまけで CloudWatch Logs、データ転送、Provisioned Concurrency など

東京リージョンだと x86 で 1 GB-秒あたり約 0.0000166667 USD、arm64(Graviton)で約 0.0000133334 USD とされています。単価は変わることがあるので、実際に計算するときは料金ページを確認したほうが確実です。

で、重要なのは メモリを増やすと CPU も比例して増える という Lambda のリソースモデルです。メモリ設定は 1MB 単位で 128MB~10,240MB まで指定できて、CPU 性能はこの値に連動します。だいたい 1,769MB でおおよそ vCPU 1個分に相当する、という目安がよく引用されます。

つまりこういうことになります。

128MB  × 8.0秒 = 1.0 GB-秒
512MB  × 2.0秒 = 1.0 GB-秒
1024MB × 0.8秒 = 0.8 GB-秒  ← メモリ8倍なのに安い

CPU バウンドな処理なら、メモリを上げたほうが安くなることが普通にあります。「メモリを下げれば安くなる」は I/O 待ちが支配的な処理にしか当てはまらないんですね。自分はここを完全に読み違えていました。

やりがちな最適化ミス5つ

1. とりあえず全部 128MB にする

いちばんやりがちだと思います。JSON をちょっと加工して DynamoDB に入れるだけ、みたいな軽い関数なら 128MB でも正解ですが、pandas で集計したり、画像をリサイズしたり、boto3 で S3 に大きめのオブジェクトを投げたりする関数だと確実に損します。

特に ネットワーク帯域もメモリ設定に連動するので、S3 との転送が多い関数を低メモリにすると転送そのものが遅くなります。S3 が遅いと思ったら自分でボトルネックを作っていた、というオチでした。

2. Power Tuning を1回やって満足する

AWS Lambda Power Tuning は Step Functions で複数のメモリ設定を実際に実行して、コストと実行時間のグラフを出してくれるツールです。SAR(Serverless Application Repository)からデプロイできます。

入力はこんな感じの JSON を Step Functions に渡すだけです。

{
  "lambdaARN": "arn:aws:lambda:ap-northeast-1:123456789012:function:my-func",
  "powerValues": [128, 256, 512, 1024, 1536, 3008],
  "num": 20,
  "payload": {"key": "value"},
  "parallelInvocation": true,
  "strategy": "balanced"
}

strategycost(最安)/ speed(最速)/ balanced(中間)から選べます。ここが個人的にいちばん有用でした。コスト最小点と実行時間最小点は一致しないことが多いので、「どちらを取るか」を明示的に決めさせられるのがいいなと。balancedbalancedWeight で寄せ具合も調整できます。

ただ、これを1回やって「はい 1024MB が最適」で終わらせると、データ量が増えたときにズレます。自分は S3 の対象ファイルが3倍になった時点で最適点が変わっていて、しばらく気づきませんでした。定期的に回すか、少なくともワークロードが変わったタイミングで再測定したほうがよさそうです。

あと parallelInvocation: true は速いですが、コールドスタートを含む挙動や下流のスロットリングに影響するので、DB を叩く関数では素直に false のほうが安全だと思っています。ここは正直まだ自分の中で結論が出てません。

3. コールドスタート対策に即 Provisioned Concurrency を入れる

これは財布に直撃するミスです。Provisioned Concurrency は「待機している時間」にも課金されるので、トラフィックが薄い個人開発だと本体の実行料金を軽く超えます。

自分の場合、月に数百回しか呼ばれない API のために PC を1個だけ有効化していた時期があり、明細を見て静かに無効化しました。トラフィックが常時ある本番 API なら意味がありますが、そうでなければ初期化処理の見直しのほうが費用対効果は高いはずです。

4. arm64 に切り替えていない

これは「ミス」というより機会損失です。Graviton(arm64)は同等構成の x86 に比べて価格性能が改善するとされていて、GB-秒の単価自体も約20%安いです。Python の場合、純粋な Python コードなら Architectures を変えるだけで動くことが多いです。

Resources:
  MyFunc:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.13
      Architectures:
        - arm64
      MemorySize: 1024

ハマるのは C 拡張を含むライブラリ(numpy、pandas、pydantic-core、Pillow など)です。Mac(Apple Silicon)で作ったら偶然動く、Intel Mac や GitHub Actions の ubuntu-latest でビルドしたら動かない、という事故が起きます。Docker で --platform linux/arm64 を明示してビルドするのが結局いちばん確実でした。

docker run --rm --platform linux/arm64 \
  -v "$PWD":/var/task public.ecr.aws/sam/build-python3.13 \
  pip install -r requirements.txt -t python/  # ここ注意、ローカルpipでやると壊れる

5. ログのコストを勘定に入れていない

意外な伏兵でした。Lambda 本体より CloudWatch Logs の取り込み料金のほうが高い、という状態は普通に起こります。デバッグ中に入れた print() がそのまま本番に残っているとじわじわ効いてきます。

対策としては、ログレベルを環境変数で制御する(Lambda の Advanced Logging Controls で AWS_LAMBDA_LOG_LEVEL を設定できます)、ログ形式を JSON にして必要な項目だけ出す、ロググループの保持期間を設定する(デフォルトは「失効しない」なので絶対に設定したほうがいい)、あたりです。頻繁に検索しないログなら Infrequent Access のログクラスに寄せる手もあります。

本番運用で使っているチューニングの流れ

大げさな話ではないですが、今はこの順番でやっています。

  • まず CloudWatch Logs の REPORT 行で Max Memory Used を確認する
  • 実際の使用量に対して割り当てが極端に余っている/足りていない関数を洗い出す
  • 候補に Power Tuning を回して balanced で決める
  • arm64 に変えられるものは変える
  • Compute Optimizer の推奨値をセカンドオピニオンとして眺める

1番目は Logs Insights のクエリで一気に見られます。これがけっこう便利でした。

filter @type = "REPORT"
| stats
    max(@maxMemoryUsed / 1000 / 1000) as maxUsedMB,
    avg(@billedDuration) as avgBilledMs,
    count(*) as invocations
  by @log
| sort maxUsedMB desc

ここで「1024MB 割り当てて 90MB しか使ってない」みたいな関数が出てくるわけですが、使用メモリが少ないからといって下げていいとは限らないのがポイントです。CPU も一緒に下がるので、実行時間が伸びて GB-秒が増える可能性がある。結局測るしかない、という結論に何度も戻ってきます。

メモリの一括更新は boto3 で雑にやっています。タグでフィルタしたほうがきれいですが、自分用スクリプトなので許してください。

import boto3

lam = boto3.client("lambda")
targets = {"convert-image": 1536, "daily-report": 512}

for name, mem in targets.items():
    lam.update_function_configuration(
        FunctionName=name,
        MemorySize=mem,
    )
    print(name, "->", mem)

本番なら当然 IaC 側(SAM / CDK / Terraform)の値を直すべきで、コンソールや API で直接変えるとドリフトします。自分は Terraform をまだ触れていないので、そのうちここも移したいなと思っています。

Compute Optimizer はどう使うか

AWS Compute Optimizer は Lambda のメモリ設定についても推奨値を出してくれます。過去14日分の CloudWatch メトリクスを見て判断する仕組みなので、呼び出し回数が少ない関数だと「データ不足」と言われて何も出ません。個人開発の関数はだいたいこれです。

なので、ある程度呼ばれている関数のスクリーニングに使って、細かい詰めは Power Tuning、という役割分担が現実的かなと。無料で有効化できるので、とりあえずオンにしておく価値はあると思います。

シリーズを通じて残ったこと

4回分を通して一番効いた最適化は、実は「メモリを上げること」でした。パフォーマンスチューニングというと処理を書き直す方向に意識が行きますが、Lambda に関してはスライダーを動かすだけで速くなって安くなることがある、という事実がわりと衝撃でした。逆に、コードを頑張って書き換えた割に効果が薄かった箇所もあって、測らずに手を動かすのは本当に無駄だなと痛感しています。

まだ手を付けられていないのが、SnapStart の Python 対応まわりと、複数関数のメモリ設定を CI で自動的に再評価する仕組みです。後者は Power Tuning を GitHub Actions から定期実行して、結果を差分として PR に出す、みたいなことができそうな気がしていますが、テスト用のペイロードをどう管理するかで詰まっています。何かうまいやり方をしている人がいたら教えてほしいところです。

そもそも「最適化された状態」は固定ではなくて、データ量やライブラリのバージョンが変わるたびに動くもの、というのが今回の一番の学びでした。

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

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

まとめ

  • Lambda の課金は GB-秒ベースなので、メモリを上げると CPU が上がって実行時間が短くなり、トータルコストが下がることがある
  • やりがちなミスは「128MB に下げる」「Power Tuning を1回だけ」「Provisioned Concurrency を安易に入れる」「arm64 に移行しない」「ログコストを見落とす」の5つ
  • 本番ではメモリ使用量の可視化 → Power Tuning → arm64 移行の順で進めるのが効率的
  • Compute Optimizer はスクリーニングツール、細かい詰めは Power Tuning で
  • 最適値は固定ではなく、ワークロード変化に応じて定期的に再測定すること

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

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

🎉 このシリーズは今回で完結です!

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