kagamihogeの日記

kagamihogeの日記です。

spring-boot 3.2 + Resilience4J

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する。よって、RestControllerAdviceExceptionHandler でその例外ハンドラーの記述が良くあるパターンと思われる。

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

ちなみに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
// 以下略

参考文献