mybook

設計問題: レート制限 — 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
end

Token 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
end

Step 4: 高レベル設計

Loading diagram...

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, RateLimiterMiddleware

Step 6: Golangでの実装(goroutineセーフ)

マイクロサービスのAPI GatewayレイヤーをGoで実装する場合の例。go-redissync/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
end

INFO

ルール設定を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アーキテクチャ詳細

Loading diagram...

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
end

Step 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
end

INFO

課金プランとレート制限を連動させる場合、ユーザーのプラン情報をリクエストごとに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
end

Step 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 SecurityTeamTopic

Step 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
end

Step 15: 面接官との深掘り会話

練習の終盤、レイカが面接官役になった。

面接官(レイカ): 「Redis Luaスクリプトの何が良いのか、もう少し詳しく説明してもらえますか?」

ソウタ: 「RedisはシングルスレッドでLuaスクリプトを実行します。つまり、GET → 判定 → INCRという3ステップをアトミックに実行できます。もし3つの独立したコマンドとして送ると、別のクライアントがGETINCRの間に割り込んで、複数のリクエストが同時に許可されてしまう競合が起きます。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中〜高なし本番環境の標準

面接での回答フレームワーク:

  1. 要件確認(機能・非機能・可用性トレードオフ)
  2. 規模の概算(メモリ・QPS・Redis容量)
  3. アルゴリズム選定と理由(Sliding Window Counterを推奨し、他との比較を示す)
  4. 高レベル設計(多層防御: WAF → API Gateway → アプリ層)
  5. 分散環境の課題(Luaスクリプト、Hash Tags、フォールバック戦略)
  6. 監視・テスト(429率のモニタリング、境界値テスト)

「レート制限は単なるrack-attackの設定じゃない」とレイカが締めくくった。「ビジネスの継続性を守るシステムだ。その深さを面接官に伝えられれば、必ず差がつく」