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に渡される。
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
endLambda版の実装
# 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' })
}
endSAM テンプレートでルーティング定義
# 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) : {}
# ...
endRailsとのエンドポイント比較
| Rails | Lambda + API Gateway |
|---|---|
config/routes.rb | template.yaml の Events |
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::RecordNotFound | DynamoDB で 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
endWARNING
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へのアップロードをトリガーに画像リサイズ処理を自動実行する——それがダイチの次の挑戦だった。