Skip to content
Published on

Circuit Breakerパターンと Resilience4j 実践実装ガイド:障害伝播遮断から復旧まで

Authors
  • Name
    Twitter
Circuit Breaker Resilience4j

はじめに

マイクロサービスアーキテクチャにおいて、サービス間のネットワーク呼び出しは本質的に不安定である。ネットワーク遅延、タイムアウト、ダウンストリームサービスの障害は日常的に発生し、これを適切に制御しなければ、単一サービスの障害がシステム全体に伝播する**カスケード障害(Cascading Failure)**が発生する。2024年末、ある大手ECプラットフォームで決済ゲートウェイ1つの応答遅延が注文サービス、在庫サービス、通知サービスまで連鎖的に麻痺させた事例が代表的である。

Circuit Breakerパターンは、電気回路のブレーカーに着想を得た障害隔離メカニズムである。Michael Nygardが2007年にRelease It!で初めて紹介して以来、Martin Fowlerのブログポストを経て、マイクロサービスの世界の中核パターンとなった。NetflixのHystrixが最初の大衆的な実装だったが、2018年にメンテナンスモードに入ったことで、Resilience4jが事実上の標準として台頭した。

本記事では、Circuit Breakerステートマシンの動作原理から、Resilience4jのコアモジュールであるCircuitBreaker、Retry、Bulkhead、RateLimiterをSpring Boot 3環境で統合実装し、Grafanaダッシュボードでモニタリングし、実際の障害シナリオでの復旧戦略まで、運用レベルで解説する。

Circuit Breakerステートマシン

Circuit Breakerの核心は、3つの状態(CLOSED、OPEN、HALF-OPEN)と2つの特殊状態(DISABLED、FORCED_OPEN)間の遷移を管理する有限状態マシン(Finite State Machine)である。

状態遷移ダイアグラム

                     失敗率 >= しきい値
         ┌─────────────────────────────────────┐
         │                                     │
         ▼                                     │
    ┌──────────┐                          ┌──────────┐
    │          │    waitDuration 経過      │          │
OPEN   │ ─────────────────────>CLOSED      (遮断)  (正常)    └──────────┘                          └──────────┘
         │                                     ▲
         │ waitDuration 経過                    │
         ▼                                     │ 試験呼び出し成功率 >= しきい値
    ┌──────────────┐                           │
HALF-OPEN   │ ──────────────────────────┘
      (試験許可)    └──────────────┘
         │ 試験呼び出し失敗率 >= しきい値
    ┌──────────┐
OPEN     (再び遮断)
    └──────────┘

状態別動作の詳細

状態リクエスト処理遷移条件メトリクス収集
CLOSEDすべてのリクエストを通過スライディングウィンドウ内の失敗率がしきい値以上でOPENに遷移成功/失敗/遅い呼び出しを記録
OPENすべてのリクエストを即座に拒否(CallNotPermittedException)waitDurationInOpenState経過後、HALF-OPENに遷移拒否された呼び出し数を記録
HALF-OPENpermittedNumberOfCallsの数だけ許可試験呼び出しの結果に応じてCLOSEDまたはOPENに遷移試験呼び出しの成功/失敗を記録
DISABLEDすべてのリクエストを通過(サーキット無効化)手動切り替えのみ可能メトリクスを収集しない
FORCED_OPENすべてのリクエストを即座に拒否手動切り替えのみ可能拒否された呼び出し数を記録

スライディングウィンドウ方式の比較

Resilience4jは2種類のスライディングウィンドウ方式を提供する。

項目COUNT_BASEDTIME_BASED
基準直近N回の呼び出し直近N秒間の呼び出し
設定例slidingWindowSize: 10slidingWindowSize: 60
メモリ使用量固定(N個の結果配列)可変(N秒間の部分集計)
適した環境呼び出し頻度が一定のサービス呼び出し頻度が不規則なサービス
評価タイミングN回目の呼び出し以降各呼び出し時にタイムウィンドウを評価

COUNT_BASEDは内部的にNサイズの循環ビット配列(circular bit array)で実装されており、各呼び出し結果をO(1)で記録し、失敗率を定数時間で計算する。TIME_BASEDはN個の部分集計バケット(partial aggregation bucket)を使用し、各バケットが1秒間の呼び出し結果を集計する。

Resilience4jアーキテクチャ

HystrixからResilience4jへの移行

Netflix Hystrixが2018年にメンテナンスモードに入って以降、Resilience4jがJVMエコシステムの標準フォールトトレランス(fault tolerance)ライブラリとなった。

比較項目Netflix HystrixResilience4j
状態メンテナンスモード(2018年以降更新なし)活発な開発(2025年 2.3.0リリース)
JavaバージョンJava 8+Java 17+(Spring Boot 3サポート)
依存関係複数(Archaius、RxJavaなど)Vavr 1つのみ
アーキテクチャモノリシック(全機能を含む)モジュラー(必要なモジュールのみ選択)
スレッドモデル別途スレッドプール必須セマフォベース(スレッドプールはオプション)
設定方式Archaius必須application.ymlとプログラマティック両方対応
リアクティブ対応RxJava 1Reactor、RxJava 2/3ネイティブサポート
関数型インターフェース限定的完全サポート(Supplier、Function、Runnableなど)
モニタリングHystrix DashboardMicrometer統合(Prometheus、Grafana)

