mybook

API Gateway + Lambda — REST API を構築する

ダイチのチームでは、ECサイトの注文APIが週1回のセールのたびに応答遅延を起こしていた。モノリシックなRailsアプリへの負荷が原因だ。まず商品検索APIをLambdaに切り出してみることにした。

「Rails で書いてた GET /products を Lambda に移す。同じインターフェースを保ちながら」

API Gateway の種類

API Gatewayには3つの種類がある。

種類ユースケースLambda との統合
REST API完全な機能が必要な場合プロキシ統合 or マッピング
HTTP APIシンプル・低レイテンシ・低コストプロキシ統合のみ
WebSocket API双方向通信カスタム統合

新しいプロジェクトでは HTTP API が推奨される。REST API より40〜60%安く、レイテンシも低い。ただし一部の高度な機能(リクエスト/レスポンスマッピング、使用量プランなど)は REST API のみ対応。

INFO

ダイチのユースケースではHTTP APIで十分。シンプルなREST APIならHTTP APIを選ぶのが現代のベストプラクティスだ。

プロキシ統合の仕組み

HTTP APIのプロキシ統合では、リクエスト全体がそのままLambdaに渡される。

Loading diagram...

Lambda が受け取る event オブジェクトの構造(HTTP API の場合):

{
  "version": "2.0",
  "routeKey": "GET /products",
  "rawPath": "/products",
  "rawQueryString": "category=shoes&page=1",
  "headers": {
    "authorization": "Bearer xxx",
    "content-type": "application/json"
  },
  "queryStringParameters": {
    "category": "shoes",
    "page": "1"
  },
  "requestContext": {
    "http": {
      "method": "GET",
      "path": "/products",
      "sourceIp": "1.2.3.4"
    },
    "requestId": "uuid-xxx"
  },
  "body": null,
  "isBase64Encoded": false
}

商品APIの実装

Railsでの元実装

まずRailsでの実装を振り返る。

# app/controllers/api/v1/products_controller.rb
module Api
  module V1
    class ProductsController < ApplicationController
      before_action :authenticate_user!
 
      def index
        products = Product
          .where(category: params[:category])
          .page(params[:page])
          .per(20)
          .order(created_at: :desc)
 
        render json: {
          products: products.map { |p| product_json(p) },
          meta: { total: products.total_count, page: params[:page]&.to_i || 1 }
        }
      end
 
      private
 
      def product_json(product)
        {
          id: product.id,
          name: product.name,
          price: product.price,
          category: product.category,
          stock: product.stock
        }
      end
    end
  end
end

Lambda版の実装

# frozen_string_literal: true
 
require 'json'
require 'aws-sdk-dynamodb'
require 'logger'
 
$logger = Logger.new($stdout)
 
# DynamoDB クライアント(コンテナ再利用でウォームスタート時に再利用される)
$dynamodb = Aws::DynamoDB::Resource.new(region: ENV['AWS_REGION'])
$table = $dynamodb.table(ENV['PRODUCTS_TABLE_NAME'])
 
def lambda_handler(event:, context:)
  $logger.info("#{event.dig('requestContext', 'http', 'method')} #{event['rawPath']}")
 
  # リクエスト情報の取得
  params = event['queryStringParameters'] || {}
  category = params['category']
  page = (params['page'] || '1').to_i
  per_page = 20
 
  # 認証チェック
  token = event.dig('headers', 'authorization')&.sub('Bearer ', '')
  return unauthorized_response unless valid_token?(token)
 
  # DynamoDB からデータ取得
  products = fetch_products(category:, page:, per_page:)
 
  {
    statusCode: 200,
    headers: {
      'Content-Type' => 'application/json',
      'Cache-Control' => 'public, max-age=60'
    },
    body: JSON.generate({
      products: products[:items],
      meta: { page:, per_page:, total: products[:total] }
    })
  }
rescue StandardError => e
  $logger.error("Error: #{e.class} - #{e.message}")
  { statusCode: 500, body: JSON.generate({ error: 'Internal Server Error' }) }
end
 
private
 
def fetch_products(category:, page:, per_page:)
  query_params = {
    index_name: 'CategoryIndex',
    key_condition_expression: 'category = :category',
    expression_attribute_values: { ':category' => category },
    limit: per_page,
    scan_index_forward: false
  }
 
  # ページネーション(DynamoDB は LastEvaluatedKey で管理)
  # 実際のプロダクションではキャッシュが必要
  result = $table.query(query_params)
 
  {
    items: result.items.map { |item| format_product(item) },
    total: result.count
  }
end
 
def format_product(item)
  {
    id: item['id'],
    name: item['name'],
    price: item['price'].to_i,
    category: item['category'],
    stock: item['stock'].to_i
  }
end
 
def valid_token?(token)
  # 実際の認証ロジック(Cognito JWT検証等)
  # ここでは簡略化
  token&.length&.positive?
end
 
def unauthorized_response
  {
    statusCode: 401,
    headers: { 'Content-Type' => 'application/json' },
    body: JSON.generate({ error: 'Unauthorized' })
  }
end

SAM テンプレートでルーティング定義

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
 
Globals:
  Function:
    Runtime: ruby3.2
    Timeout: 30
    MemorySize: 256
    Environment:
      Variables:
        PRODUCTS_TABLE_NAME: !Ref ProductsTable
        AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
 
