mybook

API ゲートウェイパターン — 入口を統一する

サービスが増えるにつれて、新しい問題が見えてきた。

「モバイルアプリのエンジニアから苦情が来てます」とケンジが報告した。「商品一覧を表示するのに、商品サービス・在庫サービス・レビューサービスの3つにAPIを叩かないといけないって」

ミサキはうなずいた。「クライアントが複数のサービスを直接呼ぶのは問題だよね。API ゲートウェイが必要だ」


なぜ API ゲートウェイが必要か

Loading diagram...

ゲートウェイなしの問題点:

問題1: クライアントが複数サービスを直接知る必要がある
  → モバイルアプリが 4つのサービスエンドポイントを管理
  → サービスの追加・変更がクライアント修正を要求

問題2: 認証をすべてのサービスで実装しなければならない
  → 各サービスが独自にJWTを検証
  → 認証ロジックの重複

問題3: クロスカッティング関心事の重複
  → レート制限・ログ・CORS の重複実装

AWS API Gateway の活用

ShopNovaでは AWS API Gateway を採用した。

# serverless.yml(Serverless Frameworkでの定義例)
service: shopnova-api-gateway
 
provider:
  name: aws
  region: ap-northeast-1
 
functions:
  proxy:
    handler: handler.proxy
    events:
      # 商品サービスへのルーティング
      - http:
          path: /api/v1/products/{proxy+}
          method: any
          authorizer:
            name: jwtAuthorizer
            type: REQUEST
      # 注文サービスへのルーティング
      - http:
          path: /api/v1/orders/{proxy+}
          method: any
          authorizer:
            name: jwtAuthorizer
            type: REQUEST
# app/controllers/concerns/api_gateway_authenticatable.rb
module ApiGatewayAuthenticatable
  extend ActiveSupport::Concern
 
  included do
    before_action :authenticate_from_gateway!
  end
 
  private
 
  # API Gateway が検証済みのトークンから展開したヘッダーを信頼する
  def authenticate_from_gateway!
    user_id = request.headers['X-User-Id']
    user_role = request.headers['X-User-Role']
 
    return render json: { error: 'Unauthorized' }, status: :unauthorized unless user_id
 
    @current_user = OpenStruct.new(id: user_id, role: user_role)
  end
 
  def current_user
    @current_user
  end
end

Lambda Authorizer: JWT 認証の集中管理

// authorizer/handler.js(Lambda Authorizer)
const jwt = require('jsonwebtoken');
 
exports.handler = async (event) => {
  const token = extractToken(event.headers?.Authorization);
 
  if (!token) {
    throw new Error('Unauthorized');
  }
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
 
    return {
      principalId: decoded.sub,
      policyDocument: allowPolicy(event.methodArn),
      context: {
        userId: decoded.sub,
        userRole: decoded.role,
        email: decoded.email
      }
    };
  } catch (err) {
    throw new Error('Unauthorized');
  }
};
 
function extractToken(authHeader) {
  if (!authHeader?.startsWith('Bearer ')) return null;
  return authHeader.slice(7);
}
 
function allowPolicy(methodArn) {
  return {
    Version: '2012-10-17',
    Statement: [{
      Action: 'execute-api:Invoke',
      Effect: 'Allow',
      Resource: methodArn
    }]
  };
}

BFF パターン: クライアントに最適化したAPI

BFF(Backend for Frontend)は、特定のクライアント(モバイル、Web、管理画面)向けに最適化したAPIレイヤー。

Loading diagram...
# モバイルBFF: app/controllers/mobile/v1/product_list_controller.rb
module Mobile
  module V1
    class ProductListController < ApplicationController
      # モバイル向けに最適化: 必要なフィールドだけ、一括取得
      def index
        products = ProductServiceClient.find_all(
          category: params[:category],
          page: params[:page],
          per_page: 20  # モバイルは少なめ
        )
 
        # 在庫情報を一括取得(N+1を防ぐ)
        product_ids = products.map { |p| p['id'] }
        stocks = InventoryServiceClient.find_batch(product_ids)
        stock_map = stocks.index_by { |s| s['product_id'] }
 
        # モバイル向けにフィールドを絞る
        render json: products.map { |product|
          {
            id: product['id'],
            name: product['name'],
            price: product['price_cents'] / 100.0,
            thumbnail_url: product['images'].first&.dig('thumbnail_url'),
            in_stock: stock_map[product['id']]&.dig('quantity').to_i > 0
          }
        }
      end
    end
  end
end
# WebBFF: app/controllers/web/v1/product_controller.rb
module Web
  module V1
    class ProductController < ApplicationController
      # Web向け: より詳細な情報、SEOメタデータ
      def show
        product = ProductServiceClient.find(params[:id])
        reviews = ReviewServiceClient.find_by_product(params[:id], limit: 5)
        related = RecommendationServiceClient.related_products(params[:id], limit: 4)
        stock = InventoryServiceClient.find(params[:id])
 
        render json: {
          product: format_product_detail(product, stock),
          reviews: {
            items: reviews['items'],
            total: reviews['total'],
            average_rating: reviews['average_rating']
          },
          related_products: related.map { |p| format_product_summary(p) },
          meta: {
            title: product['seo_title'],
            description: product['seo_description'],
            og_image: product['images'].first&.dig('large_url')
          }
        }
      end
    end
  end
