Spring WebFlux + WebClientでリトライ処理を実装する


最近業務でお手伝いさせていただく案件のなかでも、Java on Kubernetesなパターンが増えてきています。Kubernetesに限らずクラウドのマネージドPaaSで動かすにはアプリケーション側でもクラウドネイティブを考慮した実装が必要になり、特に耐障害性や回復性などを意識した実装が大事になってきます。

クラウドネイティブなアプリケーション方式設計されたシステムでは、大きな問題もなく開発者からみると「クラウド超便利」「マネージド最高か!」となりますが、ごくまれに「オンプレで動いていたそのままのアプリをクラウドに持ってきた」or「オンプレを前提とした開発手法で実装されたレガシーアプリをクラウドネイティブなPaaSサービスで動かしてみた」というケースで問題が発生することがあります。


クラウドネイティブな環境でアプリケーションを動かすときに、開発者の観点でどういうところに注意すればよいかは、来月中にきちんとまとめる予定ですが

あたりが大事なポイントになってくるかなと思っています。

このエントリでは、1つ目の

通信先のサービス(DB/外部API)は常に動いていると思うべからず

対策の一つであるアプリケーションのリトライ処理の実装について、Spring WebFluxのWebClientを使った実装の例を説明します。

そもそも、リトライ処理とは?

クラウドとは?を開発者のみなさんに一言で説明すると、以下のとおりです。

クラウド事業者が運用している巨大インフラ(サーバ/ネットワーク/ストレージ)を利用できるサービス。利用するにはAPIをCallするだけ、利用料金は従量課金、SLAが規定値を下回ったら返金

たとえば99.99%のクラウドサービスを利用した場合、1か月のうち、4.3分は停止する可能性があります。それが月次の定期メンテナンスのタイミング/深夜のユーザ利用率の低い時間帯/優先度の低いシステムであれば大きな問題にはならないかもしれません。しかし相手は機械。空気を読むことはしません。金融システムにおける五十日の決済処理のような場合も十分にありえます。あとは分かるな?

SLA停止時間(月)
99.9%43分
99.95%21分
99.99%4.3分
99.999%25.8秒

そもそも、クラウドでなくとも多数のコンポーネントから構成される大規模分散システムでは系内の障害をゼロにすることは不可能なため、障害をゼロにしよう!というアプローチではなく、回復性を高めよう!という観点でのアプリケーション処理方式の一つとしてリトライ処理を実装されてきたとおもいます。特にミッションクリティカル系システムでは、いにしえより必ず検討されてきた方式なので、令和の今あらためての新鮮味はないでしょう。

とはいえ、リトライ処理とは文字通り「リクエストを再試行する」ものなのですが、いつ、だれが、どこで、どうやってリトライを行えばいいのかはむずかしい問題です。

Exponential backoff and jitter ―― 指数関数的バックオフとジッター

リクエストもまばらでの規模が小さい場合は、n秒ごとに再試行する、などの固定値で実装するケースもあるかとおもいますが、大規模ミッションクリティカルシステムの場合データセンター障害のような大規模なトラブル時を考慮する必要があります。大規模障害時は多くのサーバ/サービスが停止していると想定されます。そのため、多数のクライアントからのリクエストがエラーになり、結果的に多数のリトライが送信されることになります。

このとき、クライアント側で固定値でリトライ処理を実装すると、全クライアントがn秒ごとに同じタイミングでリトライ処理を実施することになります。API側からみると、n秒ごとに多数のリクエストに受けることになり、過剰な負荷がかかることになります。さらにリトライストームでカスケード障害を引き起こす可能性もあります。

そのため、リトライする間隔が大事になってきます。どのような間隔でリトライを行えば、系全体でみたときに効率的なのかを解決する手法として、「Exponential backoff and jitter」という有名なリトライのアルゴリズムがあります。

Exponential backoffは、処理が失敗した後のリトライする間隔を許容可能な範囲で徐々に減らしつつ継続するアルゴリズムです。具体的には、再試行する度に、1秒後、2秒後、4秒後と指数関数的に待ち時間を長くしていきます。これにより、全体のリトライ回数を抑え、リクエストが殺到するのを防ぎます。

一般的に分散システムでは複数のコンポーネントが協調して動作します。そのためリトライのタイミングをわざとずらして衝突を回避させるために、random関数を用いたjitterというゆらぎをいれます。

