mybook

サーバーレスアーキテクチャ — 関数で考える

「サーバーがないって、どういうこと?」

新メンバーのサクラが素直に質問した。

「サーバーレスって、文字通りサーバーがないわけじゃないよね?」

「そう、サーバーは存在する。でも私たちがその存在を意識しないで良くなる。プロビジョニング、スケーリング、OSのパッチ適用—全部クラウドプロバイダーが管理してくれる。私たちはコード(関数)だけを書く」

カオリは具体例で説明した。「毎晩0時に売上集計レポートを送信するバッチ処理、どうやって実装する?」

「EC2サーバーを立てて、cronを設定して...」

「それだと、月30回しか動かない処理のために24時間サーバーを維持することになる。Lambda なら、実行した分だけ課金。月30回なら、ほぼ無料」

INFO

サーバーレスは「インフラ管理からの解放」がキーメッセージです。FaaS(Function as a Service)として、AWS Lambda・Google Cloud Functions・Azure Functionsなどが代表的なサービスです。

Lambdaの基本構造

Loading diagram...

AWS SAMでのRuby Lambda実装

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
 
Globals:
  Function:
    Runtime: ruby3.3
    Timeout: 30
    MemorySize: 512
    Environment:
      Variables:
        DATABASE_URL: !Sub "{{resolve:secretsmanager:${AWS::StackName}/db-url}}"
        
Resources:
  # 注文確認メール送信
  SendOrderConfirmationFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/send_order_confirmation.handler
      Events:
        SQSTrigger:
          Type: SQS
          Properties:
            Queue: !GetAtt OrderConfirmationQueue.Arn
            BatchSize: 10
            FunctionResponseTypes:
              - ReportBatchItemFailures
      Policies:
        - SQSPollerPolicy:
            QueueName: !GetAtt OrderConfirmationQueue.QueueName
        - Statement:
            Effect: Allow
            Action: ses:SendEmail
            Resource: "*"
            
  # 日次売上集計
  DailySalesReportFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/daily_sales_report.handler
      Timeout: 300
      Events:
        ScheduledEvent:
          Type: Schedule
          Properties:
            Schedule: cron(0 15 * * ? *)  # 毎日00:00 JST

Lambda ハンドラーの実装

# handlers/send_order_confirmation.rb
require 'json'
require 'aws-sdk-ses'
 
def handler(event:, context:)
  # SQSのバッチ処理
  failed_message_ids = []
  
  event['Records'].each do |record|
    message_id = record['messageId']
    
    begin
      body = JSON.parse(record['body'])
      order_data = JSON.parse(body['Message'])  # SNS経由の場合
      
      send_confirmation_email(order_data)
    rescue => e
      # 部分的な失敗を報告(失敗分だけ再試行)
      puts "Error processing message #{message_id}: #{e.message}"
      failed_message_ids << { itemIdentifier: message_id }
    end
  end
  
  # ReportBatchItemFailures: 失敗したメッセージのみSQSに戻す
  { batchItemFailures: failed_message_ids }
end
 
def send_confirmation_email(order_data)
  ses = Aws::SES::Client.new(region: 'ap-northeast-1')
  
  ses.send_email(
    destination: {
      to_addresses: [order_data['user_email']]
    },
    message: {
      subject: { data: "注文確認 ##{order_data['order_id']}", charset: 'UTF-8' },
      body: {
        html: {
          data: render_email_template(order_data),
          charset: 'UTF-8'
        }
      }
    },
    source: 'noreply@myapp.com'
  )
end
 
def render_email_template(order_data)
  # ERBテンプレートなど
  <<~HTML
    <h1>ご注文ありがとうございます</h1>
    <p>注文番号: #{order_data['order_id']}</p>
    <p>合計: ¥#{order_data['total_price']}</p>
  HTML
end

日次バッチ処理

# handlers/daily_sales_report.rb
require 'json'
require 'aws-sdk-rds-data'
require 'aws-sdk-ses'
 
