API Rate Limitingで本番が火を噴いた話──2年間の失敗と分散環境の落とし穴

「100req/minで制限かけとけばOK」と思っていたら盛大に炎上した経験、ありませんか?Token Bucket・Sliding Windowを実際の本番で使い分けた2年分の失敗談と解決策をまとめました。

API Rate Limiting 本番運用2年で学んだ、アルゴリズム選択と分散環境の落とし穴

正直に言うと、Rate Limitingを「ただのスロットリング」と思って軽く見ていた時期が僕にはあった。「100req/minで制限かけとけばOKでしょ」みたいなノリで実装して、後から盛大に火を噴いた経験がある。

2年ほど前、SaaS系サービスのバックエンドで急激なトラフィック増加があって、その際の対応でRate Limitingを本気で設計し直した。その後、別のマイクロサービス基盤でも似た設計をゼロから入れた。そこで気づいたこと・失敗したことをここに残しておきたい。

Redisの基本的な使い方は知っている前提で書く。「とりあえず動く実装」じゃなくて「本番で運用できる実装」の話をしたい。ちなみにうちのチームが以前書いたAPI Rate Limiting 2026|分散システム対応の実装戦略でも基礎的な戦略には触れているので、まず概要を掴みたい方はそちらも参照してほしい。


アルゴリズム、実際どれを選ぶべきか問題

Rate Limitingのアルゴリズムは主に4〜5種類ある。教科書的な説明はよく見かけるけど、「実際の現場でどれを選んだか」という話はあまり見かけない気がする。

アルゴリズムバースト対応メモリ効率実装複雑度適しているユースケース
Fixed Window Counter△ 境界問題あり◎ 低◎ 簡単内部API、緩やかな制限
Sliding Window Log◎ 正確△ 高○ 中程度精度重視の外部API
Sliding Window Counter○ ほぼ正確◎ 低○ 中程度バランス重視の汎用途
Token Bucket◎ バースト許容○ 中△ やや複雑公開API、SDKクライアント
Leaky Bucket△ バースト吸収○ 中△ やや複雑均一レート処理が必要な場合

最初に使った実装はFixed Window Counterだった。理由は単純で「Redisで1行で書けるから」。でもこれ、ウィンドウの境界部分で問題が出る。たとえば「60秒で100リクエスト」と設定したとき、59秒時点で100req送って、次の60秒開始直後にもう100req——合計200reqが実質2秒以内に処理される。これが「バウンダリバースト問題」だ。

実際に本番でこれを食らって、DBへの急激な負荷増加が起きた。サービスは落ちなかったけど、レスポンスタイムが数秒単位で悪化した。笑えない。

今うちのチームが主力で使っているのは Sliding Window CounterToken Bucket の組み合わせ。内部APIにはSWC、外部公開APIにはToken Bucket——この使い分けが、2年かけて辿り着いた落とし所になっている。

flowchart TB
    Request[APIリクエスト] --> Router{APIタイプ判定}
    Router --> |内部API| SWC[Sliding Window Counter]
    Router --> |外部公開API| TB[Token Bucket]
    Router --> |管理者API| FixedW[Fixed Window\n緩やかな制限]
    
    SWC --> RedisCluster[(Redis Cluster)]
    TB --> RedisCluster
    FixedW --> RedisCluster
    
    RedisCluster --> Check{制限超過?}
    Check --> |No| Backend[バックエンド処理]
    Check --> |Yes| Reject[429 Too Many Requests]
    
    Backend --> Response[レスポンス返却]
    Reject --> Headers[Retry-After Header付与]

Sliding Window Counterの実装、これで運用2年安定してる

SWC(Sliding Window Counter)は「現在のウィンドウのカウント × 重み + 前のウィンドウのカウント × (1 - 重み)」で推定値を出す手法だ。完全に正確じゃないけど、固定ウィンドウの境界問題はほぼ解消できる。精度とメモリ効率のバランスが良くて、個人的には汎用アルゴリズムとして一番使いやすいと感じている。

実際に使っているGoの実装を見てほしい(2026年時点、Go 1.25ベース)。

package ratelimit

import (
    "context"
    "fmt"
    "math"
    "time"

    "github.com/redis/go-redis/v9"
)

type SlidingWindowCounter struct {
    client      *redis.Client
    windowSize  time.Duration
    maxRequests int64
}