end

レート制限の実装

API Gateway レベルでのレート制限と、Rails レベルでの細かい制御。

# AWS API Gateway のレート制限設定(Terraform)
resource "aws_api_gateway_usage_plan" "shopnova" {
  name = "shopnova-usage-plan"
 
  api_stages {
    api_id = aws_api_gateway_rest_api.main.id
    stage  = aws_api_gateway_stage.prod.stage_name
  }
 
  throttle_settings {
    rate_limit  = 1000  # 1秒あたり1000リクエスト
    burst_limit = 2000  # バースト上限
  }
}
# Rails レベルの細かいレート制限
# Gemfile
gem 'rack-attack'
 
# config/initializers/rack_attack.rb
class Rack::Attack
  # IPあたりのレート制限
  throttle('req/ip', limit: 300, period: 5.minutes) do |req|
    req.ip if req.path.start_with?('/api/')
  end
 
  # 認証済みユーザーのレート制限(ゆるめ)
  throttle('req/user', limit: 1000, period: 5.minutes) do |req|
    req.env['HTTP_X_USER_ID'] if req.path.start_with?('/api/')
  end
 
  # ログイン試行のブルートフォース対策
  throttle('logins/ip', limit: 5, period: 20.seconds) do |req|
    req.ip if req.path == '/api/v1/auth/login' && req.post?
  end
 
  # レート制限超過時のレスポンス
  self.throttled_responder = lambda do |req|
    retry_after = (req.env['rack.attack.match_data'] || {})[:period]
    [
      429,
      {
        'Content-Type' => 'application/json',
        'Retry-After' => retry_after.to_s
      },
      [{ error: 'Too many requests', retry_after: retry_after }.to_json]
    ]
  end
end

API バージョニング戦略

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :products, only: [:index, :show]
      resources :orders, only: [:create, :show, :index]
    end
 
    namespace :v2 do
      # v2では商品の構造が変わった
      resources :products, only: [:index, :show]
      # v1の注文APIはv2でも変わらないのでv1にルーティング
      scope module: :v1 do
        resources :orders, only: [:create, :show, :index]
      end
    end
  end
end
# app/controllers/api/v2/products_controller.rb
module Api
  module V2
    class ProductsController < ApplicationController
      def show
        product = ProductServiceClient.find_v2(params[:id])
 
        render json: {
          data: {
            type: 'product',
            id: product['id'],
            attributes: {
              name: product['name'],
              price: { amount: product['price_cents'], currency: 'JPY' }
            }
          }
        }
      end
    end
  end
end

ヘルスチェックと可用性

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  skip_before_action :authenticate_from_gateway!
 
  def check
    checks = {
      database: check_database,
      redis: check_redis,
      product_service: check_product_service
    }
 
    status = checks.values.all? { |c| c[:status] == 'ok' } ? :ok : :service_unavailable
 
    render json: {
      status: status == :ok ? 'ok' : 'degraded',
      checks: checks,
      timestamp: Time.current.iso8601
    }, status: status
  end
 
  private
 
  def check_database
    ActiveRecord::Base.connection.execute('SELECT 1')
    { status: 'ok' }
  rescue StandardError => e
    { status: 'error', message: e.message }
  end
 
  def check_redis
    Redis.current.ping == 'PONG' ? { status: 'ok' } : { status: 'error' }
  rescue StandardError => e
    { status: 'error', message: e.message }
  end
 
  def check_product_service
    response = HTTP.timeout(1).get("#{ENV['PRODUCT_SERVICE_URL']}/health")
    response.status.ok? ? { status: 'ok' } : { status: 'error', code: response.status.to_i }
  rescue StandardError => e
    { status: 'error', message: e.message }
  end
end

ECS タスク定義での API Gateway 連携

{
  "family": "shopnova-bff",
  "networkMode": "awsvpc",
  "containerDefinitions": [
    {
      "name": "bff",
      "image": "xxxx.dkr.ecr.ap-northeast-1.amazonaws.com/shopnova-bff:latest",
      "portMappings": [{ "containerPort": 3000 }],
      "environment": [
        { "name": "PRODUCT_SERVICE_URL", "value": "http://product-service.shopnova.local" },
        { "name": "ORDER_SERVICE_URL", "value": "http://order-service.shopnova.local" },
        { "name": "RAILS_ENV", "value": "production" }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:ap-northeast-1:xxxx:secret:shopnova/bff/database-url"
        }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3
      }
    }
  ]
}

まとめ

「クライアントの窓口を一本化することで、サービスの内部構造をクライアントから隠せる」とミサキはまとめた。

API Gateway の役割:
  ✓ 認証・認可の集中管理(Lambda Authorizer)
  ✓ ルーティング
  ✓ レート制限・スロットリング
  ✓ SSL終端
  ✓ ログ・モニタリング

BFF の役割:
  ✓ クライアント固有の集約
  ✓ N+1を防ぐバッチ取得
  ✓ クライアントに最適なレスポンス形式

INFO

BFF は「各クライアントチームが所有する」のが理想。モバイルチームがMobile BFFを、Webチームが Web BFF を管理する。これにより、クライアントの要件変更がバックエンドサービスに影響しなくなる。

次章では、サービスごとのデータベース管理戦略(Database per Service パターン)を学ぶ。