MicroProfile Fault Tolerance 4.0 まとめ

f:id:Naotsugu:20220414191618p:plain


はじめに

MicroProfile 5.0 に含まれる MicroProfile Fault Tolerance 4.0 仕様のまとめです。

マイクロサービスの構築では、ある処理の実行可否やタイミングを制御したり、正常に完了しなかった処理の代替結果を提供するなどのフォールトトレラントな設計が重要になります。

MicroProfile Fault Tolerance では、以下のアノテーションにて障害に対処するための戦略を提供しています。

  • org.eclipse.microprofile.faulttolerance.Asynchronous

    • 以下の各種戦略と組み合わせ、処理を別スレッドで非同期実行する
  • org.eclipse.microprofile.faulttolerance.Timeout

    • 実行のタイムアウトの期間を定義する
  • org.eclipse.microprofile.faulttolerance.Retry

    • 実行に失敗した場合、再度実行を試みる
  • org.eclipse.microprofile.faulttolerance.Fallback

    • 実行に失敗した場合の代替策を提供する
  • org.eclipse.microprofile.faulttolerance.CircuitBreaker

    • 実行に繰り返し失敗した場合に、その呼び出しを自動的に即時失敗させる
  • org.eclipse.microprofile.faulttolerance.Bulkhead

    • 同時実行を制限し、その領域のレスポンス低下がシステム全体に波及しないようにする


これらのアノテーションは、クラスレベルまたはメソッドレベルでバインドでき、CDI によるインターセプターバインディングにより実現されます。

MicroProfileConfig による設定と、MicroProfile Metrics によるフォールトトレランスのメトリックの収集が可能となっています。  

簡単な利用例としては以下のようなものがあります。

package example;

@ApplicationScoped
public class FaultToleranceBean {

   @Retry(maxRetries = 2)
   @Timeout(400)
   @Fallback(fallbackMethod = "doWorkFallback")
   public Result doWork() {
      return callServiceA();
   }

   private Result doWorkFallback() {
      return Result.emptyResult();
   }
}

アプリケーションスコープの CDI Bean の doWork() メソッドに @Retry@Timeout@Fallback でアノテートしています。

doWork() の呼び出しは、400ms のタイムアウトが設定され、2回を超えて実行が失敗した場合には doWorkFallback() が代わりに呼び出されます。

以下のような呼び出しとなります。

Fallback(
    Retry(
        Timeout(
            Invocation(
                ctx.call()
            )
        )
    )
)

MicroProfile Fault Tolerance では、このようにアノテーションベースで耐障害性の戦略を定義することができます。 MicroProfileConfig を使い、先ほどの例の最大試行回数を変更する場合には以下のように設定を上書きすることもできます。

example.FaultToleranceBean/doWork/Retry/maxRetries=6

リポジトリは以下となります。

github.com


@Asynchronous

フォールトトレラントの各種戦略と組み合わせて、処理を非同期化します。

@Asynchronous
public CompletionStage<Connection> serviceA() {
   Connection conn = null;
   counterForInvokingServiceA++;
   conn = connectionService();
   return CompletableFuture.completedFuture(conn);
}

serviceA の呼び出しは直ちにCompletionStageを返し、メソッド本体は別のスレッドで実行されます。

@Asynchronous でアノテートされたメソッドは、FutureCompletionStage を返す必要があります。 メソッドが Future を返した場合、メソッド呼び出しは成功したと見なされ、他のFault Tolerance ポリシーが適用されないため、ほとんどのケースでは CompletionStage を戻り値にすべきです。

例えば以下のケースではメソッド呼び出しが正常に戻るため、Retryはトリガーされません。

@Asynchronous
@Retry
public Future<Connection> serviceA() {
   CompletableFuture<U> future = new CompletableFuture<>();
   future.completeExceptionally(new RuntimeException("Failure"));
   return future;
}

CompletionStage が戻り値の以下のケースでは Retry がトリガーされます(メソッドが CompletionStage を返す場合、返された CompletionStage が完了するまで、メソッド呼び出しは完了したとはみなされません)。

@Asynchronous
@Retry
public CompletionStage<Connection> serviceA() {
   CompletableFuture<U> future = new CompletableFuture<>();
   future.completeExceptionally(new RuntimeException("Failure"));
   return future;
}


