サーキットブレーカー
実装:
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 で即失敗(fail fast): 開いている間は依存先を呼ばずに即エラー。資源を守り、依存先に猶予を与える
- half-open で試す: タイムアウト後、1本だけ通して回復を確かめる。成功が続けば閉じ、失敗したら開き直す
① closed: 失敗を数えて開く
通常状態の closed では呼び出しを通し、連続失敗を数える。閾値に達したら回路を開く:
// 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 本だけ通す:
// 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。依存先は健康。呼び出しを通しながら失敗を数える
連続失敗が閾値に達すると open になり、以後の呼び出しは依存先に届く前に即失敗する(fail fast)。 待ちが発生しないので呼び出し側の資源が守られ、依存先も追い打ちを受けずに回復できる。 タイムアウト後の half-open で1本だけ試し、回復していれば closed に戻る。
設計の観点
- 閾値の決め方: 開きやすすぎると正常なゆらぎで遮断し可用性を下げ、開きにくすぎると連鎖障害を防げない。失敗率 + 最小リクエスト数(サンプルが少ないうちは判定しない)の組み合わせが定石
- タイムアウトとの併用: ブレーカーは「呼び出しの遅さ」を呼び出しタイムアウトで検知して初めて効く。タイムアウトが無いと待ち続けてしまう。両者はセットで使う
- バルクヘッドとの違い: ブレーカーは失敗時に遮断、バルクヘッド(隔壁)は依存先ごとに同時実行数を区切って 1 つの過負荷が他に波及しないようにする。目的が違い、組み合わせる
- フォールバックの設計: fail fast したとき何を返すか。キャッシュ・既定値・簡略応答で縮退する設計が、部分故障を全体故障にしない
- half-open の暴発防止: 回復直後に全リクエストを流すと再び倒す。試行を絞る(1本、あるいは徐々に増やす)ことで、脆い回復を守る
対照と実例
| 状態 | 呼び出し | 目的 |
|---|---|---|
| closed | 通す(失敗を数える) | 通常運転。異常を検知する |
| open | 即失敗(fail fast) | 呼び出し側と依存先の両方を守る |
| half-open | 1本だけ試す | 回復を恐る恐る確かめる |
裏どり:
- Netflix Hystrix: サーキットブレーカーを広めたライブラリ。失敗率・タイムアウト・バルクヘッド・フォールバックを統合。今は保守終了だが概念の教科書
- resilience4j: Hystrix の後継的な Java ライブラリ。スライディングウィンドウの失敗率で判定する現代的な実装
- Envoy / Istio: サービスメッシュがプロキシ層でブレーカーを提供。アプリを変えずに外側でかけられる
- Release It!(Michael Nygard): サーキットブレーカーパターンの初出とされる書籍。連鎖障害とその対策の原典
簡略化したこと
- 連続失敗のみ: 実物は失敗率や slow call 率で判定する。ここは連続カウントで仕組みを示した
- 論理時計: 実時計でなく Advance で進める。決定性のため
- 並行制御は最小: 単一 goroutine 前提。実物は原子カウンタやロックで並行安全にする
- フォールバック未実装: 何を返すかは設計の観点で述べるに留めた
参考資料
- Michael Nygard, Release It! — サーキットブレーカーパターンの原典
- Martin Fowler: CircuitBreaker — 定番の解説
- resilience4j ドキュメント — スライディングウィンドウ方式の実装
- 実装: resilience/circuitbreaker