実現したい事
- Spring Boot、Resilience4j、RestTemplate を使用する
- 連携先のサービスから通信エラー、500エラーが返ってきた場合、リトライする
- リトライも失敗した場合、代わりの処理を実行する
- リクエストの失敗率が閾値を超えた場合、連携先のサービスへはリクエストを投げずに代わりの処理を実行する
- 連携先のサービスが復旧した場合、連携先のサービスへリクエストを投げるように自動で復旧する
参考にした記事
構成
Spring Boot アプリケーションの作成
api-service の実装
構成図の api-service に該当するアプリケーションを作成します。
build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.20"
kotlin("plugin.spring") version "1.9.20"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
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.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// Micrometer Observation APIをOpenTelemetryにブリッジするライブラリ
implementation("io.micrometer:micrometer-tracing-bridge-otel:1.2.0")
// トレース情報を Zipkin に送信するライブラリ
implementation("io.opentelemetry:opentelemetry-exporter-zipkin:1.32.0")
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.1.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
application.yml
spring:
application:
name: api-service customer-service # Zipkin に送信されるサービス名。同一のサービスと認識されてしまう為、一意である必要がある
management:
tracing:
sampling:
probability: 1.0 # トレース情報の送信割合。1.0 で全てのトレース情報が送信される
health:
circuitbreakers.enabled: true # CircuitBreakerのヘルスインジケーターを有効にする
endpoints:
web:
exposure:
include: "health,circuitbreakers,metrics"
resilience4j.circuitbreaker:
circuitBreakerAspectOrder: 1 # 優先順位の変更。retry より後に実行されるようにする
instances:
customer-service: # 設定したい接続先システムを指定
registerHealthIndicator: true # ヘルスチェックエンドでサーキットブレーカーのステータスが確認可能になる
slidingWindowType: COUNT_BASED # slidingWindowTypeをカウントベースにする
slidingWindowSize: 3 # ここで指定した数のコール数を保持してエラーレートの計算に利用する
minimumNumberOfCalls: 3 # エラーレートを計算するのに必要な最小のコール数
permittedNumberOfCallsInHalfOpenState: 2 # HALF_OPENの時に許可される呼び出しの数。こちらに指定した回数を実行して、failureRateThreshold を満たさない場合は、OPEN に移行する
automaticTransitionFromOpenToHalfOpenEnabled: true # trueだと自動でHALF_OPENに移行する
waitDurationInOpenState: 10s # OPENからHALF_OPENに移行する前に待機する時間
failureRateThreshold: 100 # 失敗率の閾値。この数値を超えて失敗しているとOPENに移行する
# ここで指定したExceptionが発生すると失敗としてカウントする
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.RestClientException
resilience4j.retry:
retryAspectOrder: 2 # 優先順位の変更。circuitbreaker より先に実行されるようにする
instances:
customer-service:
maxAttempts: 3 # 最大試行回数
waitDuration: 1s
retryExceptions:
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.RestClientException
logging:
level:
root: INFO
-
recordExceptions
でRestClientException
を指定している理由は以下になります。
https://resilience4j.readme.io/docs/getting-started-3#fallback-methods
https://stackoverflow.com/questions/76940489/spring-boot-resilience4j-circuit-breaker-fallback-method-not-being-called - デフォルトでは、retry が circuitbreaker より後に実行される為、優先順位を指定しております。
https://resilience4j.readme.io/docs/getting-started-3#aspect-order
CustomerController.kt
import com.example.sleuthdemoclient.service.CustomerService
import org.apache.commons.logging.LogFactory
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class CustomerController(
private val customerService: CustomerService
) {
private val logger = LogFactory.getLog(CustomerController::class.java)
@RequestMapping("/customer")
fun get(): HelloRes {
logger.info("get() has been called")
val result = customerService.get()
return HelloRes(
result.value
)
}
class HelloRes(
val value: String
)
}
CustomerService.kt
import io.github.resilience4j.circuitbreaker.CallNotPermittedException
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import io.github.resilience4j.retry.annotation.Retry
import org.apache.commons.logging.LogFactory
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service
import org.springframework.web.client.HttpServerErrorException
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestTemplate
import org.springframework.web.client.getForEntity
import org.springframework.web.util.UriComponentsBuilder
@Service
class CustomerService(
private val restTemplate: RestTemplate
) {
private val logger = LogFactory.getLog(CustomerService::class.java)
@Retry(name = "customer-service")
@CircuitBreaker(name = "customer-service", fallbackMethod = "fallback")
fun get(): CustomerRes {
logger.info("get()")
val uri = UriComponentsBuilder.fromUriString("http://localhost:8081")
.path("customer")
.toUriString()
val response: ResponseEntity<CustomerRes> = restTemplate.getForEntity(
uri,
CustomerRes::class
)
return response.body!!
}
private fun fallback(exception: HttpServerErrorException): CustomerRes {
return CustomerRes("state が CLOSE または HALF_OPEN の状態で失敗")
}
private fun fallback(exception: RestClientException): CustomerRes {
return CustomerRes("state が CLOSE または HALF_OPEN の状態で通信エラー")
}
private fun fallback(exception: CallNotPermittedException): CustomerRes {
return CustomerRes("state が OPEN")
}
}
data class CustomerRes(
val value: String
)
- state については、以下に説明があります。
https://resilience4j.readme.io/docs/circuitbreaker#introduction
動作確認
Retry の実行が CircuitBreaker の failure rate に反映されるか確認、fallback が動作することの確認
結果としては最後のリクエストのみが反映されるようです。
- 実行前の failure rate の確認
http://localhost:8080/actuator/metrics/resilience4j.circuitbreaker.failure.rate
{
"name": "resilience4j.circuitbreaker.failure.rate",
"description": "The failure rate of the circuit breaker",
"measurements": [
{
"statistic": "VALUE",
"value": -1
}
],
"availableTags": [
{
"tag": "name",
"values": [
"customer-service"
]
}
]
}
- customer-service を停止して、必ずリトライされるようにする
- http://localhost:8080/customer にアクセス
- 2回リトライが発生し、合計3回リクエストを投げましたが、failure rate は -1のままでした。
minimumNumberOfCalls
を3で設定しているので、Retry が failure rate に反映されている場合は-1以外になります。 - api-service からのレスポンスは以下の結果が返ってきた為、fallback も期待通りに動いているようです。
{"value":"state が CLOSE または HALF_OPEN の状態で通信エラー"}
failureRateThreshold に指定した失敗率に達した場合に、OPEN になることの確認
- http://localhost:8080/customer に3回アクセス
- failure rate が100に変わりました。
{
"name": "resilience4j.circuitbreaker.failure.rate",
"description": "The failure rate of the circuit breaker",
"measurements": [
{
"statistic": "VALUE",
"value": 100
}
],
"availableTags": [
{
"tag": "name",
"values": [
"customer-service"
]
}
]
}
- CircuitBreaker の state が OPEN になっていることを確認する
- http://localhost:8080/actuator/circuitbreakers
{
"circuitBreakers": {
"customer-service": {
"failureRate": "100.0%",
"slowCallRate": "0.0%",
"failureRateThreshold": "100.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 3,
"failedCalls": 3,
"slowCalls": 0,
"slowFailedCalls": 0,
"notPermittedCalls": 0,
"state": "OPEN"
}
}
}
CircuitBreaker の state が OPEN の時に、Retry が実行されるか確認。fallback が動作することの確認
結果としては state が OPEN の場合は、Retry は実行されないようです。
state が CLOSE の場合のlog
state が OPEN の場合のlog
customer-service へはリクエストは投げずに即座にレスポンスを返しています。
api-service からのレスポンスは以下の結果が返ってきた為、fallback も期待通りに動いているようです。
{"value":"state が OPEN"}
OPEN から HALF_OPEN への移行することの確認
- OPEN の状態から、10秒経過すると、HALF_OPENへ移行しました。
- HALF_OPEN の状態で、
permittedNumberOfCallsInHalfOpenState
に指定されている回数アクセスした場合に、failureRateThreshold
を満たすと CLOSE へ移行し、満たさない場合は OPEN へ移行するようです。