はじめに

REST APIを公開する際、悪意のあるリクエストやシステム過負荷からサービスを保護するためにレート制限(Rate Limiting)は不可欠な機能です。本記事では、JavaのレートリミッタライブラリであるBucket4jを使用して、Spring Boot REST APIにレート制限を実装する方法を解説します。

トークンバケットアルゴリズムの基礎から、Filterによる適用方法、さらにRedisを活用した分散環境でのレート制限まで、段階的に実装していきます。

実行環境と前提条件

本記事のサンプルコードは以下の環境で動作確認を行っています。

項目 バージョン
Java 17以上
Spring Boot 3.4.x
Bucket4j 8.16.0
Redis (分散レート制限用) 7.x

前提知識として、Spring Bootの基本的なREST API開発経験があることを想定しています。

トークンバケットアルゴリズムの仕組み

レート制限を実装する前に、Bucket4jが採用しているトークンバケットアルゴリズムの仕組みを理解しましょう。

アルゴリズムの概要

トークンバケットアルゴリズムは、以下の要素で構成されます。

  1. バケット(Bucket): トークンを格納する容器
  2. 容量(Capacity): バケットに格納できるトークンの最大数
  3. リフィル(Refill): 一定間隔でトークンを補充する仕組み
  4. 消費(Consume): リクエスト時にトークンを消費
flowchart LR
    A[リクエスト受信] --> B{トークンあり?}
    B -->|Yes| C[トークン消費]
    C --> D[リクエスト処理]
    B -->|No| E[429 Too Many Requests]
    
    F[一定間隔] --> G[トークン補充]
    G --> H[バケット]

リフィル方式の違い

Bucket4jでは2種類のリフィル方式を選択できます。

Greedy(デフォルト)

可能な限り早くトークンを補充します。例えば、1分間に60トークンを補充する設定の場合、1秒ごとに1トークンずつ補充されます。

Interval

設定した間隔ごとにまとめてトークンを補充します。1分間に60トークンを補充する設定の場合、1分経過後に60トークンが一度に補充されます。

Bucket4jの基本的な使い方

依存関係の追加

まず、pom.xmlにBucket4jの依存関係を追加します。Java 17以上を使用する場合は、bucket4j_jdk17-coreを使用します。

1
2
3
4
5
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j_jdk17-core</artifactId>
    <version>8.16.0</version>
</dependency>

シンプルなBucketの作成

Bucket4jの基本的な使い方を見ていきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;

import java.time.Duration;

public class RateLimiterExample {

    public static void main(String[] args) {
        // 1分間に10リクエストまで許可するバケットを作成
        Bucket bucket = Bucket.builder()
                .addLimit(Bandwidth.builder()
                        .capacity(10)
                        .refillGreedy(10, Duration.ofMinutes(1))
                        .build())
                .build();

        // リクエスト処理のシミュレーション
        for (int i = 1; i <= 15; i++) {
            if (bucket.tryConsume(1)) {
                System.out.println("リクエスト " + i + ": 許可");
            } else {
                System.out.println("リクエスト " + i + ": 拒否(レート制限)");
            }
        }
    }
}

期待される結果:

1
2
3
4
5
6
7
リクエスト 1: 許可
リクエスト 2: 許可
...
リクエスト 10: 許可
リクエスト 11: 拒否(レート制限)
リクエスト 12: 拒否(レート制限)
...

複数のBandwidthを組み合わせる

より柔軟なレート制限を実現するため、複数のBandwidthを組み合わせることができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Bucket bucket = Bucket.builder()
        // 1秒間に5リクエストまで(バースト制限)
        .addLimit(Bandwidth.builder()
                .capacity(5)
                .refillGreedy(5, Duration.ofSeconds(1))
                .build())
        // 1分間に100リクエストまで(全体制限)
        .addLimit(Bandwidth.builder()
                .capacity(100)
                .refillGreedy(100, Duration.ofMinutes(1))
                .build())
        .build();

この設定により、短期間での急激なリクエスト増加(バースト)と、長期間での総リクエスト数の両方を制御できます。

FilterによるAPIエンドポイントへのレート制限適用

Spring Boot REST APIにレート制限を適用する最も一般的な方法は、Servlet Filterを使用することです。

RateLimitingFilterの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.example.ratelimit.filter;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class RateLimitingFilter implements Filter {

    // IPアドレスごとにBucketを管理
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String clientIp = getClientIp(httpRequest);
        Bucket bucket = buckets.computeIfAbsent(clientIp, this::createBucket);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            // レート制限情報をレスポンスヘッダーに追加
            httpResponse.addHeader("X-Rate-Limit-Remaining", 
                    String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(request, response);
        } else {
            // レート制限超過時の処理
            long waitForRefillNanos = probe.getNanosToWaitForRefill();
            long waitForRefillSeconds = waitForRefillNanos / 1_000_000_000;

            httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            httpResponse.setContentType("application/json");
            httpResponse.addHeader("X-Rate-Limit-Retry-After-Seconds", 
                    String.valueOf(waitForRefillSeconds));
            httpResponse.getWriter().write(
                    "{\"error\": \"Too Many Requests\", \"retryAfterSeconds\": " 
                    + waitForRefillSeconds + "}");
        }
    }

    private Bucket createBucket(String clientIp) {
        return Bucket.builder()
                .addLimit(Bandwidth.builder()
                        .capacity(100)
                        .refillGreedy(100, Duration.ofMinutes(1))
                        .build())
                .build();
    }

    private String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

FilterをURLパターンで制限する

特定のエンドポイントのみにレート制限を適用したい場合は、FilterRegistrationBeanを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.ratelimit.config;

import com.example.ratelimit.filter.RateLimitingFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<RateLimitingFilter> rateLimitingFilterRegistration(
            RateLimitingFilter filter) {
        
        FilterRegistrationBean<RateLimitingFilter> registration = 
                new FilterRegistrationBean<>();
        registration.setFilter(filter);
        registration.addUrlPatterns("/api/*");  // /api配下のみに適用
        registration.setOrder(1);
        return registration;
    }
}

この設定を使用する場合、Filterクラスから@Componentアノテーションを削除してください。

InterceptorによるSpringコンテキスト連携

Spring Securityなどと連携してユーザーごとにレート制限を適用したい場合は、HandlerInterceptorを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.example.ratelimit.interceptor;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class UserRateLimitInterceptor implements HandlerInterceptor {

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

    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) throws Exception {
        
        String userId = getCurrentUserId();
        Bucket bucket = userBuckets.computeIfAbsent(userId, this::createBucketForUser);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            response.addHeader("X-Rate-Limit-Remaining", 
                    String.valueOf(probe.getRemainingTokens()));
            return true;
        }

        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"Too Many Requests\"}");
        return false;
    }

    private String getCurrentUserId() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated()) {
            return auth.getName();
        }
        return "anonymous";
    }

    private Bucket createBucketForUser(String userId) {
        // ユーザーロールに応じて異なるレート制限を設定可能
        if ("admin".equals(userId)) {
            return Bucket.builder()
                    .addLimit(Bandwidth.builder()
                            .capacity(1000)
                            .refillGreedy(1000, Duration.ofMinutes(1))
                            .build())
                    .build();
        }
        return Bucket.builder()
                .addLimit(Bandwidth.builder()
                        .capacity(100)
                        .refillGreedy(100, Duration.ofMinutes(1))
                        .build())
                .build();
    }
}

Interceptorを登録するための設定クラスも作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.ratelimit.config;

import com.example.ratelimit.interceptor.UserRateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final UserRateLimitInterceptor rateLimitInterceptor;

    public WebMvcConfig(UserRateLimitInterceptor rateLimitInterceptor) {
        this.rateLimitInterceptor = rateLimitInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**");
    }
}

Redisを使った分散レート制限

複数のアプリケーションインスタンスでレート制限を共有するには、Redisをバックエンドとして使用します。

依存関係の追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<dependencies>
    <!-- Bucket4j Redis/Lettuce -->
    <dependency>
        <groupId>com.bucket4j</groupId>
        <artifactId>bucket4j_jdk17-redis</artifactId>
        <version>8.16.0</version>
    </dependency>
    
    <!-- Lettuce (Redis クライアント) -->
    <dependency>
        <groupId>io.lettuce</groupId>
        <artifactId>lettuce-core</artifactId>
        <version>6.5.2.RELEASE</version>
    </dependency>
</dependencies>

Redis設定クラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.ratelimit.config;

import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host:localhost}")
    private String redisHost;

    @Value("${spring.redis.port:6379}")
    private int redisPort;

    @Bean
    public RedisClient redisClient() {
        return RedisClient.create("redis://" + redisHost + ":" + redisPort);
    }

    @Bean
    public StatefulRedisConnection<String, byte[]> redisConnection(RedisClient redisClient) {
        RedisCodec<String, byte[]> codec = RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE);
        return redisClient.connect(codec);
    }
}

分散レート制限Filterの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.example.ratelimit.filter;

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.distributed.BucketProxy;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.api.StatefulRedisConnection;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;
import java.util.function.Supplier;

@Component
public class DistributedRateLimitingFilter implements Filter {

    private final StatefulRedisConnection<String, byte[]> redisConnection;
    private LettuceBasedProxyManager<String> proxyManager;

    public DistributedRateLimitingFilter(
            StatefulRedisConnection<String, byte[]> redisConnection) {
        this.redisConnection = redisConnection;
    }

    @PostConstruct
    public void init() {
        this.proxyManager = Bucket4jLettuce.casBasedBuilder(redisConnection)
                .build();
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String clientIp = getClientIp(httpRequest);
        String bucketKey = "rate-limit:" + clientIp;

        BucketProxy bucket = proxyManager.getProxy(bucketKey, 
                getBucketConfigurationSupplier());

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            httpResponse.addHeader("X-Rate-Limit-Remaining",
                    String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(request, response);
        } else {
            long waitForRefillSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;

            httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            httpResponse.setContentType("application/json");
            httpResponse.addHeader("X-Rate-Limit-Retry-After-Seconds",
                    String.valueOf(waitForRefillSeconds));
            httpResponse.getWriter().write(
                    "{\"error\": \"Too Many Requests\", \"retryAfterSeconds\": "
                    + waitForRefillSeconds + "}");
        }
    }

