Goにおけるサーキットブレーカーパターン:カスケード障害の防止

Goマイクロサービスにおけるカスケード障害を防止する

目次

サーキットブレーカーは、Goのサービスが故障した依存先に対して過剰な呼び出しを行うことを防ぎ、goroutine、ソケット、メモリを消費してシステム全体が崩壊するまでカスケード障害(連鎖障害)を引き起こすのを防ぎます。

難しいのは状態遷移の仕組みそのものではありません。サーキットブレーカーを設置すべき場所はどこか、何が「障害」とみなされるべきか、タイムアウトやリトライとどう連携すべきか、そしてサーキットが開いている場合にサービスがどう振る舞うべきかを決めることです。

circuit breaker

Goにおいて、サーキットブレーカーパターンは特にアウバウンド(外部向け)の呼び出しに有効です。具体的には、HTTP API、決済ゲートウェイ、検索サービス、メールプロバイダ、LLMゲートウェイ、内部マイクロサービス、および遅延したり過負荷になったり、部分的に利用不可になったりする可能性があるその他の依存先です。適切に使用すれば、サーキットブレーカーはカスケード障害を軽減します。しかし、誤った使用をすると、それは単なる厄介な障害モードになってしまうことがあります。

サーキットブレーカーが解決する問題

分散システムは、通常、きれいに障害が発生することはありません。

依存先が完全にダウンしているとは限りません。以下のような状態である可能性があります:

  • 500エラーを返している
  • 429(レートリミット超過)を返している
  • TCP接続は受け付けるが、応答を返さない
  • 300ミリ秒のところを30秒かけて応答する
  • 一部の要求のみで失敗する
  • すべてのクライアントが一斉にリトライしているために過負荷になっている

最も恐ろしいのは、ハード障害(完全なダウン)ではありません。それは「遅い依存先」です。

遅い呼び出しは、goroutine、ソケット、データベース接続、メモリ、ワーカーのキャパシティを消費します。すでに不健康な依存先を待ち続けている限り、あなたのサービスも不健康になってしまいます。

サーキットブレーカーは、依存先が障害閾値を超えた時点で素早く失敗(フェイルファスト)させることで、これを防ぎます。

永遠に以下を繰り返すのではなく:

リクエスト -> 依存先呼び出し -> 待機 -> タイムアウト -> リトライ -> 待機 -> 失敗

サービスは最終的に以下を実行します:

リクエスト -> サーキット開放 -> 即時フォールバックまたはエラー返却

この素早い失敗は必ずしも喜ばしいものではありませんが、予測可能です。予測可能な障害は、ゆっくりとした崩壊よりも運用しやすいものです。

サーキットブレーカーの3つの状態

ほとんどのサーキットブレーカーは3つの状態を使用します。

Closed(閉)

通常動作中はサーキットが閉じています。

リクエストは通過が許可されます。ブレーカーは成功と失敗を記録します。失敗の数または割合が閾値を超えると、ブレーカーは開放されます。

Closedは「永遠に安全」という意味ではありません。「現在トラフィックが許可されている」という意味です。

Open(開)

依存先が不健康であるとみなされた場合、サーキットは開放されます。

リクエストは即時拒否されます。サービスはフォールバック、キャッシュレスポンス、劣化したレスポンス、または明確なアップストリームエラーを返すべきです。

Openは依存先そのものを修復するものではありません。依存先に回復の時間を与え、呼び出し元がリソースを無駄に消費するのを防ぎます。

Half-Open(半開放)

クールダウン期間の後、ブレーカーは半開放状態に入ります。

限られた数のテストリクエストのみが通過が許可されます。これらが成功すれば、ブレーカーは閉じます。失敗すれば、ブレーカーは再び開放されます。

半開放状態は、以下の2つの悪い極端を避けるために重要です:

  • 依存先を二度と試さない
  • トラフィックをすぐにフルに戻す

状態遷移は以下のようになります:

stateDiagram-v2 [*] --> Closed Closed --> Open: 障害閾値に達した Open --> HalfOpen: タイムアウト経過 HalfOpen --> Closed: テスト成功 HalfOpen --> Open: テスト失敗

