Lambda Power Tuningを半年本番で使って分かったメモリ最適化の現実

「とりあえず512MB」で設定してませんか?Lambda Power Tuningを半年本番運用したら、コスト40%削減できた一方で思わぬ落とし穴も。自動化パイプラインの構成も公開。

先日、うちのチームで運用しているサーバーレスアーキテクチャのAWS請求書を見ていたら、Lambdaのコストが想定より30%近く膨らんでいた。犯人を探していくと、メモリ設定をデフォルト128MBかノリで512MBに設定しまくっていた過去の自分たちが出てきた。

正直、Lambda Power Tuningの存在は知っていたし、「いつかやろう」とは思っていた。でも本番でちゃんと走らせてみると、思っていた以上に奥深くて、単純に「最安値のメモリ設定を選べばいい」という話じゃないことを痛感した。半年間の試行錯誤で得た知見を、ここに書き残しておく。

関連する話として、Lambda全般の冷起動問題については以前書いたLambdaのCold Startで本番が死んだ話と2026年時点でやっと落ち着いた対策でも触れているので、合わせて読んでもらえると背景が繋がりやすいと思う。

Lambda Power Tuningとは何か、改めて整理する

AWS Lambda Power Tuning自体は、Alex Casalboniが開発したOSSのStep Functionsステートマシンで、2025年末にv5.0がリリースされた。2026年現在はSAR(Serverless Application Repository)経由で一発デプロイできるし、AWS公式もベストプラクティスとして推奨している。

仕組みはシンプルで、指定したLambda関数を複数のメモリサイズで並列実行し、実行時間とコストのトレードオフを可視化してくれる。

# SAR経由でデプロイする場合
aws serverlessrepo create-cloud-formation-change-set \
  --application-id arn:aws:serverlessrepo:us-east-1:451282441545:applications/aws-lambda-power-tuning \
  --stack-name lambda-power-tuning \
  --capabilities CAPABILITY_IAM CAPABILITY_RESOURCE_POLICY \
  --parameter-overrides '[{"name": "lambdaResource", "value": "arn:aws:lambda:ap-northeast-1:*:function:*"}, {"name": "visualizationURL", "value": "https://lambda-power-tuning.show/"}]'

v5.0で個人的に一番嬉しかったのは、複数のLambda関数をバッチで最適化できる機能と、最適化結果をAutoUpdate(自動で設定適用)できるオプションが安定したこと。以前は手動で設定変更していたので、これは地味に助かった。

実際の入力JSONはこんな感じ。

{
  "lambdaARN": "arn:aws:lambda:ap-northeast-1:123456789012:function:my-api-handler",
  "powerValues": [128, 256, 512, 1024, 1769, 3008],
  "num": 20,
  "payload": {"httpMethod": "GET", "path": "/users"},
  "parallelInvocation": true,
  "strategy": "cost",
  "autoUpdate": true,
  "dryRun": false
}

strategycostspeedbalancedの3種類から選べる。最初はとにかくcostで全部走らせていたんだけど、これが後から後悔するポイントになった(後述)。

本番で走らせてわかった「あるある」パターン

うちのサービスでは大きく3種類のLambda関数がある。APIゲートウェイ直下のレスポンス重視型、DynamoDBアクセスのI/O bound型、そしてS3+Textractを使った重いバッチ処理型。それぞれで最適なメモリ設定が全然違って、ここが面白かった。

xychart-beta
  title "Lambda メモリ設定別 コスト比較(相対値、128MB=100)"
  x-axis ["128MB", "256MB", "512MB", "1024MB", "1769MB", "3008MB"]
  y-axis "コスト(相対値)" 0 --> 200
  bar [100, 95, 88, 102, 140, 185]

API Gateway直下の関数では、128MBから256MBに上げると実行時間が約45%短縮されるのに、コストはほぼ変わらなかった。これはCPUアロケーションがメモリに比例するLambdaの仕様によるもので、処理がCPU-boundだったことを意味している。