Resilience4jコアモジュール

Resilience4jは5つのコアモジュールを独立的に、または組み合わせて使用できる。

モジュール役割主要設定
CircuitBreaker失敗率ベースの回路遮断failureRateThreshold, slidingWindowSize
Retry失敗時のリトライmaxAttempts, waitDuration, backoff
Bulkhead同時呼び出し数の制限(隔壁)maxConcurrentCalls, maxWaitDuration
RateLimiter単位時間あたりの呼び出し制限limitForPeriod, limitRefreshPeriod
TimeLimiter呼び出し時間の制限timeoutDuration, cancelRunningFuture

アノテーションベースで組み合わせる際の適用順序は以下の通りである。

外部(先に評価) ──────────────────────────────────> 内部(最後に評価)
Retry -> CircuitBreaker -> RateLimiter -> TimeLimiter -> Bulkhead

この順序は、Resilience4jがSpring AOPベースでアノテーションを処理する際のデフォルト優先順位である。resilience4j.circuitbreaker.circuitBreakerAspectOrderなどのプロパティで順序をカスタマイズすることも可能である。

Spring Boot 3統合設定

依存関係の設定

// build.gradle.kts (Spring Boot 3.3+ / Resilience4j 2.2+)
plugins {
    id("org.springframework.boot") version "3.3.5"
    id("io.spring.dependency-management") version "1.1.6"
    kotlin("jvm") version "1.9.25"
    kotlin("plugin.spring") version "1.9.25"
}

