mybook

サーキットブレーカーパターン — 障害の連鎖を断つ

「決済サービスが落ちたら……」

「アヤカさん、昨日の障害ってどんな感じでしたか?」

ユウキが聞いた。先週末、30分のダウンタイムがあった。

「決済サービスのAPIが遅くなった。1リクエストが30秒タイムアウトになって、それが積み重なってRailsのワーカーが全部埋まった。結果、関係ない注文一覧ページまで見れなくなった」

「決済と関係ないのに……」

「これを**カスケード障害(連鎖障害)**という。1点の問題が全体に広がる。電気系統と同じ。1つのコンセントがショートしたとき、家全体の電気が落ちないように、ブレーカーがある」

「それがサーキットブレーカー」

「そう。ソフトウェアでも同じ仕組みを作る」

サーキットブレーカーとは

電気の回路ブレーカーと同じ発想。過負荷を検知したら、回路を遮断して被害を食い止める。

Loading diagram...

3つの状態:

  • Closed(閉じている): 通常動作。リクエストは通過する
  • Open(開いている): 回路遮断。リクエストを即座に失敗させる
  • Half-Open(半開): 回復確認中。少数のリクエストだけ通す

INFO

「Open = 通電」ではなく「Open = 回路が開いて電流が通らない」という電気の意味。紛らわしいが、Open は NG の状態。Closed が正常動作の状態。

Railsでの実装

# lib/circuit_breaker.rb
class CircuitBreaker
  class CircuitOpenError < StandardError
    attr_reader :circuit_name, :opened_at, :will_retry_at
 
    def initialize(name, opened_at, recovery_timeout)
      @circuit_name = name
      @opened_at = opened_at
      @will_retry_at = opened_at + recovery_timeout
      super("#{name}: サーキットブレーカーが開いています。#{@will_retry_at.strftime('%H:%M:%S')}頃に自動復旧します")
    end
  end
 
  STATES = %i[closed open half_open].freeze
 
  def initialize(
    name:,
    failure_threshold: 5,
    recovery_timeout: 60,
    success_threshold: 2,
    timeout: 10
  )
    @name = name
    @failure_threshold = failure_threshold
    @recovery_timeout = recovery_timeout
    @success_threshold = success_threshold
    @timeout = timeout
 
    @state_key = "cb:#{name}:state"
    @failure_count_key = "cb:#{name}:failures"
    @success_count_key = "cb:#{name}:successes"
    @opened_at_key = "cb:#{name}:opened_at"
    @last_failure_key = "cb:#{name}:last_failure"
  end
 
  def call(&block)
    state = current_state
 
    case state
    when :open
      handle_open_state
    when :half_open
      attempt_with_tracking(half_open: true, &block)
    when :closed
      attempt_with_tracking(half_open: false, &block)
    end
  end
 
  def current_state
    stored = Rails.cache.read(@state_key)
    return :closed unless stored
 
    stored.to_sym
  end
 
  def stats
    {
      state: current_state,
      failure_count: Rails.cache.read(@failure_count_key).to_i,
      success_count: Rails.cache.read(@success_count_key).to_i,
      opened_at: Rails.cache.read(@opened_at_key),
      last_failure: Rails.cache.read(@last_failure_key)
    }
  end
 
  def reset!
    [
      @state_key, @failure_count_key,
      @success_count_key, @opened_at_key, @last_failure_key
    ].each { |key| Rails.cache.delete(key) }
  end
 
  private
 
  def handle_open_state
    opened_at_ts = Rails.cache.read(@opened_at_key).to_i
    opened_at = Time.at(opened_at_ts)
 
    if Time.current.to_i - opened_at_ts >= @recovery_timeout
      transition_to(:half_open)
      # Half-Open になったのでリトライ
      call { yield }
    else
      raise CircuitOpenError.new(@name, opened_at, @recovery_timeout)
    end
  end
 
  def attempt_with_tracking(half_open:, &block)
    Timeout.timeout(@timeout) do
      result = block.call
      record_success(half_open: half_open)
      result
    end
  rescue Timeout::Error
    record_failure("Timeout after #{@timeout}s")
    raise
  rescue CircuitOpenError
    raise
  rescue => e
    record_failure(e.message)
    raise
  end
 
  def record_success(half_open:)
    if half_open
      count = Rails.cache.increment(@success_count_key)
      if count >= @success_threshold
        transition_to(:closed)
        reset_counts
        Rails.logger.info("CircuitBreaker[#{@name}]: 回復しました(Closed に移行)")
      end
    else
      Rails.cache.write(@failure_count_key, 0, expires_in: @recovery_timeout * 2)
    end
  end
 
  def record_failure(reason)
    count = Rails.cache.increment(
      @failure_count_key, 1,
      expires_in: @recovery_timeout * 2
    )
 
    Rails.cache.write(@last_failure_key, {
      reason: reason,
      occurred_at: Time.current.iso8601,
      count: count
    }.to_json)
 
    if count >= @failure_threshold
      transition_to(:open)
      Rails.cache.write(@opened_at_key, Time.current.to_i)
      Rails.logger.error("CircuitBreaker[#{@name}]: 開きました(失敗#{count}回)")
 
      # 監視システムに通知
      CircuitBreakerOpenJob.perform_later(@name, reason)
    end
  end
 
  def transition_to(new_state)
    Rails.cache.write(@state_key, new_state.to_s, expires_in: 1.day)
    Rails.logger.warn("CircuitBreaker[#{@name}]: #{new_state} に移行")
  end
 
  def reset_counts
    Rails.cache.write(@failure_count_key, 0)
    Rails.cache.write(@success_count_key, 0)
    Rails.cache.delete(@opened_at_key)
  end