一方、DynamoDBアクセスがメインの関数は、128MBでも512MBでも実行時間がほとんど変わらなかった。待ち時間がほぼI/O waitだから当然といえば当然なんだけど、これを知らずに「とりあえず512MB」と設定していた関数が結構あった。無駄に金を払っていた期間が数ヶ月あったわけで、わかったときは少し凹んだ。

重いバッチ処理は予想通り1769MBが最適解だった。1769MBというのはvCPUが2つ割り当てられる閾値で、並列処理の恩恵をフルに受けられる。

xychart-beta
  title "関数タイプ別 実行時間(ms) vs メモリ設定"
  x-axis ["128MB", "256MB", "512MB", "1024MB", "1769MB", "3008MB"]
  y-axis "実行時間(ms)" 0 --> 3000
  line [2800, 1540, 820, 680, 660, 655]

このグラフ、API Handler系の関数の実測値なんだけど、512MBあたりで「肘」ができていて、そこから先はほぼ改善しない。コストと速度のトレードオフを考えると512MBが最適だった。1024MB以上はほぼ誤差の範囲で、増やしたメモリ分だけ無駄に課金されている状態だったということになる。

自動化パイプラインの構成と実装

「手動で毎回走らせるのは無理」というのが正直なところで、CI/CDに組み込んで定期実行する仕組みを作った。構成はこんな感じ。

graph TB
  subgraph "ap-northeast-1 / Production Account"
    subgraph "VPC外リソース"
      EventBridge["EventBridge\n(毎週月曜 9:00)"] --> SFn
      SFn["Step Functions\nPower Tuning"] --> Lambda1
      SFn --> Lambda2
      SFn --> Lambda3
    end

    subgraph "対象Lambda群"
      Lambda1["api-handler\n(現在512MB)"]
      Lambda2["batch-processor\n(現在1769MB)"]
      Lambda3["db-accessor\n(現在128MB)"]
    end

    subgraph "結果処理"
      SFn --> ResultLambda["result-aggregator"]
      ResultLambda --> DynamoDB[("最適化履歴\nDynamoDB")]
      ResultLambda --> SNS["SNS Topic"]
      SNS --> Slack["Slack通知"]
      ResultLambda --> AutoUpdate{"差異 > 20%\nかつ承認済み?"}
      AutoUpdate -- Yes --> UpdateConfig["Lambda設定\n自動更新"]
      AutoUpdate -- No --> Dashboard["CloudWatch\nDashboard"]
    end
  end

  subgraph "管理用"
    Developer["担当者"] --> ApprovalAPI["API Gateway\n承認エンドポイント"]
    ApprovalAPI --> AutoUpdate
  end

完全自動でメモリ設定を変更するのは最初は怖かったので、「差異が20%を超えたときだけSlackに通知して、担当者が承認ボタンを押したら適用する」というフローにした。2ヶ月運用してみて問題ないことを確認してから、差異が30%超の場合は自動適用に切り替えた。段階的に自動化の範囲を広げていくのが精神衛生上よかった。

Result Aggregatorの実装の核心部分はこんな感じ。

import boto3
import json
import os
from datetime import datetime

lambda_client = boto3.client('lambda')
dynamodb = boto3.resource('dynamodb')
sns_client = boto3.client('sns')

