Vinicius Aguiar
アーキテクチャ

Node.js における Circuit Breaker:システムを連鎖障害から守る

2026年4月16日 · 9分で読めます

アプリケーションが外部 API — 決済プロバイダー、マーケットプレイス、配送サービス — に依存しているとき、システムの一部が自分のコントロール外にあることを受け入れていることになります。これらの依存先は遅くなったり、エラーを返したり、単に応答しなくなったりします。保護がなければ、不安定な外部 API がシステム全体を落とす可能性があります。Circuit Breaker はそれを防ぐパターンです。

問題:連鎖障害

現実的なシナリオを想像してください:チェックアウト中に、アプリケーションがマーケットプレイスの API を呼び出します。通常この呼び出しは 200ms かかります。しかしマーケットプレイスに問題があり、応答に 30 秒かかり始めます — あるいは単に応答しません。

保護がない場合に起きること:

  1. ユーザーのチェックアウトがマーケットプレイスの応答を待ってフリーズする
  2. 待っている間に新しいリクエストが届き、それらもフリーズする
  3. サーバーのコネクションプールが枯渇する
  4. サーバーがすべてのユーザーへの応答を停止する — マーケットプレイスに依存しているユーザーだけではなく
  5. ひとつの外部依存先のせいでシステム全体がダウンする

これが連鎖障害です。不安定な依存先が障害をシステム全体に伝播させます。Circuit Breaker はこの伝播を断ち切ります。

Circuit Breaker の仕組み

このパターンは電気のブレーカーのように機能します。外部依存先への呼び出しを監視し、3 つの状態を持ちます:

  • CLOSED(閉) — 通常の状態。リクエストは外部 API へ通ります。失敗が threshold を超えて蓄積すると、OPEN へ遷移します。
  • OPEN(開) — 保護の状態。リクエストは API へ行きません。即座にフォールバックを返します。クールダウン期間の後、HALF-OPEN へ遷移します。
  • HALF-OPEN(半開) — テストの状態。テストとして1 つのリクエストを通します。成功すれば CLOSED に戻ります。失敗すれば OPEN に戻ります。
Circuit Breaker の state machine の図 — CLOSED、OPEN、HALF-OPEN の状態と遷移Circuit Breaker の state machine の図 — CLOSED、OPEN、HALF-OPEN の状態と遷移

TypeScript での実装

任意の外部呼び出しをラップできる汎用的な Circuit Breaker を実装します。異なる依存先に対して再利用できるようにするのが狙いです。

type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'

interface CircuitBreakerOptions {
  failureThreshold: number  // 開く前の失敗回数
  cooldownMs: number        // テストする前の OPEN での時間
  timeoutMs: number         // リクエストごとのタイムアウト
}

class CircuitBreaker {
  private state: CircuitState = 'CLOSED'
  private failureCount = 0
  private lastFailureTime = 0
  private readonly options: CircuitBreakerOptions

  constructor(options: Partial<CircuitBreakerOptions> = {}) {
    this.options = {
      failureThreshold: options.failureThreshold ?? 5,
      cooldownMs: options.cooldownMs ?? 60_000,
      timeoutMs: options.timeoutMs ?? 10_000,
    }
  }

  async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime >= this.options.cooldownMs) {
        this.state = 'HALF_OPEN'
      } else {
        return fallback()
      }
    }

    try {
      const result = await this.withTimeout(fn())
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      return fallback()
    }
  }

  private onSuccess() {
    this.failureCount = 0
    this.state = 'CLOSED'
  }

  private onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.failureCount >= this.options.failureThreshold) {
      this.state = 'OPEN'
    }
  }

  private withTimeout<T>(promise: Promise<T>): Promise<T> {
    return Promise.race([
      promise,
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error('Timeout')), this.options.timeoutMs)
      ),
    ])
  }

  getState(): CircuitState {
    return this.state
  }
}

実践的な使い方:API 呼び出しを守る

クラスができたので、任意の外部呼び出しをラップするのは簡単です:

// 依存先ごとに 1 つの circuit breaker
const marketplaceBreaker = new CircuitBreaker({
  failureThreshold: 3,   // 3 回の失敗で開く
  cooldownMs: 30_000,    // テストする前に 30 秒待つ
  timeoutMs: 5_000,      // 呼び出しごとに 5 秒のタイムアウト
})

const paymentBreaker = new CircuitBreaker({
  failureThreshold: 2,   // より敏感に — 決済だから
  cooldownMs: 60_000,    // 長めのクールダウン
  timeoutMs: 10_000,     // 長めのタイムアウト — 決済プロバイダーは遅い
})

// 保護された呼び出し
async function getProductFromMarketplace(productId: string) {
  return marketplaceBreaker.execute(
    // 実際の呼び出し
    () => fetch(`https://api.marketplace.com/products/${productId}`)
      .then(res => res.json()),
    // circuit が開いているときのフォールバック
    () => getCachedProduct(productId)
  )
}

重要なポイント:各外部依存先は独自の Circuit Breaker を持つべきです。マーケットプレイスが落ちると、マーケットプレイスの circuit が開きます — しかし決済プロバイダーの circuit は閉じたまま、通常どおり動作し続けます。

フォールバック戦略

フォールバックとは、circuit が開いているときにシステムが返すものです。これはもっともプロダクト的な判断を要する部分です。なぜなら、ユーザーが劣化に気づかないように、フォールバックは十分に役立つ必要があるからです。いくつかの戦略:

  • キャッシュ — 最後の有効なレスポンスを返す。変化の少ないデータ(商品カタログ、設定)に向いています。
  • デフォルト値 — 事前に定義された安全な値を返す。配送計算(「送料は後ほど確定」)や在庫(「在庫状況をご確認ください」)に向いています。
  • 機能の縮退 — チェックアウトはマーケットプレイスの検証なしで動作するが、価格は後で確定されるとユーザーに通知する。
  • 後でリトライするためのキュー — サービスが復旧したときに再試行するために操作を保存する。即時のレスポンスを必要としない操作に向いています。
