計器を付けた翌週、同じコネクタがまた遅くなりました。
前回は二晩続けて静かに落ちていたのを朝まで気づけませんでした。今度はエラーではなく、レイテンシがじわじわ伸びていく壊れ方でした。p95 が普段の 900ms 前後から 12,000ms あたりまで這い上がり、夜間ジョブは落ちてはいないものの、一件処理するたびにタイムアウト寸前で粘っている。ログを見れば異常は分かる。けれど、その夜の私は眠っていました。
数字が見えることと、数字に応じて手が動くことは別でした。可観測性で計器盤は付いたのに、針が赤に振れたときに舵を切る人がいなければ、朝まで同じ失敗を積み上げるだけです。今回はその「舵を切る」部分を、無人でも回る形にしたいと考えました。
コネクタの計器付けそのものは、以前 夜間コネクタの可観測性を個人運用に持ち込んだ記録 にまとめています。本稿はその続き、集めた信号を「退避するかどうか」の判断に変える部分です。
単純なリトライが、遅い夜には逆効果になる
最初に試したのは素朴なリトライでした。失敗したら少し待って、もう一度呼ぶ。エラーで落ちる壊れ方には効きます。けれど今回のように、コネクタが「落ちない代わりに遅い」壊れ方をしているとき、リトライは事態を悪化させました。
一件あたり 12 秒粘ってから失敗し、さらに三回リトライすれば、その一件に 48 秒を溶かします。夜間に数十件を順に回している身では、後続がすべて押し出されて、結局その晩ぶんが不発に終わりました。遅いコネクタに向かって粘るほど、健康なはずの後続処理まで巻き添えにしてしまうのです。
必要なのは「粘る」判断ではなく「今は近寄らない」判断でした。コネクタが弱っているあいだは呼び出しを控え、仕事を退避側へ回し、回復の兆しが見えたら少しずつ様子を見に戻る。これは古くからあるサーキットブレーカーの考え方そのものです。個人運用の夜間ジョブに、この遮断器を一つ挟むことにしました。
健康を測る3つの信号
ブレーカーを開くかどうかは、直近の観測窓(私は15分にしています)から取った次の三つで決めています。
信号 意味 私の初期しきい値
エラー率 観測窓内の失敗数 ÷ 全呼び出し数 0.5 を超えたら開放
p95 レイテンシ 遅い方から5%を除いた実質的な上限 8,000ms を超えたら開放
最小サンプル数 これ未満なら判定を保留する 8 件
平均ではなく p95 を見るのが肝でした。平均レイテンシは、速い応答が多いと遅い応答を薄めてしまい、一部だけがタイムアウト寸前という状態を隠します。前述の夜も、平均は 3 秒台に収まっていたのに p95 は 12 秒に達していました。裾の重さを捉えるには p95 のほうが誠実です。
最小サンプル数を設けているのは、走り出しの数件で早合点しないためです。夜間ジョブは動き始めが不安定になりがちで、最初の 2 件がたまたま遅かっただけで遮断すると、健康なコネクタまで閉め出してしまいます。
3状態のブレーカーを実装する
サーキットブレーカーは三つの状態を持ちます。通常運転の Closed(通す)、遮断中の Open(通さない)、そして回復を探る Half-Open(一回だけ通して様子を見る)です。まずは状態と観測窓を持つ本体から作ります。
import time
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class State ( Enum ):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class Sample :
ok: bool
latency_ms: float
ts: float
@dataclass
class ConnectorBreaker :
name: str
window_sec: float = 900.0 # 直近15分を健康の観測窓にする
min_samples: int = 8 # これ未満なら判定を保留する
error_rate_open: float = 0.5 # エラー率がこれを超えたら開放
p95_open_ms: float = 8000.0 # p95レイテンシがこれを超えたら開放
cooldown_sec: float = 300.0 # 開放後、探索まで待つ時間
half_open_success: int = 3 # 復旧確定に必要な連続成功数
state: State = State. CLOSED
opened_at: float = 0.0
probe_success: int = 0
_samples: deque = field( default_factory = deque)
def _prune (self, now: float ) -> None :
limit = now - self .window_sec
while self ._samples and self ._samples[ 0 ].ts < limit:
self ._samples.popleft()
def _error_rate (self) -> float :
if not self ._samples:
return 0.0
failed = sum ( 1 for s in self ._samples if not s.ok)
return failed / len ( self ._samples)
def _p95_latency (self) -> float :
if not self ._samples:
return 0.0
values = sorted (s.latency_ms for s in self ._samples)
idx = max ( 0 , int ( len (values) * 0.95 ) - 1 )
return values[idx]
_prune で観測窓の外に出た古いサンプルを毎回捨てるので、判定は常に直近15分だけを見ます。p95 は「並べ替えて上位5%の境目を取る」だけの素朴な実装ですが、数十件規模の個人開発ではこれで十分でした。
開くか、閉じるか、探るか
次に、呼び出しの前後で状態を動かす二つのメソッドを足します。allow は呼び出してよいかを答え、record は結果を受け取って状態を更新します。
def allow (self, now: Optional[ float ] = None ) -> bool :
"""呼び出し前に必ず問い合わせる。False なら退避側へ回す。"""
now = time.time() if now is None else now
self ._prune(now)
if self .state is State. OPEN :
if now - self .opened_at >= self .cooldown_sec:
self .state = State. HALF_OPEN
self .probe_success = 0
return True # 探索を一回だけ通す
return False
return True
def record (self, ok: bool , latency_ms: float ,
now: Optional[ float ] = None ) -> None :
now = time.time() if now is None else now
self ._samples.append(Sample( ok = ok, latency_ms = latency_ms, ts = now))
self ._prune(now)
if self .state is State. HALF_OPEN :
if ok:
self .probe_success += 1
if self .probe_success >= self .half_open_success:
self .state = State. CLOSED
else :
self .state = State. OPEN
self .opened_at = now
return
if len ( self ._samples) < self .min_samples:
return
if ( self ._error_rate() >= self .error_rate_open
or self ._p95_latency() >= self .p95_open_ms):
self .state = State. OPEN
self .opened_at = now
ここで意識したのは、Open から Half-Open への移行を allow の中でだけ起こすことです。時間経過という受け身のイベントを状態遷移に使うと、「誰も呼んでいないのに勝手に開いた」といった追いにくい挙動が生まれます。呼ばれたときに、経過時間を見て一回だけ探索を通す。この受動的な設計にしてから、状態の動きがログと一対一で対応するようになり、デバッグが目に見えて楽になりました。
呼び出しをブレーカーで包む
本体ができたら、実際のコネクタ呼び出しを薄いラッパーで包みます。開放中はコネクタに触れず、渡された退避関数(fallback)を返します。
import time
def call_with_breaker (breaker, fn, fallback):
"""コネクタ呼び出しをブレーカーで包む。開放中は fallback を返す。"""
if not breaker.allow():
return fallback()
start = time.perf_counter()
try :
result = fn()
except Exception :
elapsed_ms = (time.perf_counter() - start) * 1000
breaker.record( ok = False , latency_ms = elapsed_ms)
return fallback()
elapsed_ms = (time.perf_counter() - start) * 1000
breaker.record( ok = True , latency_ms = elapsed_ms)
return result
退避先の設計が、この仕組みの価値を決めます。私が採ったのは「翌朝ではなく次サイクルで拾う」退避キューです。開放中に処理できなかった仕事を捨てず、行指向の JSON として書き出しておき、後続のジョブが健康を取り戻したコネクタで拾い直します。
import json
from pathlib import Path
def enqueue_for_retry (task, path = "retry_queue.jsonl" ):
"""開放中に落ちた仕事を退避キューへ書き出す。次サイクルで拾い直す。"""
line = json.dumps(task, ensure_ascii = False )
with Path(path).open( "a" , encoding = "utf-8" ) as f:
f.write(line + " \n " )
こうしておくと、遮断は「その晩の全滅」ではなく「その仕事の先送り」になります。私の夜間ジョブでは、遅くなったコネクタに紐づく数件だけがキューへ流れ、残りは通常どおり進みました。巻き添えの範囲を、コネクタ単位に閉じ込められたわけです。
プロセスをまたいで健康を持ち越す
ここに個人開発ならではの落とし穴があります。スケジュール実行のジョブは毎回まっさらなプロセスで立ち上がるため、ブレーカーの観測窓もその都度ゼロから始まります。前の晩に赤へ振れていた記憶は、次の起動時には消えているのです。
そこで、観測サンプルだけをディスクに持ち越します。状態そのものではなく生のサンプルを保存し、起動時に読み直して観測窓へ戻すのが素直でした。
import json
from pathlib import Path
def save_samples (breaker, path):
rows = [{ "ok" : s.ok, "latency_ms" : s.latency_ms, "ts" : s.ts}
for s in breaker._samples]
Path(path).write_text(json.dumps(rows), encoding = "utf-8" )
def load_samples (breaker, path, now):
p = Path(path)
if not p.exists():
return
for row in json.loads(p.read_text( encoding = "utf-8" )):
breaker._samples.append(
Sample( ok = row[ "ok" ], latency_ms = row[ "latency_ms" ], ts = row[ "ts" ])
)
breaker._prune(now)
状態(Open/Closed)ではなくサンプルを永続化するのには理由があります。もし「Open だった」という状態だけを保存すると、次の起動時に cooldown の起点が曖昧になり、いつまで閉じておくべきかを見失います。生のサンプルを持ち越せば、起動直後に観測窓を再構築でき、そこから同じしきい値で判定をやり直せます。真実の源はあくまで観測結果に置く、という方針です。この「状態ではなく事実を持ち越す」考え方は、スケジュール生成が無音で不発に終わったことを外側から気づく仕組み でも同じ判断をしています。
Half-Open で早合点しない
実運用で一番効いたのは、Half-Open の復旧判定を一度の成功で決めないことでした。
最初は、探索の一件が通ったら即 Closed に戻していました。ところが、遅くなったコネクタは一件だけたまたま速く返すことがあり、その一件を信じて全開に戻すと、直後にまた遅延の波が来て開き直す、という往復を繰り返しました。開いたり閉じたりを繰り返す状態は、遮断していないのとほとんど変わりません。
half_open_success を 3 にして、連続で三件通って初めて Closed に戻すようにしたところ、この往復は収まりました。逆に、Half-Open 中に一件でも失敗したら即 Open に戻し、cooldown を測り直します。回復は疑いながら確かめ、悪化は一件で信じる。この非対称さが、安定して閉じ続けるコツでした。
しきい値は運用しながら決めました。私の四サイトの夜間処理では、window 15分・cooldown 5分・p95 上限 8 秒あたりが、過敏すぎず鈍すぎずの落としどころでした。コネクタごとに素の速さは違うので、p95 のしきい値だけはコネクタ単位で持たせています。普段 900ms のものと 3 秒かかるものに、同じ 8 秒を当てるのは乱暴だと感じたからです。
よくある落とし穴
いくつか、実装中に踏んだものを残しておきます。
落とし穴 症状 対処
平均レイテンシで判定 一部だけ遅い状態を見逃す p95 で裾の重さを見る
最小サンプル未設定 走り出しの数件で誤遮断 min_samples で判定を保留
一件の成功で全開 開閉の往復が止まらない 連続成功数で復旧確定
状態だけを永続化 cooldown の起点を見失う 生サンプルを持ち越す
モデル側のフォールバックと混同しないことも大切でした。モデルが落ちたときの多段退避は Claude API のグレースフル・デグラデーション設計 が扱う領域で、こちらはコネクタという「配管」の健康に閉じた話です。両者は別のレイヤーにあり、片方を直しても他方は守れません。私はモデル用とコネクタ用のブレーカーを、あえて別々に持っています。
次の一歩
もし今、夜間ジョブが「落ちてはいないが遅い」壊れ方で朝を迎えているなら、まずは一番よく使うコネクタ一つに、この ConnectorBreaker を挟んでみてください。しきい値は初期値のまま、一週間ぶんの p95 とエラー率をログに流すだけでも、そのコネクタが夜にどう息をしているかが見えてきます。数字が見えたら、しきい値は自ずと決まります。
私自身、遮断器を一つ挟んでから、赤に振れた針を朝まで放置することはなくなりました。無人の夜に舵を任せられる範囲が、また少し広がった気がしています。まだ探りながらではありますが、共に手を動かしながら整えていけたら嬉しいです。お読みいただきありがとうございました。