Spring Cloud Gatewayへのサーキットブレーカー設置での考慮点

アソビューAdvent Calendar 2023の8日目(A面)のブログです。

こんにちは! アソビューでバックエンドエンジニアをしている山野です。
今回は可用性対策の一環として、
Spring Cloud Gateway ベースのアプリケーションへのサーキットブレーカー設置での考慮点について紹介させていただければと思います。

Spring Cloud Gatewayとは

Spring Cloud GatewaySpring WebFluxベースのAPIゲートウェイを構築するためのライブラリです。
クライアントアプリケーション<->API間のシンプルなルーティングだけでなく、
任意の認証処理・アクセスフィルタリング、加重ルーティング、レートリミット、リクエスト/レスポンスの変換・加工、 そしてサーキットブレーカーなどサービスや要件に合わせて高度で柔軟な処理を独自に構築することが可能です。

弊社のサービスは複数のマイクロサービス(API)によって成り立っていますが、
このAPIに対するリクエスト処理の一元管理のためSpring Cloud Gatewayを利用しAPIゲートウェイを構築しています。

サーキットブレーカー設置実装

前提としてこの記事ではSpring Cloud Gatewayの詳細な実装方法は説明致しません。

基本

サーキットブレーカー設置にあたっては以下のライブラリを利用します。

spring-cloud-starter-circuitbreaker-reactor-resilience4j

このライブラリを利用することで、RouteLocatorクラスを利用したルーティング設定おいて、サーキットブレーカーのFilter処理(SpringCloudCircuitBreakerFilterFactory)の利用が可能となります。

@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("sample-routing"), r -> r
            .path("/api/circuit-breaker/sample-backend-api/**")
            .filters(
                    f -> f.circuitBreaker(config ->
                            config.setName("sample-backend-api")
                    )
            )
            .uri("http://sample-backend-api.com")
    )
}

考慮点

1. APIレスポンスのエラー判定

サーキットブレーカーは呼び出し先のAPIの「エラー発生率」または「レイテンシ遅延率」の 基準値(閾値)を超えた場合、APIへのリクエストを遮断する仕組みとなっています。
このうちエラー発生率は以下の計算から割り出します。

エラー発生率 = APIからのExceptionの返却回数 / APIへの総リクエスト数

クライアントアプリケーションがRestTemplate等のHTTP クライアントライブラリを利用して直接APIと通信する場合は、 API側でエラーが発生するとライブラリがAPIのレスポンスを解析→適切なExceptionに変換しクライアントへ返却してくれますが、
Spring Cloud GatewayはあくまでもクライアントとAPI間のルーティングを行うものであるため上記のような処理は行いません。
よってサーキットブレーカーはAPI側でエラーが発生しても エラーが発生していることを判別できず、 正常レスポンスが返却されたとものとして扱ってしまいます

これについての対応方法として、
サーキットブレーカーのFilter処理(SpringCloudCircuitBreakerFilterFactory)には
APIから特定のHTTPレスポンス ステータスコードが返却された場合、
そのレスポンスをエラーとして扱うことができるように実装が組み込まれています。
これを利用しサーキットブレーカーに適切なエラー判定を行わせます。

SpringCloudCircuitBreakerFilterFactoryの処理

    @Override
    public GatewayFilter apply(Config config) {
        ReactiveCircuitBreaker cb = reactiveCircuitBreakerFactory.create(config.getId());
        Set<HttpStatus> statuses = config.getStatusCodes().stream()
                .map(HttpStatusHolder::parse)
                .filter(statusHolder -> statusHolder.getHttpStatus() != null)
                .map(HttpStatusHolder::getHttpStatus).collect(Collectors.toSet());

        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange,
                    GatewayFilterChain chain) {
                return cb.run(chain.filter(exchange).doOnSuccess(v -> {
                   // ★★★ HTTP レスポンスステータスコードの判定 ★★★
                   if (statuses.contains(exchange.getResponse().getStatusCode())) {
                        HttpStatus status = exchange.getResponse().getStatusCode();
                        exchange.getResponse().setStatusCode(null);
                        reset(exchange);
                        throw new CircuitBreakerStatusCodeException(status);
                    }
                }), t -> {
                    //:省略
                }).onErrorResume(t -> handleErrorWithoutFallback(t));
            }
        };
    }

エラー対象とするHTTPレスポンス ステータスコードは 以下のようにルーティング設定に追加で指定する必要があります。

HTTPレスポンス ステータスコード指定の実装例

    // エラー対象とするHTTPレスポンス ステータスコード
 static final Set<String> CIRCUIT_BREAKER_TARGET_HTTP_STATUS = Set.of(
            "500", "501", "502", "503", "504", "505", "506", "507", "508", "510", "511"
    );

