mybook

レート制限と DDoS 対策 — サービスを守る壁

インシデントから3週間後、リョウはHTTPSとJWTの修正を終えたところで、最初の事件を振り返った。

あの攻撃で最も被害が大きかったのは、毎秒1000リクエストのブルートフォースによってデータベース接続が枯渇したことだ。認証を強化しても、大量のリクエストでサービスを止めることはできる。

「レート制限なしのAPIは、無施錠の店と同じだ」

リョウはrack-attackの実装に取り掛かった。

Rack::Attackの導入

# Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb
class Rack::Attack
  # Redisをキャッシュストアとして使用(分散環境対応)
  Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
    url: ENV['REDIS_URL'],
    namespace: "rack_attack"
  )
 
  # ====================
  # ブロックルール
  # ====================
 
  # 既知の悪意あるIPをブロック(動的に更新)
  blocklist("block-malicious-ips") do |request|
    BlockedIp.exists?(ip: request.ip)
  end
 
  # Tor出口ノードをブロック(オプション)
  blocklist("block-tor-exit-nodes") do |request|
    TorExitNode.exists?(ip: request.ip)
  end
 
  # ====================
  # スロットリング
  # ====================
 
  # 1. APIリクエスト全体のレート制限(1分間に60件)
  throttle("api/ip", limit: 60, period: 1.minute) do |request|
    request.ip if request.path.start_with?('/api/')
  end
 
  # 2. ログイン試行の制限(IPベース)
  throttle("auth/login/ip", limit: 5, period: 20.minutes) do |request|
    if request.path == '/api/v1/auth/login' && request.post?
      request.ip
    end
  end
 
  # 3. ログイン試行の制限(メールアドレスベース)
  throttle("auth/login/email", limit: 5, period: 20.minutes) do |request|
    if request.path == '/api/v1/auth/login' && request.post?
      body = JSON.parse(request.body.read) rescue {}
      request.env['rack.input'].rewind  # ボディを巻き戻す
      body['email']&.downcase&.strip
    end
  end
 
  # 4. パスワードリセットの制限
  throttle("auth/password-reset", limit: 3, period: 1.hour) do |request|
    if request.path == '/api/v1/auth/password-reset' && request.post?
      request.ip
    end
  end
 
  # 5. アカウント作成の制限
  throttle("auth/signup", limit: 10, period: 1.hour) do |request|
    if request.path == '/api/v1/auth/signup' && request.post?
      request.ip
    end
  end
 
  # 6. APIキーを使ったリクエストの制限(ユーザーごと)
  throttle("api/key", limit: 1000, period: 1.hour) do |request|
    if request.path.start_with?('/api/')
      api_key = request.env['HTTP_X_API_KEY'] ||
                request.env['HTTP_AUTHORIZATION']&.split('ApiKey ')&.last
      api_key
    end
  end
 
  # 7. エクスポートAPIの制限(負荷が高いため)
  throttle("api/export", limit: 5, period: 1.hour) do |request|
    if request.path.include?('/export') && request.get?
      request.ip
    end
  end
 
  # ====================
  # セーフリスト
  # ====================
 
  # ヘルスチェックエンドポイントは制限なし
  safelist("health-check") do |request|
    request.path == '/health'
  end
 
  # 内部サービスのIPを制限なし
  safelist("internal-services") do |request|
    internal_ips = ENV['INTERNAL_IP_RANGES']&.split(',') || []
    internal_ips.any? { |range| IPAddr.new(range).include?(request.ip) }
  end
 
  # ====================
  # レート制限時のレスポンスカスタマイズ
  # ====================
  self.throttled_responder = lambda do |request|
    match_data = request.env['rack.attack.match_data']
    now = match_data[:epoch_time]
    period = match_data[:period]
 
    headers = {
      'Content-Type' => 'application/json',
      'X-RateLimit-Limit' => match_data[:limit].to_s,
      'X-RateLimit-Remaining' => '0',
      'X-RateLimit-Reset' => (now + (period - now % period)).to_s,
      'Retry-After' => (period - now % period).to_s
    }
 
    body = JSON.generate({
      error: "Rate limit exceeded",
      code: "RATE_LIMIT_EXCEEDED",
      retry_after: (period - now % period).to_i
    })
 
    [429, headers, [body]]
  end