ちなみにAzureでは一時障害がなぜ発生するのか?どう対処すればよいか、アンチパターンは?などのガイダンスがまとまっておりますので、ぜひ。2016年のドキュメントですが、色褪せない内容になっています。 Transient fault handling

低SLAなREST APIの実装

それでは、具体的に実装を説明していきたいのですが、たとえばAzure Cognitive ServicesのAPIのSLAは99.9%ですので、1日当たり1.4分しかエラーが返ってきません。これではリトライ処理のテストができないので、もっともっとSLAの低いAPIが必要になります。

そこでリトライ処理を実装するときにテストで必要となる「SLAの低いRESR APIサービス」を用意します。Spring/JPA/H2DBを使って単純にJSONの値を返すたけのAPIですので、コードの説明は行いません。

Docker Imageを作成したので、次のコマンドを実行してコンテナを起動します。

docker run -it --rm -p 8081:8081 asashiho/retry-backend:v0.0.1

次のEndpointにアクセスしても、リクエストの30%しか200を返しません。残りの70%は500を返します。

GET http://localhost:8081/employees

運が良い場合、以下のJSONが返ります。

[
  {
    "id": 1,
    "name": "舞黒 太郎",
    "role": "Support Engineer"
  },
  {
    "id": 2,
    "name": "阿重瑠 花子",
    "role": "Cloud Solution Architect"
  }
]

いい感じ?!です。これでSLA 30%のAPIができました。

Spring WebFlux/WebClientでリトライ処理を実装する

Spring WebFluxとは

JavaでWebアプリケーションを開発するためのフレームワークとして、Spring Frameworkの「Spring MVC」が有名で、これまで広く使われていました。Spring MVCはサーブレットをベースとしており、Tomcatなどのサーブレットコンテナの上で動作するのが特徴です。ただ、Spring MVCのリクエスト処理方式は同時処理数分だけスレッドを必要とするため、クラウドのように利用したコンピューティングリソースに課金されるケースでは、必ずしも効率がよいとはいえません。

「Spring WebFlux」は、NettyなどのノンブロッキングI/Oベースのアプリケーションサーバ上で動作します。そして、ネットワークアクセスやDBアクセスなどのI/O処理の呼び出しに対して、1つのスレッドで複数のリクエストを処理でき少ないスレッドで実行できるため、メモリサイズやCPUの使用を減らすことが可能です。特にKubernetesのように、コンピューティングリソースの配置を細かくチューニングできるプラットフォームを使っている場合、ちりもつもれば山となる節約効果も期待できます。

Spring WebFluxでは「Reactor」によるリアクティブプログラミングを採用しています。リアクティブプログラミングとは、データに着目したイベント駆動型のプログラミングの一種で、通知されるデータを受け取って処理を行うハンドラを実装することによって連続的なデータを処理する手法です。Reactorはリアクティブプログラミングを実現するためのライブラリの一つであり、ノンブロッキングで非同期なリアクティブプログラミングの仕組みを提供しています。 Spring MVCでは抽象化レイヤーにServlet APIを使っていましたが、Spring WebFluxではReactive Streams Adaptersをを使うことによって低レベルなノンブロッキングI/Oの仕組みを隠蔽し、より抽象度の高いかたちでノンブロッキングな処理を実装できるようにしています。

Spring WebFluxによるWebアプリケーションの作成

Spring WebFluxを使ったWebアプリケーションは「Spring Initializr」を使ってプロジェクトのひな型を作成できます。その際、[ADD DEPENDENCIES]で[Spring Reactive Web]を追加します。

ダウンロードしたZIPのpom.xmlを確認すると以下のdependencyが登録されています。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

簡単です。

WebClientによるREST APIの呼び出し

Spring WebFluxでは、WebClientを使用して外部のREST APIを呼び出しできます。WebClientはノンブロッキングなHTTPリクエストを実現するための機能で、メソッドチェインによってHTTPリクエストを処理できます。Spring MVCではRestTemplateというHTTPクライアントを提供していますが、RestTemplateはSpring Framework 5.0でメンテナンスモードとなり、今後はWebClientを使用することが推奨されています。