def handler(event:, context:)
  puts "日次売上集計開始: #{Time.now.iso8601}"
  
  report_date = Date.yesterday
  
  # RDS Data API(サーバーレスでDBアクセス)
  data_client = Aws::RDSDataService::Client.new(region: 'ap-northeast-1')
  
  result = data_client.execute_statement(
    resource_arn: ENV['DB_CLUSTER_ARN'],
    secret_arn: ENV['DB_SECRET_ARN'],
    database: ENV['DB_NAME'],
    sql: <<~SQL,
      SELECT 
        COUNT(*) as order_count,
        SUM(total_price) as total_revenue,
        AVG(total_price) as avg_order_value,
        COUNT(DISTINCT user_id) as unique_customers
      FROM orders
      WHERE DATE(created_at) = :report_date
        AND status NOT IN ('cancelled')
    SQL
    parameters: [
      { name: 'report_date', value: { string_value: report_date.to_s } }
    ]
  )
  
  metrics = extract_metrics(result)
  
  # S3にレポートを保存
  save_report_to_s3(report_date, metrics)
  
  # Slackに通知
  post_to_slack(report_date, metrics)
  
  puts "完了: #{metrics.inspect}"
  
  { statusCode: 200, body: metrics.to_json }
rescue => e
  puts "エラー: #{e.class} - #{e.message}"
  puts e.backtrace.join("\n")
  raise  # エラーを再発生させてLambdaにリトライを任せる
end
 
def extract_metrics(result)
  row = result.records.first
  {
    order_count: row[0].long_value,
    total_revenue: row[1].double_value.to_i,
    avg_order_value: row[2].double_value.to_i,
    unique_customers: row[3].long_value
  }
end

Step Functionsで複雑なフローを管理

注文処理の複数ステップを、Step Functionsでオーケストレーションする。

# state_machine.yaml
Comment: 注文処理ワークフロー
StartAt: ValidateOrder
 
States:
  ValidateOrder:
    Type: Task
    Resource: !GetAtt ValidateOrderFunction.Arn
    Retry:
      - ErrorEquals: ["ServiceUnavailable"]
        IntervalSeconds: 2
        MaxAttempts: 3
        BackoffRate: 2
    Catch:
      - ErrorEquals: ["InvalidOrderError"]
        Next: OrderFailed
    Next: CheckInventory
    
  CheckInventory:
    Type: Task
    Resource: !GetAtt CheckInventoryFunction.Arn
    Next: ProcessPayment
    Catch:
      - ErrorEquals: ["InsufficientInventoryError"]
        Next: OrderFailed
        
  ProcessPayment:
    Type: Task
    Resource: !GetAtt ProcessPaymentFunction.Arn
    Next: UpdateInventory
    Catch:
      - ErrorEquals: ["PaymentFailedError"]
        Next: OrderFailed
        
  UpdateInventory:
    Type: Task
    Resource: !GetAtt UpdateInventoryFunction.Arn
    Next: SendConfirmation
    
  SendConfirmation:
    Type: Task
    Resource: !GetAtt SendConfirmationFunction.Arn
    Next: OrderSucceeded
    
  OrderSucceeded:
    Type: Succeed
    
  OrderFailed:
    Type: Task
    Resource: !GetAtt HandleOrderFailureFunction.Arn
    Next: OrderFailedEnd
    
  OrderFailedEnd:
    Type: Fail
    Error: OrderProcessingFailed
# Step Functionsの各ステップ実装
# handlers/validate_order.rb
def handler(event:, context:)
  order_data = event['order']
  
  raise 'InvalidOrderError' if order_data['quantity'].to_i <= 0
  raise 'InvalidOrderError' if order_data['product_id'].nil?
  
  # バリデーション通過: 次のステートに渡すデータを返す
  {
    order: order_data,
    validated_at: Time.now.iso8601
  }
end

コールドスタートとウォームアップ

サーバーレスの有名な問題「コールドスタート」。

Loading diagram...

対策1: Provisioned Concurrency