end

外部API呼び出しへの適用

# app/clients/payment_service_client.rb
class PaymentServiceClient
  CIRCUIT_BREAKER = CircuitBreaker.new(
    name: "payment_service",
    failure_threshold: 5,       # 5回失敗でOpen
    recovery_timeout: 30,       # 30秒後にHalf-Openへ
    success_threshold: 2,       # 2回成功でClosed復帰
    timeout: 10                 # 1リクエスト10秒タイムアウト
  )
 
  BASE_URL = ENV["PAYMENT_SERVICE_URL"]
 
  def charge(order_id:, amount:, user_id:, payment_method_id:)
    CIRCUIT_BREAKER.call do
      response = HTTP
        .timeout(connect: 3, read: 10)
        .auth("Bearer #{ENV['PAYMENT_API_KEY']}")
        .post("#{BASE_URL}/charges", json: {
          order_id: order_id,
          amount: amount,
          user_id: user_id,
          payment_method_id: payment_method_id,
          idempotency_key: "charge:#{order_id}"
        })
 
      unless response.status.success?
        raise PaymentApiError, "決済API エラー: #{response.status} #{response.body}"
      end
 
      JSON.parse(response.body, symbolize_names: true)
    end
  rescue CircuitBreaker::CircuitOpenError => e
    Rails.logger.warn("決済API サーキット遮断: #{e.message}")
    # フォールバック処理
    enqueue_for_retry(order_id, amount, user_id, payment_method_id)
    { status: "queued", retry_at: e.will_retry_at.iso8601 }
  end
 
  def refund(order_id:, amount: nil)
    CIRCUIT_BREAKER.call do
      response = HTTP
        .timeout(connect: 3, read: 10)
        .auth("Bearer #{ENV['PAYMENT_API_KEY']}")
        .post("#{BASE_URL}/refunds", json: {
          order_id: order_id,
          amount: amount,
          idempotency_key: "refund:#{order_id}"
        })
 
      unless response.status.success?
        raise PaymentApiError, "返金API エラー: #{response.body}"
      end
 
      JSON.parse(response.body, symbolize_names: true)
    end
  rescue CircuitBreaker::CircuitOpenError
    RefundRetryJob.set(wait: 5.minutes).perform_later(order_id: order_id, amount: amount)
    { status: "queued_for_refund" }
  end
 
  private
 
  def enqueue_for_retry(order_id, amount, user_id, payment_method_id)
    PaymentRetryJob.set(wait: 5.minutes).perform_later(
      order_id: order_id,
      amount: amount,
      user_id: user_id,
      payment_method_id: payment_method_id
    )
  end
end

フォールバック戦略

サーキットが開いたとき、どう対応するかを設計する。