サーキットブレーカー vs タイムアウト vs リトライ

よくある間違いは、サーキットブレーカー、リトライ、タイムアウトを互換性のあるものとして扱うことです。これらは関連していますが、解決する問題は異なります。

タイムアウト

タイムアウトは、1つの操作が実行可能な最大時間を制限します。

Goでは、通常、アウバウンド呼び出しにデッドラインまたはタイムアウト付きの context.Context を渡すことを意味します。

タイムアウトは以下の問いに答えます:

この1回の呼び出しに対して、どれくらい待つ覚悟があるか?

リトライ

リトライは、障害が一時的なものかもしれない場合に、操作を繰り返し実行します。

リトライは、短いネットワークの断続的な障害、一時的な503応答、接続のリセット、その他の一時的な障害に有用です。

リトライは以下の問いに答えます:

この呼び出しを再試行すべきか?

サーキットブレーカー

サーキットブレーカーは、依存先がおそらく不健康であると判断された場合、呼び出しを停止します。

それは以下の問いに答えます:

今、この依存先を呼び出すべきか?

レートリミッター

レートリミッターは、時間経過に対して許可されるトラフィックの量を制御します。

それは以下の問いに答えます:

この呼び出し元はどれくらいのトラフィックを送信すべきか?

ブロックヘッジ(Bulkhead)

ブロックヘッジはリソースを分離し、1つの依存先がすべてを消費するのを防ぎます。

それは以下の問いに答えます:

この依存先が私のサービスに与えうる被害の規模はどれくらいか?

これらのパターンは、組み合わせて使用したときに最も強力になります。タイムアウトなしのサーキットブレーカーは弱いです。ジッター(乱数)なしのリトライはリトライストームを引き起こす可能性があります。メトリクスなしのフォールバックは障害を隠してしまう可能性があります。

Goでサーキットブレーカーを使用すべき場面

サーキットブレーカーは、サービスとは独立して失敗する可能性がある依存先を呼び出す場合に使用します。

適切な候補としては以下があります:

  • 外部HTTP API
  • 決済プロセッサ
  • メールおよびSMSプロバイダ
  • 検索サービス
  • レコメンデーションサービス
  • LLM推論ゲートウェイ
  • 内部マイクロサービスのエンドポイント
  • サードパーティSaaS API
  • 遅い、または過負荷のリードサイドサービス

サーキットブレーカーは、呼び出し元が適切に機能低下(デグレード)できる場合に特に有用です。

例えば:

  • キャッシュされた商品データを返す
  • レコメンデーションブロックをスキップする
  • 決済プロバイダを一時的に利用不可としてマークする
  • 依存先が回復した後でも成功しなかったメッセージの最後のアセプトとして、デッドレターキューにワークをキューイングする
  • 部分的なレスポンスを返す
  • 明確な一時的エラーで素早く失敗する

重要な質問は「この呼び出しは失敗しうるか」ではありません。すべてが失敗しうるのです。より良い質問はこれです:

この依存先が失敗している場合、フルトラフィックをそれに送り続けるべきか?

答えが「ノー」の場合、サーキットブレーカーが役立つ可能性があります。

サーキットブレーカーを使用すべきでない場面

パターンが責任あるように聞こからといって、すべての関数にサーキットブレーカーを追加しないでください。

サーキットブレーカーは通常、以下には有用ではありません:

  • 同一プロセス内のローカル関数呼び出し
  • モノリシックな内部の単純なCRUD
  • バリデーションロジック
  • 決定論的なビジネスルール
  • CPUのみのローカル操作
  • 有用なフォールバックが存在しないコードパス
  • 冪等性のない書き込み操作
  • より強力なワークフローレイヤーで保護されている依存先

また、サーキットブレーカーは基本的な衛生管理の代替にはなりません:

  • タイムアウトを設定する
  • コンテキストを伝播させる
  • 接続プールを正しく使用する
  • エラーを明示的に処理する
  • リトライを安全にする
  • 障害率を観察する

不適切なサーキットブレーカーは、システムの推論を難しくすることがあります。実際の問題を隠したり、トラフィックを攻撃的に拒否したり、回復中に混乱を招く動作を引き起こしたりする可能性があります。