// キャッシュを使ったフォールバック
const productCache = new Map<string, Product>()

async function getProduct(id: string): Promise<Product> {
  return marketplaceBreaker.execute(
    async () => {
      const product = await fetchFromMarketplace(id)
      productCache.set(id, product) // 成功時にキャッシュを更新する
      return product
    },
    () => {
      const cached = productCache.get(id)
      if (cached) return cached
      // キャッシュなし — 在庫なしフラグ付きの商品を返す
      return { id, name: 'Produto indisponível', available: false }
    }
  )
}

リトライと組み合わせる

Circuit Breaker とリトライは異なる問題を解決します:

  • リトライ — 単発の失敗向け(あるリクエストが失敗したが、次はおそらく成功する)。指数バックオフを使います。
  • Circuit Breaker — 継続的な失敗向け(サービスがダウンしていて、試し続けても無意味)。システム全体を守ります。

正しい組み合わせ:circuit breaker の内側でリトライする。circuit はリトライが機能しているかを監視します。リトライしても呼び出しが失敗し続けるなら、circuit が開きます。

async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: Error | null = null

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error as Error
      // 指数バックオフ:1s、2s、4s
      await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
    }
  }

  throw lastError
}

// circuit breaker の内側でリトライ
async function getProductReliably(id: string) {
  return marketplaceBreaker.execute(
    () => fetchWithRetry(() => fetchFromMarketplace(id), 3),
    () => getCachedProduct(id)
  )
}

監視:circuit がいつ開いたかを知る

静かに開く circuit breaker は危険です — 外部サービスがいつ不安定になっているかを知る必要があります。状態遷移のイベントを追加することでこれが解決します:

class ObservableCircuitBreaker extends CircuitBreaker {
  private name: string

  constructor(name: string, options?: Partial<CircuitBreakerOptions>) {
    super(options)
    this.name = name
  }

  async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
    const previousState = this.getState()
    const result = await super.execute(fn, fallback)
    const currentState = this.getState()

    if (previousState !== currentState) {
      this.logTransition(previousState, currentState)
    }

    return result
  }

  private logTransition(from: string, to: string) {
    const message = `[CircuitBreaker:${this.name}] ${from} → ${to}`

    if (to === 'OPEN') {
      console.error(message) // 開いたときにアラートを出す
      // monitoring へ送る(Datadog、Sentry など)
    } else if (to === 'CLOSED') {
      console.info(message) // 復旧したときに通知する
    } else {
      console.warn(message) // half-open は遷移
    }
  }
}

これにより、外部サービスがいつ失敗し始め、いつ復旧するかが正確に分かります。本番では、これらのログはアラートシステムへ送るべきです — 決済プロバイダーの circuit が開いたら、誰かに通知される必要があります。

現実のシナリオ:一貫性のないマーケットプレイス API

私が直面したシナリオ:あるマーケットプレイスの API が商品データを一貫性なく返していました。あるときは 200ms で動作し、あるときは 15 秒かかり、あるときは 500 を返しました。チェックアウトはこの API に価格と在庫の検証を依存していました。

解決策はこうでした:

  1. 失敗 3 回の threshold とクールダウン 30 秒の Circuit Breaker
  2. 3 秒のアグレッシブなタイムアウト(3 秒で応答しなければ失敗とみなす)
  3. フォールバックとしてのキャッシュ — 商品の最後の有効なレスポンス、「価格は確定待ち」のフラグ付き
  4. 後でのリコンサイル — キャッシュの価格がまだ正しいかを 5 分ごとに確認するジョブ

結果:チェックアウトはマーケットプレイスのせいで二度とフリーズしませんでした。API が不安定なとき、ユーザーはさりげない注意書きとともにキャッシュの価格を見ていました。正常に戻ると、circuit が閉じ、すべてがリアルタイムで動作するようになりました。

Circuit Breaker を使わないとき

すべての外部呼び出しに circuit breaker が必要なわけではありません:

  • すでにレジリエントな呼び出し — 依存先がネイティブのリトライと高い SLA を持つ場合(例:AWS S3)、circuit のオーバーヘッドは不要かもしれません。
  • キュー付きの冪等な操作 — すでにリトライ付きのキューを使っている場合(例:webhook 処理)、キューがすでに circuit の役割を果たしています。
  • クリティカルでない呼び出し — analytics、外部ロギング、トラッキング。失敗してもユーザーに影響しません。

まとめ

Circuit Breaker は、外部サービスに依存するアプリケーションにとってもっとも重要なツールのひとつです。パターン自体はシンプルです — 3 つの状態を持つ state machine です。エンジニアリングを要するのは次の点です:

  1. threshold を調整する — 開く前に何回の失敗を許すか、クールダウンをどれだけにするか
  2. 役立つフォールバックを設計する — キャッシュ、デフォルト値、機能の縮退
  3. 遷移を監視する — いつ開いていつ閉じたかを知る
  4. 依存先ごとに 1 つの breaker — 障害を隔離し、ひとつの不安定な API が他に影響しないようにする
  5. リトライと組み合わせる — 単発の失敗にはリトライ、継続的な失敗には circuit

依存先とともにダウンするシステムと、優雅に劣化するシステムの違いは、ほとんど常に、適切に設定された Circuit Breaker です。