レート制限と 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
endINFO
レート制限は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
endAWS 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
endSidekiqジョブのレート制限
バックグラウンドジョブでも外部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)が有効になっている