設計問題: レート制限 — APIを守る仕組みの設計
レイカの過去——あの夜の話
「今日はレート制限(Rate Limiting)を設計してもらう」
ソウタは軽く構えていた。rack-attack を使えばいいんじゃないか、と。
しかしレイカは静かに言った。「私がこのテーマにこだわる理由がある。6年前の話だ」
深夜2時、レイカは自分のチームが開発したECサイトのAPIをモニタリングしていた。ある瞬間、CloudWatchのグラフが垂直に跳ね上がった。
「1分間に通常の300倍のリクエスト。秒間2万件。データベースの接続プールが枯渇して、正規のユーザーも全員弾かれた。Slackには『サイトが死んでいる』という報告が嵐のように来た。売上換算で4時間で1200万円の損失だった」
攻撃者は1000台以上のIPアドレスをローテーションしていた。単純なIP制限では太刀打ちできなかった。
「AWS WAFで緊急ブロックして、その夜中にレート制限の全体設計を作り直した。あの経験がなければ、今日この話を君にしていない」
ソウタは背筋が伸びた。「それ以来、レート制限は私にとって生命線だ。面接でこれが出たとき、どれだけ深く理解しているかで、その人のシステム設計力が分かる」
Step 1: 要件の確認
面接では必ず要件確認から入る。この段階で仮定を明示することが評価される。
機能要件
1. IPアドレス単位のレート制限
- 未認証リクエストは送信元IPで識別
2. ユーザー単位のレート制限
- 認証済みユーザーはuser_idで識別(複数デバイスを合算)
3. APIエンドポイント単位の設定可能なルール
- /api/auth/login: 厳しく(ブルートフォース防止)
- /api/search: 中程度(DBへの高負荷)
- /api/*: 標準ルール
4. 超過時: HTTP 429 Too Many Requests を返す
- Retry-After ヘッダーで次回試行可能時刻を通知
5. レート制限ヘッダーの返却
- X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
6. 課金プランごとに制限値が異なる
- Free / Pro / Enterprise の3ティア
7. ルール変更はダウンタイムなしで動的に反映
非機能要件
1. レート制限チェックのレイテンシ: p99で5ms以内
(通常のAPIリクエストに乗る追加コスト)
2. 分散環境(複数APIサーバー)での整合性
- 100台のサーバーがあっても制限値を正確に守る
3. 可用性: レート制限システムが落ちてもAPIは動く
- フェイルオープン設計(安全のために制限をスキップ)
4. メモリ効率
- 数百万ユーザーのカウンターを保持できる
5. 誤検知率 < 0.1%
- 正規ユーザーが誤ってブロックされる割合
「今のは完璧だ」とレイカが言った。「多くの候補者は機能要件だけ列挙して終わる。可用性のトレードオフ——制限システムが落ちたときフェイルオープンにするかフェイルクローズにするか——まで言えた人間は半分もいない」
Step 2: 規模の概算
想定トラフィック:
- アクティブユーザー: 1,000万人
- ピーク時QPS: 10万 req/sec
- レート制限チェック: 全リクエスト = 10万/sec
Redisカウンターのサイズ:
- キー: "sw:user:{user_id}:{window}" or "sw:ip:{ip}:{window}"
- 1エントリ: ~50バイト(キー30 + 値20)
- 同時アクティブキー数: 1,000万 × 2ウィンドウ = 2,000万
- 合計メモリ: 2,000万 × 50バイト = 1GB
→ ElastiCache r7g.large(13.07GB)で余裕を持って運用可能
Redisのスループット要件:
- 10万 req/sec × 2コマンド(GET + INCR)= 20万 cmd/sec
→ Redisシングルノードは10万 cmd/sec対応
→ Cluster Mode で3シャードに分散(各シャード7万 cmd/sec)
TTL管理:
- ウィンドウ60秒 × 2 = 120秒でキー自動失効
- メモリは定常的に1GB以下を維持
Step 3: アルゴリズムの比較
「アルゴリズムは4種類ある。それぞれのトレードオフを言えることが面接では重要だ」
アルゴリズム1: Token Bucket(トークンバケット)
仕組み: バケツにトークン(許可証)が入っている。リクエストごとにトークンを消費し、一定時間ごとに補充される。
Token Bucketの最大の特徴はバースト(瞬間的な大量リクエスト)を許容できる点だ。
なぜバーストを許容することが重要なのか? ユーザーが検索ページを開くと、フロントエンドは同時に10本のAPIリクエストを投げることがある。厳密に「毎秒1リクエストのみ」と制限すると、正規の使い方でも拒否されてしまう。Token Bucketではバケツに蓄積されたトークンがあれば瞬間的なバーストを許容しつつ、長期的な平均レートを制御できる。
# app/services/rate_limiter/token_bucket.rb
class RateLimiter::TokenBucket
def initialize(redis, key, capacity:, refill_rate:)
@redis = redis
@key = key
@capacity = capacity # バケツの最大容量(バースト上限)
@refill_rate = refill_rate # 1秒あたりの補充量(平均レート)
end
def allow?
now = Time.current.to_f
result = @redis.eval(
TOKEN_BUCKET_SCRIPT,
keys: [@key],
argv: [@capacity, @refill_rate, now, 1]
)
result == 1
end
# Lua スクリプトでアトミックに実行(競合状態を防ぐ)
TOKEN_BUCKET_SCRIPT = <<~LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local last_refill = tonumber(redis.call('HGET', key, 'last_refill') or now)
local tokens = tonumber(redis.call('HGET', key, 'tokens') or capacity)
-- 経過時間分のトークンを補充(上限 = capacity)
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('HSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1 -- 許可
else
-- トークン不足: last_refill だけ更新(次回補充計算のため)
redis.call('HSET', key, 'last_refill', now, 'tokens', tokens)
redis.call('EXPIRE', key, 3600)
return 0 -- 拒否
end
LUA
private_constant :TOKEN_BUCKET_SCRIPT
end| パラメータ | 意味 | 例 |
|---|---|---|
capacity | バケツの容量 = 瞬間バースト上限 | 20 |
refill_rate | 毎秒の補充量 = 長期平均レート | 10 (req/sec) |
アルゴリズム2: Leaky Bucket(リーキーバケット)
Token Bucketとの違いは出力レートが一定であること。どれだけリクエストが来ても、処理速度は固定される。
# app/services/rate_limiter/leaky_bucket.rb
# キューに積んで一定速度で処理するモデル
class RateLimiter::LeakyBucket
# leak_rate: 1秒あたりの処理数(出力が一定)
def initialize(redis, key, capacity:, leak_rate:)
@redis = redis
@key = key
@capacity = capacity
@leak_rate = leak_rate
end
def allow?
now = Time.current.to_f
result = @redis.eval(LEAKY_BUCKET_SCRIPT,
keys: [@key],
argv: [@capacity, @leak_rate, now])
result == 1
end
LEAKY_BUCKET_SCRIPT = <<~LUA
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local last_leak = tonumber(redis.call('HGET', key, 'last_leak') or now)
local queue = tonumber(redis.call('HGET', key, 'queue') or 0)
-- 経過時間分、キューから流出(leakage)
local elapsed = now - last_leak
queue = math.max(0, queue - elapsed * leak_rate)
if queue < capacity then
queue = queue + 1
redis.call('HSET', key, 'queue', queue, 'last_leak', now)
redis.call('EXPIRE', key, 3600)
return 1
else
redis.call('HSET', key, 'last_leak', now)
return 0
end
LUA
private_constant :LEAKY_BUCKET_SCRIPT
endToken Bucket vs Leaky Bucket の使い分け: Token Bucketはバーストを許容してユーザー体験を優先する。Leaky Bucketは出力を平滑化してバックエンドへの負荷を一定に保つ目的に向いている。どちらを選ぶかは保護対象次第だ。
アルゴリズム3: Fixed Window Counter(固定ウィンドウ)
最も実装が単純だが、致命的な境界問題がある。
# app/services/rate_limiter/fixed_window.rb
class RateLimiter::FixedWindow
def initialize(redis, limit:, window:)
@redis = redis
@limit = limit
@window = window
end
def allow?(identifier)
# ウィンドウID = "現在時刻 ÷ ウィンドウ秒数" の整数部
window_id = Time.current.to_i / @window
key = "fw:#{identifier}:#{window_id}"
count = @redis.incr(key)
@redis.expire(key, @window * 2) if count == 1
count <= @limit
end
end境界問題(Window Boundary Problem):
設定: 1分間に100リクエストまで
23:59:30〜23:59:59(30秒間): 100リクエスト → ウィンドウ上限到達
00:00:00 ウィンドウリセット
00:00:00〜00:00:30(30秒間): 100リクエスト → 新ウィンドウで再び許可
結果: 60秒のウィンドウに対して、30秒の間に200リクエストが通過する
= 設定値の2倍のトラフィックが通過しうる
WARNING
Fixed Window Counterは実装が最も簡単だが、ウィンドウ境界付近での攻撃に弱い。制限値を2倍のレートで突破できるため、セキュリティクリティカルなAPIには使わないこと。認証エンドポイントには必ずSliding Windowを使う。
アルゴリズム4: Sliding Window Counter(最推奨)
Fixed Windowの軽量さと、正確なレート計測を両立する手法。実装は複雑になるが、本番環境での採用に最も向いている。
数学的な説明: なぜ「近似」で十分なのか
設定: 1分間に100リクエストまで
現在時刻: 00:00:45(現ウィンドウ開始から45秒経過)
前ウィンドウ(00:00:00の1分前): 84リクエスト
現ウィンドウ(00:00:00〜): 36リクエスト
重み付きカウント:
= 前ウィンドウ × (1 - 現ウィンドウ内経過割合) + 現ウィンドウ
= 84 × (1 - 45/60) + 36
= 84 × 0.25 + 36
= 21 + 36
= 57
57 ≤ 100 → 許可
なぜ近似で十分か?
正確な計算には「直近60秒の全リクエストのタイムスタンプ」が必要。
しかし実際の使用パターンはランダムに分布するため、
加重平均は統計的に十分な精度を持つ。
誤差は制限値の数%以内に収まる。
# app/services/rate_limiter/sliding_window_counter.rb
class RateLimiter::SlidingWindowCounter
def initialize(redis, limit:, window:)
@redis = redis
@limit = limit
@window = window
end
def allow?(identifier)
now = Time.current.to_i
current_window = now / @window
prev_window = current_window - 1
current_key = "sw:#{identifier}:#{current_window}"
prev_key = "sw:#{identifier}:#{prev_window}"
results = @redis.multi do |pipe|
pipe.get(prev_key)
pipe.incr(current_key)
pipe.expire(current_key, @window * 2)
end
prev_count = results[0].to_i
current_count = results[1].to_i
# 現ウィンドウ内の経過割合
elapsed_ratio = (now % @window).to_f / @window
# 重み付き合算
weighted_count = prev_count * (1.0 - elapsed_ratio) + current_count
if weighted_count <= @limit
true
else
# ロールバック(インクリメントしたが拒否する場合)
@redis.decr(current_key)
false
end
end
# レスポンスヘッダー用の残余カウントを計算
def remaining(identifier)
now = Time.current.to_i
current_window = now / @window
prev_window = current_window - 1
prev_count = @redis.get("sw:#{identifier}:#{prev_window}").to_i
current_count = @redis.get("sw:#{identifier}:#{current_window}").to_i
elapsed_ratio = (now % @window).to_f / @window
weighted_count = prev_count * (1.0 - elapsed_ratio) + current_count
[@limit - weighted_count.ceil, 0].max
end
endStep 4: 高レベル設計
Step 5: Railsミドルウェア実装
Rackミドルウェアとして組み込む
# lib/middleware/rate_limiter_middleware.rb
class RateLimiterMiddleware
RATE_LIMIT_RULES = [
{ pattern: %r{^/api/v1/auth/login}, limit: 10, window: 60, tier_multiplier: false },
{ pattern: %r{^/api/v1/auth/}, limit: 30, window: 60, tier_multiplier: false },
{ pattern: %r{^/api/v1/search}, limit: 100, window: 60, tier_multiplier: true },
{ pattern: %r{^/api/v1/}, limit: 1000, window: 60, tier_multiplier: true },
].freeze
DEFAULT_RULE = { limit: 100, window: 60, tier_multiplier: false }.freeze
TIER_MULTIPLIERS = {
"free" => 1.0,
"pro" => 5.0,
"enterprise" => 50.0,
}.freeze
def initialize(app)
@app = app
@redis = Redis.new(url: ENV.fetch("REDIS_URL"))
@rules_loader = RateLimitRulesLoader.new
end
def call(env)
request = Rack::Request.new(env)
rule = find_rule(request.path)
identifier = identify(request, env)
limit = effective_limit(rule, env)
limiter = RateLimiter::SlidingWindowCounter.new(
@redis, limit: limit, window: rule[:window]
)
if limiter.allow?(identifier)
status, headers, body = @app.call(env)
add_rate_limit_headers!(headers, limiter, identifier, limit, rule[:window])
[status, headers, body]
else
reset_at = next_window_at(rule[:window])
[
429,
rate_limit_exceeded_headers(limit, reset_at),
[{ error: "Too Many Requests", retry_after: reset_at - Time.current.to_i }.to_json]
]
end
end
private
def identify(request, env)
user_id = env["current_user_id"] # 認証済みならuser_id
user_id ? "user:#{user_id}" : "ip:#{request.ip}"
end
def find_rule(path)
RATE_LIMIT_RULES.find { |r| path.match?(r[:pattern]) } || DEFAULT_RULE
end
def effective_limit(rule, env)
return rule[:limit] unless rule[:tier_multiplier]
tier = env["current_user_tier"] || "free"
multiplier = TIER_MULTIPLIERS[tier] || 1.0
(rule[:limit] * multiplier).to_i
end
def add_rate_limit_headers!(headers, limiter, identifier, limit, window)
remaining = limiter.remaining(identifier)
reset_at = next_window_at(window)
headers["X-RateLimit-Limit"] = limit.to_s
headers["X-RateLimit-Remaining"] = remaining.to_s
headers["X-RateLimit-Reset"] = reset_at.to_s
headers["X-RateLimit-Policy"] = "sliding-window"
end
def rate_limit_exceeded_headers(limit, reset_at)
{
"Content-Type" => "application/json",
"X-RateLimit-Limit" => limit.to_s,
"X-RateLimit-Remaining" => "0",
"X-RateLimit-Reset" => reset_at.to_s,
"Retry-After" => (reset_at - Time.current.to_i).to_s,
}
end
def next_window_at(window)
now = Time.current.to_i
((now / window) + 1) * window
end
end# config/application.rb に追加
config.middleware.insert_before ActionDispatch::RequestId, RateLimiterMiddlewareStep 6: Golangでの実装(goroutineセーフ)
マイクロサービスのAPI GatewayレイヤーをGoで実装する場合の例。go-redisとsync/atomicを使い、goroutineセーフに設計する。
// ratelimiter/sliding_window.go
package ratelimiter
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// SlidingWindowLimiter はgoroutineセーフなスライディングウィンドウ実装
type SlidingWindowLimiter struct {
rdb *redis.Client
limit int64
window int64 // seconds
fallback atomic.Bool // true = Redisが利用不可(フェイルオープン用)
}
func NewSlidingWindowLimiter(rdb *redis.Client, limit, windowSec int64) *SlidingWindowLimiter {
return &SlidingWindowLimiter{
rdb: rdb,
limit: limit,
window: windowSec,
}
}
// AllowResult はレート制限チェックの結果
type AllowResult struct {
Allowed bool
Remaining int64
ResetAt int64 // Unix timestamp
RetryAfter int64 // seconds(制限超過時のみ)
}
// Allow はidentifierのリクエストが許可されるか判定する(goroutineセーフ)
func (l *SlidingWindowLimiter) Allow(ctx context.Context, identifier string) (*AllowResult, error) {
// Redisが利用不可の場合はフェイルオープン
if l.fallback.Load() {
return &AllowResult{Allowed: true, Remaining: l.limit}, nil
}
now := time.Now().Unix()
currentWindow := now / l.window
prevWindow := currentWindow - 1
resetAt := (currentWindow + 1) * l.window
currentKey := fmt.Sprintf("sw:%s:%d", identifier, currentWindow)
prevKey := fmt.Sprintf("sw:%s:%d", identifier, prevWindow)
// Redis Pipelineで2コマンドを1往復で送信
pipe := l.rdb.Pipeline()
prevCmd := pipe.Get(ctx, prevKey)
currentCmd := pipe.Incr(ctx, currentKey)
pipe.Expire(ctx, currentKey, time.Duration(l.window*2)*time.Second)
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
// Redis障害: フォールバックモードに切り替え
l.fallback.Store(true)
go l.redisHealthCheck(ctx) // バックグラウンドで回復を監視
return &AllowResult{Allowed: true, Remaining: l.limit}, nil
}
prevCount, _ := prevCmd.Int64()
currentCount, _ := currentCmd.Int64()
elapsedRatio := float64(now%l.window) / float64(l.window)
weightedCount := float64(prevCount)*(1.0-elapsedRatio) + float64(currentCount)
if weightedCount <= float64(l.limit) {
remaining := l.limit - int64(weightedCount)
return &AllowResult{
Allowed: true,
Remaining: remaining,
ResetAt: resetAt,
}, nil
}
// 制限超過: インクリメントをロールバック
l.rdb.Decr(ctx, currentKey)
return &AllowResult{
Allowed: false,
Remaining: 0,
ResetAt: resetAt,
RetryAfter: resetAt - now,
}, nil
}
// redisHealthCheck はRedis回復を定期確認し、フォールバックを解除する
func (l *SlidingWindowLimiter) redisHealthCheck(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := l.rdb.Ping(ctx).Err(); err == nil {
l.fallback.Store(false)
return
}
}
}// ratelimiter/middleware.go
package ratelimiter
import (
"net/http"
"strconv"
)
// HTTPMiddleware はGo HTTPハンドラへのレート制限ミドルウェア
func HTTPMiddleware(limiter *SlidingWindowLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identifier := extractIdentifier(r)
result, err := limiter.Allow(r.Context(), identifier)
if err != nil {
// エラー時はフェイルオープン
next.ServeHTTP(w, r)
return
}
// 常にレート制限ヘッダーを付与
w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limiter.limit, 10))
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(result.Remaining, 10))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(result.ResetAt, 10))
if !result.Allowed {
w.Header().Set("Retry-After", strconv.FormatInt(result.RetryAfter, 10))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"Too Many Requests"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
func extractIdentifier(r *http.Request) string {
// JWTからuser_idを取得(認証済みの場合)
if userID := r.Header.Get("X-User-ID"); userID != "" {
return "user:" + userID
}
// X-Forwarded-For でロードバランサー越しのIPを取得
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return "ip:" + ip
}
return "ip:" + r.RemoteAddr
}Step 7: ルール管理システム(動的ホットリロード)
「ルールを変更するたびにデプロイが必要だと、深夜の攻撃に即応できない」とレイカは言った。
# config/rate_limits.yml
version: "2"
updated_at: "2025-01-15T00:00:00Z"
tiers:
free: { multiplier: 1.0 }
pro: { multiplier: 5.0 }
enterprise: { multiplier: 50.0 }
rules:
- name: login_endpoint
pattern: "^/api/v1/auth/login"
limit: 10
window: 60
identifier: ip # IPアドレス単位(ユーザー認証前なのでuser_idなし)
tier_multiplier: false # Free でも攻撃は防ぐ
note: "ブルートフォース攻撃対策。絶対に緩めてはいけない"
- name: search_api
pattern: "^/api/v1/search"
limit: 100
window: 60
identifier: user
tier_multiplier: true
note: "全文検索はDBに高負荷"
- name: webhook_endpoint
pattern: "^/api/v1/webhooks"
limit: 5000
window: 60
identifier: ip
tier_multiplier: false
ip_allowlist:
- "34.210.0.0/16" # 外部サービスAのIP範囲
- "52.15.0.0/16" # 外部サービスBのIP範囲
security:
block_user_agents:
- "sqlmap"
- "nikto"
- "masscan"
trusted_proxy_ips:
- "10.0.0.0/8"
- "172.16.0.0/12"# app/services/rate_limit_rules_loader.rb
class RateLimitRulesLoader
RULES_FILE = Rails.root.join("config/rate_limits.yml")
RELOAD_INTERVAL = 30.seconds
def initialize
@rules = nil
@loaded_at = nil
@mutex = Mutex.new
end
def rules
@mutex.synchronize do
reload_if_needed!
@rules
end
end
private
def reload_if_needed!
if @rules.nil? || @loaded_at.nil? || Time.current - @loaded_at > RELOAD_INTERVAL
new_rules = YAML.load_file(RULES_FILE).deep_symbolize_keys
validate!(new_rules)
@rules = new_rules
@loaded_at = Time.current
Rails.logger.info "[RateLimiter] Rules reloaded (version: #{@rules[:version]})"
end
rescue => e
# 読み込み失敗時は既存ルールを維持(古いルールの方が無制限より安全)
Rails.logger.error "[RateLimiter] Failed to reload rules: #{e.message}"
end
def validate!(rules)
raise "version required" unless rules[:version]
raise "rules must be array" unless rules[:rules].is_a?(Array)
rules[:rules].each do |rule|
raise "rule missing limit: #{rule}" unless rule[:limit]
end
end
endINFO
ルール設定をYAMLファイルに分離し、30秒ごとにホットリロードすることで、デプロイなしでルールを変更できる。さらにAWS Systems Manager Parameter StoreやConsulに設定を保存すれば、複数サーバー間でリアルタイムに設定共有できる。
Step 8: 分散環境での課題
「複数のAPIサーバーがある場合、カウンターの整合性はどう保つか?」
Redisクラスターの一貫性問題
問題: Redis Cluster では、同じキーが常に同じシャードに
配置されるとは限らない。
解決策: Hash Tags を使い、同じユーザーのキーを同じシャードに集める
通常:
sw:user:123:window_A → シャード1
sw:user:123:window_B → シャード2 ← 別シャード! MULTI が使えない
Hash Tags あり({user:123} でグルーピング):
sw:{user:123}:window_A → 同じシャード
sw:{user:123}:window_B → 同じシャード ← OK
# Hash Tags を使った実装
def window_key(identifier, window_id)
# {identifier} でハッシュタグを作成 → 同じシャードに配置される
"sw:{#{identifier}}:#{window_id}"
endフォールバック戦略の比較
フェイルオープン(採用):
Redis障害時 → レート制限チェックをスキップ → APIをそのまま通す
メリット: APIサービスが止まらない
デメリット: 攻撃を一時的に通してしまう
→ DDoS対策はAWS WAFで別レイヤーが担保するため許容
フェイルクローズ(不採用):
Redis障害時 → 全リクエストを拒否
メリット: 攻撃を確実にブロック
デメリット: 正規ユーザーも巻き込んで止まる
→ 「レート制限が原因でサービスダウン」はあり得ない
Step 9: AWSアーキテクチャ詳細
AWS API Gateway のスロットリング設定(Terraform)
# terraform/modules/api_gateway/main.tf
resource "aws_api_gateway_stage" "api" {
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = var.env
default_route_settings {
throttling_burst_limit = 5000
throttling_rate_limit = 10000
}
}
# AWS WAF: IPレート制限ルール(5分間で2000リクエスト/IP)
resource "aws_wafv2_web_acl" "main" {
name = "rate-limit-acl"
scope = "REGIONAL"
default_action { allow {} }
rule {
name = "IPRateLimitRule"
priority = 1
action { block {} }
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "IPRateLimit"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "RateLimitACL"
sampled_requests_enabled = true
}
}Step 10: セキュリティ観点
IPホワイトリストとUser-Agentベース制限
# app/middleware/security_filter_middleware.rb
class SecurityFilterMiddleware
BLOCKED_USER_AGENTS = %w[sqlmap nikto masscan zgrab dirbuster hydra].freeze
def initialize(app, config: RateLimitRulesLoader.new)
@app = app
@config = config
end
def call(env)
request = Rack::Request.new(env)
# 1. ブロック対象 User-Agent
ua = request.user_agent.to_s.downcase
if BLOCKED_USER_AGENTS.any? { |bad| ua.include?(bad) }
return [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
end
# 2. Webhook エンドポイントは許可IPのみ
if webhook_path?(request.path) && !allowed_webhook_ip?(request.ip)
return [403, { "Content-Type" => "application/json" },
[{ error: "IP not allowed" }.to_json]]
end
@app.call(env)
end
private
def webhook_path?(path)
path.match?(%r{^/api/v1/webhooks})
end
def allowed_webhook_ip?(ip)
allowlist = @config.rules.dig(:security, :trusted_proxy_ips) || []
allowlist.any? { |cidr| IPAddr.new(cidr).include?(IPAddr.new(ip)) }
end
endStep 11: 課金プランと連動したレート制限
# app/models/concerns/rate_limitable.rb
module RateLimitable
PLANS = {
free: {
api_calls_per_minute: 100,
api_calls_per_day: 10_000,
search_per_minute: 10,
},
pro: {
api_calls_per_minute: 500,
api_calls_per_day: 100_000,
search_per_minute: 50,
},
enterprise: {
api_calls_per_minute: 5_000,
api_calls_per_day: :unlimited,
search_per_minute: 500,
},
}.freeze
def rate_limit_config
PLANS[subscription_tier.to_sym] || PLANS[:free]
end
def rate_limit_exceeded?(metric, redis)
config = rate_limit_config
limit = config[metric]
return false if limit == :unlimited
window = metric.to_s.include?("day") ? 86_400 : 60
limiter = RateLimiter::SlidingWindowCounter.new(redis, limit: limit, window: window)
!limiter.allow?("user:#{id}:#{metric}")
end
end
# 使用例(コントローラー)
class Api::V1::SearchController < ApplicationController
before_action :check_search_rate_limit
def index
# 検索処理
end
private
def check_search_rate_limit
return unless current_user.rate_limit_exceeded?(:search_per_minute, Redis.current)
render json: {
error: "Search rate limit exceeded",
plan: current_user.subscription_tier,
upgrade: "https://example.com/pricing",
}, status: :too_many_requests
end
endINFO
課金プランとレート制限を連動させる場合、ユーザーのプラン情報をリクエストごとにDBから引くとボトルネックになる。JWTトークンのペイロードにプラン情報を含めるか、Redisにキャッシュして高速にアクセスする設計にする。
Step 12: レート制限ヘッダー設計
クライアントが適切にリトライできるよう、ヘッダーの設計も重要だ。
X-RateLimit-Limit: 100
→ このエンドポイントの制限値(ウィンドウ内の最大リクエスト数)
X-RateLimit-Remaining: 37
→ 現在のウィンドウで残り許可されるリクエスト数
X-RateLimit-Reset: 1705312800
→ 制限がリセットされる Unix タイムスタンプ
Retry-After: 23
→ 429 返却時のみ。次にリトライ可能になるまでの秒数(RFC 6585)
X-RateLimit-Policy: sliding-window
→ 採用しているアルゴリズムの種別(デバッグ用)
# クライアント側(Ruby)でのリトライ実装例
def api_request_with_retry(path, max_retries: 3)
retries = 0
begin
response = http_client.get(path)
response
rescue RateLimitExceeded => e
retry_after = e.response.headers["Retry-After"].to_i
retries += 1
raise if retries >= max_retries
sleep(retry_after + 1) # +1秒のバッファ
retry
end
endStep 13: 監視とアラート
# app/services/rate_limit_metrics.rb
class RateLimitMetrics
def self.record_block(identifier:, endpoint:, rule_name:)
cloudwatch = Aws::CloudWatch::Client.new
cloudwatch.put_metric_data(
namespace: "RateLimiter",
metric_data: [
{
metric_name: "BlockedRequests",
value: 1,
unit: "Count",
dimensions: [
{ name: "Endpoint", value: endpoint },
{ name: "RuleName", value: rule_name },
],
},
]
)
# Datadog APM にも送信
StatsD.increment("rate_limiter.blocked",
tags: ["endpoint:#{endpoint}", "rule:#{rule_name}"])
end
end# CloudWatch アラーム設定
Alarms:
HighBlockRate:
MetricName: BlockedRequests
Namespace: RateLimiter
Period: 60
Threshold: 1000 # 1分間で1000件ブロックされたらアラート
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref PagerDutySnsTopic
SuspiciousIP:
MetricName: BlockedRequests
Dimensions:
IdentifierType: ip
Period: 300
Threshold: 500
AlarmActions:
- !Ref SecurityTeamTopicStep 14: テスト戦略
RSpecによる境界値テスト・負荷テスト
# spec/services/rate_limiter/sliding_window_counter_spec.rb
RSpec.describe RateLimiter::SlidingWindowCounter do
let(:redis) { MockRedis.new }
let(:limiter) { described_class.new(redis, limit: 5, window: 60) }
let(:id) { "user:test_123" }
describe "#allow?" do
context "制限値以内のリクエスト" do
it "5回まで許可する" do
5.times { expect(limiter.allow?(id)).to be true }
end
end
context "制限値を超えたリクエスト" do
before { 5.times { limiter.allow?(id) } }
it "6回目を拒否する" do
expect(limiter.allow?(id)).to be false
end
end
context "ウィンドウ境界付近(境界値テスト)" do
it "前ウィンドウのカウントを加重して判定する" do
prev_window = Time.current.to_i / 60 - 1
redis.set("sw:#{id}:#{prev_window}", 4)
# 現在ウィンドウ開始から30秒経過(50%)
travel_to(Time.zone.at((Time.current.to_i / 60) * 60 + 30)) do
# 重み付きカウント: 4 × 0.5 + 0 = 2.0 → 制限5に対して余裕あり
expect(limiter.allow?(id)).to be true
end
end
end
end
end# spec/requests/rate_limiting_spec.rb
RSpec.describe "Rate Limiting Integration", type: :request do
it "Free プランは1分間に100リクエストまで許可する" do
user = create(:user, :free_plan)
token = JwtService.encode(user_id: user.id, tier: "free")
100.times do |i|
get "/api/v1/products", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok), "#{i + 1}回目が失敗"
expect(response.headers["X-RateLimit-Remaining"]).to eq((99 - i).to_s)
end
get "/api/v1/products", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(429)
expect(response.headers["Retry-After"]).to be_present
end
it "429 レスポンスには必要なヘッダーが揃っている" do
user = create(:user, :free_plan)
token = JwtService.encode(user_id: user.id, tier: "free")
101.times { get "/api/v1/products", headers: { "Authorization" => "Bearer #{token}" } }
expect(response.headers.keys).to include(
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"Retry-After"
)
end
endStep 15: 面接官との深掘り会話
練習の終盤、レイカが面接官役になった。
面接官(レイカ): 「Redis Luaスクリプトの何が良いのか、もう少し詳しく説明してもらえますか?」
ソウタ: 「RedisはシングルスレッドでLuaスクリプトを実行します。つまり、GET → 判定 → INCRという3ステップをアトミックに実行できます。もし3つの独立したコマンドとして送ると、別のクライアントがGETとINCRの間に割り込んで、複数のリクエストが同時に許可されてしまう競合が起きます。Luaスクリプトにまとめることで、その問題を完全に排除できます」
面接官(レイカ): 「Redis ClusterモードでLuaスクリプト内の複数キーを操作したい場合は?」
ソウタ: 「Redis ClusterではLuaスクリプト内で操作できるキーは、同じシャードに存在するものに限られます。ハッシュタグ {user:123} を使って、同じユーザーに関連するキーが必ず同じシャードに配置されるよう設計します。これによりマルチキーのアトミック操作も安全に行えます」
面接官(レイカ): 「モバイルアプリが急増して通常の10倍のトラフィックが来た。でもそれは攻撃ではなく正規ユーザーだった。どう対応しますか?」
ソウタ: 「まずCloudWatchで429レスポンス率の急増を検知します。攻撃ではなく正規トラフィックだと判断したら、YAMLのホットリロードで制限値を一時的に引き上げます——これがホットリロード設計の意義です。並行してECSのオートスケーリングでAPIサーバーを増やし、ElastiCacheのノード追加でRedisのキャパシティも拡張します。恒久対応としては、CDN層でのキャッシュ強化や非同期処理化を検討します」
面接官(レイカ): 「よし。それで完璧だ」とレイカは笑った。「6年前の私より10倍上手い」
まとめ
ソウタが学んだレート制限設計の要点を整理する。
| アルゴリズム | 実装コスト | 精度 | バースト許容 | 推奨用途 |
|---|---|---|---|---|
| Fixed Window | 低 | 低(境界問題) | あり | プロトタイプのみ |
| Sliding Window Log | 高 | 高 | なし | 高精度が必要な課金計算 |
| Token Bucket | 中 | 中 | あり | UX重視のAPI |
| Sliding Window Counter | 中 | 中〜高 | なし | 本番環境の標準 |
面接での回答フレームワーク:
- 要件確認(機能・非機能・可用性トレードオフ)
- 規模の概算(メモリ・QPS・Redis容量)
- アルゴリズム選定と理由(Sliding Window Counterを推奨し、他との比較を示す)
- 高レベル設計(多層防御: WAF → API Gateway → アプリ層)
- 分散環境の課題(Luaスクリプト、Hash Tags、フォールバック戦略)
- 監視・テスト(429率のモニタリング、境界値テスト)
「レート制限は単なるrack-attackの設定じゃない」とレイカが締めくくった。「ビジネスの継続性を守るシステムだ。その深さを面接官に伝えられれば、必ず差がつく」