# app/services/recommendation_service.rb
class RecommendationService
  CIRCUIT_BREAKER = CircuitBreaker.new(
    name: "recommendation_api",
    failure_threshold: 3,
    recovery_timeout: 60
  )
 
  def get_for_user(user_id)
    CIRCUIT_BREAKER.call do
      ExternalRecommendationApi.new.get(user_id: user_id)
    end
  rescue CircuitBreaker::CircuitOpenError
    # フォールバック1: キャッシュから返す
    cached = Rails.cache.read("recommendations:#{user_id}")
    return cached if cached
 
    # フォールバック2: デフォルト推薦商品(ルールベース)
    fallback_recommendations(user_id)
  end
 
  private
 
  def fallback_recommendations(user_id)
    # ユーザーの購入履歴から簡単なルールで推薦
    purchased_categories = Order.joins(order_items: :product)
                                .where(user_id: user_id, status: :delivered)
                                .select("products.category_id")
                                .distinct
                                .map(&:category_id)
 
    if purchased_categories.any?
      Product.active.in_stock
             .where(category_id: purchased_categories)
             .order("RANDOM()")
             .limit(10)
    else
      Product.active.in_stock.popular.limit(10)
    end
  end
end
# フォールバック戦略の選択肢
module FallbackStrategies
  # 1. キャッシュされた古いデータ(Stale-While-Revalidate)
  def self.stale_cache(key, max_age: 1.hour)
    Rails.cache.fetch(key, expires_in: max_age) { nil }
  end
 
  # 2. デフォルト値(空・ゼロ・N/A)
  def self.default_response(resource_type)
    case resource_type
    when :recommendations then []
    when :pricing then { status: "unavailable", price: nil }
    when :shipping then { days: "未確定", cost: nil }
    end
  end
 
  # 3. 非同期リトライにキューイング(202 Accepted パターン)
  def self.async_retry(job_class, **args)
    job_class.set(wait: 5.minutes).perform_later(**args)
    { status: "queued", message: "後ほど処理します" }
  end
 
  # 4. グレースフルデグラデーション(機能を落として動かす)
  def self.degraded_service(reason:)
    Rails.logger.warn("グレースフルデグラデーション: #{reason}")
    { available: false, reason: reason }
  end
end

AWS でのサーキットブレーカー

Loading diagram...
# CloudWatch メトリクスを自動記録するラッパー
class InstrumentedCircuitBreaker < CircuitBreaker
  def call(&block)
    start_time = Time.current
    success = false
    circuit_open = false
 
    begin
      result = super(&block)
      success = true
      result
    rescue CircuitBreaker::CircuitOpenError
      circuit_open = true
      raise
    ensure
      duration_ms = ((Time.current - start_time) * 1000).round(2)
      record_metrics(
        success: success,
        circuit_open: circuit_open,
        duration_ms: duration_ms
      )
    end
  end
 
  private
 
  def record_metrics(success:, circuit_open:, duration_ms:)
    namespace = "MyApp/CircuitBreakers"
    dimensions = [{ name: "CircuitName", value: @name }]
 
    metrics = [
      {
        metric_name: "CallCount",
        value: 1,
        dimensions: dimensions + [
          { name: "Result", value: success ? "Success" : (circuit_open ? "CircuitOpen" : "Failure") }
        ]
      },
      {
        metric_name: "Latency",
        value: duration_ms,
        unit: "Milliseconds",
        dimensions: dimensions
      }
    ]
 
    if circuit_open
      metrics << {
        metric_name: "CircuitOpenCount",
        value: 1,
        dimensions: dimensions
      }
    end
 
    Aws::CloudWatch::Client.new.put_metric_data(
      namespace: namespace,
      metric_data: metrics
    )
  rescue => e
    Rails.logger.error("CloudWatch メトリクス送信失敗: #{e.message}")
  end
end
# CloudWatch アラーム
Resources:
  PaymentCircuitOpenAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: PaymentCircuitBreakerOpen
      MetricName: CircuitOpenCount
      Namespace: MyApp/CircuitBreakers
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions:
        - !Ref OpsTeamSNSTopic
      Dimensions:
        - Name: CircuitName
          Value: payment_service
 
  PaymentHighLatencyAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: PaymentAPIHighLatency
      MetricName: Latency
      Namespace: MyApp/CircuitBreakers
      ExtendedStatistic: p95
      Period: 60
      EvaluationPeriods: 3
      Threshold: 5000  # 5秒
      ComparisonOperator: GreaterThanThreshold
      AlarmActions:
        - !Ref OpsTeamSNSTopic
      Dimensions:
        - Name: CircuitName
          Value: payment_service

テスト

