Spring Cloud Circuit Breaker(Resilience4j)を試す。

はじめに

CircuitBraekrの実装はNetflixのNetfix Hystrixが有名です。Spirngでもいくつかの実装を組み込めるようになっており、Spring Cloud傘下のプロジェクトとして公開されています。Springで使えるCircuit Breakerの実装はいくつかありますが、その中でも、Resilience4jを使ってみたいと思います。

Circuit Braekrとは

Circuit Braekr PatternはRelease it!安定性のパターンとして紹介されたフォールトレラント(障害許容)を高くするためのソフトウェアの手法の一つです。日本語訳すると遮断器と呼ばれ、家庭にある電気のブレーカーを意味します。もちろんWebソフトウェアの文脈では家庭にあるブレーカーを意味するわけではなく、通信の際のブレーカーとしての役割をソフトウェアコンポーネントとしてもたせる機構のことを意味します。通信時に通信相手先からのレスポンスによってブレーカーのようにオンオフされる(厳密にはオンオフの二値では無いのですが、この後説明します)機構を追加し、通信相手に必要以上の負荷をかけることを防ぐことを目的とします。Circuit Braekerは以下のようなステートマシンとして働きます。

CircuitBraekr

一般的なブレーカーとは違い以下の3つの状態があります。

  • OPEN:ブレーカーが開いている状態、通信を通さない。
  • CLOSE:ブレーカーが閉じている状態、通信を通す
  • HALF_OPEN:ブレーカーが半分開いている状態。試しにリクエストを送れる状態

状態遷移として以下のものがあります。

  • CloseOPEN 
    • 失敗率が設定された敷地を超えた場合
  • OpenHALF_OPEN 
    • 任意の待ち時間をおいた後
  • HALF_OPENOPEN 
    • 失敗率が設定された敷地を超えた場合
  • HALF_OPENCLODE(失敗率がリセットされる) 

失敗率が設定された敷地を下回った場合Circuit Braekerを導入すると、エラー時はデフォルトのデータを返すなどの一時的なフォールバックを組み込むことも簡単になります。

Spring Clould Circuit Braekr

Spring Clould CirCuit Braekrの実装は以下の4つがあります。

  • Netfix Hystrix
  • Resilience4J
  • Sentinel
  • Spring Retry

この中でNetfix Hystixはすでにメンテナンスモードに入ることが宣言されており、今後の利用は下の3つの中から選ぶことになると考えられます。今回は、その中でもResilience4jを使ってみたいと思います。

Resilience4j Circuit Braeker

Resilience4jはNetflix Hystrixに触発された。フォールトレラントを高くするためのライブラリです。Netflix Hystrixと違うのはJava8やファンクショナルなプログラミングのために設計され作られています。

Resilience4Jでは大きく以下のようなものが提供されています。

  • CircuitBreaker 
    • 上記で説明したCircuitBreaker
  • Bulkhead 
    • 安定性のパターンの一つとして、隔離を実現する
  • Retry 
    • リトライ
  • Cache 
    • インメモリのキャッシュ。キャッシュのヒットなどのイベントに対応するファンクションを実装することができる。

Spring Circuit BraekerではこのResilience4jの実装を用いることができます。Resilience4jのCircuitBraekerでは、一定時間内ないの失敗率を計測する時間ベースでのスライディングウィンドウと一定回数内の失敗率を計測すうるカウントベースのスライディングウィンドウを選択することができます。

CircuitBreakerCinfigについて

Resilience4jでは、CircuitBraekerCinfigと呼ばれるクラスが提供されており、CircuitBraekrに食わせることで、各種設定を行いことができます。CircuitBraekerCinfigでは以下のようなコンフィグポイントが用意されています。

Config property 説明 デフォルト値
failureRateThreshold 失敗率をパーセンテージで設定します。失敗率が設定値よりも上回った際にCircuitBreakerはCloseからOpenの状態に遷移します 50
slowCallRateThreshold スローコールの率をパーセンテージで設定します。スローコールの回数が設定値よりも上回れば、CircuitBreakerはOPEN状態に遷移し、短いサイクル(short-circuiting)の呼び出しを行なうようになります。 100
slowCallDurationThreshold CircuitBreakerが呼び出しに対して、スローコールであると判断するしきい値です。 60000 [ms]
permittedNumberOfCallsInHalfOpenState CirCuitBreakerがHALF_OPENの状態の際に許可される呼び出しの上限です 10
slidingWindowType スライディングウィンドウの種類を指定します。カウントベースのスライディングウィンドウを利用する場合はCOUNT_BASED`を時間ベースのスライディングウィンドウを利用する場合はTIME_BASED`を指定します。 COUNT_BASED
slidingWindowSize CircuitBraekerの状態がCLOSEである際の外部呼び出しの数を保存する個数を指定します。 100
minimumNumberOfCalls CurcitBraekerがエラーのカウント始めるまでの最低の呼び出し回数を指定します。例えば、10と指定されていた場合、最初の9階がエラーで落ちていたとしてもCircuitBraekerはOPENの状態に遷移しません。 10
waitDurationInOpenState CircuitBraekerがOPEWからHALF_OPENへ遷移する際の最低限待つ時間を設定します。 60000 [ms]
automaticTransitionFromOpenToHalfOpenEnabled tureで設定されていた場合、CircuitBraekrはOPENの状態からHALF_OPENの状態に自動的に遷移します。外部からの呼び出しを必要としません。 false
recordExceptions レコードとして記録されるExceptionの種類です。失敗回数としてカウントされ失敗率を引き上げます。登録されたExceptionにマッチするか、もしくはそのExceptionを継承している場合にカウントの対象となります。もし、ignoreExceptionsで明示的にExceptionが指定されている場合は無視されカウントされません。 empty
ignoreExceptions recordExceptionsの逆の設定 empty
recordException あるエクセプションがカウントされるべきか否かのPredicateを記述します。もし失敗としてカウントされるべきであればPredicatetureを返し、成功としてカウントされるべきであればPredicatefalseを返します throwable -> true(デフォルトではすべてのエクセプションが失敗のカウント対象となります。)
ignoreException recordExceptionの逆の設定 throwable -> false(デフォルトではすべてのエクセプションが失敗としてカウントされます)

Spring CircuitBraekrでも、Resilience4jのCirCuitBreakerConfig.javaを用いて細かい設定を行なうことができます。

Spring Circuit Brakerを使ってみる。

Springの公式サンプルを参考に実際にCircuit Braekerを使ってアプリを書いてみたいと思います。 アプリはGETでのリクエストを受付けhttps://httpbin.orgに対して外部接続を行います。この外部接続先はシンプルなJsonのレスポンスを返してくれるサービスです。例えば、https://httpbin.org/delay/4のようにリクエストを送ると4秒間のウェイトをはさみ下記のようなレスポンスを返してくれます。

{
  "args": {}, 
  "data": "", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 
    "Accept-Encoding": "gzip, deflate, br", 
    "Accept-Language": "ja,en-US;q=0.9,en;q=0.8", 
    "Cache-Control": "max-age=0", 
    "Host": "httpbin.org", 
    "Sec-Fetch-Mode": "navigate", 
    "Sec-Fetch-Site": "none", 
    "Sec-Fetch-User": "?1", 
    "Upgrade-Insecure-Requests": "1", 
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36", 
    "X-Amzn-Trace-Id": "Root=1-5e57dbce-b90abaeae327c84251086901"
  }, 
  "origin": "113.149.133.110", 
  "url": "https://httpbin.org/delay/4"
}

今回は上記のサイトに対するプロキシとして動くアプリを書きます。

プロジェクトの作成

Spring Initializrを使ってプロジェクトを作成します。その際Javaは11、Springのバージョンは 2.2.4を選択し、依存にはwebResilience4jを選択しました。

CircuitBraekerの設定を書く

次にCircuit Braekerの設定とRestTemplateのインスタンスをBeanとして登録するための設定を書きます。RestTemplateは外部接続のクライアントとして利用します。

@Configuration
public class CBConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder().build();
    }

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build())
                .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults()).build()
         );
    }
}

1つめの@Beanでは単にRestTemplateのインスタンスを登録しています。 2つめの@BeanではResilience4JCircuitBreakerFactoryCustomizerにくるんでBeanとして登録しています。ファクトリーのtimeLimiterConfigに外部接続の際のタイムアウトを設定しています。また、circuitBreakerConfigにはResilience4jCircuitBreakerConfigを登録できるようです。まずは特に設定はいじらないでデフォルト値を利用します。

Controllerを書く

以下のようにコントローラーを記述します。

@RestController
public class CBController {

    Logger LOG = LoggerFactory.getLogger(CBController.class);

    private RestTemplate restTemplate;
    private CircuitBreakerFactory circuitBreakerFactory;

    public CBController(RestTemplate restTemplate, CircuitBreakerFactory circuitBreakerFactory) {
        this.restTemplate = restTemplate;
        this.circuitBreakerFactory = circuitBreakerFactory;
    }


    @GetMapping("/delay/{second}")
    public Map greeting(@PathVariable int second) {
        return circuitBreakerFactory
                .create("delay")
                .run(() -> restTemplate.getForObject("https://httpbin.org/delay/" + second, Map.class),
                        t -> {
                            LOG.warn("delay call failed error", t);
                            Map<String, String> fallback = new HashMap<>();
                            fallback.put("hello", "world");
                            return fallback;
                        });
    }

}

コントローラーではGETリクエストを/delay/{second}で受付け、DIされたCircuitBraekerFactoryを用いて外部リクエストの処理を記述しています。 runメソッドの第一引数のところに実際の外部接続を記述し、第二引数に接続に失敗した際のフォールバック処理を記述っできるようです。 読めばわかるかもしれませんが、このアプリは、リクエストに成功した場合は外部接続から帰ってきた結果をそのままプロキシし、失敗した際にはログに接続失敗のメッセージを出力し、{"hello":"world"}クライアントに返却します。

作ったアプリにリクエストを送ってみる

作成したアプリを動かして、cURLを使ってリクエストを送ってみます。設定のところで外部接続のタイムアウトを5秒に設定したのでまずは成功するリクエストを送ってみます。

$ curl localhost:8081/delay/2
{"args":{},"data":"","files":{},"form":{},"headers":{"Accept":"application/json, application/*+json","Host":"httpbin.org","User-Agent":"Java/11.0.6","X-Amzn-Trace-Id":"Root=1-5e57e57a-e6556f3120f942323218a0dd"},"origin":"113.149.133.110","url":"https://httpbin.org/delay/2"}

想定通り外部接続先から帰ってきた情報がそのままプロキシされて帰ってきています。次に失敗するリクエストを送ってみます。

$ curl localhost:8081/delay/7
{"hello":"world"}

フォールバック処理に記述したレスポンスが帰ってきていますね。またログにも以下のような出力が出ていました。

log2020-02-28 00:53:08.176  WARN 18204 --- [nio-8081-exec-2] d.h.c.r.s.r.CBController                 : delay call failed error

CircuitBraekerのステートを変化させてみる

先程はデフォルトの状態の設定のCircuit Braekrを使いましたがデフォルトの状態ではCircuit Braekerのステートマシンを変化させるのが少しめんどくさいです。なので、少し設定を変更したいと思います。先程のJavaConfigを以下のように書き換えます。

@Configuration
public class CBConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder().build();
    }

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5)).build())
                .circuitBreakerConfig(
                        CircuitBreakerConfig.custom()
                                .failureRateThreshold(90)
                                .slidingWindowSize(5)
                                .minimumNumberOfCalls(1)
                                .waitDurationInOpenState(Duration.ofSeconds(30))
                                .build()
                ).build()
        );
    }
}