@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("sample-routing"), r -> r
            .path("/api/circuit-breaker/sample-backend-api/**")
            .filters(
                    f -> f.circuitBreaker(config ->
                            config.setName("sample-backend-api")
                                  // ★★★ エラー対象とするHTTP ステータスコードの指定 ★★★
                                  .setStatusCodes(CIRCUIT_BREAKER_TARGET_HTTP_STATUS)
                    )
            )
            .uri("http://sample-backend-api.com")
    )
}
2. 複数のAPI結果の結合等の独自の変換処理を伴うケースへの対応

1つのAPIからのレスポンスに対して簡単な加工・変換をするだけであれば、
ルーティング設定でModifyRequestBodyGatewayFilterを利用することで対応することが可能です。

一方で複数のAPIの結果を受け取り、加工・変換・結合を行うような複雑な処理は、
独自でFilterクラスやHandlerクラス(Service)を作成し任意の処理を実装する必要があります。

実装例

public class SampleService {
    WebClient webClient;

    SampleClientService(WebClient webClient) {
        this.webClient = webClient;
    }

    public Mono<ServerResponse> getSampleData(ServerRequest serverRequest) {
        var param1 = serverRequest.queryParam("param1");
        var param2 = serverRequest.queryParam("param2");
        var response1 = webClient
                .get()
                .uri("/api/circuit-breaker/sample-backend-1-api/?param=" + param1)
                .retrieve()
                .bodyToMono(SampleApi1.class);

        var response2 = webClient
                .get()
                .uri("/api/circuit-breaker/sample-backend-2-api?param=" + param2)
                .retrieve()
                .bodyToMono(SampleApi2.class);

        return Mono.zip(Mono.just(response1), response2)
                .flatMap(tuple -> {
                    var sample1 = tuple.getT1();
                    var sample2 = tuple.getT2();
                    // some convert and aggregate logic
                });
    }

このような処理に対するサーキットブレーカーの設置方法として、
メソッドに対しアノテーション(@CircuitBreaker)でサーキットブレーカーを設置することもできますが、

    @CircuitBreaker("sample-backend-api")
    public Mono<ServerResponse> getSampleData(ServerRequest serverRequest) {
    // :省略
    }

例えばリクエスト値によって実行するAPIを変えるなどより複雑な処理となった場合、
サーキットブレーカーの稼働基準となるエラー率やレイテンシ遅延率を決定することが非常に難しくなります。

これについての対応方法として、
今回利用しているサーキットブレーカーのライブラリはSpring WebFluxと組み合わせた実装を行うことができるため、 この仕組みを利用し、以下のようにそれぞれのAPIの実行処理に対してサーキットブレーカーを設定を行い対応しました。

実装例

public class SampleService {
    WebClient webClient;
    ReactiveCircuitBreakerFactory circuitBreakerFactory;

    SampleClientService(WebClient webClient, ReactiveCircuitBreakerFactory circuitBreakerFactory) {
        this.webClient = webClient;
        this.circuitBreakerFactory = circuitBreakerFactory;
    }

    public Mono<ServerResponse> getSampleData(ServerRequest serverRequest) {
        var param1 = serverRequest.queryParam("param1");
        var param2 = serverRequest.queryParam("param2");
        var response1 = webClient
                .get()
                .uri("/api/circuit-breaker/sample-backend-1-api/?param=" + param1)
                .retrieve()
                .bodyToMono(SampleApi1.class)
                // ★★★ transformメソッドを利用したサーキットブレーカーの設定 ★★★
                .transform(it ->
                        circuitBreakerFactory.create("sample-backend-1-api")
                                .run(it, throwable ->  fallbackSampleRestApi(throwable)));

        var response2 = webClient
                .get()
                .uri("/api/circuit-breaker/sample-backend-2-api?param=" + param2)
                .retrieve()
                .bodyToMono(SampleApi2.class)
                .transform(it ->
                        circuitBreakerFactory.create("sample-backend-2-api")
                                .run(it, throwable ->  fallbackSampleRestApi(throwable)));

        return Mono.zip(Mono.just(response1), response2)
                .flatMap(tuple -> {
                    var sample1 = tuple.getT1();
                    var sample2 = tuple.getT2();
                    // some convert and aggregate logic
                });
    }

最後に

今回はSpring Cloud Gateway ベースのアプリケーションへのサーキットブレーカー設置での考慮点について紹介しました。

アソビューでは一緒に働くメンバーを大募集しています! カジュアル面談もありますので、少しでも興味があればお気軽にご応募いただければと思います!

www.asoview.com