SmallRyeのフォールト・トレランス
マイクロサービスの分散した性質がもたらす課題の1つは、外部システムとの通信が本質的に信頼できないことです。これにより、アプリケーションの耐障害性に対する要求が高まります。より耐障害性の高いアプリケーションを簡単に作るために、Quarkusは MicroProfile Fault Tolerance仕様の実装である SmallRye Fault Toleranceを提供します。
このガイドでは、 @Timeout
、 @Fallback
、 @Retry
、 @CircuitBreaker
などの MicroProfile Fault Tolerance アノテーションの使用方法を説明します。
前提条件
このガイドを完成させるには、以下が必要です:
-
約15分
-
IDE
-
JDK 11+ がインストールされ、
JAVA_HOME
が適切に設定されていること -
Apache Maven 3.8.6
-
使用したい場合は、 Quarkus CLI
-
ネイティブ実行可能ファイルをビルドしたい場合、MandrelまたはGraalVM(あるいはネイティブなコンテナビルドを使用する場合はDocker)をインストールし、 適切に設定していること
シナリオ
このガイドで作成したアプリケーションは、グルメ・コーヒーのe-ショップのシンプルなバックエンドをシミュレートしています。このアプリケーションは、店舗で販売しているコーヒーのサンプルに関する情報を提供するRESTエンドポイントを実装しています。
そのような実装はされていませんが、エンドポイントのいくつかのメソッドが、データベースや外部のマイクロサービスなどの外部サービスとの通信を必要とし、信頼性の低い要素を導入することを想像してみましょう。
ソリューション
次のセクションで紹介する手順に沿って、ステップを踏んでアプリを作成することをお勧めします。ただし、完成した例にそのまま進んでも構いません。
Gitレポジトリをクローンするか git clone https://github.com/quarkusio/quarkus-quickstarts.git
、 アーカイブ をダウンロードします。
ソリューションは microprofile-fault-tolerance-quickstart
ディレクトリ にあります。
Mavenプロジェクトの作成
まず、新しいプロジェクトが必要です。以下のコマンドで新規プロジェクトを作成します。 :
このコマンドは、RESTEasy Reactive/JAX-RSおよびSmallRye Fault Toleranceの拡張機能をインポートして、プロジェクトを生成します。
すでにQuarkusプロジェクトが設定されている場合は、プロジェクトのベースディレクトリーで以下のコマンドを実行することで、プロジェクトに smallrye-fault-tolerance
エクステンションを追加することができます。 :
quarkus extension add 'smallrye-fault-tolerance'
./mvnw quarkus:add-extension -Dextensions='smallrye-fault-tolerance'
./gradlew addExtension --extensions='smallrye-fault-tolerance'
これにより、ビルドファイルに以下の内容が追加されます。 :
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-fault-tolerance</artifactId>
</dependency>
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
アプリケーションの準備: RESTエンドポイントとCDIビーン
このセクションでは、アプリケーションのスケルトン (骨組) を作成します。これにより、後から拡張したり、フォールトトレランス機能を追加したりすることができます。
まず、お店のコーヒーのサンプルを表すシンプルなエンティティを作成します。 :
package org.acme.microprofile.faulttolerance;
public class Coffee {
public Integer id;
public String name;
public String countryOfOrigin;
public Integer price;
public Coffee() {
}
public Coffee(Integer id, String name, String countryOfOrigin, Integer price) {
this.id = id;
this.name = name;
this.countryOfOrigin = countryOfOrigin;
this.price = price;
}
}
続いて、コーヒーのサンプルのrepositoryとして動作するシンプルなCDIビーンです。
package org.acme.microprofile.faulttolerance;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CoffeeRepositoryService {
private Map<Integer, Coffee> coffeeList = new HashMap<>();
public CoffeeRepositoryService() {
coffeeList.put(1, new Coffee(1, "Fernandez Espresso", "Colombia", 23));
coffeeList.put(2, new Coffee(2, "La Scala Whole Beans", "Bolivia", 18));
coffeeList.put(3, new Coffee(3, "Dak Lak Filter", "Vietnam", 25));
}
public List<Coffee> getAllCoffees() {
return new ArrayList<>(coffeeList.values());
}
public Coffee getCoffeeById(Integer id) {
return coffeeList.get(id);
}
public List<Coffee> getRecommendations(Integer id) {
if (id == null) {
return Collections.emptyList();
}
return coffeeList.values().stream()
.filter(coffee -> !id.equals(coffee.id))
.limit(2)
.collect(Collectors.toList());
}
}
最後に、 org.acme.microprofile.faulttolerance.CoffeeResource
クラスを以下のように作成します。 :
package org.acme.microprofile.faulttolerance;
import java.util.List;
import java.util.Random;
import java.util.concurrent.atomic.AtomicLong;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import org.jboss.logging.Logger;
@Path("/coffee")
public class CoffeeResource {
private static final Logger LOGGER = Logger.getLogger(CoffeeResource.class);
@Inject
CoffeeRepositoryService coffeeRepository;
private AtomicLong counter = new AtomicLong(0);
@GET
public List<Coffee> coffees() {
final Long invocationNumber = counter.getAndIncrement();
maybeFail(String.format("CoffeeResource#coffees() invocation #%d failed", invocationNumber));
LOGGER.infof("CoffeeResource#coffees() invocation #%d returning successfully", invocationNumber);
return coffeeRepository.getAllCoffees();
}
private void maybeFail(String failureLogMessage) {
if (new Random().nextBoolean()) {
LOGGER.error(failureLogMessage);
throw new RuntimeException("Resource failure.");
}
}
}
この時点で、コーヒーのサンプルのリストをJSON形式で表示するRESTメソッドを1つ公開しています。なお、 CoffeeResource#maybeFail()
メソッドに障害を起こすコードを導入したため、約50%のリクエストで CoffeeResource#coffees()
エンドポイント・メソッドに障害を発生させようとするものです。
アプリケーションが動作することを確認してみましょう。次のようにQuarkusの開発サーバーを起動してください。 :
quarkus dev
./mvnw quarkus:dev
./gradlew --console=plain quarkusDev
http://localhost:8080/coffee
をブラウザで開きます。いくつかのリクエストを行います(いくつかのリクエストは失敗することを想定しています)。少なくともいくつかのリクエストは、コーヒーサンプルのリストをJSONで表示してくれるはずです。残りのリクエストは、 CoffeeResource#maybeFail()
内で RuntimeException
をスローして失敗します。
おめでとうございます!(多少、信頼性に欠けるものの)Quarkusアプリケーションが完成しました!
レジリエンスの追加: リトライ
Quarkus開発サーバーを起動し、IDEで @Retry
アノテーションを CoffeeResource#coffees()
メソッドに以下のように追加し、ファイルを保存します。 :
import org.eclipse.microprofile.faulttolerance.Retry;
...
public class CoffeeResource {
...
@GET
@Retry(maxRetries = 4)
public List<Coffee> coffees() {
...
}
...
}
ブラウザの更新ボタンを押してください。Quarkusの開発サーバーが自動的に変更を検出し、アプリを再コンパイルしますので、再起動する必要はありません。
さらに何度か更新ボタンを押してみてください。実質的にすべてのリクエストが成功しているはずです。 CoffeeResource#coffees()
メソッドは、実際にはまだ約50%の確率で失敗していますが、そのたびにプラットフォームは自動的に呼び出しを再試行します!
障害がまだ発生していることを確認するには、開発サーバーの出力をチェックします。ログメッセージは以下のようになっているはずです。 :
2019-03-06 12:17:41,725 INFO [org.acm.fau.CoffeeResource] (XNIO-1 task-1) CoffeeResource#coffees() invocation #5 returning successfully
2019-03-06 12:17:44,187 INFO [org.acm.fau.CoffeeResource] (XNIO-1 task-1) CoffeeResource#coffees() invocation #6 returning successfully
2019-03-06 12:17:45,166 ERROR [org.acm.fau.CoffeeResource] (XNIO-1 task-1) CoffeeResource#coffees() invocation #7 failed
2019-03-06 12:17:45,172 ERROR [org.acm.fau.CoffeeResource] (XNIO-1 task-1) CoffeeResource#coffees() invocation #8 failed
2019-03-06 12:17:45,176 INFO [org.acm.fau.CoffeeResource] (XNIO-1 task-1) CoffeeResource#coffees() invocation #9 returning successfully
呼び出しが失敗するたびに、すぐに次の呼び出しが行われ、1つが成功するまで繰り返されることがわかります。4回の再試行を許可しているので、ユーザーが実際に失敗にさらされるためには、5回連続して失敗する必要があります。これはかなり起こりにくいことです。
レジリエンスの追加: タイムアウト
では、MicroProfile Fault Toleranceには他に何があるのでしょうか。タイムアウトについて見てみましょう。
CoffeeResource
のエンドポイントに以下の2つのメソッドを追加します。繰り返しになりますが、サーバーを再起動する必要はなく、コードを貼り付けてファイルを保存するだけです。
import org.eclipse.microprofile.faulttolerance.Timeout;
...
public class CoffeeResource {
...
@GET
@Path("/{id}/recommendations")
@Timeout(250)
public List<Coffee> recommendations(int id) {
long started = System.currentTimeMillis();
final long invocationNumber = counter.getAndIncrement();
try {
randomDelay();
LOGGER.infof("CoffeeResource#recommendations() invocation #%d returning successfully", invocationNumber);
return coffeeRepository.getRecommendations(id);
} catch (InterruptedException e) {
LOGGER.errorf("CoffeeResource#recommendations() invocation #%d timed out after %d ms",
invocationNumber, System.currentTimeMillis() - started);
return null;
}
}
private void randomDelay() throws InterruptedException {
Thread.sleep(new Random().nextInt(500));
}
}
いくつかの新しい機能を追加しました。ユーザーが現在見ているコーヒーに基づいて、関連するコーヒーをお勧めできるようにしたいのです。これは重要な機能ではなく、あれば望ましい程度の機能です。システムに負荷がかかり、おすすめ情報を得るためのロジックの実行に時間がかかりすぎる場合は、むしろタイムアウトしておすすめ情報のないUIを表示したいと考えています。
なお、タイムアウトは250msに設定し、0~500msのランダムな人工的な遅延が CoffeeResource#recommendations()
メソッドに導入されたことに注意してください。
お使いのブラウザで http://localhost:8080/coffee/2/recommendations
にアクセスして、2、3回更新してください。
org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException
でいくつかのリクエストがタイムアウトになるはずです。タイムアウトにならなかったリクエストでは、JSONに2つのおすすめコーヒーサンプルが表示されます。
レジリエンスの追加: フォールバック
関連するコーヒーを入手するための代替手段(おそらくより迅速な方法)を提供することで、おすすめ機能をさらに改善しましょう。
以下のように`CoffeeResource` にフォールバック・メソッドを、 CoffeeResource#recommendations()
メソッドに @Fallback
アノテーションを追加します。 :
import java.util.Collections;
import org.eclipse.microprofile.faulttolerance.Fallback;
...
public class CoffeeResource {
...
@Fallback(fallbackMethod = "fallbackRecommendations")
public List<Coffee> recommendations(int id) {
...
}
public List<Coffee> fallbackRecommendations(int id) {
LOGGER.info("Falling back to RecommendationResource#fallbackRecommendations()");
// safe bet, return something that everybody likes
return Collections.singletonList(coffeeRepository.getCoffeeById(1));
}
...
}
http://localhost:8080/coffee/2/recommendations
を何回も更新してください。 TimeoutException
はもう表示されないはずです。代わりに、タイムアウトが発生した場合、元のメソッドが返す2つのレコメンデーションではなく、フォールバックメソッド fallbackRecommendations()
にハードコードされた1つのレコメンデーションが表示されます。
フォールバックが本当に行われているかどうか、サーバーの出力を確認してください。 :
2020-01-09 13:21:34,250 INFO [org.acm.fau.CoffeeResource] (executor-thread-1) CoffeeResource#recommendations() invocation #1 returning successfully
2020-01-09 13:21:36,354 ERROR [org.acm.fau.CoffeeResource] (executor-thread-1) CoffeeResource#recommendations() invocation #2 timed out after 250 ms
2020-01-09 13:21:36,355 INFO [org.acm.fau.CoffeeResource] (executor-thread-1) Falling back to RecommendationResource#fallbackRecommendations()
フォールバックメソッドには、元のメソッドと同じパラメータが必要です。 |
レジリエンスの追加: サーキットブレーカー
サーキットブレーカーは,システムの一部が一時的に不安定になったときに,システム内で発生する障害の数を制限するのに有効です。サーキットブレーカーは、あるメソッドの成功と失敗を記録し、失敗したメソッドの割合が指定された閾値に達すると、サーキットブレーカーが 開き、それ以降のメソッドの呼び出しを一定時間ブロックします。
次のコードを CoffeeRepositoryService
ビーンに追加して、サーキットブレーカーの動作をデモできるようにします。 :
import java.util.concurrent.atomic.AtomicLong;
import org.eclipse.microprofile.faulttolerance.CircuitBreaker;
...
public class CoffeeRepositoryService {
...
private AtomicLong counter = new AtomicLong(0);
@CircuitBreaker(requestVolumeThreshold = 4)
public Integer getAvailability(Coffee coffee) {
maybeFail();
return new Random().nextInt(30);
}
private void maybeFail() {
// introduce some artificial failures
final Long invocationNumber = counter.getAndIncrement();
if (invocationNumber % 4 > 1) { // alternate 2 successful and 2 failing invocations
throw new RuntimeException("Service failed.");
}
}
}
そして、以下のコードを CoffeeResource
のエンドポイントに注入します。 :
public class CoffeeResource {
...
@Path("/{id}/availability")
@GET
public Response availability(int id) {
final Long invocationNumber = counter.getAndIncrement();
Coffee coffee = coffeeRepository.getCoffeeById(id);
// check that coffee with given id exists, return 404 if not
if (coffee == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
try {
Integer availability = coffeeRepository.getAvailability(coffee);
LOGGER.infof("CoffeeResource#availability() invocation #%d returning successfully", invocationNumber);
return Response.ok(availability).build();
} catch (RuntimeException e) {
String message = e.getClass().getSimpleName() + ": " + e.getMessage();
LOGGER.errorf("CoffeeResource#availability() invocation #%d failed: %s", invocationNumber, message);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(message)
.type(MediaType.TEXT_PLAIN_TYPE)
.build();
}
}
...
}
もう一つの機能を追加しました。このアプリケーションは、私たちの店における、指定されたコーヒーの残りのパッケージの量を返すことができます(単なる乱数です)。
今回は、CDIビーンに人工的な失敗を導入しました。 CoffeeRepositoryService#getAvailability()
メソッドは、2回の成功と2回の失敗の呼び出しを交互に行うことになります。
また、@CircuitBreaker`アノテーションを追加し、`requestVolumeThreshold = 4`としました。`CircuitBreaker.failureRatio
はデフォルトで 0.5 で、 CircuitBreaker.delay
はデフォルトで 5 秒です。つまり、過去4回の起動のうち2回が失敗した場合にサーキットブレーカーが開き、5秒間その状態のままになります。
これを試すには、次のようにします。 :
-
ブラウザで
http://localhost:8080/coffee/2/availability
を開いて下さい。数字が返ってくるのが見えるでしょう。 -
リフレッシュすると、この2回目のリクエストが再び成功し、数字が返ってくるはずです。
-
さらに2回リフレッシュします。2回とも、"RuntimeException:Service failed." というテキストが表示されます。これは、
CoffeeRepositoryService#getAvailability()
によってスローされる例外です。 -
さらに数回更新します。長く待ちすぎていなければ、再び例外が表示されるはずですが、今回は "CircuitBreakerOpenException: getAvailability" となっています。この例外は、サーキットブレーカーが開き、
CoffeeRepositoryService#getAvailability()
メソッドが呼び出されなくなったことを示しています。 -
5秒後にサーキットブレーカーが閉じて、再び2回のリクエストを成功させるようになります。
ランタイム設定
application.properties
ファイルの中で、実行時にアノテーションパラメータをオーバーライドすることができます。
先ほどのリトライの例で言えば :
package org.acme;
import org.eclipse.microprofile.faulttolerance.Retry;
...
public class CoffeeResource {
...
@GET
@Retry(maxRetries = 4)
public List<Coffee> coffees() {
...
}
...
}
以下の設定項目により、 maxRetries
パラメータをオーバーライドして、リトライ回数を4回から6回に変更することができます。 :
org.acme.CoffeeResource/coffees/Retry/maxRetries=6
フォーマットは fully-qualified-class-name/method-name/annotation-name/property-name=value です。また、 annotation-name/property-name=value を通じて、すべてのアノテーションに対するプロパティを設定することもできます。
|
まとめ
SmallRye Fault Toleranceは、ビジネスロジックの複雑さに影響を与えることなく、アプリケーションの耐障害性を向上させます。
Quarkusのフォールトトレランス機能を有効にするために必要なのは次の通りです。 :
-
quarkus-maven-plugin
でsmallrye-fault-tolerance
Quarkus エクステンションをプロジェクトに追加することです :コマンドラインインタフェースquarkus extension add 'smallrye-fault-tolerance'
Maven./mvnw quarkus:add-extension -Dextensions='smallrye-fault-tolerance'
Gradle./gradlew addExtension --extensions='smallrye-fault-tolerance'
-
または、以下のMavenの依存関係を単純に追加することもできます。 :
pom.xml<dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-smallrye-fault-tolerance</artifactId> </dependency>
build.gradleimplementation("io.quarkus:quarkus-smallrye-fault-tolerance")
追加リソース
SmallRye Fault Toleranceには、ここで紹介した以外にも様々な機能があります。それらについては、 SmallRye Fault Toleranceのドキュメントをご確認ください。
Quarkusでは、SmallRye Fault Toleranceのオプション機能をすぐに使用することができます。
Mutinyがサポートされているので、非同期のメソッドは CompletionStage
だけでなく Uni
を返すことができます。
MicroProfile Context PropagationはFault Toleranceと統合されているので、既存のコンテキストは自動的に非同期メソッドに伝搬されます。
これは CDI リクエストコンテキストにも当てはまります。もしオリジナルのスレッドでアクティブであれば、新しいスレッドに伝搬されますが、もしそうでなければ、新しいスレッドはそれを持ちません。 これは MicroProfile Fault Tolerance 仕様に反しており、 MicroProfile Context Propagationがある場合、この要件は適用されるべきではないと考えています。コンテキスト伝搬の主な目的は、新しいスレッドが元のスレッドと同じコンテキストを持っていることを確認することにあります。 |
非互換モードはデフォルトで有効になっています。 これは、 CompletionStage
(または Uni
) を返すメソッドには、 @Asynchronous
, @Blocking
, @NonBlocking
アノテーションなしで、非同期のフォールトトレランスが適用されることを意味します。 また、サーキットブレーカー、フォールバック、リトライは、例外そのものがどうなるべきかを判断するのに不十分な場合、例外の原因チェーンを自動的に検査することを意味します。
このモードは、非互換性は非常に小さいものの、MicroProfile Fault Tolerance仕様と互換性がありません。 完全な互換性を回復するには、このコンフィギュレーションプロパティを追加してください。 :
|
Mutinyのサポートを含む プログラム的なAPIが存在し、宣言的でアノテーションベースのAPIと統合されています。デフォルトで FaultTolerance
と MutinyFaultTolerance
の API を使うことができます。
Kotlinのサポートがあるため(Kotlin用のQuarkus拡張機能を使用することが前提)、フォールトトレランスのアノテーションで suspend
関数を保護することができます。
メトリクスは自動的に検出され、統合されます。 アプリケーションでQuarkus Extension for Micrometerを使用している場合、SmallRye Fault ToleranceからMicrometerにメトリクスが送信されます。 アプリケーションがSmallRye Metrics用のQuarkus拡張機能を使用している場合、SmallRye Fault ToleranceはMicroProfile Metricsにメトリクスを送信します。 そうでない場合は、SmallRye Fault Toleranceのメトリクスは無効になります。