デッドマンズスイッチを入れた翌週、私は自分の携帯を伏せて置くようになっていました。
無音の不発を外側から検知できるようにしたことで、たしかに空振りには気づけるようになりました。ところが検知の網を細かくするほど、通知は増えていきます。あるコネクタが数分だけ不安定になるたびに鳴り、深夜のレート制限で鳴り、朝には十数通が積み上がっている。やがて私は、その山を上から順にスワイプで消すようになりました。
そして案の定、その山の中に本物が一件、埋もれていたのです。
検知の設計は、以前 スケジュール生成が無音で不発に終わったことを外側から気づく仕組み にまとめました。今回はその続き、つまり「検知したあと、どうやって一通の報せに畳むか」の設計です。鳴らすことより、鳴らさないことのほうが、ずっと難しいと痛感した記録でもあります。
検知を足したら、今度は鳴りすぎて麻痺した
個人開発で複数サイトの夜間生成を回していると、失敗の大半は「本物ではない」ものです。一過性の 529、数分で戻るコネクタのゆらぎ、リトライで自然に解決する取りこぼし。これらを一つひとつ通知にすると、通知は事象の数だけ増えます。
問題は量そのものではなく、量が私の判断を鈍らせることでした。十通目を開くころには、一通目に何が書いてあったかを覚えていません。アラート疲れとは、感度の低下ではなく、注意の枯渇なのだと思います。
だから設計の出発点を、「何を鳴らすか」ではなく「何を鳴らさないか」に置き直しました。
アラートは事象ではなく行動を単位にする
最初に変えたのは、通知の粒度です。それまで私は「エラーが起きたら通知」という素朴な規則で運用していました。けれども私が本当に知りたいのは、エラーの発生ではなく、「いま私が起きて手を動かす必要があるか」でした。
そこで、すべての事象を三つの行動段階に振り分けることにしました。
| 段階 | 意味 | 行き先 | 例 |
| log | 記録だけ。人は見ない | JSONL 台帳 | リトライで解決した単発の 529 |
| notify | 朝まとめて見ればよい | 日次ダイジェスト | 1サイトで1本だけ生成が遅延 |
| page | いま起きて対処すべき | 即時通知(携帯) | 全サイトの成果がゼロ / 認証失効 |
この振り分けを最初に通すだけで、携帯を鳴らす対象は劇的に減ります。大切なのは、severity(重大度)を「エラーの技術的な深刻さ」ではなく「私に要求する行動」で決めることです。500 エラーでも、リトライで直り成果が出ているなら、それは log で構いません。逆に exit 0 でも成果がゼロなら、それは page です。
重複と群化 — 同じ根本原因を一通に畳む
次に効いたのが、重複の排除でした。同じコネクタが5分間に8回失敗したとき、私に必要なのは8通ではなく「そのコネクタが落ちている」という一通です。
そこで各事象に指紋(fingerprint)を与えました。指紋は「サイト × 段階 × 失敗の種別」で作ります。エラーメッセージの可変部分(タイムスタンプ・リクエストID)は指紋に含めません。同じ指紋の事象は、群化ウィンドウ(私は10分にしています)のあいだ一通に畳み、発生回数だけをカウントします。
import hashlib, re
def fingerprint(site: str, stage: str, err: str) -> str:
# 可変部分を落として「同じ原因」を同一視する
norm = re.sub(r"\d{2,}", "N", err.lower()) # 数字列を潰す
norm = re.sub(r"req_[a-z0-9]+", "req_X", norm) # リクエストIDを潰す
key = f"{site}|{stage}|{norm[:80]}"
return hashlib.sha1(key.encode()).hexdigest()[:12]
指紋を導入する前、私の通知は事象の数だけ届いていました。導入後は「原因の数」まで減ります。この差は、通知を読む側の認知負荷にそのまま効きます。
ばたつきを黙らせる — ヒステリシスという考え方
畳んでもなお残るのが、ばたつき(flapping)です。コネクタが落ちて、戻って、また落ちる。素直に状態遷移で鳴らすと、この上下のたびに「落ちました」「戻りました」が交互に届きます。
制御工学のヒステリシスを借りました。発火と解除で、しきい値をずらすのです。
| 遷移 | 条件 | 狙い |
| 正常 → 発火 | 連続 3回 失敗 | 単発の揺れでは鳴らさない |
| 発火 → 解除 | 連続 2回 成功 | 戻ってすぐには「復旧」と言わない |
発火の敷居を高く、解除の敷居も一定に置くことで、境界付近での往復が黙ります。ひとつ実感したのは、解除の通知こそ抑えるべきだということでした。「復旧しました」は安心材料に見えて、実際には注意を一度奪います。復旧はダイジェストに回し、page するのは発火だけにしました。
静音時間と、それを突き破る一線
私は夜眠りますから、深夜のすべてを page にはできません。かといって、全部を朝まで黙らせると、デッドマンズスイッチの意味が消えます。
そこで静音時間(quiet hours)に、突破ルールを一本だけ引きました。
- 深夜帯(0:00〜7:00 JST)は、原則 notify までに格下げしてダイジェストへ。
- ただし ハードダウン(全サイト成果ゼロ / 認証失効 / 課金系の停止)だけは、静音時間を突き破って page する。
判断の軸は「翌朝まで待って傷が広がるか」です。一晩の空振りは取り返せませんが、翌朝に気づいても被害は一晩分で頭打ちです。一方、認証失効や課金の停止は、放置するほど機会損失が積み上がります。だから前者は待たせ、後者は起こす。線はそこに引きました。
私はアプリ側で AdMob の収益も日々眺めていますが、fill rate が突然ゼロに張り付くような事象も、この「翌朝まで待つと傷が広がる」側に置いています。収益の配管が止まる類の障害は、記事が一本遅れることとは性質が違うからです。
段階的エスカレーション — 自己修復・通知・起こす
最後の層が、エスカレーションの階段です。私の運用では、page に至る前に自動リカバリ(pull --rebase の再試行、リポジトリ再作成、モデルのフォールバック)を挟んでいます。ならばアラートも、その自己修復の結果と連動させるべきでした。
- 自己修復を試す:失敗を検知したら、まず定義済みのリカバリを実行する。ここでは何も鳴らさない。
- 修復が効けば log:自己修復で成果が回復したら、記録だけ残す。
- 修復が効かなければ notify、それでも回復しなければ page:試行回数が上限に達し、かつ成果が依然ゼロのときに初めて起こす。
この階段の要点は、「まだ自分で直そうとしている最中」に人を呼ばないことです。呼ぶのは、機械が諦めた瞬間に限る。そうして初めて、page は「起こす価値のある報せ」になります。
実装:薄い通知ゲート
上の四つ(段階分け・指紋畳み・ヒステリシス・静音突破)を一枚のゲートにまとめると、こうなります。状態は外部の JSONL に持たせ、ジョブ自身のプロセスが死んでも失われないようにしています。
import time, json
class AlertGate:
def __init__(self, state_path, quiet=(0, 7)):
self.state_path = state_path
self.quiet = quiet
self.state = self._load()
def _load(self):
try:
return json.load(open(self.state_path))
except FileNotFoundError:
return {}
def _save(self):
json.dump(self.state, open(self.state_path, "w"))
def _in_quiet(self, hour):
lo, hi = self.quiet
return lo <= hour < hi
def observe(self, fp, ok, severity, hard_down, hour):
s = self.state.setdefault(fp, {"fail": 0, "succ": 0, "firing": False})
if ok:
s["succ"] += 1; s["fail"] = 0
if s["firing"] and s["succ"] >= 2: # 連続2成功で解除
s["firing"] = False; self._save()
return ("recover", "digest") # 復旧はダイジェストへ
self._save(); return (None, None)
# 失敗側
s["fail"] += 1; s["succ"] = 0
if not s["firing"] and s["fail"] >= 3: # 連続3失敗で発火
s["firing"] = True
self._save()
if not s["firing"]:
return (None, "log") # まだ揺れの範囲
# 発火中の行き先を決める
if severity == "page":
if self._in_quiet(hour) and not hard_down:
return ("fire", "digest") # 静音時間は格下げ
return ("fire", "page") # ハードダウンは突破
return ("fire", "notify")
このゲートを、生成ジョブの各ステップ結果に対して呼びます。返り値の行き先(page / notify / digest / log)に応じて、実際の送信は別関数へ委ねます。ゲート自体は「鳴らすかどうか」の判断だけに集中させ、送信手段(携帯通知・ダイジェスト追記・台帳書き込み)とは切り離しておくのが、後から静音時間を変えたり手段を差し替えたりするときに効きます。
指紋を作る fingerprint() と、この AlertGate を組み合わせるだけで、通知は「原因ごとに、揺れを黙らせ、深夜は畳み、本物だけ起こす」という形に落ち着きます。
運用して見えた数字と、つまずき
3週間、4サイトの夜間生成に対してこのゲートを通した結果です。
| 指標 | 導入前 | 導入後 |
| 携帯を鳴らした通知(週) | 90〜120通 | 2〜3通 |
| そのうち行動を要した割合 | 体感 数% | ほぼ全件 |
| 本物に気づくまで(発火→着手) | 数時間(埋没) | 平均12分 |
数字以上に効いたのは、携帯が鳴ったら本当に何かが起きている、という信頼が戻ったことでした。鳴らないことが安心の材料になる。その状態になって初めて、自動運用は「任せきる」に近づきます。
つまずきも書き残します。当初、指紋にエラーメッセージ全体を含めていたため、可変IDのぶんだけ指紋が散らばり、群化がまったく効きませんでした。数字列とリクエストIDを潰す正規化を入れて、ようやく畳めるようになったのです。「同じ原因を同じと見なす」ことの難しさは、この正規化の匙加減に集約されます。潰しすぎれば別の障害を一緒くたにし、潰さなければ畳めない。ここは自分のログを眺めながら、しばらく調整し続ける前提でいたほうがよいと感じています。
コネクタ側の可観測性(エラー・レイテンシの見える化)については 可観測性 public beta を機に個人運用に計器を付けた記録 に別途まとめました。計器で測り、ゲートで畳む。この二つが揃って初めて、夜間の自動実行を安心して続けられるのだと、いまは思っています。
鳴らす設計より、鳴らさない設計に時間をかけたことは、いまのところ最も割の合った投資だったと感じています。あなたの夜が、少しでも静かでありますように。