Java
timeout
retry
Failsafe
CircuitBreaker
TISDay 7

安定性のパターン大全 (とその実装)

More than 1 year has passed since last update.

Cognitect社のNygardさんが10年ぶりに改訂したRelease It! 2nd Editionがまもなくリリースされます。内容は現在のベータ5版で全て書ききっておられるようなので、是非読んでみてください。

https://pragprog.com/book/mnee2/release-it-second-edition

その中から4章の安定性パターンの概要をご紹介し、実際JavaのFailsafeライブラリを使った実装例を示したいと思います。

安定性のパターン Stability Patterns

分散システムや後続をブロッキングしてしまう重い処理は、システム全体がスローダウンしたり、無応答になってしまう危険にさらされています。クラウド時代になって、これらの安定性を保つための設計はより強調されるようになりましたが、わりと昔から様々な工夫がされてきたものでもあります。以下、Release It! 2nd Editionの4章にまとめられている安定性のパターンです。

タイムアウト Timeout

スレッドをブロックする処理は、必ずタイムアウトの設定をしましょうというパターンです。

  • 他システムへの連携ポイントやスレッドをブロックしたり、応答に時間がかかる可能性がある処理には、タイムアウトを適用する。
  • 予期しないエラーから復旧させるためにタイムアウトをさせる。
  • ネットワークが絡むところでは、即座にリトライするのではなく、ディレイさせることを考える。

サーキットブレイカー Circuit Breaker

リモート呼び出しのときに、連続してエラーが発生したり、スローレスポンスが頻発する場合、呼び出し自体を止めるというパターンです。

  • 呼び先のリソースを逼迫させることが分かっているなら、呼ぶのをやめる。
  • タイムアウトパターンと一緒に使う。
  • サーキットブレイカーの状態の変化を、外から見えるようにし、追跡・レポーティングする。

隔壁 Bulkheads

サーキットブレイカーとは逆に、呼ばれる側の重要機能のリソース確保のパターンです。

  • 重要な機能を保護するために、隔壁をつくる
  • アプリケーション内のスレッドプールを分けたり、CPUやサーバを分けたり。
  • 特にSOAやMSAのようなサービスを複数機能が共有するモデルでは、この隔壁を作ることを検討しよう。

定常状態 Steady State

  • 人手の介入する余地を無くそう。
  • アプリケーションロジックを使ってデータをパージする(DBA任せにしない)。
  • キャッシュに上限を設ける。
  • ログのローテーションを忘れないように。

フェイルファスト Fail Fast

失敗することが事前にわかっていれば、その処理自体を行わないようにしようというパターンです。

  • 応答の遅延を避け、速く失敗させる。
  • トランザクション開始前に、失敗することが検証できるのであれば、開始させない。
  • リソースを確保する前に、入力値を検証しよう

Let It Crash

部分的に再起動・再生成してクリーンな状態にした方が、システム全体に影響を与えず、元の定常状態に戻れるのであればそうしよう、というパターンです。

  • システム全体を守るために、コンポーネントをクラッシュさせる。
  • クラッシュは他のコンポーネントに影響がないようにする。
  • モノリシックなもの(スタートアップに時間のかかるもの)はクラッシュさせないようにする。

ハンドシェイク Handshaking

リモートの呼び出し時に、相手側の状態を双方でチェックし、呼び出しを止めたりするパターンです。

  • サーバ/クライアント間で、需給をコントロールできるようにする。
  • ヘルスチェックを検討する
  • 独自のローレベルなプロトコルを作るときは、ハンドシェイクを導入し、要求を受けつけられるかどうかの検証ポイントを導入する。

テストハーネス Test Harnesses

リモート呼び出しのためのスタブ(テストハーネス)を作って、テストしましょうというパターンです。

  • 仕様外の失敗をエミュレートする。
  • 呼び出し側に負荷がかかった状況(遅い応答、応答なし、壊れた応答メッセージ等)を再現させる
  • 一般的な失敗ケースにおいては、ハーネスを共有する。
  • Test Harnessパターンは他のテスト手法を置き換えるものじゃない。補完するものとして使おう。

分散ミドルウェア Decoupling Middleware

ミドルウェアはできるだけ疎結合にしましょう、というパターンです。

  • ミドルウェアの結合度の設計は、他のパターンと違い後戻りしにくいので、最後の意思決定ポイントを決めておく。
  • 連鎖しておこる複合的な障害の発生を避けるために、全体の結合度を下げる
  • 多くのアーキテクチャパターンを学び、適切に使おう

Shed Load