少し意見のあるルールですが、シンプルです:

依存先の境界にのみサーキットブレーカーを追加し、すべてに追加しない。

Goのサーキットブレーカーライブラリの選定

基本的なサーキットブレーカーを自作することもできますが、本番環境のGoサービスではライブラリを使用すべきです。

一般的でシンプルな選択肢は sony/gobreaker です。

これにより得られるものは以下の通りです:

  • 閉、開放、半開放の状態
  • 構成可能な障害閾値
  • 構成可能な開放状態のタイムアウト
  • 状態変更コールバック
  • リクエストカウンター
  • v2でのジェネリックサポート
  • 小さなAPIサーフェス

より大規模なレジリエンスパイプラインの場合は、リトライ、タイムアウト、フォールバック、レートリミッティング、ブロックヘッジ分離、およびサーキットブレーキングなど、複数のポリシーを構成するライブラリを探すこともできます。これは、操作の周りに単一のレジリエンス層を持ちたい場合に有用です。

しかし、多くのGoサービスにとって、gobreaker で十分です。

Goサーキットブレーカーパッケージの比較

Goの標準ライブラリには組み込みのサーキットブレーカーが含まれていません。実際には、通常、小さなサーキットブレーカーライブラリ、大規模なレジリエンスフレームワーク、または古いHystrixスタイルのパッケージの間で選択することになります。

新しいGoサービスの場合、判断はシンプルです:

  • 小型で焦点を絞ったサーキットブレーカーが欲しい場合は sony/gobreaker を使用する
  • リトライ、タイムアウト、フォールバック、ブロックヘッジ、レートリミット、およびその他のレジリエンスポリシーと組み合わせたサーキットブレーカーが欲しい場合は failsafe-go を使用する
  • 既存のHystrixスタイルのコードがない限り、hystrix-go で新規プロジェクトを開始しない
パッケージ 最適な用途 強み トレードオフ
sony/gobreaker/v2 HTTP/RPCクライアント周辺のシンプルなサーキットブレーカー 小さなAPI、ジェネリックv2サポート、明確な状態モデル、依存先クライアントへのラップが容易 サーキットブレーキングのみを解決;リトライ、タイムアウト、フォールバックは別途構成が必要
failsafe-go 完全なレジリエンスポリシーの構成 リトライ、フォールバック、サーキットブレーカー、タイムアウト、ブロックヘッジ、レートリミッター、キャッシュ、ヘッジ、適応型リミッター、適応型スロットラーなどのポリシー 学習すべき概念が多い;基本的なブレーカーのみが必要な場合、必要以上に重い
afex/hystrix-go 従来のHystrixスタイルシステム 馴染みのあるHystrixの概念、コマンドスタイルの実行、歴史的な使用例 古い設計;新しいGoサービスにとって最適なデフォルトではない
go-kit/kit/circuitbreaker Go kitのエンドポイントベースサービス Go kitミドルウェアのスタイルとエンドポイントアーキテクチャに適合 サービスがすでにGo kitを使用している場合にのみ有用
cep21/circuit Hystrixライクなサーキットブレーカー動作 より機能的なHystrixスタイルのアプローチ シンプルなデフォルトとしては一般的でない;小規模サービスには必要以上の機能

デフォルトの推奨は、意図的に地味です:サーキットブレーカーのみが必要な場合は、sony/gobreaker/v2 から始めます。1か所で完全なレジリエンスポリシーを表現したい場合は failsafe-go を使用します。

この使い分けにより、アーキテクチャがクリーンに保たれます。小規模なサービスクライアントに、故障した依存先の呼び出しを止めるためだけにフルレジリエンスフレームワークは必要ありません。しかし、ゲートウェイ、アグリゲータ、APIクライアントSDK、または高トラフィックの統合レイヤーは、構成されたポリシーから恩恵を受ける可能性があります。

gobreakerのインストール

新しいコードではv2パッケージを使用してください:

go get github.com/sony/gobreaker/v2

その後、インポートします:

import "github.com/sony/gobreaker/v2"

