サーキットブレーカーパターン — 障害の連鎖を断つ
「決済サービスが落ちたら……」
「アヤカさん、昨日の障害ってどんな感じでしたか?」
ユウキが聞いた。先週末、30分のダウンタイムがあった。
「決済サービスのAPIが遅くなった。1リクエストが30秒タイムアウトになって、それが積み重なってRailsのワーカーが全部埋まった。結果、関係ない注文一覧ページまで見れなくなった」
「決済と関係ないのに……」
「これを**カスケード障害(連鎖障害)**という。1点の問題が全体に広がる。電気系統と同じ。1つのコンセントがショートしたとき、家全体の電気が落ちないように、ブレーカーがある」
「それがサーキットブレーカー」
「そう。ソフトウェアでも同じ仕組みを作る」
サーキットブレーカーとは
電気の回路ブレーカーと同じ発想。過負荷を検知したら、回路を遮断して被害を食い止める。
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
endAWS でのサーキットブレーカー
# 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
endWARNING
サーキットブレーカーの状態は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回の失敗で開いた。以降の決済リクエストは即座にキューに入れられて返る。ワーカーが詰まらない。注文一覧ページは影響を受けない——それだけ」
「シンプルだ」
「電気のブレーカーと同じくらいシンプル。でも、ないと本当に困る」
次章では、高負荷時にシステムが崩壊しないようにするバックプレッシャーパターンを学びます。