予期しないアクセスの増加やDDoSのような攻撃からシステムを守るために、ロードバランサーなどで503レスポンスを返すようにしておきましょう、というパターンです。

  • 予想外の負荷にさらされたときに、それからガードできるようにしておく。
  • 応答の遅延を防ぐために、Shed Loadを使う。
  • 激しい負荷への緩衝地帯として、ロードバランサーを使って503を即座に返す。

背圧 Back Pressure

サーバ側の負荷量を、呼び出し側に伝達し、呼び出し側の流量をコントロールするパターンです。

  • コンシューマ(サブスクライバ)をスローダウンさせることによって、安全性を確保する。
  • バックプレッシャーは同一システム内で使う。外からのリクエストは代わりに「Shed Load」を使おう。
  • 応答時間を有限にするために、キューは有限にしなければならない。

調速機 Governor

  • 自動化された仕組みでシステムが悪い方向に向かっているときに、人が介入できるようスローダウンされる仕組みも用意しておく。
  • 危険な方向へのアクション(シャットダウン、削除、処理をブロックしてしまうオペレーション)が、簡単に実行できないように「抵抗」を設ける。
  • 応答曲線を考えよう。安全に動作する範囲においては何もしなくてよいが、その範囲外になれば、スローダウンさせることによって「抵抗」を増やす。

なお、Release It!はこの調子で最初から最後まで、学びどころたっぷりの内容なのです。2nd Editionは大幅に加筆されています。

Failsafe

https://github.com/jhalterman/failsafe

FailsafeはStability Patternsのタイムアウト、リトライ、サーキットブレイカーをお手軽に実装できるJavaのライブラリです。使い方が簡単なので、レガシーなシステムにもStability Patternsを導入しやすいのが特長です(ただしJava8以上でないと、コード的美しさが損なわれる)。

RetryPolicy retryPolicy = new RetryPolicy()
  .retryOn(ConnectException.class)
  .withDelay(1, TimeUnit.SECONDS)
  .withMaxRetries(3);

Connection connection = Failsafe.with(retryPolicy).get(this::connect);

リトライしたい、またはサーキットブレイカーをセットしたい処理を、Failsafeのlambda経由で実行するようにしてあげるだけです。簡単です。

Failsafe適用ケース

ケース1: ファイルが到着するまでリトライ

ファイルが到着時間になっても、届かなくてバッチが異常終了した。遅れることもあるのでリトライするようにしたい。

// ファイルが無いと、FileNotFoundException
try (InputStream in = new FileInputStream("data.csv"))) {
    // ...
}

ファイル到着をポーリングし、リトライかける
単純に1分待ってリトライを繰り返す。10分たってもファイルが来ない場合は、従来と同じように例外を投げます。

RetryPolicy retryPolicy = new RetryPolicy()
        .withDelay(1, TimeUnit.SECONDS)
        .withMaxRetries(10)
        .retryOn(FileNotFoundException.class);

try (InputStream in = Failsafe.with(retryPolicy)
        .onFailedAttempt((o, ex) -> LOG.warn("File is not found. Retry after 1minutes"))
        .get(() -> new FileInputStream("data.csv"))) {
    // ...
}

たったこれだけの修正です。

ケース2: 結果整合性における参照一貫性のためのリトライ

更新処理が結果整合性レベルで、通常はすぐに更新データを参照できるが、システム全体の負荷が高くなると更新データの参照でレコードが見つからなかったり、古いデータが参照されたりする。(結果整合性のアーキテクチャなのに、すぐに参照しようとするのが設計ミスですが…)

これも応急処置として、SELECTしているところをFailsafeで囲うことができます。

このケースでは、負荷集中しているときにリトライが頻発することが想定されるので、Backoff(指数関数的にリトライ間隔を長くする)とJitter(リトライ時間をランダムに少しだけずらす)を設定し、エラーになったリクエストがまた同時にリトライされて結局またエラーになるという自体をできるだけ避けるようにします。

RetryPolicy selectRetryPolicy = new RetryPolicy()
        .withBackoff(50, 3000, TimeUnit.MILLISECONDS)
        .withJitter(.25)
        .withMaxRetries(3) // 最大3回リトライする
        .abortIf((o, ex) -> ex.getCause() instanceof SQLException)
        .retryIf(ret -> ((Long) ret) != 1); // SELECT結果0件の場合はリトライする

...

Failsafe.with(selectRetryPolicy)
        .get(() -> {
           try(PreparedStatement stmt = conn.prepareStatement("SELECT count(*) FROM emp WHERE emp_id=?")) {
               stmt.setLong(1, id);
                try (ResultSet rs = stmt.executeQuery()) {
                    return rs.next() ? rs.getLong(1) : 0;
                }
            } catch(SQLException e) {
                throw new RuntimeException(e);
            }
        });

