外部APIやマイクロサービスが突然ダウンしたとき、タイムアウト待ちのスレッドが積み上がって連鎖的にシステムが落ちる、という経験はありませんか。こういった障害伝播を防ぐのがサーキットブレーカーパターンです。

Hystrix は 2019 年よりメンテナンスモードとなり、Spring Boot 3.x では依存から除外されています。後継として Resilience4j が事実上の標準となっています。この記事では依存追加から @CircuitBreaker@Retry@RateLimiter の実装まで、ハンズオン形式でまとめます。

サーキットブレーカーが必要な理由

外部APIが応答しないと、呼び出し側のスレッドはタイムアウトまで待ち続けます。同時リクエストが増えるとスレッドプールが枯渇し、関係のない機能まで巻き込んで全体が落ちるというのが障害連鎖のよくあるパターンです。

サーキットブレーカーはその名のとおり「ブレーカー」として動作し、失敗が増えてきたら自動で遮断してフォールバックを返します。状態は3種類あります。

  • CLOSED :通常稼働。失敗率が閾値を超えると OPEN に遷移
  • OPEN :全リクエストをすぐに遮断してフォールバックを返す
  • HALF_OPEN :一定時間後にいくつかリクエストを通して回復を確認。成功すれば CLOSED、失敗すれば再び OPEN へ

依存関係を追加する

Spring Boot 3.x(Jakarta EE)では resilience4j-spring-boot3 を使います。AOP でアノテーションを動かすので spring-boot-starter-aop も必須です。

Gradle の場合

dependencies {
    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

Spring Boot の dependency management で Resilience4j のバージョンが管理されている場合はバージョン指定を省略できます。最新版は GitHub Releases で確認してください。

Maven の場合

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Spring Boot 2.x を使っている場合は resilience4j-spring-boot2 になります。混同しないよう注意してください。

@CircuitBreaker の基本実装

@CircuitBreaker をサービスメソッドに付与するだけで動作します。fallbackMethod に指定するメソッドのシグネチャは、元のメソッドと同じ引数の末尾に Throwable を追加した形にします。

@Service
public class ProductService {

    private static final Logger log = LoggerFactory.getLogger(ProductService.class);
    private final RestTemplate restTemplate;

    public ProductService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @CircuitBreaker(name = "productApi", fallbackMethod = "getProductFallback")
    public Product getProduct(Long id) {
        return restTemplate.getForObject(
            "https://api.example.com/products/" + id, Product.class);
    }

    public Product getProductFallback(Long id, Throwable t) {
        log.warn("Circuit breaker triggered: {}", t.getMessage());
        // 独自実装が必要: new Product() など空オブジェクトやキャッシュ値を返す
        return new Product();
    }
}

フォールバックはキャッシュ値・空オブジェクト・デフォルト値など、サービスを部分的に維持できる応答を返すように設計しましょう。

HTTPクライアントの実装については RestTemplate・WebClient の使い方 も参考にしてください。

application.yml でパラメータを設定する

以下は @Retry@RateLimiter を含む全設定をまとめた例です。各アノテーションの詳細は後続セクションで解説します。

resilience4j:
  circuitbreaker:
    instances:
      productApi:
        failureRateThreshold: 50          # 失敗率50%でOPENに遷移
        waitDurationInOpenState: 10s       # OPENのまま10秒待つ
        slidingWindowSize: 10             # 直近10リクエストを評価
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5           # 最低5回の呼び出し後に評価開始
  retry:
    instances:
      productApi:
        maxAttempts: 3
        waitDuration: 500ms
  ratelimiter:
    instances:
      productApi:
        limitForPeriod: 10
        limitRefreshPeriod: 1s
        timeoutDuration: 0

permittedNumberOfCallsInHalfOpenState: 3 は HALF_OPEN 状態で試行する件数です。この3件の結果を見て CLOSED か OPEN かを判断します。minimumNumberOfCalls を設定しておくと、起動直後の少ないリクエストで誤って OPEN に遷移するのを防げます。

@Retry でリトライを実装する

一時的なネットワークエラーはリトライで対処できます。@CircuitBreaker と組み合わせる場合、Resilience4j Spring Boot スターターはデフォルトで CircuitBreaker が Retry より外側に適用されます。つまりリトライを繰り返した末の最終失敗がサーキットブレーカーの失敗カウントに加算される、という流れになります。

@CircuitBreaker(name = "productApi", fallbackMethod = "getProductFallback")
@Retry(name = "productApi")
public Product getProduct(Long id) {
    return restTemplate.getForObject(
        "https://api.example.com/products/" + id, Product.class);
}

アスペクトの適用順序はグローバル設定の resilience4j.circuitbreaker.circuit-breaker-aspect-order および resilience4j.retry.retry-aspect-order で変更できますが、デフォルトのままで多くのケースに対応できます。

@RateLimiter でレート制限を実装する

外部 API の呼び出し制限や自サービスの過負荷保護に使えます。制限を超えると RequestNotPermittedException がスローされるので、フォールバックでハンドリングします。

@RateLimiter(name = "productApi", fallbackMethod = "rateLimitFallback")
public Product getProductWithRateLimit(Long id) {
    return restTemplate.getForObject(
        "https://api.example.com/products/" + id, Product.class);
}

public Product rateLimitFallback(Long id, RequestNotPermittedException e) {
    throw new ResponseStatusException(
        HttpStatus.TOO_MANY_REQUESTS, "レート制限中です。しばらく待ってから再試行してください。");
}

Actuator でサーキットブレーカーの状態を確認する

spring-boot-starter-actuator を追加したうえで、以下の設定を入れると /actuator/circuitbreakers で全インスタンスの状態と統計を確認できます。

management:
  endpoints:
    web:
      exposure:
        include: health,info,circuitbreakers,circuitbreakerevents
  endpoint:
    health:
      show-details: always

動作確認:障害シミュレーションと状態遷移を見る

サーキットブレーカーの状態遷移を手元で確認するには、ProductService.getProduct() を呼ぶコントローラーエンドポイントを用意して、外部 API URL を到達不能なホストに向けるのが確実です。

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.getProduct(id);
    }
}

