REST APIを公開していると、特定のクライアントが大量のリクエストを送り続けてサーバーが重くなる問題に直面することありますよね。REST APIの基本的な作り方はこちらも参考にしてください。

この記事では Bucket4j と Spring Boot の OncePerRequestFilter を組み合わせて、HTTP層でのレートリミットをゼロから実装します。IP単位・APIキー単位どちらにも対応でき、制限超過時に HTTP 429 を返す処理まで含めて解説します。

Resilience4j @RateLimiterとの違い

まず混同しやすいポイントを整理しておきましょう。

比較項目Resilience4j @RateLimiterBucket4j + Filter
主な用途アプリ内スロットリング(外部API呼び出し保護)HTTPクライアント単位の制限
制限の単位メソッド・サービス全体IP・APIキーなど
適用タイミングアプリケーション層Servlet Filter層
超過時の動作例外スローHTTP 429レスポンス

Resilience4jは「自分たちのアプリが外部サービスを呼び出しすぎないようにする」ためのもの。Bucket4j + Filterは「外部からの過剰なアクセスをブロックする」ためのものです。競合ではなく補完関係なので、両方を組み合わせて使うことも多いですよ。

Resilience4jのCircuit Breaker実装についてはこちらの記事で詳しく解説しています。

依存関係を追加する

Mavenの場合。

<dependency>
    <groupId>com.github.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.10.1</version>
</dependency>

Gradleの場合。

implementation 'com.github.bucket4j:bucket4j-core:8.10.1'

Spring Boot本体への追加依存はなく、これだけで使い始められます。

トークンバケットアルゴリズムとは

Bucket4jはトークンバケットアルゴリズムを採用しています。バケツの中に「トークン」が一定速度で補充され続け、リクエストが来るたびにトークンを1つ消費します。バケツが空になったらそのリクエストは拒否します。設定するのは「容量(最大トークン数)」と「補充レート」の2つだけです。

Bucketの容量と補充レートを設定する

Bandwidth でポリシーを定義します。Bucket4j 8.x では builder スタイルを使います。

Bandwidth limit = Bandwidth.builder()
        .capacity(60)
        .refillGreedy(60, Duration.ofMinutes(1))
        .build();
Bucket bucket = Bucket.builder().addLimit(limit).build();

これで「1分間に最大60リクエスト、毎分60個補充」という設定になります。refillGreedy はトークンを即時補充するモードで、refillIntervally はインターバルごとにまとめて補充します。短時間のバーストを許容するかどうかで使い分けてください。

OncePerRequestFilterでレートリミットFilterを実装する

FilterとInterceptorの違いについてはこちらの記事も参考にしてください。

IPアドレスを識別キーとしたFilter実装です。

@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String clientKey = getClientIp(request);
        Bucket bucket = buckets.computeIfAbsent(clientKey, k -> createBucket());

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            filterChain.doFilter(request, response);
        } else {
            long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.setStatus(429);
            response.setHeader("Retry-After", String.valueOf(waitSeconds));
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");
            response.getWriter().write("{\"error\": \"Too Many Requests\"}");
        }
    }

    private String getClientIp(HttpServletRequest request) {
        // X-Forwarded-Forはクライアントが偽装できるため、信頼できるリバースプロキシ配下でのみ使用する
        // 直接公開環境では getRemoteAddr() のみを使う方が安全
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }

    private Bucket createBucket() {
        Bandwidth limit = Bandwidth.builder()
                .capacity(60)
                .refillGreedy(60, Duration.ofMinutes(1))
                .build();
        return Bucket.builder().addLimit(limit).build();
    }
}

computeIfAbsent() でIPごとに初回アクセス時だけBucketが生成されます。tryConsumeAndReturnRemaining() を使うと消費結果と残りトークン情報を一度に取得できるので、Retry-After ヘッダーの値も正確に計算できます。

注意 X-Forwarded-For ヘッダーはクライアントが自由に偽装できます。X-Forwarded-For: 127.0.0.1 を送るだけでレート制限を回避できてしまうため、NginxやALBなど信頼できるリバースプロキシ配下でのみ有効です。直接公開する環境では getRemoteAddr() を使う方が安全です。

ConcurrentHashMapのメモリ管理に注意

ConcurrentHashMap にIPごとのBucketを追加し続けると、ユニークIPが増えるにつれてメモリも増え続けます。大量のIPからアクセスされるDDoS状況では OutOfMemoryError のリスクもあるため、本番運用では Caffeine キャッシュへの置き換えを検討しましょう。

Cache<String, Bucket> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.HOURS)
        .maximumSize(10_000)
        .build();
// buckets.computeIfAbsent() の代わりに cache.get(key, k -> createBucket()) を使う

APIキー単位に拡張する

IPではなくAPIキーを識別子にしたい場合は、取得する値を変えるだけです。

private String getClientKey(HttpServletRequest request) {
    String apiKey = request.getHeader("X-API-Key");
    if (apiKey != null && !apiKey.isBlank()) {
        return "apikey:" + apiKey;
    }
    return "ip:" + getClientIp(request);
}

doFilterInternal() 内の getClientIp(request)getClientKey(request) に置き換えればAPIキー対応に切り替わります。識別キーの種類ごとに createBucket() を分けると、APIキー保持者は1分300リクエスト、未認証IPは1分30リクエストといったポリシーを細かく制御できます。JWT認証の実装はこちらを参考にしてみてください。

URLパターンを絞る場合の設定

@Component をつければ全エンドポイントに適用されます。特定パスだけに絞りたい場合は FilterRegistrationBean を使います。

@Bean
public FilterRegistrationBean<RateLimitFilter> rateLimitRegistration(RateLimitFilter filter) {
    FilterRegistrationBean<RateLimitFilter> bean = new FilterRegistrationBean<>(filter);
    bean.addUrlPatterns("/api/*");
    bean.setOrder(1);
    return bean;
}

この場合は RateLimitFilter から @Component を外してください。setOrder(1) は Spring Security(通常 Order=-100 付近)より後に実行されるため、認証済みリクエストにのみレート制限を適用したい場合に適しています。認証前にブロックしたい場合は負の値に設定してください。

インメモリ構成とRedisの選択基準

今回の実装はインメモリで完結するため、アプリインスタンスが複数台になると インスタンス間でBucketが共有されません

インメモリ(ConcurrentHashMap)Redis(Bucket4j-Redis)
向いているケース単一インスタンス・PoCマルチインスタンス・本番
セットアップ簡単Redis環境が必要
正確性インスタンス単位クラスタ全体で正確

スケールアウトが必要な場合は Lettuceを使うなら bucket4j-redis-lettuce、Jedisを使うなら bucket4j-redis-jedis への移行を検討してください。APIのインターフェースはほぼ同じなので移行コストは低めです。

curlで動作確認する

# ループして429が返ることを確認する
for i in $(seq 1 70); do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/hello
done

61回目あたりから 429 が返り、レスポンスヘッダーに Retry-After も含まれていることが確認できます。

まとめ

Bucket4jとOncePerRequestFilterを組み合わせれば、依存関係を1つ追加するだけでHTTP層のレートリミットを実装できます。

  • Bucket4j 8.xBandwidth.builder() スタイルで設定する
  • X-Forwarded-For はリバースプロキシ配下でのみ信頼し、直接公開環境では getRemoteAddr() を使う
  • 長期運用では ConcurrentHashMap から Caffeine キャッシュへの置き換えを検討する
  • マルチインスタンスが必要になったら bucket4j-redis-lettuce / bucket4j-redis-jedis へ移行する

例外ハンドリングの詳細はこちらの記事も参考にしてみてください。