ケース3: 貧弱なAPIサーバを守る

商品の検索を、別サーバプロセスのAPIを叩いてとってくるが、そこのリソースが貧弱でよく503エラーを返す。

これは商品検索サーバのリソースを保護するためにサーキットブレイカーパターンが適用できます。

circuitBreaker = new CircuitBreaker()
        .withSuccessThreshold(3)
        .withFailureThreshold(5)
        .failOn(IOException.class) // コネクションタイムアウトなどネットワーク系のエラーは失敗扱いする
        .failIf(res -> Response.class.cast(res).code() >= 500); // サーバサイドのエラーは失敗扱いする

Stability Patternsの実装例

上記ケース3のサンプルコードを書いてみたので、ここに説明書きを残しておきます。

https://github.com/kawasima/stability-sandbox

貧弱なAPIサーバは以下のような実装です。
Undertowでサーバを作っていて、Workerスレッドの数だけ重い処理(ここではRSA鍵ペアの生成)を同時に受け付けるようtaskQueueを用意しています。taskQueueに入らなかったリクエストは即座に503が返ります(Shed Loadの簡易実装)。

TestHarnessServer.java
    private KeyPairGenerator keyGen;
    private BlockingQueue taskQueue;
    private AtomicLong taskCounter = new AtomicLong(0);

    // ...

    HttpHandler normalJsonReponseHandler = exchange -> {
        long t1 = System.currentTimeMillis();
        KeyPair key = keyGen.generateKeyPair();
        long elapse = System.currentTimeMillis() - t1;
        final Long taskNo = exchange.getAttachment(TASK_COUNTER_KEY);
        exchange.getResponseSender().send("{"
                + "\"id\":\"" + taskNo + ","
                + "\"key\":\"" + key.toString() + "\","
                + "\"elapse\":" + elapse
                + "}");
        responseTime.record(Duration.ofMillis(elapse));
        taskQueue.remove(taskNo);
    };

    // ...

    public void jsonResponse(HttpServerExchange exchange) {
        final long taskNo = taskCounter.getAndAdd(1);
        if (!enabledShedLoad || taskQueue.offer(taskNo)) {
            if (exchange.isInIoThread()) {
                exchange.putAttachment(TASK_COUNTER_KEY, taskNo);
                exchange.dispatch(normalJsonReponseHandler);
                return;
            }
        } else {
            counter503.increment();
            exchange.setStatusCode(503);
        }
    }

HTTPリクエストを投げる方は、前述のサーキットブレイカーを使います。
Failsafeはサーキットブレイカーとリトライを同時に実現できるので、1度だけリトライする仕組みを入れてます。
リトライでも失敗した場合、Failsafeはフォールバック用に代わりのダミーオブジェクトを返すことができます。サーキットブレイカーがOpen状態のときに、サーバにリクエスト投げず503のレスポンスをFailsafeから返すことによって、それがサーバから発生したものか、サーキットブレイカーから発生したものか区別することなく、503発生時の処理を共通化することができます。

APIRequester.java
BlockingQueue<Runnable> workerQueue = new ArrayBlockingQueue<>(512);
ExecutorService service = new ThreadPoolExecutor(40,40,0, TimeUnit.NANOSECONDS, workerQueue,
        (r, executor) -> {
            LOG.error("reject request");
        });

for (int i = 0; i<1000; i++) {
    service.submit(() -> {
        Request request = new Request.Builder()
                .url("http://localhost:3000/json")
                .get()
                .build();
        try {
            Response response = Failsafe
                    .with(retryPolicy)
                    .with(circuitBreaker)
                    .withFallback(new Response.Builder()
                            .protocol(Protocol.HTTP_1_1)
                            .request(request)
                            .code(503)
                            .message("Circuit breaker is open")
                            .build())
                    .onRetry((o, t) -> counterRetry.increment())
                    .get(() -> httpClient.newCall(request).execute());
            if (response.code() == 200) {
                try {
                    LOG.info(response.body().string());
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            } else {
                counter503.increment();
            }
        } catch (Throwable t) {
            // FIXME プログラムバグ検知用
            LOG.error("something wrong:", t);
        }
    });
    TimeUnit.MILLISECONDS.sleep(50);
}

これで実行してみると、サーバ側のワーカースレッドが一杯になったタイミングで、サーキットブレイカーが作動して、サーバへのリクエストが遮断されるようになる様がみてとれます。

TODO: あとで実行結果貼る

まとめ

Release It!は、パターンが分かりやすく書いてあるだけでなく、現実世界でおきた事例に照らし合わせて、パターン・アンチパターンが出てくるので、読み物としても非常に面白いのでオススメです。