# 常に起動済みのコンテナを確保(コスト増加)
OrderProcessorFunction:
  Type: AWS::Serverless::Function
  Properties:
    ProvisionedConcurrencyConfig:
      ProvisionedConcurrentExecutions: 5  # 常に5つ起動

対策2: コードの軽量化

# NG: 全てのgemをrequireする
require 'rails'  # 重い
 
# OK: 必要なものだけrequire
require 'json'
require 'aws-sdk-ses'  # 必要なサービスのみ
require_relative '../lib/email_renderer'

対策3: グローバル変数でクライアントを使い回す

# コンテナ再利用時にクライアントを再初期化しない
$ses_client ||= Aws::SES::Client.new(region: 'ap-northeast-1')
$dynamodb ||= Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
 
def handler(event:, context:)
  # $ses_client は2回目以降再利用される
  $ses_client.send_email(...)
end

冪等性の実装

サーバーレスではリトライが頻発する。同じリクエストが複数回実行されても安全であることが重要。

# handlers/process_payment.rb
def handler(event:, context:)
  order_id = event['order']['id']
  
  # 冪等性キー:同じ注文の決済は1回だけ
  idempotency_key = "payment-#{order_id}"
  
  # DynamoDBでべき等性チェック
  dynamodb = $dynamodb
  
  begin
    dynamodb.put_item(
      table_name: ENV['IDEMPOTENCY_TABLE'],
      item: {
        'pk' => idempotency_key,
        'status' => 'INPROGRESS',
        'ttl' => (Time.now + 24.hours).to_i
      },
      condition_expression: 'attribute_not_exists(pk)'  # 既存があればエラー
    )
  rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
    # 既に処理済み: キャッシュされた結果を返す
    existing = dynamodb.get_item(
      table_name: ENV['IDEMPOTENCY_TABLE'],
      key: { 'pk' => idempotency_key }
    ).item
    
    return existing['result']
  end
  
  # 決済処理
  result = charge_stripe(event['order'])
  
  # 結果をキャッシュ
  dynamodb.update_item(
    table_name: ENV['IDEMPOTENCY_TABLE'],
    key: { 'pk' => idempotency_key },
    update_expression: 'SET #s = :status, #r = :result',
    expression_attribute_names: { '#s' => 'status', '#r' => 'result' },
    expression_attribute_values: { ':status' => 'COMPLETED', ':result' => result }
  )
  
  result
end

サーバーレスのトレードオフ

メリット:
✅ インフラ管理不要(サーバーのパッチ適用、OSアップデートなし)
✅ 自動スケール(トラフィックスパイクに対応)
✅ 使用した分だけ課金
✅ 高可用性が標準(Lambdaは自動でマルチAZ)
✅ イベント駆動と相性が良い

デメリット:
❌ コールドスタート(初回レイテンシ)
❌ 実行時間制限(Lambda最大15分)
❌ デバッグが難しい(ローカル環境の再現が困難)
❌ ベンダーロックイン
❌ 長時間処理には不向き
❌ ステートレスの強制(セッションは外部に持つ必要あり)

WARNING

サーバーレスは「全てのシステム」に適切ではありません。リクエストが常時高頻度で来るAPIサーバーはEC2/ECSの方が安価な場合が多いです。イベント駆動のバッチ処理、Webhook処理、スケジュール実行に特に向いています。

ローカル開発

# AWS SAM CLIでローカルテスト
sam local invoke SendOrderConfirmationFunction \
  --event events/order_placed.json
 
# API Gatewayをローカルでエミュレート
sam local start-api --port 3001
 
# Step Functions ローカルエミュレーター
docker run -p 8083:8083 amazon/aws-stepfunctions-local

カオリはチームに提案した。「Railsは引き続きメインのAPIサーバーとして使う。サーバーレスは、夜間バッチ・イベント処理・Webhook受信の専用にする。ハイブリッドが正解だと思う」


次章では「イベント駆動アーキテクチャ」を学ぶ。EventBridgeとSNS/SQSを使って、サービス間を疎結合にリアクティブに連携させる設計を見ていこう。