@Timeout

@Timeout で処理完了までの時間に制限を設けることができます。

@Timeout(400)
public Connection serviceA() {
   ...
   return conn;
}

この例では メソッド serviceA が完了するまでに400ms以上かかった場合、正常に戻ったとしても実行は失敗となり TimeoutException がスローされます。

@Timeout は、@Fallback @CircuitBreaker @Asynchronous @Bulkhead @Retry と一緒に使用することができます。

ただし、このタイムアウト処理は Thread.interrupt() に頼っているため、特定のシナリオでは割り込みが機能しない場合があるとこには注意する必要があります。

  • スレッドがブロッキングI/O(データベースやファイル読み書き待ちなど)でブロックしている場合
  • スレッドがCPU負荷の高いタスクで待機しておらず、割り込みの有無をチェックしていない
  • 割り込み例外をキャッチし、割り込みを無視して処理を続行するコードの場合

ブロッキングI/Oの割り込みについては以下を参照してください。

blog1.mammb.com


他のアノテーションとの組み合わせ時の動作は以下のようになります。

  • @Timeout@Asynchronousと併用し、生成されたスレッドでの作業がタイムアウトした場合、メインスレッドで Future を get() 呼び出すと、フォールトトレランスの TimeoutException をラップした ExecutionException がスローされる

  • @Timeout@Fallback と共に使用された場合、TimeoutException がスローされるとフォールバックメソッドまたはハンドラが呼び出される

  • @Timeout@Retry と共に使用すると、@Retry アノテーションの定義に応じて、TimeoutException が発生したときに再試行が行われ、タイムアウトは、再試行のたびに再開される。

  • @Timeout@Retry@Asynchronousと併用し、再試行がTimeoutExceptionの結果である場合、元の試行がまだ実行中であっても、遅延時間後に再試行が開始される

  • @Timeout@CircuitBreaker と共に使用される場合、TimeoutException はCircuitBreakerによって失敗とみなさる

  • @Timeout@Bulkhead および @Asynchronous と共に使用する場合、@Timeout で測定する実行時間は、実行が Bulkhead キューに追加された時点から実行が完了するまでの期間となる


@Retry

短時間のネットワークの不具合から回復するために、@Retry を使用して同じ操作を再試行することができます。

例えば、IO例外を除くすべての例外をリトライするには、以下のようにします。

@Retry(retryOn = Exception.class, abortOn = IOException.class)
public void service() {
    underlyingService();
}

例外が Error でも Exception でもない Throwable を直接実装した例外の場合は移植不可能な動作になります。

@Retry には以下を指定することができます。

  • maxRetries
    • 再試行回数の最大値
  • delay
    • 各再試行間の遅延時間
  • delayUnit
    • 遅延の単位
  • maxDuration
    • 再試行を実行する最大時間
  • durationUnit
    • 再試行を実行する最大時間の単位
  • jitter
    • 再試行遅延のランダムな変化
  • jitterDelayUnit
    • ジッターの単位
  • retryOn
    • 再試行する失敗を指定
  • abortOn
    • 失敗したときに中止するよう指定

jitter は遅延にランダムな変化を付けるために利用します。 遅延時間はゼロより大きな [delay - jitter, delay + jitter]の範囲となります。

以下の例では、0-800ms の範囲で遅延します。

@Retry(delay = 400, maxDuration = 3200, jitter = 400, maxRetries = 10)
public Connection serviceA() {
    return connectionService();
}
  • @Retry は、@Fallback @CircuitBreaker@Asynchronous @Bulkhead @Timeout と一緒に使用することができます。
  • @Fallback を指定すると、全てのリトライが実行された後もメソッドが失敗した場合に呼び出されます。


@Fallback

@Fallback でアノテートされたメソッドが失敗した場合に、Fallback メソッドが呼び出されます。

フォールバックを指定するには、2つの方法があります。

  • FallbackHandler クラスを指定する
  • fallbackMethod を指定する

FallbackHandler クラスを指定する場合は、 FallbackHandler を実装したクラスを用意し、以下のように指定します。

@Retry(maxRetries = 1)
@Fallback(StringFallbackHandler.class)
public String serviceA() {
    counterForInvokingServiceA++;
    return nameService();
}