def handler(event, context):
    results = event.get('results', [])
    table = dynamodb.Table(os.environ['HISTORY_TABLE'])
    notifications = []

    for result in results:
        function_name = result['functionARN'].split(':')[-1]
        current_memory = get_current_memory(function_name)
        optimal_memory = result['bestConfig']['memorySize']
        current_cost = result['currentCost']
        optimal_cost = result['bestConfig']['cost']

        # コスト差異を計算
        if current_cost > 0:
            cost_diff_pct = ((current_cost - optimal_cost) / current_cost) * 100
        else:
            cost_diff_pct = 0

        # 履歴をDynamoDBに保存
        table.put_item(Item={
            'functionName': function_name,
            'timestamp': datetime.utcnow().isoformat(),
            'currentMemory': current_memory,
            'optimalMemory': optimal_memory,
            'costDiffPercent': str(round(cost_diff_pct, 2)),
            'powerTuningStats': json.dumps(result)
        })

        # 20%以上差異がある場合は通知キューに追加
        if cost_diff_pct >= 20:
            notifications.append({
                'functionName': function_name,
                'currentMemory': current_memory,
                'optimalMemory': optimal_memory,
                'costDiffPercent': round(cost_diff_pct, 2)
            })

    if notifications:
        send_slack_notification(notifications)

    return {'processed': len(results), 'notified': len(notifications)}


def get_current_memory(function_name):
    config = lambda_client.get_function_configuration(FunctionName=function_name)
    return config['MemorySize']


def send_slack_notification(notifications):
    message = "🔧 *Lambda Power Tuning 最適化候補*\n\n"
    for n in notifications:
        message += f"• `{n['functionName']}`\n"
        message += f"  現在: {n['currentMemory']}MB → 推奨: {n['optimalMemory']}MB "
        message += f"(コスト削減: {n['costDiffPercent']}%)\n\n"

    sns_client.publish(
        TopicArn=os.environ['SNS_TOPIC_ARN'],
        Message=json.dumps({'default': message, 'slack': message}),
        MessageStructure='json'
    )

これが動くようになってから、最適化の見落としがほぼなくなった。以前は「なんか請求書高いな」と思ってから調査していたけど、今は毎週月曜に通知が来るので先手を打てるようになった。受け身から能動的な運用に変わった感じで、地味に精神的にも楽になった。

「コスト最小」だけを追うと失敗する話

ここが一番言いたいことかもしれない。最初の頃、strategy: "cost"で全関数を最適化して喜んでいたんだけど、一部の関数でユーザーからレスポンスが遅いという報告が来た。

調べてみると、コスト最小のメモリ設定にしたAPI Handlerが128MBに設定されて、実行時間が2.8秒になっていた。確かにコストは最安値だけど、Lambda関数がAPI Gatewayのタイムアウトに近い値で動いていたわけで、これは明らかにまずい。コストを削ろうとしてユーザー体験を壊しかけた、という典型的な失敗だった。

以来、関数の役割によって戦略を使い分けるルールを作った。

関数タイプstrategy補足
API Gateway直下balancedレスポンス時間 < 500ms を優先
SQS/SNS非同期処理cost多少遅くてもユーザー影響なし
EventBridgeバッチcost同上
Step Functions内speed全体の実行時間に影響するため
ストリーム処理(Kinesis)balancedスロットリング考慮

この分類は正直まだ完全じゃなくて、チームで継続的に見直している途中。特にStep Functions内の関数は、全体の課金がどうなるか次第でcostの方がいいケースもある。単純に決め打ちできないのが難しいところでもあり、面白いところでもある。

あと、2026年現在のLambda Power Tuningはx86_64arm64(Graviton3)の比較もできる。実際に走らせてみると、うちの場合はPythonランタイムの処理でarm64が約20%コスト削減できた一方、Java Lambdaでは差が小さかった。SnapStartとの組み合わせが有効なケースについてはLambda SnapStart 2026年実装ガイドでも詳しく書いたので参考にしてほしい。

pie title arm64 vs x86_64 コスト比較(対象14関数)
  "arm64の方が安い" : 9
  "x86_64の方が安い" : 2
  "誤差範囲内(5%未満)" : 3

9/14でarm64が有利だったのはまあ予想通りとして、2関数でx86_64が有利だったのは意外だった。どちらも正規表現処理が重い関数で、何かアーキテクチャ固有の最適化が効いているのかもしれない。正直まだ深追いできていないけど、「arm64に全移行」という判断を一律にするのは危険だと思った。Power Tuningで関数ごとに検証してから判断する、これに尽きる。

半年間の最適化結果と運用コスト

