Skip to content

サーキットブレーカー

実装: resilience/circuitbreaker/ / 実行: go test ./resilience/circuitbreaker/

落ちている依存先を呼び続けると、タイムアウト待ちのスレッドが溜まって呼び出し側まで倒れる。連鎖障害だ。サーキットブレーカーは、失敗が続いたら回路を開いて以後の呼び出しを即失敗させ、依存先に回復の猶予を与える。一定時間後に半開で1本だけ試し、回復していれば閉じる。この章では closed / open / half-open の状態機械を実装し、それが連鎖障害を止める仕組みを確かめる。

この章で作るもの

マイクロサービスでは、あるサービスが別のサービスを呼ぶ。呼んだ先が落ちていたらどうなるか。素朴には、呼び出しはタイムアウトするまで待つ。その間スレッド(やコネクション)は塞がったままだ。落ちた依存先へのリクエストが秒間何千と来れば、待ちのスレッドが積み上がり、呼び出し側の資源が尽きて、呼び出し側まで応答不能になる。その呼び出し側を呼んでいた別のサービスも同じ道をたどる。1 つの障害がドミノ倒しでシステム全体に広がるのが連鎖障害(cascading failure)だ。

サーキットブレーカーは、家庭の電気ブレーカーと同じ発想でこれを止める。過電流が続いたら回路を落とし、機器を守る。ソフトウェアでは、失敗が続いたら回路を開いて、以後の呼び出しを依存先に届ける前に即座に失敗させる(fail fast)。待たずに失敗するので資源が枯れず、依存先には呼び出しが来なくなって回復の余地が生まれる。

ただし開きっぱなしでは、依存先が直っても気づけない。そこで一定時間たったら**半開(half-open)**という中間の状態に移り、試しに1本だけ呼び出しを通して、成功したら閉じ、失敗したらまた開く。通常運転の closed、遮断中の open、様子見の half-open。この3状態の行き来が仕組みの全体像になる。

        連続失敗が閾値に到達
 ┌──────────────────────────────┐
 ▼                              │
closed ──失敗を数える──▶ open ──タイムアウト──▶ half-open
 ▲  通常運転             即失敗(fail fast)      1本だけ試す
 │                                              │      │
 └──────連続成功で回復──────────────────────────┘   失敗で即戻る
サーキットブレーカーの状態遷移。通常は closed。失敗が続くと open(即失敗)。一定時間後 half-open で1本試し、回復していれば closed、駄目なら open に戻る

順に作る。

  1. closed で失敗を数える: 通常は呼び出しを通し、連続失敗を数える。閾値に達したら開く
  2. open で即失敗(fail fast): 開いている間は依存先を呼ばずに即エラー。資源を守り、依存先に猶予を与える
  3. half-open で試す: タイムアウト後、1本だけ通して回復を確かめる。成功が続けば閉じ、失敗したら開き直す

① closed: 失敗を数えて開く

通常状態の closed では呼び出しを通し、連続失敗を数える。閾値に達したら回路を開く:

go

// Call は fn を回路の状態に応じて実行する。
//   - closed: 実行し、成否を数える。連続失敗が閾値に達したら開く
//   - open: 実行せず ErrOpen(fail fast)。ただしタイムアウト経過なら half-open へ
//   - half-open: 1 本だけ試す。成功を重ねれば閉じ、失敗したら即開き直す
func (b *Breaker) Call(fn func() error) error {
	b.maybeHalfOpen()

	switch b.state {
	case StateOpen:
		return ErrOpen

	case StateHalfOpen:
		if b.probing {
			// 既に試行中。同時の 2 本目は通さない。
			return ErrOpen
		}
		b.probing = true
		err := fn()
		b.probing = false
		if err != nil {
			b.trip() // 回復していない。開き直す
			return err
		}
		b.successes++
		if b.successes >= b.cfg.SuccessThreshold {
			b.close()
		}
		return nil

	default: // StateClosed
		err := fn()
		if err != nil {
			b.failures++
			if b.failures >= b.cfg.FailureThreshold {
				b.trip()
			}
			return err
		}
		b.failures = 0 // 成功で連続失敗をリセット
		return nil
	}
}

見どころは成功時のリセットだ。連続失敗を数えるので、間に1回でも成功が挟まればカウントは 0 に戻る。テストでは「2回失敗 → 成功 → 2回失敗」で開かないこと(連続でないから)を固定した。一時的なばらつき(たまたま1回失敗)では開かず、失敗が途切れず続いているときだけ開く。実物はこれを連続回数でなく、直近ウィンドウの失敗率(例: 50%超)で判定することも多い。

② open: fail fast で資源を守る

いったん開くと、呼び出しは依存先に届く前に ErrOpen で即座に失敗する。テストで固定したのは、開いている間 fn が呼ばれないことだ。これが fail fast の核心で、タイムアウトを待たないから呼び出し側のスレッドが塞がらない。

この即失敗は、一見すると単なるエラーの前倒しに見えるが、2 つの意味がある。1 つは呼び出し側の保護で、待ち行列が積み上がらないので資源が枯れない。もう 1 つは依存先の保護で、落ちているサービスに追い打ちのリクエストを送らないので、回復に専念できる。障害時に呼び出しを止めることが、両側を守る。

