PythonでCircuit Breakerを実装する:エラーのAPIにシステム全体を道連れにさせないために

Python tutorial - IT technology blog
Python tutorial - IT technology blog

実例:1つのサービスが「くしゃみ」をすると、システム全体が「ダウン」する

Microservicesシステムが順調に稼働していたのに、午前2時に突然一斉にダウンしてしまいました。ログを見ると、メインサーバーのスレッドが完全に枯渇し、リクエストを一切受け付けられない状態です。冷や汗をかきながらデバッグした結果、犯人が判明しました。提携先の決済APIのレスポンスが遅延していたのです。こちらのコードは愚直にリクエストを送り続け、タイムアウトになるまで各リクエストで30秒間待ち(wait)続けていました。

相手側のAPIのレスポンスが遅くなると、こちら側のリクエストはラッシュアワーの渋滞のように溜まっていきます。各リクエストは一定量のRAMやCPUリソースを占有します。同時に100個のリクエストが滞留するだけで、Pythonアプリケーション全体のリソースが枯渇し、クラッシュしてしまいます。これこそが分散システムにおける真の悪夢、Cascading Failure(連鎖的障害)です。

なぜ「Timeout」と「Retry」だけでは不十分なのか?

多くの開発者は「タイムアウトを短く設定すれば済む話だ」と考えがちです。しかし、現実はそう単純ではありません。接続先のサービスが既にダウンしているか、深刻な過負荷状態にある場合、無理にリクエストを送り続けることは事態を悪化させるだけです。

  • 障害中のサービスに追い打ちをかける: 弱っているサービスに対して毎秒数千のリクエストを浴びせれば、復旧のチャンスを奪うことになります。
  • リソースの無駄遣い: タイムアウトが1秒であっても、接続の確立や例外処理を繰り返すコストが発生します。
  • 最悪のユーザー体験: 即座にエラーを返す代わりに、ユーザーを5〜10秒間ローディング画面で待たせた挙げ句、失敗を通知することになります。

これが、ソフトウェアのためのインテリジェントな「ブレーカー」である Circuit Breaker Pattern が必要な理由です。

解決策:Circuit Breaker – コードを保護する安全装置

このメカニズムは家庭のブレーカー(安全器)と全く同じように動作します。電流が過負荷になると、テレビや冷蔵庫を守るためにブレーカーが落ちます。ソフトウェアでは、Circuit BreakerがAPI呼び出しを監視します。エラー率が閾値を超えると、即座に「遮断(Open)」します。それ以降、そのAPIへのリクエストは実際には送信されず、即座に拒否されます。

Circuit Breakerは主に3つの状態で動作します:

  1. Closed(閉路): 通常状態。すべてのリクエストが通過します。エラーが発生すると、エラーカウンターが増加します。
  2. Open(開路): エラーが閾値(例:5回連続エラー)に達すると、回路が遮断されます。リクエストはブロックされ、即座にエラーが返されます。
  3. Half-Open(半開): 一定の待機時間(例:30秒)の後、システムは「様子見」のためにいくつかのリクエストの通過を許可します。成功すれば回路を閉じ(Closed)、依然としてエラーが出る場合は再び遮断(Open)します。

circuitbreakerライブラリによるクイック実装

複雑なエラーカウントロジックを自作する必要はありません。Pythonの circuitbreaker ライブラリは非常に軽量で、Flask、FastAPI、Djangoなどに極めて簡単に統合できます。

インストール

pip install circuitbreaker

Sử dụng Decorator để bọc hàm gọi API

為替レートを取得する関数を例に挙げます。@circuit でラップするだけで、実際のプロジェクトで最もスマートに実装できます。

import requests
from circuitbreaker import circuit

# failure_threshold: 3回連続でエラーが発生したら回路を遮断
# recovery_timeout: 再試行(Half-Open)まで10秒待機
@circuit(failure_threshold=3, recovery_timeout=10)
def call_external_api():
    response = requests.get("https://api.example.com/data", timeout=2)
    response.raise_for_status()
    return response.json()

# テスト実行
for i in range(10):
    try:
        print(f"呼び出し回数 {i+1}:", end=" ")
        call_external_api()
        print("成功!")
    except Exception as e:
        print(f"エラー: {e}")

4回目の呼び出し時、もし前の3回がエラーだった場合、関数自体が実行されません。ライブラリは数ミリ秒以内に即座に CircuitBreakerError をスローします。

複雑なケース向けのカスタマイズ

すべてのエラーが回路遮断の対象となるわけではありません。404 Not Found は通常ロジックのミスであり、サーバーダウンではありません。500 Internal Server ErrorConnectTimeout が発生したときのみ回路を遮断すべきです。

from circuitbreaker import CircuitBreaker

class PaymentServiceBreaker(CircuitBreaker):
    FAILURE_THRESHOLD = 5
    RECOVERY_TIMEOUT = 60
    EXPECTED_EXCEPTIONS = (requests.exceptions.ConnectTimeout, requests.exceptions.HTTPError)

@PaymentServiceBreaker()
def process_payment():
    # 決済ロジック
    pass

フォールバックメカニズム(代替処理)

回路が遮断されたとき、アプリケーションに真っ白な画面を表示させてはいけません。キャッシュからデータを取得したり、デフォルト値を返したりする「プランB」を用意しましょう。

from circuitbreaker import CircuitBreakerError

def get_product_price(product_id):
    try:
        return call_api_with_circuit(product_id)
    except CircuitBreakerError:
        # 回路遮断時、アプリを落とさないようRedisから古い価格を取得
        return redis_cache.get(f"price:{product_id}")

運用における「痛い目を見て学んだ」経験則

実際の運用を何度も経験して得られた、いくつかの重要な注意点を挙げます。

  • 閾値を敏感にしすぎない: もし failure_threshold=1 に設定すると、わずかなネットワークの遅延だけでシステムが「自滅」してしまいます。ほとんどのサービスにおいて、5〜10回のエラーが安全な閾値です。
  • モニタリングは必須: いつ回路が遮断されたかを知る必要があります。メトリクスをGrafanaに送りましょう。回路が頻繁に遮断される場合は、提携先が深刻な問題を抱えており、手動での介入が必要な兆候です。
  • Circuit BreakerはTimeoutの代わりではない: リクエストには必ず timeout を設定してください。Circuit Breakerは返ってきた結果に基づいて動作するものであり、無限にハングしているリクエストを自ら切断することはできません。

Circuit Breakerを導入することで、コードは格段に「タフ」になります。システム全体がダウンする代わりに、エラーが発生している機能だけを一時的に切り離すことができます。外部APIを扱っているなら、今すぐ導入して枕を高くして眠りましょう!

Share: