自宅の太陽光発電の数字を、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 minCPU 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点。

  1. ウォッチドッグを setInterval でループの外に置く。 これが本質。ループが詰まっても、外から観測して落とせる
  2. 復旧手段は process.exit(1)。 今回実際に効いたのがコンテナ再起動だったので、それを自動でやらせる。crash-only設計にして、restart_policy: on-failure + delay: 10s でオーケストレータに任せる
  3. 「値が変わらない」を異常として検知する。 これが今回の本丸

しきい値は実測から決めた

「値が変わらない」で落とす設計は、しきい値を間違えると誤爆する。勘で決めず、生データから測った。

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) でいい。 自前で再接続を書くより、オーケストレータに任せるほうが確実に効く(実際、今回効いたのは外からの再起動だった)
  • しきい値は実測から決める。 生データで「正常時の最長」を測って、その数倍に置く
  • プロセス系メトリクスは最初から出しておく。 事故が起きてから欲しくなるが、そのときには取れない
  • 監視の設定を足したら、必ず一度発火させて到達を確認する

壊れていたことより、壊れていることを知る手段が無かったことのほうが問題だった。監視を足すときは「これが黙っているとき、正常なのか、監視自体が死んでいるのか」を区別できるかを先に考える。