dependencies {
    // Resilience4j Spring Boot 3 スターター
    implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")

    // 個別モジュール(スターターに含まれるが明示的宣言推奨)
    implementation("io.github.resilience4j:resilience4j-circuitbreaker")
    implementation("io.github.resilience4j:resilience4j-retry")
    implementation("io.github.resilience4j:resilience4j-bulkhead")
    implementation("io.github.resilience4j:resilience4j-ratelimiter")
    implementation("io.github.resilience4j:resilience4j-timelimiter")

    // Micrometer + Prometheus(モニタリング)
    implementation("io.github.resilience4j:resilience4j-micrometer")
    implementation("io.micrometer:micrometer-registry-prometheus")

    // Spring Boot Actuator
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-aop")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Kotlin Coroutines(オプション)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

統合設定ファイル

# application.yml - Resilience4j 統合設定
resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        slowCallRateThreshold: 80
        slowCallDurationThreshold: 3s
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.HttpServerErrorException
        ignoreExceptions:
          - com.example.order.exception.BusinessValidationException
    instances:
      paymentGateway:
        baseConfig: default
        failureRateThreshold: 40
        waitDurationInOpenState: 60s
        slidingWindowSize: 20
      inventoryService:
        baseConfig: default
        failureRateThreshold: 60
        slowCallDurationThreshold: 5s
      notificationService:
        baseConfig: default
        failureRateThreshold: 70
        waitDurationInOpenState: 15s

  retry:
    configs:
      default:
        maxAttempts: 3
        waitDuration: 1s
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2.0
        exponentialMaxWaitDuration: 10s
        retryExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - com.example.order.exception.BusinessValidationException
    instances:
      paymentGateway:
        baseConfig: default
        maxAttempts: 2
        waitDuration: 2s
      inventoryService:
        baseConfig: default
        maxAttempts: 4
      notificationService:
        baseConfig: default
        maxAttempts: 5
        waitDuration: 500ms

  bulkhead:
    configs:
      default:
        maxConcurrentCalls: 25
        maxWaitDuration: 500ms
    instances:
      paymentGateway:
        baseConfig: default
        maxConcurrentCalls: 15
      inventoryService:
        baseConfig: default
        maxConcurrentCalls: 30
      notificationService:
        baseConfig: default
        maxConcurrentCalls: 50

  ratelimiter:
    configs:
      default:
        limitForPeriod: 100
        limitRefreshPeriod: 1s
        timeoutDuration: 500ms
    instances:
      paymentGateway:
        baseConfig: default
        limitForPeriod: 50
      inventoryService:
        baseConfig: default
        limitForPeriod: 200

  timelimiter:
    configs:
      default:
        timeoutDuration: 5s
        cancelRunningFuture: true
    instances:
      paymentGateway:
        baseConfig: default
        timeoutDuration: 10s
      inventoryService:
        baseConfig: default
        timeoutDuration: 3s

# Actuatorメトリクス公開
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus,circuitbreakers,retries
  endpoint:
    health:
      show-details: always
  health:
    circuitbreakers:
      enabled: true
  metrics:
    distribution:
      percentiles-histogram:
        resilience4j.circuitbreaker.calls: true
        resilience4j.retry.calls: true
    tags:
      application: order-service

設定で注目すべき点は、configs.defaultで基本プロファイルを定義し、各インスタンスでbaseConfig: defaultを指定して共通設定を継承する構造である。サービスごとの特性に合わせてしきい値のみオーバーライドすれば、設定の重複を最小化できる。

CircuitBreaker実践実装

アノテーションベース実装(Kotlin)

// PaymentGatewayClient.kt
@Service
class PaymentGatewayClient(
    private val restClient: RestClient,
    private val paymentRetryQueue: PaymentRetryQueue,
    private val paymentCacheStore: PaymentCacheStore,
) {
    companion object {
        private val log = LoggerFactory.getLogger(PaymentGatewayClient::class.java)
        const val CB_NAME = "paymentGateway"
    }

    @CircuitBreaker(name = CB_NAME, fallbackMethod = "paymentFallback")
    @Retry(name = CB_NAME)
    @Bulkhead(name = CB_NAME)
    fun processPayment(request: PaymentRequest): PaymentResponse {
        log.info("Calling payment gateway for orderId={}", request.orderId)

        val response = restClient.post()
            .uri("https://payment-api.internal/v2/charges")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .body(PaymentResponse::class.java)
            ?: throw PaymentGatewayException("Empty response from payment gateway")

        log.info("Payment processed: orderId={}, txId={}", request.orderId, response.transactionId)
        return response
    }

    /**
     * フォールバックメソッド:CircuitBreaker OPENまたは例外発生時に呼び出される。
     * メソッドシグネチャは元のメソッドと同一 + 最後のパラメータにExceptionを受け取る必要がある。
     */
    private fun paymentFallback(request: PaymentRequest, ex: Exception): PaymentResponse {
        log.warn(
            "Payment fallback activated: orderId={}, reason={}",
            request.orderId, ex.message
        )

        return when (ex) {
            is CallNotPermittedException -> {
                // CircuitBreaker OPEN状態:キューに入れて非同期処理
                paymentRetryQueue.enqueue(request)
                PaymentResponse(
                    orderId = request.orderId,
                    status = PaymentStatus.QUEUED,
                    message = "決済がキューに登録されました。まもなく処理されます。",
                    transactionId = null,
                )
            }
            is BulkheadFullException -> {
                // Bulkhead飽和:即時リトライを誘導
                PaymentResponse(
                    orderId = request.orderId,
                    status = PaymentStatus.RETRY_LATER,
                    message = "現在、決済リクエストが集中しています。しばらくしてから再度お試しください。",
                    transactionId = null,
                )
            }
            else -> {
                // その他の例外:キャッシュされた決済情報があれば返却
                val cached = paymentCacheStore.getLastSuccess(request.orderId)
                if (cached != null) {
                    log.info("Returning cached payment for orderId={}", request.orderId)
                    cached.copy(status = PaymentStatus.CACHED)
                } else {
                    paymentRetryQueue.enqueue(request)
                    PaymentResponse(
                        orderId = request.orderId,
                        status = PaymentStatus.PENDING,
                        message = "決済処理中にエラーが発生しました。自動リトライされます。",
                        transactionId = null,
                    )
                }
            }
        }
    }
}

プログラマティック方式の実装(Java)

アノテーションの代わりにCircuitBreakerRegistryを直接使用すると、ランタイムで動的にサーキットブレーカーを生成したり設定を変更したりできる。

// InventoryServiceClient.java
@Service
@Slf4j
public class InventoryServiceClient {

    private final CircuitBreaker circuitBreaker;
    private final Retry retry;
    private final Bulkhead bulkhead;
    private final RestClient restClient;

    public InventoryServiceClient(
            CircuitBreakerRegistry cbRegistry,
            RetryRegistry retryRegistry,
            BulkheadRegistry bulkheadRegistry,
            RestClient.Builder restClientBuilder) {

        this.circuitBreaker = cbRegistry.circuitBreaker("inventoryService");
        this.retry = retryRegistry.retry("inventoryService");
        this.bulkhead = bulkheadRegistry.bulkhead("inventoryService");
        this.restClient = restClientBuilder
                .baseUrl("https://inventory-api.internal")
                .build();

        // イベントリスナー登録
        registerEventListeners();
    }

    public InventoryResponse checkStock(String productId, int quantity) {
        // デコレータチェーン:Bulkhead -> CircuitBreaker -> Retry -> 実際の呼び出し
        Supplier<InventoryResponse> decorated = Decorators
                .ofSupplier(() -> doCheckStock(productId, quantity))
                .withBulkhead(bulkhead)
                .withCircuitBreaker(circuitBreaker)
                .withRetry(retry)
                .withFallback(
                    List.of(
                        CallNotPermittedException.class,
                        BulkheadFullException.class,
                        IOException.class
                    ),
                    ex -> stockFallback(productId, quantity, ex)
                )
                .decorate();

        return decorated.get();
    }

    private InventoryResponse doCheckStock(String productId, int quantity) {
        return restClient.get()
                .uri("/v1/stock/{productId}?qty={qty}", productId, quantity)
                .retrieve()
                .body(InventoryResponse.class);
    }

    private InventoryResponse stockFallback(
            String productId, int quantity, Throwable ex) {
        log.warn("Inventory fallback: productId={}, reason={}", productId, ex.getMessage());
        // 在庫が不確実な場合、注文を受け付けつつ非同期検証を予約
        return InventoryResponse.builder()
                .productId(productId)
                .available(true)
                .reservationStatus(ReservationStatus.TENTATIVE)
                .message("在庫確認遅延:暫定承認後、非同期検証予定")
                .build();
    }

    private void registerEventListeners() {
        circuitBreaker.getEventPublisher()
            .onStateTransition(event -> {
                log.warn("[CircuitBreaker] {} state: {} -> {}",
                    event.getCircuitBreakerName(),
                    event.getStateTransition().getFromState(),
                    event.getStateTransition().getToState());
            })
            .onError(event ->
                log.error("[CircuitBreaker] {} error: {} ({}ms)",
                    event.getCircuitBreakerName(),
                    event.getThrowable().getMessage(),
                    event.getElapsedDuration().toMillis())
            )
            .onSuccess(event ->
                log.debug("[CircuitBreaker] {} success ({}ms)",
                    event.getCircuitBreakerName(),
                    event.getElapsedDuration().toMillis())
            )
            .onCallNotPermitted(event ->
                log.warn("[CircuitBreaker] {} call not permitted (OPEN state)",
                    event.getCircuitBreakerName())
            );

        retry.getEventPublisher()
            .onRetry(event ->
                log.info("[Retry] {} attempt #{} (wait: {}ms)",
                    event.getName(),
                    event.getNumberOfRetryAttempts(),
                    event.getWaitInterval().toMillis())
            );
    }
}

Retry、Bulkhead、RateLimiterの組み合わせ

RetryとExponential Backoff

リトライ戦略で最も重要なのは、指数バックオフ(exponential backoff)とジッター(jitter)の組み合わせである。固定間隔リトライは、多数のクライアントが同時にリトライしてサーバーに負荷を集中させる**サンダリングハード(thundering herd)**問題を引き起こす。

// RetryConfigをプログラマティック方式でカスタマイズ
@Configuration
class ResilienceConfig {

    @Bean
    fun customRetryConfig(): RetryConfig {
        return RetryConfig.custom<RetryConfig>()
            .maxAttempts(4)
            .intervalFunction(
                // 指数バックオフ + ジッター:1s, 2s(+jitter), 4s(+jitter), 8s(+jitter)
                IntervalFunction.ofExponentialRandomBackoff(
                    Duration.ofSeconds(1),   // 初期待機時間
                    2.0,                     // 倍数
                    Duration.ofSeconds(15)   // 最大待機時間
                )
            )
            .retryOnException { ex ->
                // リトライ対象の例外を判別
                when (ex) {
                    is IOException -> true
                    is TimeoutException -> true
                    is HttpServerErrorException -> true
                    is ConnectException -> true
                    else -> false
                }
            }
            .ignoreExceptions(
                BusinessValidationException::class.java,
                IllegalArgumentException::class.java
            )
            .failAfterMaxAttempts(true) // 最大リトライ後にMaxRetriesExceededExceptionをスロー
            .build()
    }

    @Bean
    fun retryRegistry(customRetryConfig: RetryConfig): RetryRegistry {
        return RetryRegistry.of(customRetryConfig)
    }
}

Bulkhead:セマフォ vs スレッドプール

Bulkheadは船の隔壁に着想を得たパターンで、1つのサービス呼び出しがすべてのリソースを独占しないように隔離する。Resilience4jは2種類のBulkhead実装を提供する。

項目SemaphoreBulkheadThreadPoolBulkhead
隔離レベル同時呼び出し数の制限別のスレッドプールで実行
呼び出しスレッド呼び出し元のスレッドをそのまま使用専用スレッドプールのスレッドを使用
戻り値の型同期的な戻り値CompletionStage戻り値
オーバーヘッド低いスレッドコンテキスト切り替えコスト
適した環境ほとんどのHTTP呼び出しCPU集約的なタスク、完全な隔離が必要な場合
設定maxConcurrentCalls, maxWaitDurationmaxThreadPoolSize, coreThreadPoolSize, queueCapacity
# ThreadPoolBulkhead 設定例
resilience4j:
  thread-pool-bulkhead:
    instances:
      heavyProcessing:
        maxThreadPoolSize: 10
        coreThreadPoolSize: 5
        queueCapacity: 20
        keepAliveDuration: 100ms
        writableStackTraceEnabled: true

RateLimiterの設定と適用

RateLimiterは単位時間あたりの呼び出し許可数を制限して、外部APIのレート制限超過を防止したり、内部サービスを過負荷から保護したりする。

// RateLimiterとCircuitBreakerの組み合わせ
@Service
@Slf4j
public class ExternalApiClient {

    private final RestClient restClient;

    @CircuitBreaker(name = "externalApi", fallbackMethod = "apiFallback")
    @RateLimiter(name = "externalApi")
    @Retry(name = "externalApi")
    public ApiResponse callExternalApi(ApiRequest request) {
        log.debug("Calling external API: endpoint={}", request.getEndpoint());

        return restClient.post()
                .uri(request.getEndpoint())
                .body(request.getPayload())
                .retrieve()
                .body(ApiResponse.class);
    }

    private ApiResponse apiFallback(ApiRequest request, RequestNotPermitted ex) {
        // RateLimiterによって拒否された場合
        log.warn("Rate limit exceeded for external API: {}", request.getEndpoint());
        return ApiResponse.rateLimited(
                "リクエスト上限を超えました。" +
                "limitForPeriod設定を確認するか、しばらくしてから再度お試しください。"
        );
    }

    private ApiResponse apiFallback(ApiRequest request, Exception ex) {
        // その他の例外(CircuitBreaker OPEN、ネットワークエラーなど)
        log.warn("External API fallback: endpoint={}, reason={}",
                request.getEndpoint(), ex.getMessage());
        return ApiResponse.error("外部API呼び出しに失敗しました:" + ex.getMessage());
    }
}

フォールバックメソッドをオーバーロードする際の注意点は、Resilience4jが例外タイプを基準に最も具体的なフォールバックを選択することである。RequestNotPermitted(RateLimiter拒否)とException(一般例外)を分離すれば、例外の原因に応じて異なるフォールバックロジックを実行できる。

Grafanaモニタリングダッシュボード

Prometheusメトリクス収集

Resilience4jはMicrometerを通じて自動的にメトリクスを公開する。Spring Boot Actuatorの/actuator/prometheusエンドポイントで以下のメトリクスを確認できる。

# CircuitBreaker状態確認(0=CLOSED, 1=OPEN, 2=HALF_OPEN, 3=DISABLED, 4=FORCED_OPEN)
resilience4j_circuitbreaker_state{name="paymentGateway"}

# 失敗率(%)
resilience4j_circuitbreaker_failure_rate{name="paymentGateway"}

# 遅い呼び出し率(%)
resilience4j_circuitbreaker_slow_call_rate{name="paymentGateway"}

# 呼び出し統計(kind: successful, failed, ignored, not_permitted)
rate(resilience4j_circuitbreaker_calls_seconds_count{name="paymentGateway"}[5m])

# 呼び出し遅延時間分布(ヒストグラム)
histogram_quantile(0.95,
  rate(resilience4j_circuitbreaker_calls_seconds_bucket{name="paymentGateway"}[5m])
)

# Retry リトライ回数
increase(resilience4j_retry_calls_total{name="paymentGateway", kind="successful_with_retry"}[1h])
increase(resilience4j_retry_calls_total{name="paymentGateway", kind="failed_with_retry"}[1h])

# Bulkhead 利用可能な同時呼び出し数
resilience4j_bulkhead_available_concurrent_calls{name="paymentGateway"}

# RateLimiter 利用可能な許可数
resilience4j_ratelimiter_available_permissions{name="externalApi"}

Grafanaダッシュボード JSON構成

Grafanaダッシュボードで構成すべき主要パネルと各パネルのPromQLクエリを整理する。

パネル1 - CircuitBreaker状態ゲージ

resilience4j_circuitbreaker_state{application="order-service"}

バリューマッピングで0=CLOSED(緑)、1=OPEN(赤)、2=HALF_OPEN(黄)をマッピングする。

パネル2 - 失敗率推移(Time Series)

resilience4j_circuitbreaker_failure_rate{application="order-service", name=~".*"}

しきい値ライン(failureRateThreshold)を追加して、サーキットがOPENに遷移するタイミングを視覚的に確認する。

パネル3 - 呼び出し成功/失敗率(Stacked Bar)

sum by (name, kind) (
  rate(resilience4j_circuitbreaker_calls_seconds_count{application="order-service"}[5m])
)

パネル4 - P95応答時間(Time Series)

histogram_quantile(0.95,
  sum by (le, name) (
    rate(resilience4j_circuitbreaker_calls_seconds_bucket{application="order-service"}[5m])
  )
)

パネル5 - Bulkhead同時呼び出し状況(Gauge)

resilience4j_bulkhead_max_allowed_concurrent_calls{application="order-service"}
- resilience4j_bulkhead_available_concurrent_calls{application="order-service"}

アラートルールの設定

GrafanaまたはPrometheus Alertmanagerに以下のアラートルールを登録する。

# prometheus-alerts.yml
groups:
  - name: resilience4j_alerts
    rules:
      - alert: CircuitBreakerOpen
        expr: resilience4j_circuitbreaker_state == 1
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: 'CircuitBreaker OPEN: {{ $labels.name }}'
          description: >
            サービス{{ $labels.application }}の
            {{ $labels.name }}サーキットブレーカーがOPEN状態です。
            ダウンストリームサービスの障害を確認してください。

      - alert: HighFailureRate
        expr: resilience4j_circuitbreaker_failure_rate > 30
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: 'High failure rate: {{ $labels.name }} ({{ $value }}%)'
          description: >
            {{ $labels.name }}の失敗率が{{ $value }}%で、
            警告しきい値(30%)を超えています。

      - alert: BulkheadSaturation
        expr: >
          (resilience4j_bulkhead_max_allowed_concurrent_calls
          - resilience4j_bulkhead_available_concurrent_calls)
          / resilience4j_bulkhead_max_allowed_concurrent_calls > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: 'Bulkhead 80%飽和: {{ $labels.name }}'

      - alert: ExcessiveRetries
        expr: >
          rate(resilience4j_retry_calls_total{kind="failed_with_retry"}[5m])
          / rate(resilience4j_retry_calls_total[5m]) > 0.5
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: 'Retry失敗率50%超過: {{ $labels.name }}'

トラブルシューティングガイド

問題1:CircuitBreakerがOPENに遷移しない

症状:明らかに失敗が発生しているのに、サーキットがCLOSED状態を維持する。

原因分析

  • minimumNumberOfCallsに達していない。デフォルト値は100なので、呼び出し頻度が低いサービスではスライディングウィンドウが埋まる前に障害が解消されることがある。
  • 例外がignoreExceptionsに含まれている。ビジネス例外だけでなく、意図しない例外までignoreリストにないか確認する。
  • 例外がrecordExceptionsに含まれていない。recordExceptionsを明示すると、リストにない例外は失敗として記録されない。

解決方法minimumNumberOfCallsをサービスの呼び出し頻度に合わせて調整し、recordExceptionsignoreExceptionsのリストを点検する。

問題2:RetryとCircuitBreakerの組み合わせ時に予想以上の呼び出しが発生

症状:maxAttempts=3に設定したのに、ダウンストリームサービスに5回以上の呼び出しが記録される。

原因分析:アノテーションの適用順序で、RetryがCircuitBreakerの外側に位置する。そのため、CircuitBreakerが失敗を記録した後、RetryがCircuitBreakerを通じて再度呼び出しを試みる。CircuitBreakerのHALF-OPEN状態で試験呼び出しが追加されると、総呼び出し数が予想を超えることがある。

解決方法:RetryのmaxAttemptsを控えめに設定し、CircuitBreakerのslidingWindowSizeとRetryのmaxAttemptsの組み合わせが生成する最大呼び出し数を計算して、ダウンストリームの負荷を予測する。

問題3:フォールバックメソッドが呼び出されない

症状:CircuitBreakerがOPENなのに、CallNotPermittedExceptionがクライアントに直接伝播される。

原因分析:フォールバックメソッドのシグネチャが元のメソッドと正確に一致していない。フォールバックメソッドは元のメソッドのすべてのパラメータを同じ順序と型で受け取り、最後のパラメータとしてException(または特定の例外型)を追加する必要がある。

解決方法:フォールバックメソッドのシグネチャを点検する。戻り値の型も元のメソッドと正確に一致する必要がある。以下は正しい例である。

// 元のメソッド
@CircuitBreaker(name = "svc", fallbackMethod = "fallback")
public OrderResponse getOrder(String orderId, boolean includeDetails) { ... }

// 正しいフォールバック(パラメータ同一 + Exception追加)
private OrderResponse fallback(String orderId, boolean includeDetails, Exception ex) { ... }

// 誤ったフォールバック - コンパイルは通るがランタイムでマッチング失敗
private OrderResponse fallback(String orderId, Exception ex) { ... }  // パラメータ不足
private void fallback(String orderId, boolean includeDetails, Exception ex) { ... }  // 戻り値の型不一致

問題4:TIME_BASEDウィンドウでメモリ使用量が増加

症状:TIME_BASEDスライディングウィンドウを使用しているが、ヒープメモリ使用量が徐々に増加する。

原因分析:slidingWindowSizeが大きすぎる設定になっている。例えば、slidingWindowSize=600(10分)に設定すると600個の部分集計バケットが維持される。トラフィックが高い場合、各バケットの呼び出し記録が蓄積されてメモリを消費する。

解決方法:TIME_BASEDではslidingWindowSizeを60秒以下に設定し、長期的な推移はPrometheusメトリクスで観察する。メモリに敏感な環境ではCOUNT_BASEDを優先使用する。

運用チェックリスト

本番環境にCircuit Breakerをデプロイする前に、必ず確認すべき項目を整理する。

設定の検証

  • slidingWindowSizeとminimumNumberOfCallsの比率が適切か(minimumNumberOfCallsはslidingWindowSizeの50%以下を推奨)
  • failureRateThresholdがサービス特性に合わせて設定されているか(決済:30-40%、通知:60-70%)
  • waitDurationInOpenStateがダウンストリームサービスの平均復旧時間と合っているか
  • slowCallDurationThresholdが正常応答時間のP99以上に設定されているか
  • recordExceptionsとignoreExceptionsが正確に分類されているか

モニタリングの確認

  • Prometheusでresilience4jメトリクスが正常に収集されているか
  • GrafanaダッシュボードにCircuitBreaker状態、失敗率、呼び出し統計が表示されているか
  • CircuitBreaker OPENアラートがSlack、PagerDutyなどに配信されているか
  • OPEN状態の持続時間を追跡するメトリクスがあるか

フォールバック戦略の検証

  • すべてのCircuitBreakerにフォールバックメソッドが接続されているか
  • フォールバックメソッドが意味のあるレスポンスを返却しているか(単純なnull返却は禁止)
  • フォールバックメソッド自体で例外が発生した場合、どう処理されるか
  • キャッシュフォールバック使用時にキャッシュ有効期限ポリシーが設定されているか
  • 代替サービスフォールバック使用時に、そのサービスのCircuitBreakerも設定されているか

テストの検証

  • ユニットテストでCircuitBreakerの状態遷移(CLOSED、OPEN、HALF-OPEN)を検証したか
  • 統合テストで実際のタイムアウト、ネットワークエラーシナリオを再現したか
  • カオスエンジニアリングツール(Chaos Monkey、Litmus)で障害注入テストを実施したか
  • 負荷テストでBulkhead飽和時の動作を確認したか

デプロイ戦略

  • 新しいCircuitBreaker設定はカナリーデプロイで一部のトラフィックにのみ先に適用する
  • 設定変更時にConfig Server(Spring Cloud Config)や環境変数を通じてダウンタイムなしで反映できるか
  • CircuitBreaker設定のGit履歴管理ができているか
  • ロールバック計画が策定されているか

障害事例と復旧

事例1:リトライストームによるダウンストリーム過負荷

状況:決済サービスの応答時間が増加し始めた。注文サービスでRetryがmaxAttempts=5、固定間隔1秒で設定されていた。注文サービスのインスタンスが20台、毎秒100件の注文が発生する状況で、決済サービスに毎秒最大10,000件(100 x 20 x 5)のリクエストが殺到した。

原因:指数バックオフ(exponential backoff)とジッター(jitter)なしの固定間隔リトライを使用していた。また、CircuitBreakerなしでRetryを単独使用していたため、失敗時にもリトライが継続した。

復旧手順

  1. 直ちにRetryを無効化するか、maxAttemptsを1に設定してリトライを停止する
  2. 決済サービスの負荷が安定したら、指数バックオフ + ジッターを適用したRetry設定に切り替える
  3. CircuitBreakerをRetryの内側に配置して、サーキットがOPENの場合はリトライ自体を遮断する

再発防止:Retryは必ずCircuitBreakerと併用し、指数バックオフ + ランダムジッターをデフォルトで適用する。固定間隔リトライは禁止ポリシーとする。

事例2:誤った例外分類によるサーキット永久OPEN

状況:在庫サービスに新機能をデプロイした後、特定の商品照会時に400 Bad Requestが返されるようになった。この400レスポンスがHttpClientErrorExceptionとしてキャッチされ、失敗率に含まれた結果、CircuitBreakerがOPENに遷移してすべての在庫照会がブロックされた。正常な商品照会まで不可能になった。

原因recordExceptionsにHttpClientErrorException(4xx)が含まれていた。4xxエラーはクライアント側の問題であるため、サーキットブレーカーが介入すべき事案ではない。サーキットブレーカーはサーバー側の障害(5xx、タイムアウト、接続失敗)にのみ反応すべきである。

復旧手順

  1. CircuitBreakerをFORCED_CLOSEに手動切り替えして、直ちに正常トラフィックを復元する
// Actuatorエンドポイントで状態を強制遷移
// POST /actuator/circuitbreakers/{name}/force-close
circuitBreakerRegistry.circuitBreaker("inventoryService")
    .transitionToForcedOpenState();  // または transitionToClosedState()
  1. recordExceptionsからHttpClientErrorExceptionを削除し、ignoreExceptionsに追加する
  2. 設定反映後、FORCED_CLOSEを解除して正常なCircuitBreaker動作に復帰する

再発防止:例外分類の原則を文書化する。4xx(クライアントエラー)はignoreExceptions、5xx(サーバーエラー)はrecordExceptions、ビジネスバリデーション例外はignoreExceptionsに分類する。

事例3:HALF-OPENボトルネックによるトラフィック損失

状況:決済サービスが復旧した後もオーダー処理量が回復しなかった。トラフィック分析の結果、CircuitBreakerがHALF-OPEN状態でpermittedNumberOfCallsInHalfOpenState=1に設定されており、わずか1件の試験呼び出ししか許可していなかった。この試験呼び出しが間欠的に失敗し、OPENとHALF-OPENを繰り返すフラッピング(flapping)が発生していた。

原因:permittedNumberOfCallsInHalfOpenStateの値が低すぎた。試験呼び出しが1件だと、単一の失敗でも再びOPENに戻るため、ダウンストリームサービスが間欠的にしか応答しない状況ではCLOSEDに復帰しにくい。

復旧手順

  1. permittedNumberOfCallsInHalfOpenStateを5-10に引き上げる
  2. automaticTransitionFromOpenToHalfOpenEnabled: trueを確認して、手動介入なしで自動遷移するようにする
  3. waitDurationInOpenStateをダウンストリームサービスの平均復旧時間に合わせて調整する

再発防止:HALF-OPENの試験呼び出し数は最低3件以上に設定し、失敗率しきい値と組み合わせて統計的に有意な判断ができるようにする。OPEN-HALF_OPENフラッピングをモニタリングアラートに追加する。

上級パターン:カスタムCircuitBreakerレジストリ

サービス数が増えると、各サービスに対して個別にCircuitBreakerを設定するのが非効率になることがある。動的にCircuitBreakerを生成・管理するカスタムレジストリを実装できる。

// DynamicCircuitBreakerFactory.kt
@Component
class DynamicCircuitBreakerFactory(
    private val circuitBreakerRegistry: CircuitBreakerRegistry,
    private val meterRegistry: MeterRegistry,
) {
    private val log = LoggerFactory.getLogger(javaClass)

    /**
     * サービス名ベースでCircuitBreakerを動的に生成する。
     * 既に存在する場合は既存インスタンスを返却する。
     */
    fun getOrCreate(
        serviceName: String,
        tier: ServiceTier = ServiceTier.STANDARD,
    ): CircuitBreaker {
        return circuitBreakerRegistry.circuitBreaker(serviceName) {
            buildConfigForTier(tier)
        }.also { cb ->
            registerMetrics(cb)
            log.info(
                "CircuitBreaker created/retrieved: name={}, tier={}, state={}",
                serviceName, tier, cb.state
            )
        }
    }

    private fun buildConfigForTier(tier: ServiceTier): CircuitBreakerConfig {
        return when (tier) {
            ServiceTier.CRITICAL -> CircuitBreakerConfig.custom()
                .failureRateThreshold(30f)
                .slowCallRateThreshold(60f)
                .slowCallDurationThreshold(Duration.ofSeconds(2))
                .waitDurationInOpenState(Duration.ofSeconds(60))
                .slidingWindowSize(20)
                .minimumNumberOfCalls(10)
                .permittedNumberOfCallsInHalfOpenState(5)
                .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .build()

            ServiceTier.STANDARD -> CircuitBreakerConfig.custom()
                .failureRateThreshold(50f)
                .slowCallRateThreshold(80f)
                .slowCallDurationThreshold(Duration.ofSeconds(3))
                .waitDurationInOpenState(Duration.ofSeconds(30))
                .slidingWindowSize(10)
                .minimumNumberOfCalls(5)
                .permittedNumberOfCallsInHalfOpenState(3)
                .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .build()

            ServiceTier.BEST_EFFORT -> CircuitBreakerConfig.custom()
                .failureRateThreshold(70f)
                .slowCallRateThreshold(90f)
                .slowCallDurationThreshold(Duration.ofSeconds(5))
                .waitDurationInOpenState(Duration.ofSeconds(15))
                .slidingWindowSize(5)
                .minimumNumberOfCalls(3)
                .permittedNumberOfCallsInHalfOpenState(2)
                .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .build()
        }
    }

    private fun registerMetrics(cb: CircuitBreaker) {
        TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(
            circuitBreakerRegistry
        ).bindTo(meterRegistry)
    }

    enum class ServiceTier {
        CRITICAL,    // 決済、認証などのコアサービス
        STANDARD,    // 在庫、配送などの一般サービス
        BEST_EFFORT  // 通知、レコメンドなどの非コアサービス
    }
}

このファクトリを使用すると、サービスティアに応じて適切なCircuitBreaker設定が自動的に適用される。CRITICALサービスは低い失敗率しきい値と長い待機時間で保守的に保護し、BEST_EFFORTサービスは高いしきい値で柔軟に運用する。

テスト戦略

CircuitBreaker導入時に必ず作成すべきテストケースを整理する。

// CircuitBreakerIntegrationTest.java
@SpringBootTest
@AutoConfigureMockMvc
class CircuitBreakerIntegrationTest {

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private RestClient paymentRestClient;

    @Test
    @DisplayName("失敗率しきい値超過時にCircuitBreakerがOPENに遷移する")
    void shouldTransitionToOpenOnFailureThreshold() {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway");
        cb.reset(); // テスト隔離のため状態をリセット

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);

        // slidingWindowSize=20, failureRateThreshold=40 の場合
        // minimumNumberOfCalls=10以上呼び出し後、40%以上失敗でOPEN
        // 10回呼び出し中5回失敗 = 50%失敗率 -> OPEN遷移
        for (int i = 0; i < 5; i++) {
            cb.onSuccess(100, TimeUnit.MILLISECONDS);
        }
        for (int i = 0; i < 5; i++) {
            cb.onError(100, TimeUnit.MILLISECONDS, new IOException("connection refused"));
        }

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
        assertThat(cb.getMetrics().getFailureRate()).isGreaterThanOrEqualTo(40f);
    }

    @Test
    @DisplayName("OPEN状態でwaitDuration経過後にHALF-OPENに遷移する")
    void shouldTransitionToHalfOpenAfterWaitDuration() throws Exception {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway");
        cb.reset();
        cb.transitionToOpenState();

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);

        // waitDurationInOpenState経過のシミュレーション
        // (テストでは短いwaitDuration設定を使用するか、直接遷移)
        cb.transitionToHalfOpenState();

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN);
    }

    @Test
    @DisplayName("HALF-OPENでの試験呼び出し成功時にCLOSEDに復帰する")
    void shouldTransitionToClosedOnSuccessfulTrialCalls() {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway");
        cb.reset();
        cb.transitionToOpenState();
        cb.transitionToHalfOpenState();

        // permittedNumberOfCallsInHalfOpenState=5 回分の成功
        for (int i = 0; i < 5; i++) {
            cb.onSuccess(50, TimeUnit.MILLISECONDS);
        }

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
    }

    @Test
    @DisplayName("CircuitBreaker OPEN時にフォールバックメソッドが正常に呼び出される")
    void shouldInvokeFallbackWhenCircuitIsOpen() throws Exception {
        // サーキットを強制的にOPENに切り替え
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway");
        cb.transitionToForcedOpenState();

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productId\": \"P001\", \"quantity\": 1}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.payment.status").value("QUEUED"))
            .andExpect(jsonPath("$.payment.message").exists());

        // テスト後に状態を復元
        cb.transitionToClosedState();
    }
}

参考資料