PythonとTenacityでデータベースサーキットブレーカーを構築する:自動リトライ、スマートバックオフ、自己回復接続

Database tutorial - IT technology blog
Database tutorial - IT technology blog

午前2時のインシデントが変えたデータベース障害への向き合い方

きっかけはトラフィックの急増だった。PostgreSQLデータベースは負荷に耐えられず遅くなり、クエリはタイムアウトし始めた。アグレッシブなリトライロジックで設定されたアプリケーションは、毎秒数千回の再接続を試みてデータベースを叩き続けた。回復するどころか、データベースは完全にダウンしてしまった。40分のダウンタイム、不満を抱えたチーム、そしてポストモーテムで浮かび上がった根本原因は一つ:サーキットブレーカーがなかったこと。

こんな経験で午前2時に呼び出されたことがある人なら、その辛さはよくわかるはずだ。このガイドでは、私のような苦い経験をしなくて済むよう、Pythonで本格的なデータベースサーキットブレーカーを構築する方法を解説する。

データベース障害時に実際に何が起きているか

ジュニア開発者が見落としがちな障害のカスケードはこうだ:

  1. 高負荷や長時間クエリによりデータベースが遅くなる
  2. アプリケーションのリクエストがタイムアウトし始める
  3. リトライロジックが作動する——失敗したリクエストそれぞれが3回リトライ
  4. リトライだけでデータベースの負荷が3倍になる
  5. データベースがさらに遅くなり、タイムアウトとリトライが増加
  6. 完全崩壊

これがリトライストーム(「サンダリングハード」とも呼ばれる)だ。信頼性を高めるためのリトライロジックが、データベースを壊す元凶になってしまう。根本原因はリトライそのものではなく、リトライがシステム全体の健全性を把握していないことだ。データベースが明らかに休憩を必要としている状況でも、リトライは容赦なく続く。

3つのアプローチ——なぜ多くのチームが間違った選択をしてしまうのか

方法1:シンプルなリトライ

import time
import psycopg2

def query_with_retry(sql, max_retries=3):
    for attempt in range(max_retries):
        try:
            conn = psycopg2.connect("postgresql://localhost/mydb")
            cursor = conn.cursor()
            cursor.execute(sql)
            return cursor.fetchall()
        except Exception:
            if attempt == max_retries - 1:
                raise
            time.sleep(1)

多くの人がここから始める。問題は、データベースの過負荷時にこれがストレスを増幅させることだ。100人の同時ユーザー × 3回のリトライ = 300回の接続試行が苦しんでいるデータベースを叩く。状況を積極的に悪化させてしまう。

方法2:Tenacityによる指数バックオフ

Tenacityはバックオフをスマートに処理するPythonのリトライライブラリだ。これは本質的な改善になる:

pip install tenacity psycopg2-binary
from tenacity import retry, stop_after_attempt, wait_exponential
import psycopg2

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=30)
)
def query_db(sql):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql)
    return cursor.fetchall()

指数バックオフとはリトライの間隔が伸びていくことを意味する:1秒、2秒、4秒、8秒…これにより試行の合間にデータベースが息を整える時間ができる。方法1よりはるかに優れている。しかし、データベースが明らかに長時間ダウンしているときにリトライを完全に停止するメカニズムはまだない。壊れたシステムにリクエストを投げ続けているだけで、ただ遅くなっただけだ。

方法3:サーキットブレーカーパターン(正しいツール)

サーキットブレーカーの概念は電気工学から来ている。回路が過負荷になると、ブレーカーが作動してシステムを保護する。状況が落ち着いたらリセットする。ソフトウェアでは、サーキットブレーカーは3つの状態を持つ:

  • クローズ——通常動作、リクエストは通過する
  • オープン——ブレーカーが作動、リクエストはデータベースに触れることなく即座に失敗する
  • ハーフオープン——クールダウン後、データベースが回復したか確認するために1つのテストリクエストを通す

核心はこれだ:サーキットがオープン状態のとき、データベースへの攻撃を完全に停止する。これにより、データベースが自力で回復するための余地が生まれる。

実装:サーキットブレーカー + Tenacity の組み合わせ

これが私が本番環境で使っている実装だ。Tenacityのバックオフとカスタムサーキットブレーカーを組み合わせることで、両方のいいとこ取りができる——一時的なエラーに対するスマートなリトライと、データベースが本当にダウンしているときの完全停止だ。

ステップ1:サーキットブレーカークラス

import time
import threading
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class DatabaseCircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED
        self._lock = threading.Lock()

    def record_failure(self):
        with self._lock:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
                print(f"[サーキットブレーカー] オープン — {self.failure_count} 件の障害を検出")

    def record_success(self):
        with self._lock:
            self.failure_count = 0
            self.state = CircuitState.CLOSED

    def can_attempt(self):
        with self._lock:
            if self.state == CircuitState.CLOSED:
                return True
            if self.state == CircuitState.OPEN:
                elapsed = time.time() - self.last_failure_time
                if elapsed >= self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    print("[サーキットブレーカー] ハーフオープン — 回復をテスト中")
                    return True
                return False
            return True  # HALF_OPEN: プローブを1回許可