半年間でうちのチームのLambda関数(約40関数)を全て最適化した結果を正直に書く。

xychart-beta
  title "月次Lambda費用推移(USD)"
  x-axis ["2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04"]
  y-axis "費用(USD)" 0 --> 4000
  line [3420, 3380, 2890, 2460, 2180, 2050]

3420ドルから2050ドルに下がったので、約40%の削減になった。当初の目標が30%だったので目標は達成している。ただし、この数字にはarm64移行の効果も含まれているし、同時期に不要なLambda関数を整理したりもしているので、Power Tuning単体の効果は多分15〜20%くらいだと思っている。「40%削減!」と社内でドヤ顔したかったけど、正確に言うと盛り込みすぎなのでここでは正直に書いておく。

コスト削減額の大半は意外なところから来ていた。少数の「ヘビー関数」が全体の60%以上のコストを占めていて、そこの最適化が効いた。具体的にはS3+Textract処理の関数が3008MBで動いていたのを1769MBに下げただけで、月200ドル以上削減できた。全関数を均等に最適化しようとするより、まずコスト上位の関数を特定することの方が何倍も効果的だった。

逆に失敗したのは、本番トラフィックに近い条件で事前テストしなかった関数でパフォーマンス劣化を引き起こしたこと。Power Tuningのデフォルト設定(num=10〜20)はコールドスタート込みの値なので、ウォームアップ済みの本番環境とは乖離がある。今はnum=50以上、warmup: trueオプションを使うようにしている。

{
  "lambdaARN": "arn:aws:lambda:ap-northeast-1:123456789012:function:heavy-processor",
  "powerValues": [512, 1024, 1769, 2048, 3008],
  "num": 50,
  "payload": "<本番に近いサイズのペイロード>",
  "parallelInvocation": true,
  "strategy": "balanced",
  "warmup": true,
  "warmupNum": 3,
  "autoUpdate": false,
  "dryRun": false
}

warmupオプションで先に数回実行してコンテナをウォームアップしてからベンチマークを取るので、より本番に近い結果が得られる。これ、最初は知らなくてずっと使ってなかった。ドキュメントをちゃんと読もうという話ではあるんだけど、こういうオプションはもう少し目立つ場所に書いておいてほしい。

サーバーレスのコスト管理という観点では月額500万円の請求書を見て動いた。AWS費用を30%削減した3ヶ月の実装記録も参考になるはずで、Lambda以外の観点も含めて整理されている。

まとめ

半年間Lambda Power Tuningを本番で使い続けて学んだことをまとめる。

  • strategy: "cost"だけで全関数を最適化するのは危険。API Gatewayに繋がった関数はbalancedspeedを基本にすること。関数の役割で戦略を分類するテーブルを作っておくと運用が楽になる。

  • warmup: truenum: 50は必須。デフォルト設定のままだと本番とズレた結果が出て、最適化したはずがパフォーマンス劣化を引き起こす。

  • arm64移行は一律じゃなくて関数ごとに判断。うちの場合は9/14関数でarm64が有利だったが、2関数は逆転した。Power Tuningはアーキテクチャ比較にも使えるので、必ずセットで検証すること。

  • EventBridgeで週次自動実行する仕組みを作るのが投資対効果高い。手動でやる運用は2ヶ月で破綻した。承認フロー込みの自動化が現実的な着地点だと思う。

  • コスト削減は一部のヘビー関数に集中している。まず関数ごとのコスト分布を確認して、上位20%の関数から優先的に最適化するのが効率的。

次にやりたいのは、デプロイパイプライン(CodePipeline)と統合して、新しい関数をデプロイするたびに自動でPower Tuningが走る仕組みを作ること。今は週次で走らせているけど、デプロイ直後が一番最適化の効果が高いタイミングだと思っていて、そこを自動化できれば「デプロイしたまま放置」という状況をほぼなくせるはず。誰かすでにやってる人いたら教えてほしい。

U

Untanbaby

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

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

関連記事