Goにおける基本的なサーキットブレーカー

HTTP呼び出しの周りに小さな例を示します。

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"

    "github.com/sony/gobreaker/v2"
)

var ErrTemporaryUnavailable = errors.New("依存先が一時的に利用不可です")

type UserClient struct {
    baseURL string
    http    *http.Client
    cb      *gobreaker.CircuitBreaker[[]byte]
}

func NewUserClient(baseURL string) *UserClient {
    settings := gobreaker.Settings{
        Name:        "user-service",
        MaxRequests: 3,
        Interval:    30 * time.Second,
        Timeout:     10 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            return counts.ConsecutiveFailures >= 5
        },
        OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
            fmt.Printf("circuit breaker %s changed from %s to %s\n", name, from, to)
        },
    }

    return &UserClient{
        baseURL: baseURL,
        http: &http.Client{
            Timeout: 3 * time.Second,
        },
        cb: gobreaker.NewCircuitBreaker[[]byte](settings),
    }
}

func (c *UserClient) GetUser(ctx context.Context, userID string) ([]byte, error) {
    result, err := c.cb.Execute(func() ([]byte, error) {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodGet,
            c.baseURL+"/users/"+userID,
            nil,
        )
        if err != nil {
            return nil, err
        }

        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            return nil, fmt.Errorf("user service returned %d", resp.StatusCode)
        }

        if resp.StatusCode == http.StatusNotFound {
            return nil, fmt.Errorf("user not found")
        }

        if resp.StatusCode >= 400 {
            return nil, fmt.Errorf("user service client error: %d", resp.StatusCode)
        }

        return io.ReadAll(resp.Body)
    })

    if errors.Is(err, gobreaker.ErrOpenState) {
        return nil, ErrTemporaryUnavailable
    }

    if errors.Is(err, gobreaker.ErrTooManyRequests) {
        return nil, ErrTemporaryUnavailable
    }

    return result, err
}

これは完全な本番環境のクライアントではありませんが、以下の形状を示しています:

  • ブレーカーがアウバウンド呼び出しをラップしている
  • HTTPリクエストにコンテキストが渡されている
  • HTTPクライアントにタイムアウトが設定されている
  • サーバー側の障害がブレーカーの障害としてカウントされる
  • 開放状態のエラーがアプリケーションエラーに変換される

gobreakerの設定

重要な設定を理解しておく価値があります。

Name

Name はブレーカーを識別します。

安定した特定の名称を使用してください:

payment-api
search-service
llm-gateway
user-service

曖昧な名称は避けてください:

http-client
external-call
default

この名称はログやメトリクスで必要になります。

MaxRequests

MaxRequests は、ブレーカーが半開放状態のときに許可されるリクエスト数を制御します。

小さな値の方が通常は安全です。半開放の目的は、即時にフルトラフィックを送信するのではなく、回復をテストすることです。

Interval

Interval は、ブレーカーが閉じている間に内部カウントをクリアするタイミングを制御します。

ゼロの場合、カウントは自動的にクリアされません。非ゼロのインターバルは、ブレーカーにローリング的な記憶窓を与えます(ただし、完全なスライディングウィンドウの実装と同じではありません)。

Timeout

Timeout は、ブレーカーが半開放に遷移する前に開放状態を維持する時間を制御します。

タイムアウトが短すぎると、回復していない依存先に対してサービスはプロービングを続けてしまいます。長すぎると、回復が遅れてしまいます。

保守的な値(例:10〜30秒)から始め、本番環境のメトリクスに基づいて調整します。

ReadyToTrip

ReadyToTrip は、ブレーカーを開放すべきかどうかを決定します。

シンプルなルールは連続した障害です:

ReadyToTrip: func(counts gobreaker.Counts) bool {
    return counts.ConsecutiveFailures >= 5
}

これは推論しやすいですが、高ボリュームのサービスには適していない可能性があります。

別のオプションは、最小リクエスト数の後の障害比率です:

ReadyToTrip: func(counts gobreaker.Counts) bool {
    total := counts.Requests
    failures := counts.TotalFailures

    if total < 20 {
        return false
    }

    return float64(failures)/float64(total) >= 0.5
}