end

INFO

レート制限はIPアドレスとアカウントの両方でかける「二重ロック」が効果的です。IPだけでは分散プロキシを使った攻撃に弱く、アカウントだけでは新規登録を大量に作られてしまいます。

レート制限の通知と監視

# config/initializers/rack_attack.rb(続き)
# レート制限イベントの記録
ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload|
  request = payload[:request]
  match = request.env['rack.attack.matched']
  match_data = request.env['rack.attack.match_data']
 
  # 構造化ログを出力
  Rails.logger.warn({
    event: "rate_limit_triggered",
    matched: match,
    ip: request.ip,
    path: request.path,
    method: request.request_method,
    count: match_data[:count],
    limit: match_data[:limit],
    period: match_data[:period]
  }.to_json)
 
  # CloudWatchカスタムメトリクスに記録
  Aws::CloudWatch::Client.new.put_metric_data(
    namespace: 'StockFlow/Security',
    metric_data: [{
      metric_name: 'RateLimitTriggered',
      dimensions: [{ name: 'Rule', value: match }],
      value: 1,
      unit: 'Count'
    }]
  )
 
  # 短時間に多数のレート制限が発生したらアラート
  if match.include?('login') && match_data[:count] >= match_data[:limit] * 2
    SecurityAlertJob.perform_later(
      type: 'brute_force_detected',
      ip: request.ip,
      match: match
    )
  end
end

カスタムレート制限ヘッダー

クライアントがレート制限を事前に把握できるようにヘッダーを追加する。

# app/controllers/concerns/rate_limit_headers.rb
module RateLimitHeaders
  extend ActiveSupport::Concern
 
  included do
    after_action :set_rate_limit_headers
  end
 
  private
 
  def set_rate_limit_headers
    limit = 60  # 1分あたりの上限
    cache_key = "rack_attack:api/ip:#{request.ip}:#{Time.current.to_i / 60}"
    current_count = Rails.cache.read(cache_key).to_i
 
    response.headers['X-RateLimit-Limit'] = limit.to_s
    response.headers['X-RateLimit-Remaining'] = [limit - current_count, 0].max.to_s
    response.headers['X-RateLimit-Reset'] = (Time.current.beginning_of_minute + 1.minute).to_i.to_s
  end
end

AWS Shield でDDoS防御

Loading diagram...
# AWS Shield Advanced の有効化(月額3000ドル)
aws shield create-subscription
 
# 保護対象リソースを登録
aws shield create-protection \
  --name "StockFlow-ALB-Protection" \
  --resource-arn "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:loadbalancer/app/stockflow-alb/xxxxx"

Shield Advancedのメリット:

  • DDoS対応チーム(DRT)による24/7サポート
  • DDoS攻撃によるAWS費用の払い戻し
  • 高度なDDoS検出レポート
  • AWS WAFとの統合

AWS WAFでのレート制限

WAFレベルでもレート制限を設定することで、リクエストがアプリケーションに到達する前にブロックできる。

