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
endLambda 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
endAPI バージョニング戦略
# 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
endECS タスク定義での 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 パターン)を学ぶ。