GETリクエストはWebClientのgetメソッドを使います。Endpointはuriメソッドに指定します。 ここでは指定していませんが、acceptメソッドを使用するとAcceptヘッダなどのリクエストに必要な情報を設定できます。retriveメソッドを実行したあと、レスポンスの処理方法を設定します。

ここでは、低SLAなAPIから取得したJSONをEmployee[]に格納しています。

private WebClient webClient = WebClient.builder().build();

public Mono<Employee[]> get() {

    return webClient.get()
        .uri("http://localhost:8081/employees"))
        .retrieve()
        .bodyToMono(Employee[].class)

WebClientの処理結果はFlux<>またはMono<>でラッピングされています。

Flux<>はReactive StreamsにおけるPublisherインタフェースを実装しており、0個以上のデータを持つデータ列を扱うためのクラスです。 一方のMono<>Publisherインタフェースを実装したクラスですが、0個または1個のデータを扱うところが違いです。

Flux<>Mono<>にはoperatorが用意されており、operatorをメソッドチェーンで繋げていくことによって処理を実装できます。operatorはラムダ関数を引数として受け取り、ラムダ関数で定義した内容に基づいた処理を行うPublisherを生成します。

少し脱線しますが、たとえはmapは個々のデータに対して処理を実行するoperatorで値を100倍したいときは次のようなコードを書きます。

Flux.just(1, 2, 3, 4, 5)
  .map(i -> i * 100)
  .subscribe(d -> log.info(d));

Mono.just(10)
  .map(i -> i * 100)
  .subscribe(d -> log.info(d));

そのほかにも、非同期にデータを処理するflatMapや、条件に合致するデータのみを抽出するfilterなどがあります。便利。

https://projectreactor.io/

WebClientによるREST API呼び出しのリトライ処理

リトライ処理の実装も実にシンプルでMono<>Flux<>には2つのRetry operatorが用意されています。

retry operatorの場合

retryメソッドに再試行したい回数を指定します。

public Mono<Employee[]> get() {

    return webClient.get()
        .uri(this.getUri())
        .retrieve()
        .bodyToMono(Employee[].class)
        .retry(3);

このケースだとWebClientからどのようなエラーが返されても最大3回リトライされます。またLong.MAX_VALUEを渡すと無限に再試行するのでご注意ください。

retry operator

retryWhen operatorの場合

実際にプロダクションで使うときは、もうちょっと細かいチューニングが必要です。その場合はretryWhen operatorを使います。

retryWhen operator

たとえば、最大3回リトライするけど、それぞれの間に2秒の一定間隔をあけたい場合です。通信先が一時障害のとき、そんなにすぐには復旧しないケースもあるでしょう。ちょっと待て、そう焦るでない、、、というケースです。

public Mono<Employee[]> get() {

    return webClient.get()
        .uri(this.getUri())
        .retrieve()
        .bodyToMono(Employee[].class)
        .retryWhen(Retry.fixedDelay(3,Duration.ofSeconds(2)));
}

リトライの間隔を一定ではなく、徐々に長くしたいこともあります。ただでさえ値を返せなくてイラっとしているところ、何度もしつこく催促されるとAPIもやる気がなくなるでしょう。

次の場合は、最大3回リトライするけど、リトライ間隔を指数関数的にのばしたい場合です。おおよそ2秒後・4秒後・8秒後にリトライされます。

public Mono<Employee[]> get() {

    return webClient.get()
        .uri(this.getUri())
        .retrieve()
        .bodyToMono(Employee[].class)
        .retryWhen(Retry.backoff(3,Duration.ofSeconds(2)));
}

jitterによる衝突回避

backoff opetatorにはjitterの値をチューニングできます。デフォルトでは、0.5に設定されています。これは、遅延の最大50%のjitterを入れます。設定可能な値は0-1までで、0はjitterなし、1はdelayに対して100%のjitterを指定することになります。

次の例は、0.75のjitterを入れた場合です。

public Mono<Employee[]> get() {

    return webClient.get()
        .uri(this.getUri())
        .retrieve()
        .bodyToMono(Employee[].class)
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(2))
            .jitter(0.75));
}

ログの出力

リトライ処理をするときに、ログ出力したくなることもあるとおもいます。その場合は、doBeforeRetry/doAfterRetryメソッドでリトライ処理のトリガー時にロギングを入れてあげれはいいかなと思います。受け取ったRetrySignaltotalRetriesでリトライの回数-1が取得できます。