これにより、極小サンプル数でサーキットが開放されるのを防ぎます。

OnStateChange

OnStateChange は、ログやメトリクスを出力すべき場所です。

最低限、以下を記録してください:

  • ブレーカー名
  • 旧状態
  • 新状態
  • タイムスタンプ

本番システムでは、ブレーカーの状態をメトリクスとして公開してください。ログはデバッグに有用ですが、アラートやダッシュボードにはメトリクスの方が適しています。

IsSuccessful

IsSuccessful は、どのエラーが障害としてカウントされるかを決定できます。

これは重要です。

すべてのエラーがブレーカーを開放すべきではありません。例えば、ユーザーサービスからの 404 Not Found は有効なビジネス結果である可能性があります。400 Bad Request は依存先のせいではなく、呼び出し元のせいかもしれません。

503 Service Unavailable、タイムアウト、接続のリセット、または 429 Too Many Requests は、実際の依存先の健康信号である可能性があります。

ここでは注意が必要です。間違ったエラーをカウントすることは、ノイジーなサーキットブレーカーを作成する最も簡単な方法の一つです。

何が障害としてカウントされるべきか?

ここにはエンジニアリングの判断が求められます。

通常、以下を障害としてカウントします:

  • ネットワークタイムアウト
  • 接続拒否
  • 接続のリセット
  • HTTP 500
  • HTTP 502
  • HTTP 503
  • HTTP 504
  • 繰り返し発生する429応答
  • 依存先からの不正なレスポンス
  • アウバウンド呼び出し中のコンテキスト期限切れ

通常、以下を依存先の障害としてカウントしません:

  • バリデーションエラー
  • ローカルシリアライズエラー
  • 予想される404応答
  • 呼び出し元側の認証失敗
  • ビジネスルールの拒否
  • ユーザー入力のエラー

ブレーカーは、一般的なアプリケーションの障害ではなく、依存先の健康状態を表すべきです。

サーキットブレーカーと context.Context

Goにおいて、サーキットブレーカーは context.Context の代替にはなりません。

サーキットブレーカーは呼び出しを試行するかどうかを決定します。コンテキストは、その呼び出しがどれくらい実行可能であり、呼び出し元がいなくなった場合に停止すべきかどうかを制御します。

良いアウバウンド呼び出しは、通常、両方を持っているべきです:

ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()

data, err := client.GetUser(ctx, userID)

コンテキストは呼び出しチェーンを通じて流れるべきです:

着信リクエストのコンテキスト
-> サービスメソッド
-> クライアントメソッド
-> HTTPリクエスト
-> 依存先

リクエストスコープのコード内で分離されたバックグラウンドコンテキストを作成するのは避けてください。ユーザーリクエストがキャンセルされた場合、ダウンストリーム作業も通常は停止すべきです。

穏やかなルールがあります:

ブレーカーはシステムを保護する。コンテキストはリクエストを保護する。

通常、両方とも必要です。

サーキットブレーカーとリトライ

リトライとサーキットブレーカーは一緒にうまく機能できますが、順序が重要です。

最も安全なデフォルトは:

試行ごとのタイムアウト
バックオフとジッター付きのリトライ
依存先呼び出しの周囲のサーキットブレーカー

しかし、普遍的な答えはありません。何をカウントしたいかを考えてください。

各リトライ試行がブレーカーを通過する場合、1つのユーザーリクエストが複数の障害に貢献する可能性があります。これにより、ブレーカーがより早く開放されるかもしれませんが、これは良い面もあれば悪い面もあります。

ブレーカーが整個リトライ操作をラップする場合、ブレーカーは1人のユーザーリクエストにつき1回の最終的な成功または失敗のみを目にします。これは穏やかですが、失敗した試行の数を隠してしまう可能性があります。

多くのアプリケーションサービスにおいて、以下の形状は妥当です:

ユーザーリクエスト
-> サーキットブレーカー
   -> リトライポリシー
      -> タイムアウト付きの1回のHTTP試行

これは、ブレーカーが依存先操作が呼び出し元にとって最終的に機能したかどうかを追跡することを意味します。