application.yml の外部 API URL を存在しないホスト(例:http://localhost:9999)に設定しておくと、リクエストのたびに接続エラーが発生します。

for i in $(seq 1 10); do curl -s http://localhost:8080/products/1; done

minimumNumberOfCalls を超えて失敗率が閾値に達すると OPEN に遷移します。Actuator で状態を確認してみましょう。

curl http://localhost:8080/actuator/circuitbreakers
# => "state": "OPEN" が確認できる

waitDurationInOpenState で設定した時間が経過すると HALF_OPEN に遷移し、permittedNumberOfCallsInHalfOpenState 件のリクエストで回復を判断します。成功すれば "state": "CLOSED" に戻ります。

ハマりポイント

spring-boot-starter-aop を入れ忘れる これが一番多いです。AOP がないとアノテーションがそもそも動きません。依存追加後は必ず再ビルドを。

同一クラス内からの呼び出しが効かない Spring AOP はプロキシ経由でインターセプトするため、同じクラス内で this.getProduct() と呼んでも AOP が動きません。サービスクラスを別クラスに分離して DI してください。

fallbackMethod のシグネチャが合わない 引数の型や数が違うと実行時に NoSuchMethodException になります。末尾に Throwable(またはその具象クラス)を追加する形式を守ってください。

resilience4j-spring-boot23 を混同する Spring Boot 3.x(Jakarta EE)では必ず resilience4j-spring-boot3 を使います。2.x 用スターターを入れると依存解決は通っても実行時に動作しないことがあります。

まとめ

Resilience4j を使えば、@CircuitBreaker@Retry@RateLimiter を組み合わせて外部 API の障害に強い実装をシンプルに書けます。最初は @CircuitBreaker だけ入れて Actuator で動作確認し、必要に応じて @Retry を足していくのがおすすめです。

エラーハンドリングの全体設計は REST API の例外処理 もあわせて読むと、より実践的な実装ができます。非同期処理と組み合わせたい場合は Spring Boot の非同期処理 も参考にしてみてください。イベント駆動型アーキテクチャで障害対策を強化したい場合は Kafka との連携 も検討してみてください。