# spec/lib/circuit_breaker_spec.rb
RSpec.describe CircuitBreaker do
  let(:breaker) do
    CircuitBreaker.new(
      name: "test_#{SecureRandom.hex(4)}",
      failure_threshold: 3,
      recovery_timeout: 10,
      success_threshold: 1
    )
  end
 
  after { breaker.reset! }
 
  describe "Closed 状態" do
    it "成功するリクエストは通過する" do
      result = breaker.call { "success" }
      expect(result).to eq("success")
    end
 
    it "失敗が閾値未満なら Closed のまま" do
      2.times { breaker.call { raise "error" } rescue nil }
      expect(breaker.current_state).to eq(:closed)
    end
  end
 
  describe "失敗が閾値を超えた場合" do
    before do
      3.times { breaker.call { raise "error" } rescue nil }
    end
 
    it "Open 状態になる" do
      expect(breaker.current_state).to eq(:open)
    end
 
    it "即座に CircuitOpenError を発生させる" do
      expect { breaker.call { "anything" } }
        .to raise_error(CircuitBreaker::CircuitOpenError)
    end
 
    it "CircuitOpenError にリトライ時刻が含まれる" do
      begin
        breaker.call { "anything" }
      rescue CircuitBreaker::CircuitOpenError => e
        expect(e.will_retry_at).to be > Time.current
      end
    end
  end
 
  describe "タイムアウト後のHalf-Open" do
    before do
      3.times { breaker.call { raise "error" } rescue nil }
      # recovery_timeout を過ぎた状態を再現
      opened_at = Time.current - 11.seconds
      Rails.cache.write("cb:#{breaker.instance_variable_get(:@name)}:opened_at", opened_at.to_i)
    end
 
    it "成功後に Closed に戻る" do
      expect { breaker.call { "success" } }.not_to raise_error
      expect(breaker.current_state).to eq(:closed)
    end
  end
 
  describe "スレッドセーフ性" do
    it "並列アクセスでも状態が正しく管理される" do
      threads = 10.times.map do
        Thread.new do
          breaker.call { "success" } rescue nil
        end
      end
      threads.each(&:join)
      # エラーが発生しないこと
    end
  end
end

WARNING

サーキットブレーカーの状態はRedisなどの共有キャッシュに保存する。各Railsワーカープロセスがメモリに持つと、プロセスAはOpenなのにプロセスBはClosedという不整合が起きる。ElastiCacheを使い、全プロセスで状態を共有すること。

運用の観点:サーキットブレーカーの管理

# 管理画面用:サーキットブレーカーの状態一覧
class CircuitBreakerDashboard
  MONITORED_CIRCUITS = %w[
    payment_service
    recommendation_api
    inventory_service
    delivery_service
    search_service
  ].freeze
 
  def self.all_stats
    MONITORED_CIRCUITS.map do |name|
      breaker = CircuitBreaker.new(name: name)
      stats = breaker.stats
      {
        name: name,
        state: stats[:state],
        failure_count: stats[:failure_count],
        last_failure: stats[:last_failure] ? JSON.parse(stats[:last_failure]) : nil,
        opened_at: stats[:opened_at] ? Time.at(stats[:opened_at]) : nil
      }
    end
  end
 
  def self.reset(circuit_name)
    breaker = CircuitBreaker.new(name: circuit_name)
    breaker.reset!
    Rails.logger.info("CircuitBreaker手動リセット: #{circuit_name}")
  end
end

まとめ

状態動作目的
Closed全リクエストを通す通常動作
Open即座に失敗させる過負荷な外部サービスを保護・自システムを保護
Half-Open少数のリクエストを通す回復確認

カスケード障害の防止に加えて:

  • 外部サービスへの余分なリクエストを止め、回復を助ける
  • エラーレスポンスが速くなり(30秒 → 即時)、ユーザー体験が改善
  • フォールバックにより部分的な機能提供が継続できる

「昨日の障害はどう防げましたか?」ユウキが聞いた。

「決済APIのサーキットブレーカーが5回の失敗で開いた。以降の決済リクエストは即座にキューに入れられて返る。ワーカーが詰まらない。注文一覧ページは影響を受けない——それだけ」

「シンプルだ」

「電気のブレーカーと同じくらいシンプル。でも、ないと本当に困る」


次章では、高負荷時にシステムが崩壊しないようにするバックプレッシャーパターンを学びます。