mybook

パフォーマンスとコスト — コールドスタートと最適化

ECサイトのLambda移行から3ヶ月が経った。全体的には快調だったが、ある日ダイチは気になるグラフを発見した。

「この P99 レイテンシが時々500msを超えてる。何だろう?」

CloudWatch のメトリクスを掘り下げると、パターンが見えてきた。アクセスが少ない深夜から朝方に、たまにリクエストが来たときだけ遅い。これはコールドスタートだ。

コールドスタートの解剖

なぜコールドスタートが発生するか

Loading diagram...

コールドスタートは以下の3フェーズで構成される。

フェーズ時間制御可否
コンテナ起動50-200ms不可(AWSが管理)
ランタイムロード100-500ms言語選択で影響
初期化コード任意あなたが制御

Rubyはコールドスタートが比較的長い言語の一つだ(Go/Python より長く、Java/Node.jsと同程度)。

コールドスタートの計測

# CloudWatch Logs Insights でコールドスタートを計測
fields @timestamp, @message
| filter @message like /REPORT/
| parse @message "Duration: * ms Billed Duration: * ms Memory Size: * MB Max Memory Used: * MB Init Duration: * ms"
    as duration, billedDuration, memorySize, maxMemoryUsed, initDuration
| filter ispresent(initDuration)  # Init Duration があるのがコールドスタート
| stats
    avg(initDuration) as avgInitDuration,
    max(initDuration) as maxInitDuration,
    count(*) as coldStartCount

コールドスタート対策

1. 初期化コードの最適化

ハンドラ関数の外にある「初期化コード」はコールドスタート時のみ実行される。これを最小限にする。

# 悪い例: ハンドラ内で毎回接続を作る
def lambda_handler(event:, context:)
  # 毎リクエストでクライアント生成(ウォームスタートでも毎回実行される)
  dynamodb = Aws::DynamoDB::Client.new
  s3 = Aws::S3::Client.new
  result = dynamodb.get_item(...)
end
 
# 良い例: グローバルスコープで一度だけ初期化
# コールドスタート時のみ実行され、ウォームスタートでは再利用される
require 'aws-sdk-dynamodb'
require 'aws-sdk-s3'
 
$dynamodb = Aws::DynamoDB::Client.new
$s3 = Aws::S3::Client.new
 
def lambda_handler(event:, context:)
  # $dynamodb, $s3 は既に初期化済み
  result = $dynamodb.get_item(...)
end
# 遅延初期化パターン(重い依存関係に有効)
$payment_client = nil
 
def payment_client
  $payment_client ||= begin
    secret = get_secret(ENV['PAYMENT_SECRET_ARN'])
    PaymentSDK::Client.new(api_key: secret['api_key'])
  end
end
 
def lambda_handler(event:, context:)
  # payment_client が必要になった時点で初めて初期化
  result = payment_client.charge(...)
end

2. require の最適化

# 悪い例: 不要な gem を読み込む
require 'rails'          # 絶対NG: Rails全体
require 'active_record'  # DBが必要な関数のみ
require 'nokogiri'       # HTMLパースが必要な関数のみ
 
# 良い例: 必要なものだけ require する
require 'json'           # 標準ライブラリ
require 'logger'         # 標準ライブラリ
require 'aws-sdk-dynamodb'  # 使うサービスのみ(aws-sdk-allは使わない)
# gem のサイズ確認
gem list --details | grep -E "^(aws-sdk-|rails|nokogiri)"
 