func NewSlidingWindowCounter(client *redis.Client, windowSize time.Duration, maxRequests int64) *SlidingWindowCounter {
    return &SlidingWindowCounter{
        client:      client,
        windowSize:  windowSize,
        maxRequests: maxRequests,
    }
}

// IsAllowed はレートリミットチェックを行い、許可するかどうかを返す
func (s *SlidingWindowCounter) IsAllowed(ctx context.Context, key string) (bool, *RateLimitInfo, error) {
    now := time.Now()
    windowSec := int64(s.windowSize.Seconds())
    currentWindow := now.Unix() / windowSec
    prevWindow := currentWindow - 1

    currentKey := fmt.Sprintf("rl:%s:%d", key, currentWindow)
    prevKey := fmt.Sprintf("rl:%s:%d", key, prevWindow)

    // Luaスクリプトでアトミックに処理
    script := redis.NewScript(`
        local current_key = KEYS[1]
        local prev_key = KEYS[2]
        local max_requests = tonumber(ARGV[1])
        local window_size = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local current_window = tonumber(ARGV[4])
        
        -- 現在のウィンドウの経過時間の割合
        local elapsed = now % window_size
        local prev_weight = 1 - (elapsed / window_size)
        
        local prev_count = tonumber(redis.call('GET', prev_key) or 0)
        local current_count = tonumber(redis.call('GET', current_key) or 0)
        
        -- スライディングウィンドウの推定値
        local estimated = math.floor(prev_count * prev_weight) + current_count
        
        if estimated >= max_requests then
            return {0, estimated, current_count, prev_count}
        end
        
        -- カウントインクリメント
        redis.call('INCR', current_key)
        redis.call('EXPIRE', current_key, window_size * 2)
        
        return {1, estimated + 1, current_count + 1, prev_count}
    `)

    result, err := script.Run(ctx, s.client,
        []string{currentKey, prevKey},
        s.maxRequests,
        windowSec,
        now.Unix(),
        currentWindow,
    ).Int64Slice()

    if err != nil {
        // Redisがダウンしてもサービスを止めない。フェイルオープン戦略
        return true, nil, fmt.Errorf("redis error (fail-open): %w", err)
    }

    allowed := result[0] == 1
    estimated := result[1]
    remaining := s.maxRequests - estimated
    if remaining < 0 {
        remaining = 0
    }

    info := &RateLimitInfo{
        Limit:     s.maxRequests,
        Remaining: remaining,
        ResetAt:   time.Unix((currentWindow+1)*windowSec, 0),
    }

    return allowed, info, nil
}

type RateLimitInfo struct {
    Limit     int64
    Remaining int64
    ResetAt   time.Time
}

ポイントがいくつかある。

Luaスクリプトでアトミック処理 はマスト。GoやPythonから複数のRedisコマンドを叩くと、並列リクエスト処理時に競合状態が起きる。Luaスクリプトで1コマンドにまとめることでアトミック性を担保している。

フェイルオープン戦略 も重要な設計判断だった。Redisがダウンしたときにリクエストをすべて拒否する(フェイルクローズ)か、全部通す(フェイルオープン)か。うちのサービスはSaaS系でダウンタイムのコストが高いので、フェイルオープンを選んだ。セキュリティ重視のAPIなら逆にフェイルクローズにするべきで、ここは好みが分かれると思う——正直どっちが正解とは言い切れない。


分散環境で詰まった、Race ConditionとRedis Clusterの落とし穴

ここが一番の本題かもしれない。単一Redisサーバーで動く実装は比較的簡単だけど、Redis Clusterや複数のRedisノードがある環境では話が変わってくる。

問題1: Redis Cluster でのキー分散

Redis Clusterはキーをハッシュスロットで分散する。rl:user:123:1234567890rl:user:123:1234567891(前後のウィンドウ)が別のノードに配置されると、Luaスクリプトでのアトミック操作ができなくなる。

解決策は HashTag を使うこと。キーに {user:123} のように {} を含めると、スロット計算が {} 内のみで行われ、同一ノードに配置される。

// NG: 異なるスロットに分散する可能性
currentKey := fmt.Sprintf("rl:%s:%d", userID, currentWindow)
prevKey    := fmt.Sprintf("rl:%s:%d", userID, prevWindow)

