データ集計ジョブで「日次の処理件数が異常に多い」と判定されてサーキットブレーカーが連日発動する事故がありました。
調査した結果、入力件数自体は増えていないにも関わらず、集計時に同じレコードが複数回カウントされて閾値を超えていました。冪等でない日次集計の罠と、その修正を書きます。
何が起きたか
サーキットブレーカーの誤発動
業務ロジックで「日次の処理件数が直近 7 日平均の 2 倍を超えたら異常」とみなしてサーキットブレーカーをトリップさせ、後続処理を止める仕組みを入れていました。
1def check_daily_count(today_count: int, last_7d_avg: float) -> bool:2 if today_count > last_7d_avg * 2:3 circuit_breaker.trip("daily_count_anomaly")4 return False5 return Trueある日から 連続でトリップ するようになりました。実データを見ると:
1今日: 12,400 件2直近 7 日平均: 3,100 件3判定: 12,400 > 6,200 → トリップしかし入力データの量は変わっていません。何かがおかしい。
原因:日次集計が冪等でなかった
コードの構造
日次集計ジョブの実装は次のようになっていました。
1def aggregate_daily(target_date: date):2 new_records = fetch_records_for_date(target_date)3 for r in new_records:4 # 「過去にカウントされていたら追加しない」と思っていた5 daily_summary[target_date]["count"] += 16 save_summary(daily_summary)問題: daily_summary は永続化されていて、ジョブが再実行されると 既存の値に加算 してしまいます。
11 回目の実行: count = 0 → 310022 回目の実行: count = 3100 → 620033 回目の実行: count = 6200 → 930044 回目の実行: count = 9300 → 12400サーキットブレーカーが「閾値超過」を検知したのは 正しい振る舞い で、本当の異常は「同じジョブが 4 回走っていた」ことでした。
なぜ複数回走っていたか
launchd の KeepAlive 設定で、スクリプトが exit 1 で終了したら再起動 するようにしていました。
1<key>KeepAlive</key>2<dict>3 <key>SuccessfulExit</key>4 <false/>5</dict>集計の途中で DB の一時的な接続エラー が起き、exit 1。launchd が再起動 → 既存の集計に追加加算 → exit 1 → ループ。
ジョブのログを見ると 1 日に 4〜5 回起動していました。リトライ自体は想定内ですが、冪等性が欠けていたことが連鎖障害を引き起こしました。
修正:冪等な集計に書き直す
Before: 加算ベース
1daily_summary[target_date]["count"] += 1 # 加算After: 上書きベース(冪等)
1def aggregate_daily(target_date: date):2 records = fetch_records_for_date(target_date)3 summary = {4 "count": len(records),5 "total_value": sum(r["value"] for r in records),6 "categories": Counter(r["category"] for r in records),7 }8 upsert_summary(target_date, summary) # date をキーに上書きupsert_summary は date をキーにして、既存があれば置き換え、無ければ作成します。何度実行しても結果は同じ(冪等)。
upsert の実装例(PostgreSQL)
1def upsert_summary(target_date: date, summary: dict):2 sql = """3 INSERT INTO daily_summary (target_date, count, total_value, categories)4 VALUES (%s, %s, %s, %s)5 ON CONFLICT (target_date) DO UPDATE SET6 count = EXCLUDED.count,7 total_value = EXCLUDED.total_value,8 categories = EXCLUDED.categories,9 updated_at = NOW()10 """11 cursor.execute(sql, (target_date, summary["count"], ...))PostgreSQL の ON CONFLICT DO UPDATE を使えば、target_date の重複時に上書きできます。
検知の仕組みも作る
修正したものの、「冪等じゃないコード」が将来も書かれる可能性は残ります。検知の仕組みを足しました。
Idempotency テスト
ジョブを 2 回連続で実行して結果が同じかを確かめるテストを書きます。
1def test_aggregate_is_idempotent(setup_test_data):2 target_date = date(2026, 5, 1)3
4 # 1 回目5 aggregate_daily(target_date)6 summary_1 = load_summary(target_date)7
8 # 2 回目9 aggregate_daily(target_date)10 summary_2 = load_summary(target_date)11
12 assert summary_1 == summary_2, "ジョブは冪等であるべき"このテストを CI に入れることで、「2 回実行で結果が変わる」コードを早期に潰せます。
実行回数の可視化
launchd のジョブが何回起動したかをログから集計できるようにしました。
1# 当日の起動回数2grep "$(date +%Y-%m-%d)" /var/log/aggregation.log \3 | grep "starting aggregation" \4 | wc -l「1 日 1 回しか起動しないはず」のジョブが 4 回起動していたら、サーキットブレーカーがトリップする前に気づけます。
サーキットブレーカー側の改善
サーキットブレーカーが「異常を検知」したとき、何が異常なのかをログに残すようにしました。
1def check_daily_count(today_count: int, last_7d_avg: float, target_date: date) -> bool:2 threshold = last_7d_avg * 23 if today_count > threshold:4 circuit_breaker.trip(5 reason="daily_count_anomaly",6 details={7 "target_date": str(target_date),8 "today_count": today_count,9 "last_7d_avg": last_7d_avg,10 "threshold": threshold,11 "ratio": today_count / max(last_7d_avg, 1),12 },13 )14 return False15 return Trueratio まで残しておくと、「どの程度の異常で発動したか」が後で追えます。今回のケースは ratio=4.0 で、4 倍に達していたので「ジョブが 4 回走った」と推測できました。
連鎖障害の発生図
11. DB 一時エラー → スクリプト exit 122. launchd が再起動 → 集計が再実行33. 加算ベースの集計が二重計上44. サーキットブレーカーが閾値超過を検知 → トリップ55. 後続ジョブが全部止まる66. 翌日も DB 一時エラー → 同じ連鎖観点: サーキットブレーカーは「異常を正しく検知」していました。問題は「異常じゃないものを異常に見せたコード」(冪等性の欠如)でした。
監視側のチューニングだけで対応していたら、根本原因が見つからずに閾値を緩める方向に行っていたはずです。
学び
1. 「リトライされたとき」を必ず考える
外部要因で再起動されるジョブは多いです。1 回目と N 回目の結果が同じになる書き方を最初から意識します。
1# Bad: 加算2counter += 13
4# Good: 入力から決定的に計算5counter = len(records)2. 冪等性は「テストで担保」する
レビューで「これ冪等ですか?」と聞いて確認するのは見落としやすいです。CI で 2 回実行して結果が同じか確かめるテストを書くと、機械的に検知できます。
3. サーキットブレーカーは「正しく動いた」ときも分析する
ブレーカーが発動したとき「閾値が厳しすぎた?」とまず疑いがちですが、正しく異常を検知していたケースが現実にあります。発動したら必ず details を読んで根本原因を追います。
4. launchd の KeepAlive はリトライ条件を吟味する
KeepAlive で再起動するなら、冪等なジョブだけにします。集計や送信のような副作用がある処理に無条件で KeepAlive を付けると、二重実行が起きます。
まとめ
| 観点 | Before | After |
|---|---|---|
| 集計ロジック | 加算(+= 1) | 上書き(upsert) |
| 再実行時の挙動 | 二重計上 | 同じ結果(冪等) |
| CI テスト | なし | 2 回実行 → 同一結果を assert |
| ログ | 起動回数が見えない | 起動回数を可視化 |
| ブレーカーログ | reason のみ | reason + details で根本原因が追える |
サーキットブレーカーの「誤発動」と思った事象が、実は 正しい異常検知 で、本当のバグは集計ジョブの冪等性欠如でした。
監視側のチューニングではなく 集計側の書き直し で根治できる場合があります。「ブレーカーが鳴った」ときは、まず鳴った原因を疑わず、鳴らされた事象を信じて掘るのが大事だと感じました。