自宅の太陽光発電の数字を、Prometheusに取り込んで眺めている。パネルのモニタがSPAなので、Puppeteerで画面を読んでメトリクスに変換する小さなexporterを書いて、Raspberry Pi 5のDocker Swarmで動かしていた。
2026年8月、発電量の傾向を調べようとしてグラフを開いた。5月31日から7月21日まで、発電0.9kW・消費0.7kWで完全に平らだった。
51日分のデータが無効だった。そして51日間、誰も気づかなかった。
なぜアラートが鳴らなかったのか
問題はここだ。監視は入れてあった。それでも鳴らなかった。
- スクレイプは15秒ごとに成功し続けていた。
upは 1 のまま。down系のアラートには原理的にかからない - 欠測ではない。 Prometheusには「同じ値の実サンプル」が240件/時で入り続けた。歯抜けにならないので、グラフを見ても穴が開かない
/metricsも正常に応答していた。 ヘルスチェックも通る
つまり、監視の観点から見ると完全に健康だった。死んでいたのは値の中身だけで、それを見る仕組みが無かった。
グラフを見れば平らなのは一目で分かる。でも毎日グラフを開く人はいない。異常は「見に行ったときだけ見つかる」状態だった。
原因: ウォッチドッグが監視対象と同じループの中にいた
コードはこうなっていた。
while (true) {
if (Date.now() - sessionStart >= RECONNECT_INTERVAL_MS) break; // ← 再起動の判定
try { await fetchValues(page); ... } // ← ここで止まると
await sleep(5000);
}
1時間ごとにブラウザを作り直す作りで、その判定をループの先頭に置いていた。安全装置としては素直に見える。
でも fetchValues の await が永久に解決しなくなったとき、ループは次の周回に進まない。Date.now() の判定に一生到達しない。
安全装置が、守るべき対象と運命を共有していた。 ループが詰まったら、ループの中にある再起動判定も一緒に詰まる。当たり前のことだが、この形で書くと気づきにくい。
launchBrowser に protocolTimeout: 60000 は付けてあった。効かなかった。呼び出し側で時間を区切っていなかったので、戻ってこない await をそのまま待ち続けた。タイムアウトはライブラリに任せきりにせず、呼ぶ側でも区切る。
CPUの波形が裏を取ってくれた
原因を特定できたのは、cAdvisorのCPUメトリクスが残っていたからだった。
正常時は毎時 :35〜:40 に 1.9% → 3.7% のスパイクが立つ。1時間ごとのブラウザ再起動だ。
| 日 | CPU min | CPU max | 毎時スパイク |
|---|---|---|---|
| 2026-05-29(正常) | 1.87% | 3.76% | あり |
| 2026-06-15(凍結中) | 1.25% | 1.34% | なし |
| 2026-08-14(正常) | 1.86% | 3.87% | あり |
凍結中の51日間はスパイクが1本も立っていない。 ブラウザ再起動が一度も発火していなかった、という直接の証拠になった。
CPUが0ではなく /metrics も応答していたので、イベントループ自体は生きていた。同期ブロックではなく「戻ってこないPromise」だと分かる。ベースラインが1.9%から1.28%に落ちているのも、SPAが更新描画をやめた分の仕事が消えたためと整合する。
凍結が解けたのは7月21日15:57で、これはコンテナが別タスクに入れ替わった瞬間と一致していた。つまり復旧させたのは外からのコンテナ再起動であって、内部の再接続機構ではない。51日間、自力では一度も立ち直れていなかった。
併発していたバグ: 一番欲しいデータが最初から無かった
事後解析で、もうひとつ間抜けなものが見つかった。
collectDefaultMetrics({ registry }) // ← 正しくは register
オプション名を間違えていた。prom-client は知らないオプションを黙って無視するので、プロセス系メトリクス(ヒープ、イベントループ遅延、GC)は全部グローバルレジストリ側に行き、/metrics には出ていなかった。
scrapeされていた系列は発電と消費の2本だけ。事後解析にいちばん使えるはずのデータが、最初から一度も取れていなかった。
原因の特定にcAdvisorのCPU波形を使うことになったのは、これが無かったからだ。オプション名のtypoは、静かに、しかし確実に効く。
直し方: 定時再起動をやめ、異常時に自分で死ぬ
直すときに、方針を一つ決めた。
1時間ごとの無条件再起動を廃止し、異常を検知したときだけ自分でプロセスを落とす。
無駄に再起動してCPUを使うより、異常があったときだけ落ちればいい。毎時3.7%まで跳ねていたスパイクも消える。
実装で変えたのは3点。
- ウォッチドッグを
setIntervalでループの外に置く。 これが本質。ループが詰まっても、外から観測して落とせる - 復旧手段は
process.exit(1)。 今回実際に効いたのがコンテナ再起動だったので、それを自動でやらせる。crash-only設計にして、restart_policy: on-failure+delay: 10sでオーケストレータに任せる - 「値が変わらない」を異常として検知する。 これが今回の本丸
しきい値は実測から決めた
「値が変わらない」で落とす設計は、しきい値を間違えると誤爆する。勘で決めず、生データから測った。
2026-08-08〜08-14の15秒粒度・40,258点で「同じ値が続いた時間」を調べた。
- 発電のみ: 最長919分(夜間は0が続くので、単独では判定に使えない)
- 消費のみ: 最長20.8分(p99.9 = 15.2分)
- 発電と消費が同時に固まった時間: 最長20.75分(p99.9 = 14.75分)
夜間の発電0を「凍結」と誤判定しないために、両方が同時に変化しないことを条件にする。実測の最長が20.75分なので、当初は3倍の余裕を見て60分にした(後に30分へ短縮した)。
誤爆しても数秒の再起動で済み、失うのは数サンプルだけだ。復旧が安いなら、しきい値は攻めていい。
もうひとつのウォッチドッグは「値を取得できない」で、こちらは10分にした。正常時の取得間隔は5秒なので、十分に余裕がある。今回の凍結モードはこちらでも捕まる。
教訓
upは「プロセスが生きているか」しか見ていない。 値が正しいかは別の問題で、別の仕組みが要る- 値のアラートには、必ず鮮度のアラートを添える。 「値がおかしい」だけでなく「値が更新されていない」を見る。同じ値が入り続けるのは欠測より厄介で、グラフにも穴が開かない
- ウォッチドッグは監視対象の外に置く。 ループの中に再起動判定を書くと、ループが詰まったとき一緒に詰まる
- タイムアウトはライブラリ任せにせず、呼ぶ側でも区切る
- 復旧手段は
process.exit(1)でいい。 自前で再接続を書くより、オーケストレータに任せるほうが確実に効く(実際、今回効いたのは外からの再起動だった) - しきい値は実測から決める。 生データで「正常時の最長」を測って、その数倍に置く
- プロセス系メトリクスは最初から出しておく。 事故が起きてから欲しくなるが、そのときには取れない
- 監視の設定を足したら、必ず一度発火させて到達を確認する
壊れていたことより、壊れていることを知る手段が無かったことのほうが問題だった。監視を足すときは「これが黙っているとき、正常なのか、監視自体が死んでいるのか」を区別できるかを先に考える。