より低レベルのクライアントでは、以下の形状も理にかなっています:

ユーザーリクエスト
-> リトライポリシー
   -> サーキットブレーカー
      -> タイムアウト付きの1回のHTTP試行

これは、ブレーカーが各試行を保護することを意味します。

より重要なルールはこれです:

無謀にリトライしないこと。

以下を使用してください:

  • 小さな最大リトライ数
  • 指数関数的バックオフ
  • ジッター
  • 試行ごとのタイムアウト
  • 全体のリクエスト期限
  • 書き込みの冪等性
  • リトライ試行のメトリクス

これらがない場合、リトライは小規模な障害をより大きな障害に変える可能性があります。リトライの安全性について詳しくは、実際に機能する分散システムにおける冪等性をご覧ください。

サーキットブレーカーと冪等性

サーキットブレーカーはリトライの隣によく現れ、リトライは冪等性の問いを提起します。

読み取り操作では、リトライは通常安全です。

書き込み操作では、リトライは危険を伴う可能性があります。

この決済呼び出しを考えてみましょう:

POST /charge

リクエストがタイムアウトした場合、決済は失敗しましたか?おそらくそうです。成功しましたが応答が失われましたか?それもそうです。

冪等性キーなしでリトライすると、二重課金される可能性があります。

書き込み操作では、以下のいずれかを使用してください:

  • 冪等性キー
  • リクエストID
  • 操作ID
  • 一意制約
  • トランザクショナルアウトボックス
  • ワークフローオーケストレーション
  • 明示的な照合

サーキットブレーカーは、故障した決済プロバイダへの呼び出しを続けるのを防ぐことができますが、安全でないリトライを安全にするものではありません。

サーキットブレーカーとフォールバック

サーキットが開いている場合、サービスには計画が必要です。

考えられるフォールバック戦略としては以下があります:

  • キャッシュデータを返す
  • 警告付きで古いデータを返す
  • 非重要なセクションを省略する
  • ワークを後でキューイングする
  • 別のプロバイダに切り替える
  • 一時的エラーを返す
  • 機能低下した状態を表示する
  • リクエストを素早く失敗させる

フォールバックは正直であるべきです。

例えば、以下は通常良好です:

{
  "status": "temporary_unavailable",
  "message": "Recommendations are temporarily unavailable"
}

以下は危険です:

{
  "recommendations": []
}

空のリストは有効な結果のように見えます。障害を隠し、ユーザーを混乱させ、デバッグを難しくする可能性があります。

沈黙するフォールバックは魅力的です。しかし、危険でもあります。

サーキットブレーカーと観測性(Observability)

観測性のないサーキットブレーカーは、主に予期せぬ動作の生成器です。

少なくとも以下のメトリクスを追跡してください:

  • 現在のブレーカー状態
  • 状態変更
  • 許可された呼び出し
  • 拒否された呼び出し
  • 成功
  • 障害
  • タイムアウト
  • フォールバックレスポンス
  • リトライ試行
  • ダウンストリームレイテンシ
  • ダウンストリームステータスコード

有用なラベルには以下が含まれます:

  • ブレーカー名
  • 依存先名
  • 操作名
  • ステータスクラス
  • エラーカテゴリ

ユーザーID、完全なURL、リクエストID、または生エラーメッセージなど、高カルディナリティのラベルは避けてください。

ダッシュボードから以下の質問に答える必要があります:

  • 現在どのサーキットブレーカーが開放されているか?
  • どれくらいの頻度で開放されるか?
  • どの依存先が開放の原因となったか?
  • ユーザーはフォールバックレスポンスを見ているか?
  • ブレーカー開放後にレイテンシは改善したか?
  • ブレーカー開放前にリトライ量が急増したか?
  • 依存先は回復したか?

ブレーカーを観測できない場合、それを調整することはできません。メトリクスとよく連携する構造化ロギングについては、Goでのslogによる構造化ロギングをご覧ください。

より本番環境向けのHTTPクライアント形状

実際のサービスでは、ハンドラーの周りにサーキットブレーカーのロジックを散らばせないでください。

依存先の周りに小さなクライアントパッケージを作成してください。

