はじめに#
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が採用しているトークンバケットアルゴリズムの仕組みを理解しましょう。
アルゴリズムの概要#
トークンバケットアルゴリズムは、以下の要素で構成されます。
- バケット(Bucket): トークンを格納する容器
- 容量(Capacity): バケットに格納できるトークンの最大数
- リフィル(Refill): 一定間隔でトークンを補充する仕組み
- 消費(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 <--> RRedisを使用することで、どのアプリケーションインスタンスがリクエストを処理しても、一貫したレート制限が適用されます。
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のレート制限実装について解説しました。
学習したポイント
- トークンバケットアルゴリズムの仕組みとGreedy/Interval方式の違い
- Bucket4jの基本的なAPI(Bucket、Bandwidth、Refill)の使い方
- Servlet Filterを使用したAPIエンドポイントへのレート制限適用
- HandlerInterceptorによるSpring Securityとの連携
- Redisを使用した分散環境でのレート制限
- Bucket4j Spring Boot Starterによる宣言的な設定
レート制限は単なるセキュリティ機能ではなく、システムの安定性とユーザー体験の両方を向上させる重要な機能です。本記事の内容を参考に、プロジェクトに適したレート制限を実装してください。
参考リンク#