    private Supplier<BucketConfiguration> getBucketConfigurationSupplier() {
        return () -> BucketConfiguration.builder()
                .addLimit(Bandwidth.builder()
                        .capacity(100)
                        .refillGreedy(100, Duration.ofMinutes(1))
                        .build())
                .build();
    }

    private String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

application.ymlの設定

1
2
3
4
spring:
  redis:
    host: localhost
    port: 6379

分散レート制限のアーキテクチャ

flowchart TB
    subgraph クライアント
        C1[Client 1]
        C2[Client 2]
    end
    
    subgraph ロードバランサー
        LB[Load Balancer]
    end
    
    subgraph アプリケーション
        A1[App Instance 1<br/>RateLimitFilter]
        A2[App Instance 2<br/>RateLimitFilter]
    end
    
    subgraph Redis
        R[(Redis<br/>Bucket State)]
    end
    
    C1 --> LB
    C2 --> LB
    LB --> A1
    LB --> A2
    A1 <--> R
    A2 <--> R

Redisを使用することで、どのアプリケーションインスタンスがリクエストを処理しても、一貫したレート制限が適用されます。

Bucket4j Spring Boot Starterの活用

より簡単にレート制限を実装したい場合は、Bucket4j Spring Boot Starterを使用できます。

依存関係の追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.13.0</version>
</dependency>

<!-- キャッシュプロバイダー(例:Caffeine) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

application.ymlでの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
spring:
  cache:
    cache-names:
      - rate-limit-buckets
    caffeine:
      spec: maximumSize=100000,expireAfterAccess=3600s

bucket4j:
  enabled: true
  filters:
    - cache-name: rate-limit-buckets
      url: /api/.*
      http-response-body: '{"error": "Too Many Requests"}'
      rate-limits:
        - cache-key: getRemoteAddr()
          bandwidths:
            - capacity: 100
              time: 1
              unit: minutes
              refill-speed: greedy

この設定により、コードを書かずにYAMLファイルのみでレート制限を実装できます。

ユーザー別レート制限の設定例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bucket4j:
  enabled: true
  filters:
    - cache-name: rate-limit-buckets
      url: /api/.*
      strategy: first
      rate-limits:
        # 管理者は制限なし
        - execute-condition: "@securityService.isAdmin()"
          skip-condition: "true"
          bandwidths:
            - capacity: 1000000
              time: 1
              unit: minutes
        # 認証済みユーザー
        - execute-condition: "@securityService.isAuthenticated()"
          cache-key: "@securityService.username()"
          bandwidths:
            - capacity: 500
              time: 1
              unit: minutes
        # 未認証ユーザー(IPベース)
        - cache-key: getRemoteAddr()
          bandwidths:
            - capacity: 50
              time: 1
              unit: minutes

運用上の考慮事項

メモリリークの防止

インメモリでBucketを管理する場合、古いエントリを適切にクリーンアップする必要があります。

1
2
3
4
5
// Caffeineキャッシュを使用した例
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
        .expireAfterAccess(Duration.ofHours(1))
        .maximumSize(100000)
        .build();

モニタリングとメトリクス

レート制限の状況を監視するため、Micrometerとの連携を推奨します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;

@Component
public class RateLimitMetrics {
    
    private final Counter rejectedCounter;
    private final Counter consumedCounter;

    public RateLimitMetrics(MeterRegistry registry) {
        this.rejectedCounter = Counter.builder("rate_limit.rejected")
                .description("Number of rejected requests")
                .register(registry);
        this.consumedCounter = Counter.builder("rate_limit.consumed")
                .description("Number of consumed tokens")
                .register(registry);
    }

    public void recordRejected() {
        rejectedCounter.increment();
    }

    public void recordConsumed() {
        consumedCounter.increment();
    }
}

まとめ

本記事では、Bucket4jを使用したSpring Boot REST APIのレート制限実装について解説しました。

学習したポイント

  1. トークンバケットアルゴリズムの仕組みとGreedy/Interval方式の違い
  2. Bucket4jの基本的なAPI(Bucket、Bandwidth、Refill)の使い方
  3. Servlet Filterを使用したAPIエンドポイントへのレート制限適用
  4. HandlerInterceptorによるSpring Securityとの連携
  5. Redisを使用した分散環境でのレート制限
  6. Bucket4j Spring Boot Starterによる宣言的な設定

レート制限は単なるセキュリティ機能ではなく、システムの安定性とユーザー体験の両方を向上させる重要な機能です。本記事の内容を参考に、プロジェクトに適したレート制限を実装してください。

参考リンク