ステップ2:データベース呼び出しのラッピング

from tenacity import retry, stop_after_attempt, wait_exponential, RetryError
import psycopg2

db_circuit = DatabaseCircuitBreaker(failure_threshold=5, recovery_timeout=30)

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
def _execute_query(sql, params=None):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql, params or ())
    result = cursor.fetchall()
    conn.close()
    return result

def safe_query(sql, params=None):
    if not db_circuit.can_attempt():
        raise Exception("サーキットオープン — データベース利用不可、リクエストをスキップ")
    try:
        result = _execute_query(sql, params)
        db_circuit.record_success()
        return result
    except RetryError as e:
        db_circuit.record_failure()
        raise Exception(f"リトライ後もクエリが失敗: {e}")
    except Exception as e:
        db_circuit.record_failure()
        raise

ステップ3:自動回復のためのバックグラウンドヘルスチェック

ハーフオープン状態は、入ってくるトラフィックからのテストリクエストを自動的に処理する。トラフィックが少ないバックグラウンドサービスの場合は、定期的なヘルスプローブを追加して、実際のリクエストを待ち続けてサーキットが永遠にオープンのままにならないようにしよう:

def db_health_check(circuit_breaker, interval=15):
    """バックグラウンドスレッド:サーキットがオープンの際にDBをプローブする。"""
    while True:
        time.sleep(interval)
        if circuit_breaker.state == CircuitState.OPEN:
            try:
                conn = psycopg2.connect(
                    "postgresql://localhost/mydb",
                    connect_timeout=3
                )
                conn.close()
                circuit_breaker.record_success()
                print("[ヘルスチェック] データベース回復 — サーキットCLOSED")
            except Exception:
                print("[ヘルスチェック] データベースはまだ利用不可")

health_thread = threading.Thread(
    target=db_health_check,
    args=(db_circuit,),
    daemon=True
)
health_thread.start()

ステップ4:動作確認

if __name__ == "__main__":
    for i in range(20):
        try:
            result = safe_query("SELECT NOW()")
            print(f"クエリ {i+1}: OK — {result}")
        except Exception as e:
            print(f"クエリ {i+1}: 失敗 — {e}")
        time.sleep(0.5)

自分の環境に合わせたパラメータ調整

最も重要な2つの数値:

  • failure_threshold:サーキットがオープンになるまでの失敗回数。低すぎる(1〜2)と一時的なネットワークの不具合でブレーカーが落ちる。高すぎると苦しんでいるデータベースに問い合わせを投げ続ける。ほとんどの環境では5から始めると良い。
  • recovery_timeout:プローブリクエストを許可するまでの待機秒数。Postgresでは30秒が安全なデフォルト値だ。接続キューを解消するのにより時間がかかるような高負荷の場合、私は60〜120秒を使っている。

私自身のワークフローから一つ実用的なメモ:サンプルデータが必要なテストシナリオを設定する際、インポートスクリプト用にCSVフィクスチャをJSONに変換する必要がよくある。私はtoolcraft.app/ja/tools/data/csv-to-jsonを使っている——完全にブラウザ上で動作するので、データが外部に送信されることはない。本番データに近いものを扱う際に便利だ。

状態遷移の概要

CLOSED    → (failure_threshold に達した)   → OPEN
OPEN      → (recovery_timeout が経過した)  → HALF_OPEN
HALF_OPEN → (テストリクエスト成功)         → CLOSED
HALF_OPEN → (テストリクエスト失敗)         → OPEN

このパターンがカバーしないこと

サーキットブレーカーはデータベースへの過負荷からアプリを守るが、他の信頼性レイヤーの代わりにではなく、それらと並存するものだ:

  • コネクションプーリング:PgBouncer または SQLAlchemy の組み込みプールを使って、OSレベルで同時接続数の上限を設ける
  • 読み取りレプリカ:プライマリとレプリカに対して独立してサーキットブレーカーを設定する——それぞれ異なる障害モードを持つ
  • グレースフルデグラデーション:サーキットがオープンのとき、APIが何を返すかを決める——キャッシュデータ、明示的な503、または安全なデフォルト値。静かに失敗するのは大声で失敗するよりも悪い

これを導入すれば、かつてアプリケーション全体の停止にカスケードしていたデータベースのインシデントは、1分以内に自己回復する傾向がある。データベースに息を整える余地が生まれ、健全な状態に戻ればアプリは自動的に再接続する。冒頭で触れた40分のダウンタイム?データベースレイヤー全体にサーキットブレーカーを追加した後、同じ障害モードは誰も目を覚ます前に自然に解消された45秒の小さな乱れになった。

Share: