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
// 以下略

参考文献

火災後のセントリーの耐火金庫の新品交換

マスターロック・セントリー日本株式会社が販売している耐火金庫は、火災の被害が発生した場合、以下のサポートQ&Aにあるように所定の手続きを踏めば新品と交換できる。

www.sentryjp.com

ライフタイムワランティーとは何ですか? 万一火災で金庫が燃えてしまった場合、中身を除き金庫を新品とお取替え致します。 最初のご購入者が使用される限り、このサービスが受けられます。(キャッシュボックスとパーソナル セキュリティセーフ シリーズは除く) https://www.sentryjp.com/support/faq_q.php?scode=CW

必要書類

サポートによると以下の書類を郵送してください、とのこと。耐火金庫の写真は火災現場での撮影が必要なので、もしサポートを受けたい場合は片付け前にあらかじめ撮影の必要がある。

必要書類 説明
写真1・製品の外観 火災の被害を確認可能な耐火金庫の外観写真
写真2・製造番号 フタ内側に製造番号などが記載されている。それが確認可能な写真。後述
罹災証明書のコピー 消防署が発行する公的書類。原本ではなくコピーで良い。火災保険の申請時に原本を作成しているはずなので、その時にコピーを何通か用意しておくのが良い
本人確認書類のコピー マイナンバーカード、運転免許証、などのコピー
受取住所 新品の配送先住所

写真例

念のためモザイクをかけているがフタの裏側に製造番号などが記載されているのは分かると思う。

ちなみに、写真から分かるとおり中身の通帳などは無事だった。一般的な火災に備えるだけなら同社のポータブルシリーズで十分だろう。もちろん、盗難やより強力な火災、水害に備えるのであれば上位モデルが適する。

感想

新しい耐火金庫を買いなおした後に新品交換サービスの存在を知ってしまったのであった。なので、必要書類は実際に交換手続きをしたわけではない。

住宅火災における川崎市の市税減免の審査基準と手続

川崎市には天災および人為的災害で家屋や家財に損害が発生した場合に市税の減免を受けられる場合がある。ただ、減免額が「被災の状況に応じて8分の1から全額まで」とあるのみで、どういう基準なのかは記載が無い。

なお、以下の記述は賃貸住宅の罹災経験に基づく記述である。賃貸住宅は家財のみが対象で、住宅は所有物では無いので無関係である。戸建ての場合はまた変わってくるので、本文書を参考にする場合はその点を注意して欲しい

www.city.kawasaki.jp

減免の審査基準

ざっくり言うと、火災保険のカバー率が家財の70%未満な場合に減免の資格が発生するようだ。詳細は後述する。たとえば、以下図のように、消失家財の評価額が仮に100万だったとする。火災保険の支払額が70万だったとすると、最終的な損害の金額は30万となる。消失家財の10分の3が30万なので、これが最低ラインとなる。この場合だと、火災保険が60万とか50万の場合に減免基準を満たすようだ。

川崎市のwebサイトには見つからなかったが、窓口で受けた説明と同様な基準の自治体があるのでそちらから引用する。

減免が受けられるのは、住宅又は家財に被害を受け、損害の金額(保険金、損害賠償金その他これに類するものにより補填される部分の金額を除く。)が、所有する住宅又は家財の価格の10分の3以上で、前年の合計所得金額が1,000万円以下である方です。 https://city.hashima.lg.jp/1246.html 羽島市 - 火災にあわれた方へ(市税) より抜粋

上の図ではざっくり「存在の金額」は家財評価額から火災保険の支払額を引いた金額としたが、正確な「損害の金額」は「(保険金、損害賠償金その他これに類するものにより補填される部分の金額を除く。)」である。とはいえ多くのケースにおいて補填金額とはほぼ火災保険の支払額と同義であろう。

狛江市にも同様な記述があり、かつ、こちらには所得基準も併せて掲載されている。おそらく川崎市も似たようなものだろう。

所有に係る住宅や家財に損害の金額(補填されるべき金額除く)が住宅や家財の価格の 10 分の3以上であり、かつ合計所得金額が1,000 万 円 以 下である者のうち右に掲げる事由に該当する者 https://www.city.komae.tokyo.jp/index.cfm/41,66927,c,html/66927/20150915-164452.pdf

ただし、これはあくまでも公開されているおおむねの基準である。減免を受けられるかどうかは審査があるので、実際に減免されるかどうかは審査を受けないと分からない。

減免申請の相談

まず担当窓口に審査基準を満たすかどうかを電話相談する。必要書類を揃えるのはそのあとで良いが、面倒なものもあるので申請するつもりがあるなら事前に準備しておいた方がよい。

川崎市の相談先一覧は下記のとおり。区ごとに管轄事務所が異なるので、現住所の担当事務所に電話相談する。

https://www.city.kawasaki.jp/kurashi/category/16-5-5-0-0-0-0-0-0-0.html

減免申請に必要な書類

書類 説明
罹災証明書(原本) 消防署で発行する。おそらく火災保険の申請で一度発行しているはずなので、要領はそれと同じ。消防署に罹災証明書(原本)の発行依頼をし、面会予約をし、現地で受け取る。
火災保険の契約状況が分かるもの 契約書でも何でも良い。保険会社に連絡すればPDF等で火災保険契約照会を出力してくれる。
火災保険の金額が分かるもの メールなりハガキなりで支払明細が来るはずなのでそれでOK
マイナンバーカード 身分証明書
損害品明細書 火災保険申請時に消失家財の明細を作成していると思うが、それと同様な書類が必要。川崎市指定フォーマット必須なのか、保険会社に提出したのと同じ書類が流用できるのか、は不明。なお、消失家財だけでなく、消失を免れた残存家財も記述の必要がある。おそらく、保険会社とは審査基準が微妙に異なるのだろう。

減免の感想

最終的には私は減免申請はしなかった。どう考えても損害金額が30%以上にはならないと思われたし、損害品明細書を再度作成する手間に見合うとは思えなかった。メチャクチャ面倒な手間をかけて、どう見ても通らない審査を申請する気にはならなかった。窓口担当者の意見としても、私のケースは一般的で十分な火災保険支払いがあるのでまず審査は通らないだろう、との事だった。

おそらくだが、住宅火災に対する市税の減免とは生活困窮者に対する緊急措置なのだろう。損額の金額が50%とか60%とかだとまぁまぁきついし、100%近くなれば税金を払っているレベルでは無くなる可能性が高い。というか、そういう状況は火災保険が何らかの事情で降りないとか、そもそも未加入とかだろう。そういうケースに対する最低限度のセーフティネットかな? という感触がある。

火災保険でおおむね生活復旧出来てるなら税金免除するには当たらない、という事なのだろう。感情的には納得しづらいが、ロジックには妥当性があるように思える。