この記事の概要
- サービス運用をしているとこんなことが起きる
- 予期せぬ負荷でレスポンスが悪化する、システムが正常に稼働しなくなる
- 異常のあるサーバーにアクセスし続けて事態をより悪化させる
- このようなことからサービスを守るためにrate limitやcircuit breakerを導入したい
- spring-cloud-gatewayでできるみたいだから試してみる!
- (僕の開発現場ではspringbootを使用しているので、いろんなことがspringでできると嬉しいのです)
- 開発環境はMac
spring-cloud-gatewayとは?
- ドキュメント:https://spring.io/projects/spring-cloud-gateway
- gatewayの名の通り、routingをしてくれます
- それだけではなく、routeごとに以下のようなことができます
- ヘッダーやパラメータを付与する
- 認証をかける
- 条件によってフィルタリングする(今回のメイン)
とりあえず簡単なサンプルを
build.gradle
plugins {
id 'org.springframework.boot' version '2.2.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
id "com.github.ben-manes.versions" version "0.28.0" // 最新バージョンを確認するためにいつもいれてます
}
group = 'rhirabay'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "Hoxton.SR1")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux' // 内部API用
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
application.yml
spring:
cloud:
gateway:
routes:
- id: sample
uri: "http://localhost:8080"
predicates:
- Path=/sample/**
filters:
- PrefixPath=/api
- id
- 任意の識別子を記述します
- 一意であればなんでも良いはずです
- uri
- routing先を記述します
- pathは記述しても使われません
- pathはすべてgatewayへのアクセス時のpathが使われます
- predicates
- routing条件を記述します
- この例だとPathが
/sample
で始まるものをroutingします - headerやcookie、query、時間指定とかもできます
- filters
- リクエストに付与する情報を付与したり、リクエストに制限をかけることができます
- この例だとpathの先頭に
/api
を付与します - 今回のrate limitやcircuit breakerの制御はここで行います
SampleController.java
@Slf4j
@RestController
@RequestMapping("/api")
public class SampleController {
@RequestMapping("/**")
public Mono<RequestSummary> api(ServerHttpRequest request) {
var summary = RequestSummary.of(
request.getURI().toString(),
request.getMethodValue()
);
log.info("request summary. {}", summary);
return Mono.just(summary);
}
@Value(staticConstructor = "of")
public static class RequestSummary {
private String uri;
private String method;
}
}
- 裏側のAPI
- どんなリクエストがきたのかログに出すして返すだけ
実行!
curl http://localhost:8080/sample
> {"uri":"http://localhost:8080/api/sample","method":"GET"}
いざ本題
Rate Limit ~予期せぬ負荷からサービスを守る~
- ドキュメント
- 例えば「秒間10リクエストを超えるアクセスがきた場合に後ろのAPIを叩かずにgatewayで返す」みたいなことができる
- アクセス数の管理はここではredisで行う
実装
build.gradle
...
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
...
-
org.springframework.boot:spring-boot-starter-data-redis-reactive
を追加
application.yml
spring:
redis:
host: localhost
port: 6379
cloud:
gateway:
routes:
- id: ratelimit
uri: "http://localhost:8080"
predicates:
- Path=/ratelimit/**
filters:
- PrefixPath=/api
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 10
- redisに接続するので設定値を追加
- localにredisを起動している場合は、defautlのままでイケるので記載不要ですが、念の為
- filtersに
RequestRateLimiter
を追加-
redis-rate-limiter.replenishRate
:これを超えると429(Too Many Requests)を返す -
redis-rate-limiter.burstCapacity
:一時的にここまで叩いても良いという設定値
-
@Configuration
public class RateLimitAutoConfiguration {
@Bean
public KeyResolver simpleKeyResolver() {
return exchange -> Mono.just("sample");
}
}
-
KeyResolver
によりrate limitをかける単位を決めることができる - もしユーザーごとにかけたければユーザー識別子を、叩き元のホストごとにかけたければホスト情報を
Mono.just
の引数に設定する - ここでは固定の文字列を入れているのでroute全体で10rpsのrate limitがかかる
動作確認
locustで負荷をかけてみる
※locustについては記事の最後の補足をご覧ください


カスタム
pathごとにrate limitをかける
KeyResolverでrate limitをかける単位を変更可能
@Configuration
public class RateLimitAutoConfiguration {
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getPath().toString());
}
}

Pathごとに10rpsだけ正常に受け付けていて、超えた分はエラーになっている!
HTTP StatusやResponse Body
Circuit Breaker ~異常のあるサーバーを切り離す~
実装
検証用のバックエンドAPI
...
@RequestMapping("/randomSleep/**")
public Mono<Integer> randomSleep() {
var sleepMillis = 1;
var id = i.getAndAdd(1) % 2;
if (id == 0) {
sleepMillis = 300;
}
log.info("id: {}, cnt: {}, sleep: {}", id, i.get(), sleepMillis);
return Mono.just(sleepMillis)
.delayElement(Duration.ofMillis(sleepMillis));
}
@RequestMapping("/fallback")
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public void fallback() {
log.warn("fallback");
return;
}
...
- /randomSleep:1リクエストごとにsleep時間が「1ms→300ms→1ms→300ms→...」と切り替わるAPI
- /fallback:フォールバック用のAPI。ログを出力するだけ
依存関係を追加
build.gradle
...
dependencies {
...
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'
...
}
...
gatewayの実装
application.yml
spring:
cloud:
gateway:
routes:
- id: circuitbreaker
uri: "http://localhost:8080"
predicates:
- Path=/circuitbreaker/**
filters:
- PrefixPath=/api/randomSleep
- name: CircuitBreaker
args:
name: myCircuitBreaker
fallbackUri: forward:/api/fallback
- fallbackUri:フォールバック先のURLを指定。ここでは
/api/fallback
にフォワード
@Configuration
public class CircuitBreakerAutoConfiguration {
@Bean
public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
.build());
}
}
- ほとんどデフォルト設定のまま
-
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
これによって、レスポンスタイムが200msを超えるものはフォールバックされる
実行
log
id: 0, cnt: 1, sleep: 300
fallback
id: 1, cnt: 2, sleep: 1
id: 0, cnt: 3, sleep: 300
fallback
id: 1, cnt: 4, sleep: 1
id: 0, cnt: 5, sleep: 300
fallback
id: 1, cnt: 6, sleep: 1
id: 0, cnt: 7, sleep: 300
300msのsleepをするとフォールバックの方にアクセスが行ってる!
カスタマイズ
一定の条件ですべてフォールバックに流す
- デフォルトだと50%がfailの状況が100リクエスト続いたらフォールバックに流れる
log
id: 1, cnt: 96, sleep: 1
id: 0, cnt: 97, sleep: 300
fallback
id: 1, cnt: 98, sleep: 1
id: 0, cnt: 99, sleep: 300
fallback
id: 1, cnt: 100, sleep: 1
id: 0, cnt: 101, sleep: 300
fallback
fallback
fallback
fallback
- これを30%、10リクエストにしたかったらこんな感じ
@Configuration
public class CircuitBreakerAutoConfiguration {
@Bean
public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.failureRateThreshold(30) // エラー比率
.slidingWindowSize(10) // エラー継続リクエスト数
.build())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
.build());
}
}
リクエスト数ではなく、エラーが継続している秒数で制御することも可能!
@Bean
public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.failureRateThreshold(30) // エラー比率
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED)
.slidingWindowSize(10) // エラー継続秒数
.build())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
.build());
}
フォールバックからの復帰
デフォルトでは「60秒間間隔」で「10件」バックエンドのAPIを実行。
failureRateThreshold
を下回るエラー比率でレスポンスが帰ってきたらCircuitBreakerはCLOSE。
これを「30秒間隔」で「5件」にしたかったらこんな感じ
@Bean
public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.failureRateThreshold(30) // エラー比率
.slidingWindowSize(10) // エラー継続件数
.waitDurationInOpenState(Duration.ofSeconds(30)) // OPEN→HALF OPENまでの間隔
.permittedNumberOfCallsInHalfOpenState(5) // HALF OPEN時のリクエスト数
.build())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
.build());
}
circuitbreakerが開始するまではfallbackに流したくない
@Bean
public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
.circuitBreakerConfig(CircuitBreakerConfig.custom()
.failureRateThreshold(30) // エラー比率
.slidingWindowSize(10) // エラー継続件数
.waitDurationInOpenState(Duration.ofSeconds(30)) // OPEN→HALF OPENまでの間隔
.permittedNumberOfCallsInHalfOpenState(5) // HALF OPEN時のリクエスト数
// レスポンスタイムが200ms以上のリクエストが50%を超えるとOPEN状態に遷移
// それまでfallbackには流さない
.slowCallDurationThreshold(Duration.ofMillis(200))
.slowCallRateThreshold(50)
.build())
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(100)).build())
.build());
}
-
slowCallDurationThreshold
とslowCallRateThreshold
を指定する
結論
- サービスが食い切れるリクエスト以上のリクエストが来ないようにrate limitで流量を制御!
- 障害でレスポンスが悪化したときのためにCircuitBreakerでフォールバック!
補足
Redis
環境構築
brew install redis
brew services start redis
Locust
インストール
こちらをご覧ください
https://qiita.com/rhirabay/items/bf9c6c2b75412668377b
定義ファイル
ratelimit.py
from locust import HttpLocust, TaskSet, task, between, constant
class UserBehavior(TaskSet):
@task(1)
def task1(self):
self.client.get("/ratelimit/task1", verify=False)
@task(1)
def task2(self):
self.client.get("/ratelimit/task2", verify=False)
class WebsiteUser(HttpLocust):
task_set = UserBehavior
wait_time = constant(1)
circuitbreaker.py
from locust import HttpLocust, TaskSet, task, between, constant
class UserBehavior(TaskSet):
@task(1)
def task2(self):
self.client.get("/circuitbreaker", params={}, verify=False)
class WebsiteUser(HttpLocust):
task_set = UserBehavior
wait_time = constant(0.5)