public Mono<Employee[]> get() {

    return webClient.get()
        .uri(this.getUri())
        .retrieve()
        .bodyToMono(Employee[].class)
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(2))
            .jitter(0.75)
            .doBeforeRetry(sig -> {
                log.error("Retrying: " + sig.totalRetries());
            }));
}

@RestControllerアノテーションのついた適当なControllerクラスを書き、@GetMappingアノテーションでパスを設定し、サービスを呼び出します。

@GetMapping("/retry")
public String retry(Model model) {
    log.info("Start: ");
    Mono<Employee[]> userData = frontService.get();

    model.addAttribute("userData", userData);
    return "retry";
}

/retryにアクセスすると、以下のようにロギングされます。3回がんばってリトライしたけど結局だめで、RetryExhaustedExceptionが捕捉されています。

2021-09-24 15:18:50.277  INFO 1806 --- [or-http-epoll-1] c.e.front.controller.FrontController     : Start: 
2021-09-24 15:18:50.293 ERROR 1806 --- [or-http-epoll-6] com.example.front.service.FrontService   : Retrying: 0
2021-09-24 15:18:53.220 ERROR 1806 --- [or-http-epoll-3] com.example.front.service.FrontService   : Retrying: 1
2021-09-24 15:18:56.417 ERROR 1806 --- [or-http-epoll-4] com.example.front.service.FrontService   : Retrying: 2
2021-09-24 15:19:08.181 ERROR 1806 --- [or-http-epoll-5] a.w.r.e.AbstractErrorWebExceptionHandler : [24868deb-2]  500 Server Error for HTTP GET "/retry"

reactor.core.Exceptions$RetryExhaustedException: Retries exhausted: 3/3

ログの時間も見てみましょう。リトライ間隔が徐々に増えているのが分かります。

回数時刻間隔
015:18:50.277
115:18:50.29300:02.9
215:18:53.22000:03.2
315:18:56.41700:11.9

なお、プロダクションで利用する場合は、リトライのパラメータ値などはアプリケーション外部から注入できるようにしておくとよいと思います。application.propertiesに設定して@ConfigurationPropertiesアノテーションを設定することで、DIコンテナに自動的にインジェクションされます。

# retry parameter
retry.backoff=10
retry.duration=2
retry.jitter=0.75
package com.example.front.util;

import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Getter;
import lombok.Setter;

import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix="retry")
@Getter @Setter
public class RetryProperties {

    private int backoff;
    private int duration;
    private double jitter;

}

Configuration Propertiesを使うと型安全なプロパティ設定が簡単にできて良いです。

※おまけ

クラウドの耐障害性検証やカオス注入などのトラブル検証などで、今回の低SLAなダメダメREST APIを今後も使うシーンがあるかもしれない、と思いAzure Container Instanceを使って簡易的なREST APIを作成する手順を書きました。

まず、Azureの東日本リージョンにリソースグループを作成します

az login
RG_NAME=whimsical-api
ACI_NAME=whimsical-api

az group create -n $RG_NAME -l japaneast

次のコマンドを実行してAzure Container Instanceを起動します。

az container create \
    -g $RG_NAME \
    -n $ACI_NAME \
    --image asashiho/retry-backend:v0.0.1 \
    --dns-name-label whimsical-api \
    --ports 8081

次のコマンドを実行すると、ACIの完全修飾ドメイン名(FQDN)とそのプロビジョニング状態が表示されます。

az container show \
    -g $RG_NAME \
    -n $ACI_NAME \
    --query "{FQDN:ipAddress.fqdn,ProvisioningState:provisioningState}" \
    --out table

Visual Studio Code ExtentionのREST ClientなどでACIのEndpointにアクセスします。

GET http://whimsical-api.japaneast.azurecontainer.io:8081/employees/1
GET http://whimsical-api.japaneast.azurecontainer.io:8081/employees

ACIのリソースグループを削除する場合は次のコマンドを実行します。

az group delete -n $RG_NAME

ACIはDockerコンテナをさくっと動かすときには大変便利です。

まとめ

本エントリーでは、Spring WebFlux/WebClientを使った外部REST APIの呼び出しとリトライ処理の実装についてまとめました。

リトライ大事。回復性大事。

以上