API ゲートウェイ — サービスの入り口
バラバラになった入り口
マイクロサービスへの移行完了から3ヶ月が経った。EchoTaskのサービス群は順調に稼働していたが、ある月曜日の朝、ハルトのSlackに緊急メッセージが飛び込んだ。
「iOSアプリが起動直後に500エラーを返している。ユーザーから大量のレポートが来ている!」
ハルトはログを追った。問題の根は単純だった。Notification Serviceを新しいホストに移行した際、iOSアプリに埋め込まれたURLをハードコードで持っていたのだ。
Task Service: https://task-api.echotask.internal:8001
Auth Service: https://auth-api.echotask.internal:8002
Notification Service: https://notification-api.echotask.internal:8003 ← 旧URL
Analytics Service: https://analytics-api.echotask.internal:8004
「クライアントが各サービスのエンドポイントを直接持っている設計が根本的な問題だ」とハルトは気づいた。サービスが増えるたびに、モバイルアプリのアップデートが必要になる。認証はどのサービスも個別に実装している。レートリミットも重複コードだらけだ。
APIゲートウェイはこれらすべての問題を解決するアーキテクチャパターンだ。
APIゲートウェイの役割
クライアントはAPIゲートウェイという唯一の窓口だけを知ればよい。ゲートウェイが認証・ルーティング・変換などの横断的関心事(Cross-cutting Concerns)を一手に引き受ける。
APIゲートウェイが担う横断的関心事:
| 機能 | 内容 |
|---|---|
| 認証・認可 | JWTの検証、Cognitoとの統合、パーミッションチェック |
| レートリミット | APIの過剰利用を制限、DoS対策 |
| ルーティング | URLパスに基づくサービス振り分け |
| SSL終端 | HTTPSの終点として証明書を集中管理 |
| リクエスト変換 | ヘッダーの追加・削除・VTLによる変換 |
| ログ・モニタリング | アクセスログの集中管理・CloudWatchとの統合 |
| キャッシュ | レスポンスのキャッシュでバックエンド負荷軽減 |
| WebSocket | リアルタイム通信のサポート |
API Gateway vs ALB vs CloudFront の選択基準
AWSでは「APIの前に何を置くか」について3つの主要な選択肢がある。ハルトのチームは以下の比較表を作って議論した。
| 観点 | API Gateway (HTTP API) | ALB (Application Load Balancer) | CloudFront + Lambda@Edge |
|---|---|---|---|
| 主な用途 | マネージドAPI管理 | L7ロードバランシング | CDN + エッジ処理 |
| 認証統合 | Cognitoネイティブ対応 | 手動実装 | Lambda@Edgeで実装 |
| レートリミット | 組み込み | WAF連携 | WAF連携 |
| コスト | リクエスト課金(百万件あたり$1) | 時間課金($0.008/h〜)+ LCU | リクエスト課金 + 転送量 |
| レイテンシ | 低(1〜5ms追加) | 最低(1ms未満追加) | 最低(エッジで処理) |
| VTLテンプレート | あり(REST API) | なし | なし |
| WebSocket | ネイティブ対応 | ネイティブ対応 | 非対応 |
| gRPC | 非対応 | 対応 | 非対応 |
| カスタムドメイン | 対応 | 対応 | 対応 |
| 適しているケース | マイクロサービスAPI管理 | 高トラフィック・WebSocket・gRPC | 静的コンテンツ + APIキャッシュ |
EchoTaskのケースでは、マイクロサービスの認証・ルーティング管理が主目的だったため HTTP API(API Gateway V2) を選択した。将来的にはCloudFrontを前段に置いてグローバルキャッシュを追加する計画だ。
INFO
HTTP API vs REST API の使い分け
API Gateway には旧世代の「REST API(V1)」と新世代の「HTTP API(V2)」がある。
- REST API: ステージ変数・VTLマッピング・使用量プランが必要な場合
- HTTP API: それ以外の大多数のケース。コストが約65%安く、レイテンシも低い
新規プロジェクトでは HTTP API を選ぶのがデフォルトの正解。
OpenAPI / Swagger によるAPI定義の管理
APIゲートウェイを導入したとき、「どのエンドポイントが存在するか」「どんなリクエスト/レスポンスか」を一元管理する必要が生じた。ハルトは OpenAPI 仕様(旧 Swagger)でAPI定義を管理することにした。
# openapi/task-service.yaml(抜粋)
openapi: "3.0.3"
info:
title: EchoTask Task Service API
version: "1.0"
components:
securitySchemes:
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
Task:
type: object
required: [id, title, status]
properties:
id: { type: integer }
title: { type: string }
status: { type: string, enum: [todo, in_progress, done] }
due_date: { type: string, format: date, nullable: true }
created_at: { type: string, format: date-time }
security: [{ BearerAuth: [] }]
paths:
/tasks:
get:
summary: タスク一覧取得
parameters:
- { name: status, in: query, schema: { type: string } }
- { name: page, in: query, schema: { type: integer, default: 1 } }
responses:
"200": { description: タスク一覧 }
"401": { description: 認証エラー }
post:
summary: タスク作成
requestBody:
required: true
content:
application/json:
schema: { type: object, required: [title], properties: { title: { type: string } } }
responses:
"201": { description: 作成成功 }rswag-specs gem を使うとRSpecのテストコードからOpenAPI仕様を自動生成できる。コードとドキュメントが常に同期される点が最大のメリットだ。
# spec/requests/api/v1/tasks_spec.rb
require 'swagger_helper'
RSpec.describe 'Task API', type: :request do
path '/api/v1/tasks' do
get 'タスク一覧取得' do
tags 'Tasks'; security [BearerAuth: []]; produces 'application/json'
parameter name: :status, in: :query, type: :string, required: false
response '200', 'タスク一覧' do
schema type: :object,
properties: { tasks: { type: :array, items: { '$ref' => '#/components/schemas/Task' } } }
let(:Authorization) { "Bearer #{token_for(create(:user))}" }
run_test!
end
response '401', '認証エラー' do
let(:Authorization) { 'Bearer invalid' }
run_test!
end
end
end
end
# bundle exec rake rswag:specs:swaggerize → swagger/v1/swagger.yaml を自動生成APIバージョニング戦略
「v1 の /tasks の仕様を変えたい。でも既存のモバイルクライアントが壊れる」という問題はAPIの永遠の悩みだ。ハルトはチームと3種類のバージョニング戦略を比較した。
| 方法 | 例 | メリット | デメリット |
|---|---|---|---|
| URLパス(推奨) | GET /api/v2/tasks | ルーティングが簡潔・ログが読みやすい | URLが変わる |
| カスタムヘッダー | Accept-Version: v2 | URLを汚さない | クライアント実装が複雑・ブラウザで扱いにくい |
| クエリパラメータ | GET /api/tasks?version=2 | デバッグが楽 | キャッシュしにくい・本番非推奨 |
EchoTaskはURLパスバージョニングを採用した。
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :tasks, only: [:index, :show, :create, :update, :destroy]
end
namespace :v2 do
resources :tasks, only: [:index, :show, :create, :update, :destroy]
end
end
end
# v2: カーソルページネーションとフィルタリングを追加
module Api::V2
class TasksController < ApplicationController
def index
tasks = current_user.tasks
.filter_by(params[:filters])
.cursor_paginate(after: params[:cursor])
render json: TaskV2Serializer.new(tasks).serializable_hash
end
end
endAWS API Gateway の設定
EchoTaskではHTTP APIを選択した(低コスト・低レイテンシ)。
# cloudformation/api-gateway.yaml(抜粋)
Resources:
HttpApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: echo-task-api
ProtocolType: HTTP
CorsConfiguration:
AllowHeaders: ["Content-Type", "Authorization"]
AllowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
AllowOrigins: ["https://app.echotask.com"]
MaxAge: 86400
JwtAuthorizer:
Type: AWS::ApiGatewayV2::Authorizer
Properties:
ApiId: !Ref HttpApi
AuthorizerType: JWT
Name: echo-task-jwt
IdentitySource: "$request.header.Authorization"
JwtConfiguration:
Audience: ["echo-task-api"]
Issuer: !Sub "https://cognito-idp.ap-northeast-1.amazonaws.com/${UserPool}"
# 認証必須ルート
TasksRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref HttpApi
RouteKey: "ANY /api/v1/tasks/{proxy+}"
AuthorizationType: JWT
AuthorizerId: !Ref JwtAuthorizer
Target: !Sub "integrations/${TaskServiceIntegration}"
# 認証不要ルート(ログイン・サインアップ)
AuthRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref HttpApi
RouteKey: "POST /api/v1/auth/{proxy+}"
AuthorizationType: NONE
Target: !Sub "integrations/${AuthServiceIntegration}"
TaskServiceIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref HttpApi
IntegrationType: HTTP_PROXY
IntegrationUri: !Sub "http://${TaskServiceALB.DNSName}/api/v1/tasks/{proxy}"
IntegrationMethod: ANY
PayloadFormatVersion: "1.0"
RequestParameters:
# 検証済みのユーザーIDをダウンストリームに転送
"append:header.X-User-ID": "$context.authorizer.claims.sub"
"append:header.X-User-Email": "$context.authorizer.claims.email"
ApiStage:
Type: AWS::ApiGatewayV2::Stage
Properties:
ApiId: !Ref HttpApi
StageName: "$default"
AutoDeploy: true
DefaultRouteSettings:
ThrottlingBurstLimit: 500
ThrottlingRateLimit: 100
AccessLogSettings:
DestinationArn: !GetAtt ApiAccessLogGroup.Arn
Format: '{"id":"$context.requestId","ip":"$context.identity.sourceIp","uid":"$context.authorizer.claims.sub","method":"$context.httpMethod","path":"$context.path","status":"$context.status","ms":"$context.responseLatency"}'
ApiAccessLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/apigateway/echo-task
RetentionInDays: 30VTLマッピングテンプレート(REST APIのレスポンス変換)
REST API(V1)ではVelocity Template Language(VTL)でリクエスト・レスポンスをサーバーレスに変換できる。DynamoDBを直接バックエンドに使う場合に活用されることが多い。
# DynamoDBレスポンス → JSON 変換の例(VTL)
ResponseTemplates:
application/json: |
#set($item = $input.path('$.Item'))
{
"id": "$item.id.S",
"title": "$item.title.S",
"status": "$item.status.S",
"created_at": "$item.created_at.S"
}Cognito User Pool との統合
ハルトは認証基盤としてAmazon Cognitoを選んだ。JWT発行・リフレッシュトークン管理・MFA・メール検証をすべてマネージドサービスに委譲できる点が決め手だ。
# cloudformation/cognito.yaml(抜粋)
Resources:
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: echo-task-users
UsernameAttributes: [email]
AutoVerifiedAttributes: [email]
Policies:
PasswordPolicy: { MinimumLength: 8, RequireUppercase: true, RequireNumbers: true }
MfaConfiguration: OPTIONAL
EnabledMfas: [SOFTWARE_TOKEN_MFA]
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: echo-task-app
GenerateSecret: false
ExplicitAuthFlows: [ALLOW_USER_PASSWORD_AUTH, ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH]
AccessTokenValidity: 1 # 1時間
RefreshTokenValidity: 30 # 30日
TokenValidityUnits: { AccessToken: hours, RefreshToken: days }RailsでのCognitoトークン検証
CognitoのJWTはRS256で署名されており、/.well-known/jwks.json で公開された公開鍵で検証する。
# app/services/cognito_token_verifier.rb
class CognitoTokenVerifier
ISSUER = "https://cognito-idp.ap-northeast-1.amazonaws.com/#{ENV['COGNITO_USER_POOL_ID']}"
JWKS_URI = "#{ISSUER}/.well-known/jwks.json"
def self.verify(token)
# JWKSをRailsキャッシュに1時間保持(起動のたびにHTTPしない)
jwks = Rails.cache.fetch('cognito_jwks', expires_in: 1.hour) do
JSON.parse(Faraday.get(JWKS_URI).body)
end
header = JWT.decode(token, nil, false).last
key_data = jwks['keys'].find { |k| k['kid'] == header['kid'] }
raise AuthError, '公開鍵が見つかりません' unless key_data
rsa_key = JWT::JWK.import(key_data).keypair
payload, = JWT.decode(token, rsa_key, true,
algorithms: ['RS256'], iss: ISSUER, verify_iss: true)
payload.with_indifferent_access
rescue JWT::ExpiredSignature then raise AuthError, 'トークンの有効期限が切れています'
rescue JWT::DecodeError => e then raise AuthError, "無効なトークン: #{e.message}"
end
end
# app/controllers/concerns/cognito_authenticatable.rb
module CognitoAuthenticatable
extend ActiveSupport::Concern
included { before_action :authenticate_request! }
private
def authenticate_request!
token = request.headers['Authorization']&.delete_prefix('Bearer ')
raise AuthError, 'トークンが指定されていません' unless token
@current_claims = CognitoTokenVerifier.verify(token)
rescue AuthError => e
render json: { error: e.message }, status: :unauthorized
end
def current_user_id = @current_claims[:sub]
def current_user_email = @current_claims[:email]
end認証フロー(Cognito + JWT)
レートリミット
API Gatewayのスロットリングはアカウント全体を守るが、ユーザー個別の制限はアプリケーション層で実装する。
# config/initializers/rack_attack.rb
class Rack::Attack
# Redisをストアとして使用
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV['REDIS_URL']
)
# IPアドレスによる制限(未認証含む全アクセス)
throttle('api/ip', limit: 300, period: 5.minutes) do |req|
req.ip if req.path.start_with?('/api/')
end
# 認証済みユーザーによる制限(ユーザーIDで識別)
throttle('api/user', limit: 1000, period: 1.hour) do |req|
# API Gatewayがヘッダーに付けたユーザーID
req.env['HTTP_X_USER_ID']
end
# ログイン試行の制限(ブルートフォース対策)
throttle('login/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 |env|
match_data = env['rack.attack.match_data']
now = match_data[:epoch_time]
period = match_data[:period]
retry_after = period - (now % period)
[
429,
{
'Content-Type' => 'application/json',
'Retry-After' => retry_after.to_s,
'X-RateLimit-Reset' => (now + retry_after).to_i.to_s
},
[{ error: 'リクエスト数の上限に達しました。しばらく待ってから再試行してください。',
retry_after: retry_after }.to_json]
]
end
endBFFパターンの実装詳細(GraphQL BFF)
クライアントの種類(Web・iOS・Android)によって必要なデータが異なる場合、BFF(Backend for Frontend) パターンが有効だ。EchoTaskでは graphql-ruby を使いGraphQL BFFを構築した。
GraphQL BFFの最大のメリットは「クライアントが必要なフィールドだけを宣言的に取得できる」点だ。RESTではエンドポイントごとに返すフィールドが固定されるが、GraphQLではクエリで指定できる。
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
# ダッシュボード: 複数サービスを1リクエストで集約
field :dashboard, Types::DashboardType, null: false
def dashboard
uid = context[:current_user_id]
{
tasks: TaskServiceClient.new.recent_tasks(user_id: uid, limit: 10),
notifications: NotificationServiceClient.new.unread(user_id: uid),
analytics: AnalyticsServiceClient.new.weekly_summary(user_id: uid)
}
end
field :tasks, [Types::TaskType], null: false do
argument :status, String, required: false
argument :page, Integer, required: false, default_value: 1
end
def tasks(status: nil, page: 1)
TaskServiceClient.new.list(
user_id: context[:current_user_id], status: status, page: page
)
end
end
end
# スキーマ定義(graphql-batchでN+1を防ぐ)
class EchoTaskSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Batch
endGolangでのHTTPミドルウェア実装例
ハルトのチームにはGoが得意なメンバーもいた。Notification ServiceはGoで書かれており、ミドルウェアチェーンで認証・レートリミット・ログ記録を実装した。
// middleware/auth.go — JWKSキャッシュ + JWT検証
func JWTAuth(jwksURL string) func(http.Handler) http.Handler {
cache := jwk.NewCache(context.Background())
cache.Register(jwksURL)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
http.Error(w, `{"error":"認証が必要です"}`, http.StatusUnauthorized)
return
}
keys, _ := cache.Get(r.Context(), jwksURL)
tok, err := jwt.Parse([]byte(strings.TrimPrefix(h, "Bearer ")),
jwt.WithKeySet(keys), jwt.WithValidate(true))
if err != nil {
http.Error(w, `{"error":"無効なトークンです"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), ctxUserID, tok.Subject())))
})
}
}
// middleware/rate_limit.go — インメモリ(本番はRedisに差し替え)
func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler {
var mu sync.Mutex
type entry struct{ n int; reset time.Time }
m := map[string]*entry{}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
k, _ := r.Context().Value(ctxUserID).(string)
if k == "" { k = r.RemoteAddr }
mu.Lock()
e, ok := m[k]
if !ok || time.Now().After(e.reset) { e = &entry{reset: time.Now().Add(window)}; m[k] = e }
e.n++; n := e.n
mu.Unlock()
if n > limit { http.Error(w, `{"error":"レートリミット超過"}`, http.StatusTooManyRequests); return }
next.ServeHTTP(w, r)
})
}
}
// middleware/logging.go — slogで構造化ログ
func Logging(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
uid, _ := r.Context().Value(ctxUserID).(string)
log.Info("req", "method", r.Method, "path", r.URL.Path,
"status", rw.code, "ms", time.Since(start).Milliseconds(), "uid", uid)
})
}
}
// チェーンの組み立て
// handler := Logging(log)(RateLimit(1000, time.Hour)(JWTAuth(jwksURL)(mux)))WARNING
Goのレートリミットはインメモリ実装に注意
上記の実装はシングルインスタンスでは動くが、ECSで複数タスクが並ぶと各インスタンスが独立したカウンターを持ってしまう。本番では go-redis/redis_rate を使った分散レートリミットに切り替えること。
WebSocket APIの設定
ハルトが次に取り組んだのはリアルタイム通知だ。タスクのステータスが変わったとき、担当者のブラウザに即座に反映したい。API GatewayのWebSocket APIを使えば、Lambda + DynamoDBで完全サーバーレスなプッシュ通知を実現できる。
# cloudformation/websocket-api.yaml
Resources:
WebSocketApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: echo-task-realtime
ProtocolType: WEBSOCKET
RouteSelectionExpression: "$request.body.action"
ConnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebSocketApi
RouteKey: "$connect"
AuthorizationType: NONE
Target: !Sub "integrations/${ConnectIntegration}"
DisconnectRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref WebSocketApi
RouteKey: "$disconnect"
Target: !Sub "integrations/${DisconnectIntegration}"
ConnectIntegration:
Type: AWS::ApiGatewayV2::Integration
Properties:
ApiId: !Ref WebSocketApi
IntegrationType: AWS_PROXY
IntegrationUri: !Sub "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/${WebSocketConnectFunction.Arn}/invocations"
WebSocketStage:
Type: AWS::ApiGatewayV2::Stage
Properties:
ApiId: !Ref WebSocketApi
StageName: production
AutoDeploy: true接続時はLambdaでDynamoDBに connection_id → user_id を保存する。Task Serviceがタスク更新を検知したとき、DynamoDBからコネクションIDを引いて ApiGatewayManagementApi.post_to_connection でプッシュする。
# app/services/websocket_notifier.rb
class WebSocketNotifier
def self.notify_task_update(user_id:, task:)
db = Aws::DynamoDB::Client.new
apigw = Aws::ApiGatewayManagementApi::Client.new(endpoint: ENV['WEBSOCKET_ENDPOINT'])
msg = { action: 'task_updated', task: TaskSerializer.new(task).as_json }.to_json
db.query(table_name: ENV['CONNECTIONS_TABLE'], index_name: 'UserIdIndex',
key_condition_expression: 'user_id = :uid',
expression_attribute_values: { ':uid' => user_id }).items.each do |conn|
apigw.post_to_connection(connection_id: conn['connection_id'], data: msg)
rescue Aws::ApiGatewayManagementApi::Errors::GoneException
db.delete_item(table_name: ENV['CONNECTIONS_TABLE'],
key: { connection_id: conn['connection_id'] })
end
end
endEchoTaskの新しい全体構成
全ての仕組みが揃ったところで、EchoTaskのAPIレイヤーを俯瞰してみよう。
INFO
この章のキーポイント
- APIゲートウェイ: 横断的関心事(認証・レートリミット・ルーティング)を集中管理。クライアントはURLを1つ知るだけでよい
- 選択基準: HTTP API(V2)が低コスト・低レイテンシでほとんどのケースに最適。gRPCが必要なときはALBを選ぶ
- OpenAPI + rswag: テストコードからドキュメントを自動生成することでAPI仕様とコードの乖離を防ぐ
- バージョニング: URLパスバージョニングが最もシンプルで運用しやすい
- Cognito統合: JWKSによる公開鍵検証で、各サービスが独立してトークンを検証できる
- GraphQL BFF: クライアント種別ごとに最適なレスポンスを返し、オーバーフェッチを排除する
- Goミドルウェア: 認証・レートリミット・ログ記録をチェーンとして組み合わせる
- WebSocket API: API Gatewayのマネージド機能でリアルタイム通信を低コストで実現
まとめと次のステップ
APIゲートウェイの導入でEchoTaskのアーキテクチャは大きく改善された。
- 認証ロジックが一元化され、各サービスは
X-User-IDヘッダーを信頼するだけでよい - クライアントは
https://api.echotask.comという1つのURLだけを知ればよい - レートリミットはRack::Attack + API Gatewayで二重管理
- 新サービスの追加はCloudFormationにルーティング設定を追加するだけ
- WebSocket APIでリアルタイム通知が実現できた
しかし新たな問題が浮上してきた。タスクの完了を記録するとき、Task ServiceとNotification Serviceの両方に書き込む処理がある。もし途中でNotification Serviceがダウンしたら? タスクは完了したのに通知は送られない——これはデータの不整合だ。
「分散システムでどうやってデータの一貫性を保証するか?」
次の章では、Sagaパターンとイベント駆動アーキテクチャを使った分散トランザクション管理を学ぶ。