FallbackHandler は以下のインターフェースとして提供されています。

public interface FallbackHandler<T> {
    /**
     * Handle the previous calling failure and then call alternative methods or perform any alternative operations.
     * 
     * @param context
     *            the execution context
     * 
     * @return the result of the fallback
     */
    T handle(ExecutionContext context);

}

戻り値の型は、フォールバック対象のメソッドと揃えておく必要があります。

なお、ExecutionContext は以下となります。

public interface ExecutionContext {

    public Method getMethod();

    public Object[] getParameters();

    public Throwable getFailure();

}


fallbackMethod を指定する場合は以下のように指定します。

@Retry(maxRetries = 2)
@Fallback(fallbackMethod = "fallbackForServiceB")
public String serviceB() {
    counterForInvokingServiceB++;
    return nameService();
}

private String fallbackForServiceB() {
    return "myFallback";
}


@Fallback には以下を指定することができます。

  • value
    • FallbackHandler クラスを指定する
  • fallbackMethod
    • fallbackMethod を指定する
  • applyOn
    • フォールバックをトリガする例外のリストを指定する
  • skipOn
    • フォールバックをトリガしない例外のリストを指定する
@Retry(maxRetries = 2)
@Fallback(
    applyOn = { ExceptionA.class, ExceptionB.class },
    skipOn = ExceptionBSub.class,
    fallbackMethod = "fallbackForServiceB")
public String serviceB() {
   return nameService();
}

private String fallbackForServiceB() {
    return "myFallback";
}

上記の場合、ExceptionBSub が発生した場合は、フォールバックはトリガされずに ExceptionBSub がそのままスローされます。

ExceptionAExceptionB やそのサブクラスの例外が発生した場合は、指定されたフォールバックがトリガーされます。 それ以外の例外が発生した場合は、その例外がそのままスローされます。

メソッドが Error でも Exception でもない Throwable を投げる場合、移植不可能な動作になります。

BulkheadExceptionCircuitBreakerOpenExceptionTimeoutException などのフォールトトレラント例外はフォールバック対象となります。


他のアノテーションとの組み合わせ時の動作は以下のようになります。

  • @Fallback が他のアノテーションと一緒に使用された場合、フォールバックは、他のすべてのフォールトトレランス処理が行われた後に例外がスローされる場合に呼び出される

  • @Retry の場合、Fallback は最大試行回数を超えた場合に処理される

  • CircuitBreaker では、メソッド呼び出しに失敗したときにいつでも呼び出され、Circuit が開いている場合、常に Fallback が呼び出される


@CircuitBreaker

サーキットブレーカーは、機能不全に陥ったサービスやAPIが繰り返し呼び出されることを防ぎます。 あるサービスが頻繁に失敗する場合、サーキットブレーカーが開き、一定時間が経過するまでそのサービスへの呼び出しが早期失敗します。

サーキットブレーカーは以下の3つの状態があります。

  • Closed

    • 通常の状態で、各呼び出しが成功したか失敗したかを記録し、ローリングウィンドウに直近の結果を記録
    • ローリングウィンドウが一杯になり、その失敗の割合が failureRatio を上回ると、サーキットブレーカーが開かれる
  • Open

    • サーキットブレーカーが開いている場合、サーキットブレーカーの下で動作しているサービスへの呼び出しは CircuitBreakerOpenExceptionで即座に失敗する
    • 設定した遅延時間の後、サーキットブレーカは半開状態に遷移する
  • Half-open

    • 半開放状態では、設定した回数のサービスの試行が行われる
    • 試行のうち1回でも失敗すると、サーキットブレーカは Open 状態に戻り、全ての試行が成功すれば Closed に戻る


@CircuitBreaker には以下を指定することができます。

  • requestVolumeThreshold

    • サーキットブレーカーが Closed するときに使用されるローリングウィンドウのサイズを指定
  • failureRatio

    • サーキットブレーカーが Open する原因となるローリングウィンドウ内の故障比率を指定
  • successThreshold

    • サーキットブレーカーが Half-open 時に許可される試行呼び出しの回数を指定
  • delaydelayUnit

    • サーキットブレーカーが Open 状態を維持する時間を指定
  • failOn

    • スローされた例外が failOn パラメータのいずれかに割り当て可能であれば、失敗とみなされます
  • skipOn

    • スローされた例外が skipOn パラメータのいずれかに割り当て可能であれば、成功とみなされます