以下のような設定を追加しました。

failureRateThreshold(30)OPENの状態にすぐ遷移するように失敗率のしきい値を30%に設定
slidingWindowSize ⇨ デフォルトだとサイズが100で大きすぎるため5に設定
minimumNumberOfCalls ⇨ 特にウェイトの時間などは必要ないので、最小値である1を設定
waitDurationInOpenState ⇨ OPENの状態でいろいろ試してみたいので30秒を設定

それではリクエストを投げてみたいと思います。まずはスライディングウィンドウのキューをすべて成功で埋めるため、以下のリクエストを5回送ります。

$ curl localhost:8081/delay/2

キューに成功が5回溜まったところで、Circuit Braekerの状態をOPENにしてみたいと思います。失敗率が30%を上回れば良いので、2回失敗するリクエストを投げ、+1回で成功するはずのリクエストを投げてみます。

$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m5.039s
user    0m0.006s
sys 0m0.007s
$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m5.043s
user    0m0.019s
sys 0m0.006s
$ time curl localhost:8081/delay/2
{"hello":"world"}
real    0m0.038s
user    0m0.021s
sys 0m0.005s

通信等や諸々のオーバヘットがありますが、最初の2回の呼び出しは、タイムアウトとして設定した5秒でフォールバックのレスポンスが返ってきています。そして、3回目の成功するリクエスcurl localhost:8081/delay/2もフォールバックのレスポンスが即時で返ってきています。CircuitBraekerの状態がOPENになり、外部接続への通信が行われずにレスポンスが返されていることがわかります。

また、成功するリクエストに対してはログには以下のように表示されていました。

io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'delay' is OPEN and does not permit further calls

今度はOPENの状態で30秒待って、HALF_OPENの状態にしてから、リクエストを投げてみたいと思います。 まずは、HALF_OPENの状態で失敗するリクエストを送ってみます。

$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m0.017s
user    0m0.005s
sys 0m0.005s

# OPENの状態なのでフォールバックのレスポンスが即時で返ってきています。
# この状態で30秒待ちます。

$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m5.022s
user    0m0.011s
sys 0m0.000s
$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m0.016s
user    0m0.005s
sys 0m0.005s

30秒待った後のリクエストはタイムアウトで設定した5秒たった後にリクエストがフォールバックの処理が返ってきていることがわかります。 その後のリクエストはまた即時返ってきています。Circuit BraekerがHALF_OPENの状態で、外部接続のリクエストを一度投げてみて、失敗したので、OPENの状態に遷移したことが見て取れます。 ちなみに、HALF_OPENの状態を示すようなログは特になかったです。

今度は、HALF_OPENの状態で成功するリクエストを送ってみたいと思います。

$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m0.016s
user    0m0.005s
sys 0m0.005s

# 30秒待つ

$ time curl localhost:8081/delay/2
{"args":{},"data":"","files":{},"form":{},"headers":{"Accept":"application/json, application/*+json","Host":"httpbin.org","User-Agent":"Java/11.0.6","X-Amzn-Trace-Id":"Root=1-5e59e4ef-d05e3cd9d33af739fb8c8c58"},"origin":"103.5.140.152","url":"https://httpbin.org/delay/2"}
real    0m2.810s
user    0m0.015s
sys 0m0.011s

$ time curl localhost:8081/delay/6
{"hello":"world"}
real    0m5.024s
user    0m0.005s
sys 0m0.009s

30秒待った後のリクエストが成功しています。そして、その後の失敗するリクエストはタイムアウトの時間を置いてからレスポンスが返ってきています。これはCircuit Braekerの状態がHALF_OPENからCLOSEに遷移したためです。これで、一通りの状態と状態遷移は試せたと思います。

感想

今回は、Spring Circuit BraekerのResilience4jの実装を試してみました。Circuit Braekerはリトライなどの機構と合わせて使うと更に効果を発揮します。Spring RetryでもCircuitBrakerを使うことが可能なようなので、また別の機会に試してみたいと思います。