// OK: HashTagで同一スロットに強制配置
currentKey := fmt.Sprintf("rl:{%s}:%d", userID, currentWindow)
prevKey    := fmt.Sprintf("rl:{%s}:%d", userID, prevWindow)

これ、ハマったときは原因特定に半日使った。Redis Clusterのログを見てもそれっぽいエラーが出ず、「なんか数値がおかしい」という症状だったので余計に気づくのが遅れた。地味にしんどい。

問題2: 複数データセンター(マルチリージョン)での整合性

グローバル展開したサービスで、us-east-1とap-northeast-1にそれぞれRedisを置いたとき、クロスリージョンのRate Limitingをどうするかという問題が出た。

flowchart TB
    subgraph US["us-east-1"]
        LB_US[ALB] --> API_US[API Servers]
        API_US --> Redis_US[(Redis Primary)]
    end
    
    subgraph JP["ap-northeast-1"]
        LB_JP[ALB] --> API_JP[API Servers]
        API_JP --> Redis_JP[(Redis Primary)]
    end
    
    Redis_US <-->|非同期レプリケーション| Redis_JP
    
    User_US([USユーザー]) --> LB_US
    User_JP([JPユーザー]) --> LB_JP
    
    style US fill:#dbeafe,stroke:#3b82f6
    style JP fill:#dcfce7,stroke:#16a34a

マルチリージョンでの完全な整合性は「速度か正確さか」のトレードオフになる。非同期レプリケーションにすると、瞬間的に制限を超えたリクエストが通る可能性がある。実際に経験した環境では、最大で設定値の1.3倍程度まで超えることがあった。

正直まだ完全な正解を見つけていないんだけど、現時点の落とし所はこうなっている:

  • グローバルに厳密に管理したいリソース(課金に直結するもの): リージョン間で同期処理 → レイテンシを受け入れる
  • それ以外のリソース保護目的: 非同期レプリケーション + 若干の超過を許容

という2段構えだ。「全部厳密に」は現実的じゃないと割り切った。セキュリティ観点からのAPI保護についてはOWASP Top 10 2024対策|脆弱性10項目の実装方法と企業の守り方も参考になる。


ヘッダー設計とエラーレスポンス、これ意外と軽視されがち

Rate Limitingを実装したは良いけど、クライアント側がどうリトライすべきか分からない——そういう実装をよく見かける。地味に大事なところなのに、後回しにされがちな部分だ。

標準的なRate Limitヘッダーには2系統ある。2026年現在、RFC 6585 + draft-ietf-httpapi-ratelimit-headers が広く使われている。

# レスポンスヘッダー例
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1746201600
Retry-After: 30
RateLimit-Policy: 100;w=60

実際のミドルウェア実装(Goの gin フレームワーク使用):

func RateLimitMiddleware(limiter *SlidingWindowCounter) gin.HandlerFunc {
    return func(c *gin.Context) {
        // キー生成: IPベース or 認証ユーザーIDベース
        key := getClientKey(c)
        
        allowed, info, err := limiter.IsAllowed(c.Request.Context(), key)
        if err != nil {
            // フェイルオープン: エラーをログに記録して通す
            c.Set("rate_limit_error", err)
            c.Next()
            return
        }
        
        if info != nil {
            // 常にヘッダーを付与する(制限内でも)
            c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", info.Limit))
            c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", info.Remaining))
            c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", info.ResetAt.Unix()))
        }
        
        if !allowed {
            retryAfter := time.Until(info.ResetAt).Seconds()
            c.Header("Retry-After", fmt.Sprintf("%d", int(math.Ceil(retryAfter))))
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error": "rate_limit_exceeded",
                "message": "Too many requests. Please retry after the specified time.",
                "retry_after": int(math.Ceil(retryAfter)),
            })
            c.Abort()
            return
        }
        
        c.Next()
    }
}

func getClientKey(c *gin.Context) string {
    // JWTがあればユーザーID、なければIPアドレス
    if userID, exists := c.Get("user_id"); exists {
        return fmt.Sprintf("user:%v", userID)
    }
    return fmt.Sprintf("ip:%s", c.ClientIP())
}

「制限内でも常にヘッダーを付与する」ようにしているのは重要なポイントだ。クライアント(特にサードパーティの開発者)が現在の使用状況をモニタリングできるようにするためで、これがないと「なんか突然429が来た」という問い合わせが来る。実際に来た。複数回。