サーキットブレーカの状態が遷移すると、サーキットブレーカの記録はリセットされます。

以下の例では、直近の4回のリクエスト(requestVolumeThreshold)の内、2つが失敗した場合、failureRatio が 0.5 に達するため、次のリクエストは CircuitBreakerOpenException となります。

@CircuitBreaker(
    successThreshold = 10,
    requestVolumeThreshold = 4,
    failureRatio = 0.5,
    delay = 1000)
public Connection serviceA() {
   Connection conn = null;
   counterForInvokingServiceA++;
   conn = connectionService();
   return conn;
}

なお、サーキットブレーカーは呼び出しの状態を常に監視するためアノテーション付与した Bean のライフサイクルに関係なく、シングルトンとなります。

他のアノテーションとの組み合わせ時の動作は以下のようになります。

  • @Timeout@Fallback@Asynchronous@Bulkhead@Retryと一緒に使用することができます

  • @Fallback と一緒に使用した場合、CircuitBreakerOpenException がスローされたときにフォールバックメソッドまたはハンドラが呼び出されるようになります

  • @Retry と一緒に使用した場合、各再試行がサーキットブレーカーによって成功または失敗として記録され、CircuitBreakerOpenException がスローされた場合に @Retry の設定により、再試行される場合がある

  • @Bulkhead と一緒に使用した場合、バルクヘッドに入ろうとする前にサーキットブレーカーがチェックされる 。バルクヘッドに入ろうとして BulkheadException が発生する場合、サーキットブレーカーの failOn 属性の値によっては失敗としてカウントされることがある


@Bulkhead

インスタンスにアクセスする同時リクエストの数を制限することで、その領域のレスポンス低下がシステム全体に波及しないようにします。

したがって、Bulkheadパターンは、複数のコンテキストからアクセスされるコンポーネントに @Bulkheadを適用する場合にのみ有効となります。

バルクヘッドには、スレッドプール分離(thread pool isolation)とセマフォ分離(semaphore isolation)という2種類のアプローチがあります。 @Asynchronous と一緒に使用した場合はスレッドプール分離が使用されます(待ち行列のサイズとともに最大同時リクエスト数を設定可能)。 @Asynchronous なしで使用した場合は、セマフォ分離が仕様されます(同時リクエスト数の設定のみが可能)。


セマフォ分離の例は以下のようになり、同時リクエストの最大値を5に制限しています。

@Bulkhead(5)
public Connection serviceA() {
    ...
}

最大リクエストの最大値に達すると、BulkheadException がスローされ余分なリクエストは失敗します。

スレッドプール分離の例は以下のようになり、最大同時リクエスト数を 5 に、待機キューサイズを 8 に制限しています。

@Asynchronous
@Bulkhead(value = 5, waitingTaskQueue = 8)
public Future<Connection> serviceA() {
    ...
}

スレッドプール方式の場合、リクエストを待ち行列に追加できない場合に、BulkheadException がスローされます。

なお、サーキットブレーカーは呼び出しの状態を常に監視するためアノテーション付与した Bean のライフサイクルに関係なく、シングルトンとなります。


他のアノテーションとの組み合わせ時の動作は以下のようになります。

  • @Fallback@CircuitBreaker@Asynchronous@Timeout@Retryと一緒に使用することができる

  • @Fallback と一緒に使用した場合、BulkheadException がスローされると、その Fallback が呼び出される

  • @Retry と一緒に使用した場合、BulkheadException 失敗した場合、@Retry で設定した遅延時間だけ待ってから再試行される。バルクヘッドによって実行が許可された後に @Retry によって処理される別の例外がスローされると、呼び出しはまずバルクヘッドを出て、実行中の同時リクエストのカウントを 1 つ減らし、@Retry で設定された遅延時間だけ待ってからバルクヘッドに再アクセスしようとする。この時点で、リクエストは受理されるか、キューに入れられるか、BulkheadException する(この場合、さらに再試行される可能性がる)