# terraform/waf_rate_limiting.tf
resource "aws_wafv2_web_acl" "rate_limiting" {
  name  = "rate-limiting"
  scope = "REGIONAL"
 
  # IPベースのレート制限(5分間で1000リクエストを超えたらブロック)
  rule {
    name     = "IPRateLimit"
    priority = 1
    action { block {} }
    statement {
      rate_based_statement {
        limit              = 1000
        aggregate_key_type = "IP"
        scope_down_statement {
          byte_match_statement {
            field_to_match { uri_path {} }
            positional_constraint = "STARTS_WITH"
            search_string         = "/api/"
            text_transformation { priority = 0; type = "LOWERCASE" }
          }
        }
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "IPRateLimit"
      sampled_requests_enabled   = true
    }
  }
 
  # ログイン専用の厳しいレート制限
  rule {
    name     = "LoginRateLimit"
    priority = 2
    action { block {} }
    statement {
      rate_based_statement {
        limit              = 20  # 5分間で20回
        aggregate_key_type = "IP"
        scope_down_statement {
          byte_match_statement {
            field_to_match { uri_path {} }
            positional_constraint = "EXACTLY"
            search_string         = "/api/v1/auth/login"
            text_transformation { priority = 0; type = "LOWERCASE" }
          }
        }
      }
    }
  }
}

CloudFrontによるグローバルDDoS対策

# terraform/cloudfront.tf
resource "aws_cloudfront_distribution" "api" {
  origin {
    domain_name = aws_lb.main.dns_name
    origin_id   = "stockflow-alb"
 
    custom_header {
      name  = "X-Origin-Secret"
      value = var.cloudfront_origin_secret  # ALBでこのヘッダーを検証
    }
  }
 
  # キャッシュしない(APIなので)
  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    compress         = true
    viewer_protocol_policy = "redirect-to-https"
 
    forwarded_values {
      query_string = true
      headers      = ["Authorization", "Content-Type", "X-API-Key"]
      cookies { forward = "none" }
    }
 
    min_ttl     = 0
    default_ttl = 0
    max_ttl     = 0
  }
 
  web_acl_id = aws_wafv2_web_acl.rate_limiting.arn
 
  geo_restriction {
    restriction_type = "none"  # 地理的制限は要件次第
  }
}

リトライ戦略(クライアント側)

レート制限を実装したら、クライアント側も適切なリトライ戦略を持つべきだ。

# app/services/api_client_with_retry.rb
class ApiClientWithRetry
  MAX_RETRIES = 3
  BASE_DELAY = 1.0  # 秒
 
  def self.request(method, path, options = {})
    retries = 0
 
    begin
      response = Faraday.send(method, "https://api.example.com#{path}", options)
 
      case response.status
      when 429
        retry_after = response.headers['Retry-After']&.to_i || calculate_backoff(retries)
        raise TooManyRequestsError.new(retry_after: retry_after)
      when 503
        raise ServiceUnavailableError
      else
        response
      end
 
    rescue TooManyRequestsError => e
      retries += 1
      if retries <= MAX_RETRIES
        sleep_time = e.retry_after || calculate_backoff(retries)
        Rails.logger.warn("Rate limited. Retrying in #{sleep_time}s (attempt #{retries}/#{MAX_RETRIES})")
        sleep(sleep_time)
        retry
      else
        raise
      end
    end
  end
 
  def self.calculate_backoff(retries)
    # 指数バックオフ + ジッター
    base = BASE_DELAY * (2 ** retries)
    jitter = rand(0.0..1.0) * base * 0.3
    base + jitter
  end
end

Sidekiqジョブのレート制限

バックグラウンドジョブでも外部APIを叩く場合は制限が必要だ。

# Gemfile
gem 'sidekiq-throttled'
 
# app/jobs/external_api_sync_job.rb
class ExternalApiSyncJob < ApplicationJob
  include Sidekiq::Throttled::Worker
 
  sidekiq_throttle(
    concurrency: { limit: 5 },          # 同時実行数
    threshold: { limit: 100, period: 60 }  # 1分間に100件
  )
 
  def perform(item_id)
    item = InventoryItem.find(item_id)
    ExternalWarehouseApi.sync(item)
  rescue ExternalWarehouseApi::RateLimitError => e
    # エクスポネンシャルバックオフでリトライ
    retry_in = e.retry_after || (2 ** executions)
    raise self.class.set(wait: retry_in.seconds).perform_later(item_id)
  end
end

チェックリスト

  • Rack::Attackでエンドポイント別のレート制限を実装している
  • ログイン、パスワードリセット等の認証エンドポイントは特に厳しく制限している
  • レート制限はIPとアカウントの両方に適用している
  • レート制限ヘッダー(X-RateLimit-*)をレスポンスに含めている
  • レート制限イベントをCloudWatchに記録している
  • AWS WAFでL7レベルのレート制限を設定している
  • CloudFrontでグローバルなDDoS防御を実装している
  • ヘルスチェックエンドポイントをセーフリストに追加している
  • クライアント側でエクスポネンシャルバックオフを実装している
  • AWS Shield(少なくともStandard)が有効になっている