本番で導入して気づいた、モニタリングと運用の勘所

Rate Limitingを入れたら終わりじゃなくて、むしろそこからが本番だと思っている。どのユーザーが何回429を受けているか、正常なリクエストをブロックしていないか——これを継続的にモニタリングしないと、気づかないうちに「使えないAPI」になっている可能性がある。

導入直後は429率が8%を超えて正直焦った。ただ、閾値チューニングとエラーメッセージの改善を繰り返すことで、クライアント側が正しいリトライ戦略を実装してくれるようになり、現在は2%を切っている。

xychart-beta
    title "Rate Limit 429レスポンス率の週次推移"
    x-axis ["Week1", "Week2", "Week3", "Week4", "Week5", "Week6", "Week7", "Week8"]
    y-axis "429率 (%)" 0 --> 15
    bar [8.2, 7.8, 6.1, 4.3, 3.8, 2.1, 1.9, 1.7]
    line [8.2, 7.8, 6.1, 4.3, 3.8, 2.1, 1.9, 1.7]

モニタリングで見ている主要メトリクスはこんな感じ:

# Prometheus メトリクス定義例
rate_limit_requests_total = Counter(
    'rate_limit_requests_total',
    'Total rate limit decisions',
    ['status', 'endpoint', 'plan_tier']  # status: allowed/rejected
)

rate_limit_redis_duration = Histogram(
    'rate_limit_redis_duration_seconds',
    'Redis operation duration for rate limiting',
    buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1]
)

# アラート条件の例
# 429率が5%超えたら即通知
# Redis操作のp99が50ms超えたら警告
# フェイルオープン発動(Redisエラー)が1分間に10回超えたら即通知

うちのチームではインシデント対応の最新ベストプラクティス2026でも触れているようなSLO設計と組み合わせて、Rate Limitingの健全性自体をSLI/SLOで管理している。

運用面でもう一つ重要なのが レート制限の動的変更 だ。コードのデプロイなしに閾値を変更できるようにしておかないと、障害対応時やキャンペーン期間に詰む——これは声を大にして言いたい。うちはRedisのHash型で設定を持っていて、管理者APIから変更できるようにしている。

// Redisから動的に閾値を取得
func (s *DynamicRateLimiter) getLimit(ctx context.Context, planTier string) (int64, error) {
    key := fmt.Sprintf("rl:config:%s", planTier)
    val, err := s.client.HGet(ctx, key, "max_requests").Int64()
    if err == redis.Nil {
        // フォールバック: デフォルト値を使用
        return s.defaultLimits[planTier], nil
    }
    return val, err
}

Feature Flagとの組み合わせも有効で、Feature Flag導入で本番バグ対応が1時間から5分に短縮した話のアプローチを使うと、Rate Limiting設定の変更をより安全にロールアウトできる。


まとめ

2年間Rate Limitingをガチで運用してきて、学んだことを整理する。

1. アルゴリズムは用途で選ぶ。「とりあえずFixed Window」はやめる バウンダリバースト問題は本番でちゃんと発生する。汎用用途にはSlidingWindowCounter、バーストを許容したい公開APIにはTokenBucketが、今の僕のベストプラクティスになっている。

2. Redis ClusterではHashTagを必ず使う 忘れると「なんか数値がおかしい」という謎の症状に悩まされる。HashTagによるスロット固定はマルチキー操作の前提条件だ。

3. フェイルオープンかフェイルクローズか、最初に決めておく Redisがダウンしたときの振る舞いを事前に設計しておかないと、障害対応中に迷う。サービスの特性に応じて、どちらを選ぶか腹を決めておくべき。

4. クライアントが自律的に動けるヘッダー設計をする X-RateLimit-RemainingRetry-After を常に返すようにするだけで、問い合わせが劇的に減る。本当に。

5. 動的変更できる設計にしておく デプロイなしに閾値を変更できることは、本番運用では必須に近い。後から足そうとすると思ったより大変なので、最初から仕込んでおくのがおすすめ。

次のアクション: まず既存のAPIにX-RateLimit-*ヘッダーが付いているか確認してみてほしい。付いていなかったら、そこから始めるだけでもクライアント開発者体験がかなり上がるはず。

皆さんはマルチリージョンのRate Limitingどうしていますか?ここは正直まだ「これが正解」と言い切れていないので、知見があればぜひ教えてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事