mybook

レート制限とスロットリング — 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/")
end

AWS 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: v2
Loading 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 AlertSNSTopic

WARNING

レート制限は「制限を設けること」ではなく「適切なサービス品質を守ること」です。制限が厳しすぎると正当なユーザーを弾き、緩すぎると攻撃に脆弱になります。パートナーの実使用パターンを分析して設定しましょう。

テスト

# 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に「ありがとう」のスタンプが来た。

次章では、エラーが起きたときに開発者が何が悪かったかを理解できる「エラーハンドリング」を設計する。