spring-boot + Resilience4J を試す。
事前準備
mockoon
https://mockoon.com/ バックエンドAPIの挙動をあれこれ試すのに何らかのmockserverがあると便利。今回はGUIであれこれ変更しながら試すのに便利なmockoonを使用した。
ソースコードおよび解説
https://www.baeldung.com/spring-boot-resilience4j を元に各種機能を試していく。というか、サンプルコードはほぼコピペである。
build.gradle
https://start.spring.io/ でResilienceとか入力してbuild.gradleを生成する。reference#starters によるとreactive/non-reactiveで使うライブラリが異なるがspring-initializrなら自動判別してくれる。
plugins { id 'java' id 'org.springframework.boot' version '3.2.4' id 'io.spring.dependency-management' version '1.1.4' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { sourceCompatibility = '17' } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } ext { set('springCloudVersion', "2023.0.1") } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } tasks.named('test') { useJUnitPlatform() }
CircuitBreaker
https://resilience4j.readme.io/docs/circuitbreaker
まず、挙動確認用にmockoonのバックエンドAPIを呼び出すクラスを作る。たとえば、以下は503を返し、CircuitBreakerの挙動を確認しやすくするために2secのwaitをする。こういうメソッドを作成したり、mockoonでwaitやresponse statusを変更したり、で動作確認する。
@Component public class ExternalApiCaller { RestTemplate client = new RestTemplate(); public String response503_2sec() { System.out.println("call response503-2sec"); return client.getForObject("http://localhost:3001/response503-2sec", String.class); } }
@CircuitBreaker
を付与する。name
はプロパティファイルで使用する。
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; @RestController @RequiredArgsConstructor public class SampleController { final ExternalApiCaller externalApi; @GetMapping("/circuit-breaker") @CircuitBreaker(name = "CircuitBreakerService") public String circuitBreaker() { return externalApi.response503_2sec(); } }
src\main\resources\application.properties
でCircuitBreakerの挙動を定義する。
resilience4j.circuitbreaker.instances.CircuitBreakerService.failure-rate-threshold=50 resilience4j.circuitbreaker.instances.CircuitBreakerService.minimum-number-of-calls=5 resilience4j.circuitbreaker.instances.CircuitBreakerService.automatic-transition-from-open-to-half-open-enabled=true resilience4j.circuitbreaker.instances.CircuitBreakerService.wait-duration-in-open-state=5s resilience4j.circuitbreaker.instances.CircuitBreakerService.permitted-number-of-calls-in-half-open-state=3 resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-size=10 resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-type=count_based
プロパティ一覧と説明のリファレンスは https://resilience4j.readme.io/docs/circuitbreaker にある。
上記サンプル設定の意味は、直近10回のAPI呼出しのうち50%以上つまり5回以上失敗したらcircuitbreakerがOPENになる。ただし判定開始の最小値である5回目以降からcircuitbreakerの判定は実際に行われる。OPEN -> HALF-OPENは5秒で自動遷移する。HALF-OPENになると合計3回アクセスを許可してその失敗率がthreshold以上だと再度OPENに戻され、そうでなければCLOSEに復帰する。
プロパティ | 説明 |
---|---|
slidingWindowType | ウィンドウの種類でcount_based or time_based を指定可能。回数・時間どちらで判定するかを指定する。このプロパティに従い slidingWindowSize も同様に意味合いが変化する。 |
slidingWindowSize | ウィンドウサイズ。count_based なら直近のAPI呼出回数、time_based なら直近の経過秒数。 |
failureRateThreshold | 失敗率がこのパーセント以上になるとOPEN -> HALF_OPENに遷移する。 |
minimumNumberOfCalls | 判定開始するAPI呼出回数の最低値。API呼出回数がこの値より小さいと判定は行わず、たとえ失敗率がfailureRateThreshold以上でもHALF_OPENに遷移しない。 |
waitDurationInOpenState | この時間が経過するとOPEN -> HALF_OPENに自動的に遷移する。 |
permittedNumberOfCalls | HALF_OPENで許可されるAPI呼出回数。この回数実行後に判定が行われ、失敗率がfailureRateThreshold以上なら再度OPENに戻され、そうでなければCLOSEDに復帰する。なお、この回数のAPI呼出が終了する前にそれ以上のAPI呼出をすると無条件にCallNotPermittedException をスローする。この挙動を確認するには、mockoonでwaitを2秒とかにしてHALF_OPENにして http://localhost:8080/circuit-breaker をブラウザから連打するとpermittedNumberOfCallsより多い回数は無条件に例外になるのが分かる。*1 |
automatic-transition-from-open-to-half-open-enabled | trueだとモニタースレッドでHALF_OPENへ遷移し、falseだとAPI呼出時に行う。モニタースレッドというリソースの追加投入で遷移をリアルタイムに行う場合にtrueにする。 |
Retry
https://resilience4j.readme.io/docs/retry
import io.github.resilience4j.retry.annotation.Retry; // ... 省略 @GetMapping("/retry") @Retry(name = "retryApi") public String retryApi() { return externalApi.response503(); }
resilience4j.retry.instances.retryApi.max-attempts=3 resilience4j.retry.instances.retryApi.wait-duration=1s
これは見た通りの機能で上記サンプルプロパティだと3回リトライしてその間隔は1秒となる。すべてのリトライが失敗すると単にその原因の例外がthrowされる。
Bulkhead
https://resilience4j.readme.io/docs/bulkhead
機能としては、あるクライアントの同時実行数を制限する……のだが、例えばmicrosoftのアーキテクチャパターン解説(https://learn.microsoft.com/th-th/azure/architecture/patterns/bulkhead)を読むとどうも深淵な意図があるらしく、正直良く分からない。
俺の解釈としては、もし同時実行数を制限せずに一部のクライアントがアタックするとmax-connectionsなどまでリソースを食いつぶしてシステム全体がダウンし、他のすべてのクライアントに影響が出てしまう。これを防ぐため、すべてのクライアントに対して同時実行数を制限する事でシステム全体を保護する。このように理解している。
この機能を使用するにはクラスパスにresilience4j-bulkheadを別途追加する(https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j/bulkhead-pattern-supporting.html)。他機能と比べると若干毛色が違うからだろうか?
implementation 'io.github.resilience4j:resilience4j-bulkhead'
import io.github.resilience4j.bulkhead.annotation.Bulkhead; // ..省略 @GetMapping("/bulkhead") @Bulkhead(name="bulkheadApi") public String bulkheadApi() { return externalApi.response200(); }
resilience4j.bulkhead.instances.bulkheadApi.max-concurrent-calls=3 resilience4j.bulkhead.instances.bulkheadApi.max-wait-duration=1
上記サンプルプロパティだと同時実行数は3回でそこに達すると1ms待機する。待機しても同時実行数に空きが出ないとBulkheadFullException
をthrowする。
プロパティ | 説明 |
---|---|
maxConcurrentCalls | bulkheadが許可する同時実行数。chromeと別のブラウザをそれぞれ開いて同時に実行してみると、各ブラウザが異なるbulkheadを使用するのが分かる。ただ具体的なbulkheadの単位までは未調査。 |
maxWaitDuration | bulkheadが一杯の場合にブロックする秒数。5秒くらいにしてブラウザを連打すると分かりやすく挙動確認できる。 |
RateLimiter
https://resilience4j.readme.io/docs/ratelimiter
単位時間当たりのAPI呼出回数に制限をかける。いわゆるAPIのレート制限。
import io.github.resilience4j.ratelimiter.annotation.RateLimiter; // 省略 @GetMapping("/rate-limiter") @RateLimiter(name = "rateLimiterApi") public String rateLimitApi() { return externalApi.response200(); }
resilience4j.ratelimiter.instances.rateLimiterApi.limit-for-period=5 resilience4j.ratelimiter.instances.rateLimiterApi.limit-refresh-period=60s resilience4j.ratelimiter.instances.rateLimiterApi.timeout-duration=0s
プロパティ | 説明 |
---|---|
limitForPeriod | ある判定時間における最大アクセス回数。 |
limitRefreshPeriod | 判定時間のリフレッシュ間隔。 |
timeoutDuration | もし最大アクセス回数に達していたらこの時間だけ待機する。それでもダメなら RequestNotPermitted をthrowする。 |
例外処理
上記で見てきたように、何らか判定条件を満たせない場合はそれに対応した例外をthrowする。よって、RestControllerAdvice
と ExceptionHandler
でその例外ハンドラーの記述が良くあるパターンと思われる。
import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class ControllerAdvice { @ExceptionHandler({CallNotPermittedException.class}) @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) public void handleCallNotPermittedException(CallNotPermittedException e) { System.out.println("CallNotPermittedException" + e); }
また、fallbackMethod
を使う方法もある。下記のように各アノテーションで例外発生時に呼ばれるハンドラを登録できる。
@GetMapping("/rate-limiter") @RateLimiter(name = "rateLimiterApi", fallbackMethod = "fallbackRateLimiter") public String rateLimitApi() { return externalApi.response200(); } public String fallbackRateLimiter(RequestNotPermitted ex) { System.out.println(ex); return "ng"; }
その他
controller以外でも付与可能
spring-boot + resilience4jのサンプルコードを見るとcontrollerに付与する事が多いし、実際そうする事が多いのだろうけど、AOPなのでそこ以外でも使用可能。
@Component public class ExternalApiCaller { @CircuitBreaker(name = "CircuitBreakerService") public String response503_2sec() { // ...省略
おそらく、この場合は ExceptionHandler
の集中例外処理ではなく、個別に例外をtry-catchになると思われる。なので、例えば、もしバックエンドAPIの片方が落ちてももう片方が生きてれば、片方を切り捨てて結果を返す、のようなこともやろうと思えば出来る。
複数アノテーションの併用
例えば、以下のようにRetryとCircuitBreakerを併用できる。
@GetMapping("/circuit-breaker") @CircuitBreaker(name = "CircuitBreakerService") @Retry(name = "retryApi") public String circuitBreaker() {
AOPの順序は https://resilience4j.readme.io/docs/getting-started-3#aspect-order に記述があり Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( Function ) ) ) ) )
となっている。全部盛りした場合にどうなるかは以下が分かりやすい。
First Bulkhead creates a threadpool. Then TimeLimiter can limit the time of the threads. RateLimiter limits the number of calls on that function for a configurable time window. Any exceptions thrown by TimeLimiter or RateLimiter will be recorded by CircuitBreaker. Then retry will be executed.
https://jsession4d.com/a-quick-guide-to-resilience4j-with-spring-boot/ より抜粋
metrics
- https://resilience4j.readme.io/docs/micrometer
- https://docs.spring.io/spring-cloud-circuitbreaker/docs/current/reference/html/spring-cloud-circuitbreaker-resilience4j.html#_collecting_metrics
ちなみにGrafanaの設定追加をすれば https://resilience4j.readme.io/docs/grafana-1 みたいな表示になるらしい。
actuator
このブログエントリではサンプルプロパティから省略した(management.endpoints.web.exposure.include
とか)が、https://www.baeldung.com/spring-boot-resilience4j の通り各種プロパティで有効化できる。たとえば http://localhost:8080/actuator/circuitbreakers で以下のようなJSONが取得できる。
{ "circuitBreakers": { "CircuitBreakerService": { "failureRate": "-1.0%", "slowCallRate": "-1.0%", "failureRateThreshold": "50.0%", "slowCallRateThreshold": "100.0%", "bufferedCalls": 0, "failedCalls": 0, "slowCalls": 0, "slowFailedCalls": 0, "notPermittedCalls": 0, "state": "CLOSED" } } }
Prometheus
Prometheusを使う場合は以下の依存性を追加する。
implementation 'io.micrometer:micrometer-registry-prometheus'
http://localhost:8080/actuator/prometheus にアクセスすると以下のような結果が得られる。
# TYPE resilience4j_circuitbreaker_calls_seconds summary resilience4j_circuitbreaker_calls_seconds_count{group="none",kind="successful",name="CircuitBreakerService",} 0.0 resilience4j_circuitbreaker_calls_seconds_sum{group="none",kind="successful",name="CircuitBreakerService",} 0.0 resilience4j_circuitbreaker_calls_seconds_count{group="none",kind="failed",name="CircuitBreakerService",} 0.0 resilience4j_circuitbreaker_calls_seconds_sum{group="none",kind="failed",name="CircuitBreakerService",} 0.0 resilience4j_circuitbreaker_calls_seconds_count{group="none",kind="ignored",name="CircuitBreakerService",} 0.0 resilience4j_circuitbreaker_calls_seconds_sum{group="none",kind="ignored",name="CircuitBreakerService",} 0.0 // 以下略
参考文献
- https://docs.spring.io/spring-cloud-circuitbreaker/docs/current/reference/html/spring-cloud-circuitbreaker-resilience4j.html - spring-cloud-circuitbreaker-resilience4jのドキュメント
- https://resilience4j.readme.io/ - Resilience4jのドキュメント
- https://www.baeldung.com/spring-boot-resilience4j - 参考にしたサイト