例の構造:

internal/
  userservice/
    client.go
    errors.go
    metrics.go

ハンドラーはgobreakerの詳細を知る必要はありません。ドメインレベルのクライアントメソッドに依存すべきです:

type UserService interface {
    GetUser(ctx context.Context, userID string) (*User, error)
}

その後、実装には以下を含めることができます:

  • HTTPリクエスト作成
  • コンテキスト伝播
  • ブレーカー実行
  • ステータスコード処理
  • レスポンスデコード
  • メトリクス
  • エラーマッピング

これにより、レジリエンスポリシーを依存先の境界近くに保つことができます。境界におけるエラー分類の詳細については、Goのエラー処理アーキテクチャ:境界とパターンをご覧ください。

アプリケーションアーキテクチャにおけるサーキットブレーカーの位置づけ

サーキットブレーカーパターンは、統合境界に属します。

Goアプリケーションでは、これは通常以下を意味します:

graph LR A[Handler] --> B[Application Service] B --> C[Dependency Client] C --> D[Circuit Breaker] D --> E[HTTP / RPC / DB / Queue]

可能であれば、ビジネスロジックからブレーカーを除外してください。

ビジネスレイヤーは、以下のドメインエラーを理解すべきです:

決済プロバイダ利用不可
レコメンデーション利用不可
プロフィールサービスタイムアウト

gobreakerの状態を理解する必要はありません。

この分離により、アーキテクチャはクリーンに保たれます:

  • トランスポートの関心事はクライアント内に留まる
  • レジリエンスポリシーは依存先近くに留まる
  • ドメインロジックは読みやすく保たれる
  • ハンドラーは薄く保たれる
  • テストが書きやすくなる

この記事は、本番環境でのアプリアーキテクチャトピックの一部です — 統合パターン内の冪等性、アウトボックス、サガ、およびオーケストレーションガイドと一緒に。

よくある間違い

間違い1:タイムアウトがない

サーキットブレーカーは、呼び出しが終了しない限り、遅い呼び出しを自動的に停止しません。

アウバウンド操作が永遠にハングする可能性がある場合、ブレーカーは障害を十分に早く認識できないかもしれません。

常にタイムアウトを使用してください。

間違い2:すべてに1つのグローバルブレーカーを使用する

すべての依存先に1つのブレーカーを使用しないでください。

故障したメールプロバイダが、決済プロバイダのサーキットを開放してはいけません。遅い検索エンドポイントが、ユーザープロフィール呼び出しをブロックしてはいけません。

障害モードが異なる場合、それぞれの依存先操作に個別のブレーカーを使用してください。

間違い3:呼び出し元のエラーを依存先の障害としてカウントする

サービスが悪質な入力を送信し、400 Bad Request を受け取った場合、それは通常、ダウンストリームの障害ではありません。

ブレーカーを自らのバグで訓練しないでください。

間違い4:非冪等な書き込みのリトライ

リトライは無償ではありません。書き込み、決済、メッセージ、または副作用の重複を引き起こす可能性があります。

リトライする前に、書き込みを冪等性にしてください。

間違い5:フォールバックの陰で障害を隠す

フォールバックは適切に機能低下させるべきであり、現実を偽造すべきではありません。

依存先がダウンしている場合、メトリクスとログがそれを明確にするべきです。

間違い6:本番データなしで調整する

例からコピーした閾値は、単なる出発点です。

以下に基づいて調整してください:

  • リクエストボリューム
  • 通常の障害率
  • 依存先のレイテンシ
  • ユーザーへの影響
  • 回復時間
  • フォールバックの品質

間違い7:キャパシティ管理の代わりにサーキットブレーカーを使用する

サーキットブレーカーは、以下の代替ではありません:

  • 負荷遮断(Load shedding)
  • レートリミッティング
  • キュー制限
  • オートスケーリング
  • データベースチューニング
  • 接続プールの制限
  • アップストリームクォータ

それはレジリエンス戦略の一部です。

実践的なデフォルト値

内部HTTP依存先を呼び出す典型的なGoサービスの場合、合理的な出発点は以下のようになるかもしれません:

HTTPクライアントタイムアウト:2〜5秒
リクエストごとのコンテキストタイムアウト:呼び出し元のSLAに基づく
ブレーカー障害ルール:5回の連続障害、または20リクエスト後の50パーセントの障害率
開放タイムアウト:10〜30秒
半開放リクエスト:1〜5
リトライ数:1〜3試行
リトライバックオフ:ジッター付きの指数関数的

これらは普遍的な値ではありません。安全-ishな出発点です。

ユーザー向けAPIでは、総レイテンシ予算を厳格に保ってください。バックグラウンドジョブでは、より長い待機時間を許容できるかもしれません。決済プロバイダでは、リトライと冪等性についてずっと注意深く扱う必要があります。

サーキットブレーカーのチェックリスト

サーキットブレーカーを追加する前に、以下の質問に答えてください:

  • どの依存先を保護しているのか?
  • どの操作を保護しているのか?
  • どのエラーが依存先の障害としてカウントされるのか?
  • ブレーカーによって無視されるべきエラーは何か?
  • 各呼び出しに適用されるタイムアウトは何か?
  • リトライは許可されているか?
  • 書き込みは冪等性か?
  • サーキットが開放された場合、どうなるのか?
  • フォールバックはあるか?
  • フォールバックはメトリクスで確認できるか?
  • サーキットが頻繁に開放される場合、誰がアラートを受けるのか?
  • デプロイ後にブレーカーはどのように調整されるのか?

これらに答えることができない場合、ブレーカーを追加することは、レジリエンスよりも混乱を引き起こす可能性があります。

Goでのサーキットブレーカーのテスト

ライブラリの内部状態遷移機ではなく、動作をテストしてください。

有用なテストには以下が含まれます:

  • 依存先が成功し、レスポンスが返される
  • 依存先が繰り返し失敗し、サーキットが開放される
  • 開放されたサーキットが一時的エラーを返す
  • クライアント側のバリデーションエラーがブレーカーを作動させない
  • コンテキストタイムアウトが尊重される
  • 期待通りにフォールバックレスポンスが返される
  • 状態変更時にメトリクスが出力される

統合スタイルのテストにはフェイクHTTPサーバーを使用してください:

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()

ユニットテストでは、依存先をインターフェースの後ろに隠し、フェイク実装を注入してください。

テストは決定論的に保ってください。長時間の実際の待機を避けてください。テストでは短いブレーカータイムアウトを設定してください。フェイク時間と分離されたバブルで並行Goコードをテストする方法については、testing/synctestを使用したGoの並行コードのテストをご覧ください。

自作のサーキットブレーカーを作るべきか?

小さなサーキットブレーカーを構築することは、良い学習演習です。状態遷移機を理解するのに役立ちます。

本番コードでは、要件が非常に特定されていない限り、メンテナンスされているライブラリを優先してください。

本番環境のブレーカーは、以下を処理する必要があります:

  • 並行性
  • 状態遷移
  • カウンター
  • 半開放プローブ
  • コールバック
  • カスタム障害分類
  • レースフリーな動作
  • 予測可能なエラー処理

不可能ではありませんが、微妙に間違うのは簡単です。

地味なライブラリが通常、より良い選択です。

結論

サーキットブレーカーパターンは、魔法のような信頼性の粉塵(リダスト)ではありません。

Goでは、小さく明示的なレジリエンススタックの一部として使用されたときに、最も効果的に機能します:

コンテキストタイムアウト
+ バックオフとジッター付きのリトライ
+ サーキットブレーカー
+ フォールバック
+ メトリクス

このパターンは、特にリモートサービス(遅くなったり部分的に利用不可になったりする可能性がある)の依存先境界で最も有用です。

カスケード障害を防ぐために使用してください。依存先が明確に不健康な場合に素早く失敗するために使用してください。過負荷のシステムが回復する余地を与えるために使用してください。

しかし、タイムアウト、冪等性、観測性、またはクリーンアーキテクチャを無視するための言い訳として使用しないでください。

良いサーキットブレーカーは、障害をより明確かつ低コストにします。悪いものは、ただ障害をより不可解にするだけです。

参考文献

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。