fail fast したときに何を返すかは設計判断になる。エラーをそのまま返すか、キャッシュした古い値を返すか、簡略化した代替応答を返すか(フォールバック)。「おすすめ商品」なら空を返しても画面は出る。機能を落としてでも動き続けるこの作り(縮退)が、部分の故障を全体の故障にしないための設計になる。

③ half-open: 1本試して回復を測る

開きっぱなしでは、依存先が回復しても永遠に閉じない。そこで一定時間後に half-open へ移り、試しに 1 本だけ通す:

go

// maybeHalfOpen は open のままタイムアウトが過ぎていれば half-open へ移す。
func (b *Breaker) maybeHalfOpen() {
	if b.state == StateOpen && b.now-b.openedAt >= b.cfg.OpenTimeout {
		b.state = StateHalfOpen
		b.successes = 0
		b.probing = false
	}
}

// trip は回路を開く。closed からも half-open からも遷移する。
func (b *Breaker) trip() {
	b.state = StateOpen
	b.openedAt = b.now
	b.failures = 0
	b.successes = 0
	b.probing = false
}

// close は回路を閉じる(回復)。
func (b *Breaker) close() {
	b.state = StateClosed
	b.failures = 0
	b.successes = 0
	b.probing = false
}

half-open の要点は、試行を絞ることだ。回復したか分からない依存先に、いきなり全リクエストを流したら、まだ脆い状態にとどめを刺しかねない。だから 1 本だけ通す。テストでは、試行の最中に来た 2 本目が弾かれること(同時に 1 本だけ)を固定した。成功が SuccessThreshold 回続けば閉じて通常運転に戻り、1 回でも失敗したら即座に開き直してまたタイムアウトを待つ。回復を恐る恐る確かめる作りになっている。

動かす

下のデモは、依存先の健康状態を切り替えながらブレーカーの反応を追える。依存先を落とすと連続失敗でやがて open になり、以後の呼び出しが即失敗するのが見える。時計を進めて half-open にし、依存先を回復させてから試すと closed に戻り、回復させずに試すと open に戻る。状態機械の全経路をたどれる。

デモサーキットブレーカーclosed
closed通す・数える
open即失敗
half-open1本試す
依存先健康
連続失敗0/3
論理時計now = 0

初期状態: closed。依存先は健康。呼び出しを通しながら失敗を数える

1 / 13

連続失敗が閾値に達すると open になり、以後の呼び出しは依存先に届く前に即失敗する(fail fast)。 待ちが発生しないので呼び出し側の資源が守られ、依存先も追い打ちを受けずに回復できる。 タイムアウト後の half-open で1本だけ試し、回復していれば closed に戻る。

設計の観点

  • 閾値の決め方: 開きやすすぎると正常なゆらぎで遮断し可用性を下げ、開きにくすぎると連鎖障害を防げない。失敗率 + 最小リクエスト数(サンプルが少ないうちは判定しない)の組み合わせが定石
  • タイムアウトとの併用: ブレーカーは「呼び出しの遅さ」を呼び出しタイムアウトで検知して初めて効く。タイムアウトが無いと待ち続けてしまう。両者はセットで使う
  • バルクヘッドとの違い: ブレーカーは失敗時に遮断、バルクヘッド(隔壁)は依存先ごとに同時実行数を区切って 1 つの過負荷が他に波及しないようにする。目的が違い、組み合わせる
  • フォールバックの設計: fail fast したとき何を返すか。キャッシュ・既定値・簡略応答で縮退する設計が、部分故障を全体故障にしない
  • half-open の暴発防止: 回復直後に全リクエストを流すと再び倒す。試行を絞る(1本、あるいは徐々に増やす)ことで、脆い回復を守る

対照と実例

状態呼び出し目的
closed通す(失敗を数える)通常運転。異常を検知する
open即失敗(fail fast)呼び出し側と依存先の両方を守る
half-open1本だけ試す回復を恐る恐る確かめる

裏どり:

  • Netflix Hystrix: サーキットブレーカーを広めたライブラリ。失敗率・タイムアウト・バルクヘッド・フォールバックを統合。今は保守終了だが概念の教科書
  • resilience4j: Hystrix の後継的な Java ライブラリ。スライディングウィンドウの失敗率で判定する現代的な実装
  • Envoy / Istio: サービスメッシュがプロキシ層でブレーカーを提供。アプリを変えずに外側でかけられる
  • Release It!(Michael Nygard): サーキットブレーカーパターンの初出とされる書籍。連鎖障害とその対策の原典

簡略化したこと

  • 連続失敗のみ: 実物は失敗率や slow call 率で判定する。ここは連続カウントで仕組みを示した
  • 論理時計: 実時計でなく Advance で進める。決定性のため
  • 並行制御は最小: 単一 goroutine 前提。実物は原子カウンタやロックで並行安全にする
  • フォールバック未実装: 何を返すかは設計の観点で述べるに留めた

参考資料