Resources:
  # HTTP API の定義
  ProductsApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      StageName: v1
      CorsConfiguration:
        AllowOrigins:
          - "https://ec-site.example.com"
        AllowHeaders:
          - Authorization
          - Content-Type
        AllowMethods:
          - GET
          - POST
          - PUT
          - DELETE
 
  # 商品一覧取得
  ListProductsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/products/
      Handler: list.lambda_handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ProductsTable
      Events:
        ListProducts:
          Type: HttpApi
          Properties:
            ApiId: !Ref ProductsApi
            Path: /products
            Method: GET
 
  # 商品詳細取得
  GetProductFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/products/
      Handler: get.lambda_handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ProductsTable
      Events:
        GetProduct:
          Type: HttpApi
          Properties:
            ApiId: !Ref ProductsApi
            Path: /products/{id}
            Method: GET
 
  # 商品登録(管理者用)
  CreateProductFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/products/
      Handler: create.lambda_handler
      Policies:
        - DynamoDBWritePolicy:
            TableName: !Ref ProductsTable
      Events:
        CreateProduct:
          Type: HttpApi
          Properties:
            ApiId: !Ref ProductsApi
            Path: /products
            Method: POST
 
  # DynamoDB テーブル
  ProductsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
        - AttributeName: category
          AttributeType: S
        - AttributeName: created_at
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: CategoryIndex
          KeySchema:
            - AttributeName: category
              KeyType: HASH
            - AttributeName: created_at
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
 
Outputs:
  ProductsApiUrl:
    Value: !Sub "https://${ProductsApi}.execute-api.${AWS::Region}.amazonaws.com/v1"

パスパラメータとクエリパラメータの扱い

# GET /products/{id}
def lambda_handler(event:, context:)
  # パスパラメータ
  product_id = event.dig('pathParameters', 'id')
 
  # クエリパラメータ
  include_reviews = event.dig('queryStringParameters', 'include_reviews') == 'true'
 
  # ヘッダー(大文字小文字を正規化する)
  headers = (event['headers'] || {}).transform_keys(&:downcase)
  auth_token = headers['authorization']&.sub('Bearer ', '')
 
  # POSTのボディ
  body = event['body']
  parsed_body = body ? JSON.parse(body) : {}
 
  # ...
end

Railsとのエンドポイント比較

Loading diagram...
RailsLambda + API Gateway
config/routes.rbtemplate.yamlEvents
before_action :authenticate_user!Lambda Layer (共通認証ミドルウェア)
render json: ...{ statusCode:, body: JSON.generate(...) }
params[:id]event.dig('pathParameters', 'id')
request.headers['Authorization']event.dig('headers', 'authorization')
ActiveRecord::RecordNotFoundDynamoDB で nil チェック → 404

Lambda Layer で共通処理を切り出す

認証ロジックや共通ユーティリティは Lambda Layer に切り出すと複数関数間で共有できる。

# Layer 用ディレクトリ構造
mkdir -p layers/auth/ruby/lib/ruby/gems/3.2.0
 
# SAM テンプレート
Resources:
  AuthLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: auth-layer
      ContentUri: layers/auth/
      CompatibleRuntimes:
        - ruby3.2
 
  ListProductsFunction:
    Type: AWS::Serverless::Function
    Properties:
      Layers:
        - !Ref AuthLayer
# layers/auth/ruby/lib/auth_helper.rb
module AuthHelper
  JWT_SECRET = ENV['JWT_SECRET']
 
  def authenticate!(event)
    token = extract_token(event)
    raise UnauthorizedError unless valid_jwt?(token)
    decode_jwt(token)
  end
 
  private
 
  def extract_token(event)
    headers = (event['headers'] || {}).transform_keys(&:downcase)
    headers['authorization']&.sub('Bearer ', '')
  end
 
  def valid_jwt?(token)
    return false if token.nil? || token.empty?
    # JWT 検証ロジック
    true
  end
end

WARNING

Lambda関数ごとに独立したコンテナで動くため、Railsの ApplicationController のような「全アクションで実行される共通ロジック」は意識的に仕組みを作る必要がある。Lambda Layer + require による共通モジュールの読み込みが定番のアプローチだ。

スロットリングとレート制限

# HTTP API のスロットリング設定
ProductsApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    DefaultRouteSettings:
      ThrottlingBurstLimit: 200   # 同時接続の上限
      ThrottlingRateLimit: 100    # 秒あたりリクエスト数

スロットリング超過時は 429 Too Many Requests が返される。クライアント側でのリトライ(Exponential Backoff)実装が推奨される。

ダイチの成果

商品検索APIのLambda移行を終えて、ダイチはメトリクスを比較した。

【Before: Rails + EC2】
- 平均レスポンスタイム: 280ms
- セール時ピーク: 2000ms(タイムアウト発生)
- 月間コスト: ~¥15,000(EC2の一部)

【After: Lambda + API Gateway HTTP API】
- 平均レスポンスタイム: 45ms(ウォームスタート)
- セール時ピーク: 52ms(自動スケーリングで安定)
- 月間コスト: ~¥800(リクエスト数ベース)

チームリーダーの田中さんが笑った。「コストが20分の1か。他のAPIも移行したくなってきたな」

次のターゲットは、商品画像のアップロード処理だ。S3へのアップロードをトリガーに画像リサイズ処理を自動実行する——それがダイチの次の挑戦だった。