レート制限とスロットリング — APIを守る壁
DoS攻撃の夜
「サーバーが落ちそうです!」
深夜2時、ケンタからの緊急連絡が入った。CloudWatchのグラフが異常を示している。1秒間に10,000リクエスト。単一のIPアドレスから、同じエンドポイントへの連続攻撃だ。
サクラはVPNを繋いでサーバーにログインした。DBコネクションが枯渇している。レート制限がなかったのは明らかなミスだった。
「今夜中に対策を打つ」
レート制限の種類
Loading diagram...
| アルゴリズム | 特徴 | 採用例 |
|---|---|---|
| 固定ウィンドウ | 実装簡単、境界問題あり | 初歩的な実装 |
| スライディングウィンドウ | より正確 | GitHub API |
| トークンバケット | バーストを許容しながら平均を制限 | AWS、Stripe |
rack-attackの実装
# Gemfile
gem 'rack-attack'
gem 'redis'
# config/initializers/rack_attack.rb
class Rack::Attack
# Redisストアを使用(分散環境対応)
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
)
# ============================================
# IPベースのレート制限
# ============================================
# 全エンドポイント: IPあたり60秒間300リクエスト
throttle("api/ip", limit: 300, period: 60) do |req|
req.ip if req.path.start_with?("/api/")
end
# ログイン試行: IPあたり10分間5回
throttle("api/login/ip", limit: 5, period: 600) do |req|
if req.path == "/api/v1/auth/login" && req.post?
req.ip
end
end
# ============================================
# APIキーベースのレート制限
# ============================================
# APIキーあたり60秒間1000リクエスト
throttle("api/key", limit: 1000, period: 60) do |req|
req.env["HTTP_X_API_KEY"] if req.path.start_with?("/api/")
end
# ============================================
# ブロックリスト
# ============================================
# 悪意あるIPをブロック
blocklist("block malicious IPs") do |req|
BlockedIp.exists?(ip: req.ip)
end
# ============================================
# 許可リスト(レート制限をバイパス)
# ============================================
safelist("allow from localhost") do |req|
req.ip == "127.0.0.1" || req.ip == "::1"
end
# パートナーIPは制限緩和(個別設定)
safelist("trusted partners") do |req|
TrustedPartnerIp.exists?(ip: req.ip)
end
# ============================================
# エラーレスポンスのカスタマイズ
# ============================================
self.throttled_responder = ->(env) {
now = Time.now
match_data = env["rack.attack.match_data"]
headers = {
"Content-Type" => "application/json",
"X-RateLimit-Limit" => match_data[:limit].to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => (now + (match_data[:period] - now.to_i % match_data[:period])).to_i.to_s,
"Retry-After" => (match_data[:period] - now.to_i % match_data[:period]).to_s
}
body = JSON.generate({
errors: [{
code: "rate_limit_exceeded",
message: "リクエスト数の制限に達しました。しばらく待ってから再試行してください。"
}]
})
[429, headers, [body]]
}
end
# config/application.rb
class Application < Rails::Application
config.middleware.use Rack::Attack
endレート制限ヘッダー
クライアントがレート制限状況を把握できるよう、ヘッダーで通知する。
# app/controllers/application_controller.rb
after_action :add_rate_limit_headers
private
def add_rate_limit_headers
api_key = request.headers["X-API-Key"]
return unless api_key
# Redisから現在の使用状況を取得
period = 60
key = "rack::attack:#{Time.now.to_i / period}:api/key:#{api_key}"
count = Rails.cache.read(key).to_i
limit = 1000
response.headers["X-RateLimit-Limit"] = limit.to_s
response.headers["X-RateLimit-Remaining"] = [limit - count, 0].max.to_s
response.headers["X-RateLimit-Reset"] = (Time.now + (period - Time.now.to_i % period)).to_i.to_s
endクライアントが受け取るヘッダー:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1705312800
429エラー時:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312800
Retry-After: 42
INFO
クライアントは Retry-After ヘッダーを読んでその秒数待ってからリトライすべきです。指数バックオフ(待ち時間を徐々に増やす)も有効です。
プランベースのレート制限
課金プランによって制限を変える設計。
# app/models/api_plan.rb
class ApiPlan < ApplicationRecord
PLANS = {
free: {
requests_per_minute: 60,
requests_per_day: 1_000,
description: "無料プラン"
},
starter: {
requests_per_minute: 300,
requests_per_day: 10_000,
description: "スタータープラン"
},
pro: {
requests_per_minute: 1_000,
requests_per_day: 100_000,
description: "プロプラン"
},
enterprise: {
requests_per_minute: 10_000,
requests_per_day: -1, # 無制限
description: "エンタープライズプラン"
}
}.freeze
end
# config/initializers/rack_attack.rb
throttle("api/plan", limit: ->(req) {
api_key = req.env["HTTP_X_API_KEY"]
partner = ApiKey.find_by(key: api_key)&.partner
plan = partner&.api_plan&.to_sym || :free
ApiPlan::PLANS[plan][:requests_per_minute]
}, period: 60) do |req|
req.env["HTTP_X_API_KEY"] if req.path.start_with?("/api/")
endAWS API Gatewayのスロットリング
AWS側でもレート制限を設定し、多層防御を実現する。
# CloudFormation
ApiGatewayUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: StarterPlan
Description: スタータープラン
Throttle:
BurstLimit: 100 # 瞬間最大リクエスト数
RateLimit: 50 # 1秒あたりのリクエスト数
Quota:
Limit: 10000 # 1日の上限
Period: DAY
ApiStages:
- ApiId: !Ref ApiGateway
Stage: v2
ApiGatewayEnterprisePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: EnterprisePlan
Throttle:
BurstLimit: 5000
RateLimit: 1000
ApiStages:
- ApiId: !Ref ApiGateway
Stage: v2Loading diagram...
多層防御により、どのレイヤーでも攻撃を止められる。
WAFルール
AWS WAFで基本的な攻撃パターンをブロック。
{
"Name": "RateLimitRule",
"Priority": 1,
"Statement": {
"RateBasedStatement": {
"Limit": 2000,
"AggregateKeyType": "IP",
"EvaluationWindowSec": 300
}
},
"Action": {
"Block": {
"CustomResponse": {
"ResponseCode": 429,
"CustomResponseBodyKey": "RateLimitBody"
}
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "RateLimitRule"
}
}CloudWatchでの監視
# レート制限発火のメトリクス送信
Rack::Attack.instrument_throttle do |req, _data|
CloudWatch::Client.new.put_metric_data(
namespace: "TechBridge/API",
metric_data: [{
metric_name: "ThrottledRequests",
value: 1,
dimensions: [
{ name: "Path", value: req.path },
{ name: "IP", value: req.ip }
]
}]
)
end# CloudFormationでのアラーム
ThrottleAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: APIThrottleHigh
MetricName: ThrottledRequests
Namespace: TechBridge/API
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 100
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref AlertSNSTopicWARNING
レート制限は「制限を設けること」ではなく「適切なサービス品質を守ること」です。制限が厳しすぎると正当なユーザーを弾き、緩すぎると攻撃に脆弱になります。パートナーの実使用パターンを分析して設定しましょう。
テスト
# spec/requests/rate_limiting_spec.rb
RSpec.describe "Rate Limiting" do
it "1分間に制限回数を超えるとリジェクトされる" do
api_key = create(:api_key)
# 制限回数まで送信(テスト用に小さい値を設定)
stub_const("RATE_LIMIT", 5)
5.times do
get "/api/v1/users", headers: { "X-API-Key" => api_key.key }
expect(response).to have_http_status(:ok)
end
# 6回目はリジェクト
get "/api/v1/users", headers: { "X-API-Key" => api_key.key }
expect(response).to have_http_status(:too_many_requests)
expect(json_response["errors"][0]["code"]).to eq("rate_limit_exceeded")
end
endサクラの夜明け
午前4時、レート制限が本番環境に展開された。攻撃IPは即座にブロックされ、サーバーは安定した。
「レート制限はユーザーへの罰ではなく、全ユーザーへの公平なサービス品質保証だ」
ケンタからのSlackに「ありがとう」のスタンプが来た。
次章では、エラーが起きたときに開発者が何が悪かったかを理解できる「エラーハンドリング」を設計する。