# Lambda デプロイパッケージのサイズ確認(50MB以下が目標)
sam build && du -sh .aws-sam/build/*/

3. Provisioned Concurrency

コールドスタートを完全に排除したい場合は Provisioned Concurrency を使う。指定した数のコンテナを常時ウォーム状態で維持する。

Resources:
  ProductsFunction:
    Type: AWS::Serverless::Function
    Properties:
      AutoPublishAlias: live
      ProvisionedConcurrencyConfig:
        ProvisionedConcurrentExecutions: 5  # 5コンテナを常時ウォーム状態に
 
  # スケジュールでプロビジョニング数を変動させる(コスト最適化)
  ScaleUpSchedule:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 20
      MinCapacity: 5
      ResourceId: !Sub "function:${ProductsFunction}:live"
      ScalableDimension: lambda:function:ProvisionedConcurrency
      ServiceNamespace: lambda
      ScheduledActions:
        - ScheduledActionName: scale-up-for-sale
          Schedule: "cron(0 9 * * ? *)"  # 毎朝9時にスケールアップ
          ScalableTargetAction:
            MinCapacity: 20
            MaxCapacity: 20
        - ScheduledActionName: scale-down-after-sale
          Schedule: "cron(0 22 * * ? *)"  # 夜10時にスケールダウン
          ScalableTargetAction:
            MinCapacity: 5
            MaxCapacity: 5

INFO

Provisioned Concurrency は追加費用がかかる(GB-秒あたり$0.0000041667)。セール期間中だけ有効にするスケジューリングが費用対効果に優れる。通常時はウォームスタートでもP95が100ms以下なら不要。

メモリとコストのトレードオフ

LambdaのCPUはメモリに比例して割り当てられる。メモリを増やすと実行速度が上がり、結果的にコストが下がることがある。

コスト = (リクエスト数 × $0.0000002) + (実行時間 × メモリ(GB) × $0.0000166667)

例1: 128MB, 実行時間 500ms
コスト = (1,000,000 × $0.0000002) + (500/1000 × 0.125 × $0.0000166667 × 1,000,000)
       = $0.20 + $1.04 = $1.24/月(100万リクエスト)

例2: 512MB, 実行時間 200ms(メモリ4倍でCPU増、実行速度2.5倍向上)
コスト = (1,000,000 × $0.0000002) + (200/1000 × 0.5 × $0.0000166667 × 1,000,000)
       = $0.20 + $1.67 = $1.87/月

この例では128MBのほうが安いが、画像処理のような重い計算では逆転する。AWS Lambda Power Tuning というツールで最適なメモリサイズを自動探索できる。

# Lambda Power Tuning のデプロイと実行
# https://github.com/alexcasalboni/aws-lambda-power-tuning
npm install -g serverless
serverless deploy
 
# 自動チューニング実行(10回実行して最適値を探索)
aws stepfunctions start-execution \
  --state-machine-arn "arn:aws:states:ap-northeast-1:xxx:stateMachine:powerTuningMachine" \
  --input '{
    "lambdaARN": "arn:aws:lambda:ap-northeast-1:xxx:function:ProductsFunction",
    "powerValues": [128, 256, 512, 1024, 2048, 3008],
    "num": 10,
    "payload": {"path": "/products", "httpMethod": "GET"},
    "parallelInvocation": true,
    "strategy": "cost"
  }'

X-Ray によるトレーシング

パフォーマンスのボトルネックを特定するには、AWS X-Ray の分散トレーシングが有効だ。

Globals:
  Function:
    Tracing: Active  # 全関数でX-Rayトレースを有効化
# X-Ray SDK for Ruby
require 'aws-xray-sdk'
 
XRay.configure do |c|
  c.daemon_address = '127.0.0.1:2000'
end
 
def lambda_handler(event:, context:)
  # カスタムサブセグメントで詳細なトレース
  XRay.recorder.capture('DynamoDB:GetProduct') do |seg|
    seg.put_annotation('product_id', event.dig('pathParameters', 'id'))
    result = $dynamodb.get_item(...)
    seg.put_metadata('item_size', result.item&.to_json&.bytesize)
    result
  end
end

X-Ray コンソールで確認できるサービスマップ:

Loading diagram...

この例では、S3 へのアクセスがボトルネック(120ms)だと分かる。

CloudWatch メトリクスとアラート

# 重要なメトリクスへのアラート設定
LambdaErrorAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    AlarmName: !Sub "${AWS::StackName}-lambda-errors"
    MetricName: Errors
    Namespace: AWS/Lambda
    Dimensions:
      - Name: FunctionName
        Value: !Ref ProductsFunction
    Statistic: Sum
    Period: 60
    EvaluationPeriods: 1
    Threshold: 10
    ComparisonOperator: GreaterThanThreshold
    AlarmActions:
      - !Ref AlertSnsTopic
 
LambdaDurationAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    AlarmName: !Sub "${AWS::StackName}-lambda-duration"
    MetricName: Duration
    Namespace: AWS/Lambda
    Dimensions:
      - Name: FunctionName
        Value: !Ref ProductsFunction
    ExtendedStatistic: p99  # P99レイテンシ
    Period: 300
    EvaluationPeriods: 2
    Threshold: 3000  # 3秒を超えたらアラート
    ComparisonOperator: GreaterThanThreshold

コスト管理の実践

AWS Cost Explorer での可視化

# CLIでLambdaのコストを確認
aws ce get-cost-and-usage \
  --time-period Start=2024-01-01,End=2024-01-31 \
  --granularity MONTHLY \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["AWS Lambda"]}}' \
  --metrics "UnblendedCost"

コスト最適化のチェックリスト

最適化項目期待効果
不要な CloudWatch Logs 保持期間を短縮ストレージコスト削減
Lambda メモリを Power Tuning で最適化実行コスト削減
DynamoDB を PAY_PER_REQUEST から Provisioned に変更(高スループット時)DynamoDB コスト削減
S3 Intelligent-Tiering 有効化ストレージコスト削減
CloudFront キャッシュで Lambda 呼び出し削減Lambda コスト削減

ダイチのECサイトでの最適化結果:

Lambda 最適化後の月次コスト(100万リクエスト想定):
- Lambda 実行費: ¥180
- API Gateway: ¥350
- DynamoDB: ¥800
- CloudFront: ¥500
- その他(SQS, SES等): ¥200
合計: 約 ¥2,030/月

Before(EC2ベース): 約 ¥30,000/月
削減率: 93%

「コストが93%削減されたのは驚きだったけど、それ以上に深夜の呼び出しがなくなったことが大きい」とダイチはチームへの報告でそう述べた。

WARNING

コスト削減の罠: トラフィックが一定量を超えると、Lambdaのコストはオンデマンドインスタンスを上回る場合がある。月1億リクエストを超えるような高トラフィックでは、Reserved Instancesを使ったECSやEC2のほうが安くなることも。常にコスト試算を行うこと。