DB サーバーが落ちてもアラートが飛ばない、と気づいたのが発端だった。ヘルスチェック用のエンドポイントと外形監視を足して、それは塞いだ。

そのあとで「他に監視漏れは無いか」と全体を棚卸しした。同じ型の穴が、同じ日に4件見つかった。 観点を変えてもう一度探したら、さらに4件出た。

1巡目で見つかった4件

穴何が起きるか
資格情報の期限チェックが17日間死んでいた暗号化の移行で読み込み元の形式が変わり、毎日失敗していた。「失敗時は前回値を残す」設計のため、17日前の値が今日の値の顔で出続け、失効アラートは Normal のままだった
失効アラートが特定の名前を決め打ちしていた同じ日に足した別のトークンが対象外。失効しても、そのトークンで動いている監視が黙って止まる
監視サーバーが落ちると、監視も通知も同時に黙るPrometheus・Thanos・Grafana・blackbox が全部1台の Raspberry Pi に載っていて、全ルールが noDataState=OK。開発機のバックアップ失敗すら、この Grafana が評価している
VPS の指標が届かなくなっても鳴らない接続数・メモリ・ディスクの3本がすべて noDataState=OK。トークン失効・トンネル停止・exporter の死、どれでも静かになる

1つ目は別に書いた。この記事では、3つ目の「監視サーバー自身」の話を中心に書く。

共通する構造

4件に共通しているのは、「監視が死んでいる状態」と「監視していて正常な状態」が、外から見て同じ形をしていることだ。

  • noDataState="OK" は、誤報を避けるための正しい設定だ。系列が一時的に欠けただけで鳴られては困る
  • 「失敗したら古い値を残す」は、値の正しさを守るための正しい設計だ。半端な値を書き込むより、前回の確定値のほうがいい

それぞれ単体では妥当なのに、組み合わさると沈黙が正常と区別できなくなる。そして「アラートが来ていない」を「すべて正常」の証拠として読んでいた。それが誤りだった。

監視の土台は、土台の外から見る

一番大きい穴は、監視の土台そのものだった。

Prometheus(収集)、Thanos(保持)、Grafana(評価と Slack 通知)、blackbox(外形監視)。全部 Raspberry Pi 5 の上に載っている。この1台が落ちると、系列が全部消える。全ルールが noDataState=OK なので、全部 Normal に落ちる。1本も鳴らないまま、静かになる。

しかも、開発機のバックアップが失敗したことを知らせるアラートも、この Grafana が評価している。つまり Raspberry Pi が死ぬと、「Raspberry Pi が死んだことを Raspberry Pi に知らせてもらう」という循環になる。

土台の生死を土台自身に報告させると循環する。 土台を経由しない通知経路が1本要る。

開発機から毎朝1回、点検に行く

開発機(自宅の Linux 機)から Raspberry Pi へ ssh で点検に行き、異常なら開発機から Slack の webhook を直接叩く。Raspberry Pi の Grafana を経由しない。

点検内容は4つ。Grafana のコンテナが居るか、/api/health が200か、Prometheus の取り込みが5分以内か、ディスクに5GB 以上空きがあるか。判定は開発機側で行い、Raspberry Pi 側は結果を返すだけだ。

この点検結果は、わざと Prometheus に書いていない。書いても評価するのは Raspberry Pi の Grafana なので、Raspberry Pi が死んでいる状況では役に立たない。土台の外から、土台を経由しない経路で鳴らす。それがこの仕組みの唯一の存在意義で、それ以外の機能を持たせると意義が薄れる。

鍵は専用にして、強制コマンドで縛る

ssh で点検に行くには鍵が要る。開発機の普段の鍵はパスフレーズ付きで ssh-agent 前提なので、systemd のタイマーからは使えない。実際ここで詰まって、Permission denied (publickey) で偽の 🚨 を1本 Slack に出した。

専用の鍵を1本作った。パスフレーズ無し。その代わり、Raspberry Pi 側の authorized_keys で縛る。

command="/usr/local/bin/home-watchdog-probe",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty,restrict ssh-ed25519 AAAA… forge-home-watchdog

command= で実行できるコマンドを点検スクリプトに固定し、restrict と no-pty でそれ以外を全部禁じる。この鍵が漏れても、できるのは点検結果を読むことだけで、シェルは取れない。

誤報は回数で避ける

自宅回線の瞬断で鳴らないよう、3回連続で失敗したときだけ送る(20秒間隔)。1回の失敗で鳴らすと、回線が10秒止まるたびに起こされる。

2巡目: 観点を変えたら、さらに4件

4件を塞いだあと、観点を変えてもう一度洗い直した。1巡目は「アラートの対象」を見ていた。2巡目は層で切った。 収集・評価・通知・実体・寿命・枠。

違うものが出た。

穴何が起きるか
Prometheus が Grafana 自身を取っていなかったGrafana の評価エラーも配送失敗も、時系列で見えない
execErrState="OK" でクエリエラーが Normal に見える壊れたルールが「正常」の顔で並ぶ
Slack への配送失敗が誰にも見えない鳴っているのに届かない状態に気づけない
NVMe は温度しか見ていなかったSMART が故障を申告しても無視。バックアップ先のディスクで、これが唯一の遠隔コピー

同じ切り口で何度探しても、同じものしか見つからない。 1巡目で「対象」を見て4件、2巡目で「層」を見て4件。3巡目に別の切り口があれば、また出ると思う。

塞がっていたことを確認した層

見つからなかった、も記録として残した。次に探すとき、同じところを二度見なくて済む。

  • 通知のルーティングは全アラート → Slack の1本で、マッチャの漏れは無い
  • 公開している19ホストはすべて外形監視の下にある(301 だけ返す2ホストは意図的に除外)
  • Worker はすべて第2の入口(workers.dev)を閉じてある
  • バックアップは gzip -t と restic check --read-data-subset で中身まで検証している

副産物: 外形監視は常時3%ほど失敗している

probe_success の24時間平均が96〜98%だった。サービス側は正常で、DNS でも接続でもなく、probe_duration_seconds が複数ホストで揃って9.50秒(タイムアウト10秒の直前)に達している。

Raspberry Pi から外へ出る経路が、時々数秒止まっている。 アラートには for があるので鳴らないが、ダッシュボードの稼働率は実態より低く出る。原因は追っていない。承知したうえで読む、という扱いにした。

残っている穴(意図的に塞いでいない)

全部は塞いでいない。塞がない理由も書いておく。

  • GitHub Actions の残り分数: デプロイがこの枠に依存しているのに未監視。課金 API が権限不足で読めない
  • ドメインの登録期限: 失効すれば全滅するが、API トークンにレジストラの読み取り権限が無い。権限を足せば監視できる
  • Actions のデプロイ失敗: 未監視だが GitHub が標準でメールを送る。現に困っていない
  • 落ちても実害の小さいホスト2つは、外形監視の対象外のまま

「全部監視する」は目標ではない。黙っているとき、それが正常なのか死んでいるのかを区別できることが目標で、区別できない穴から順に塞いだ。

まとめ

  • 「アラートが来ない」は「すべて正常」の証拠ではない。 監視が死んでいても同じ形になる設定が、正しい設定の組み合わせで簡単に生まれる
  • noDataState=OK と「失敗したら古い値を残す」は、それぞれ正しい。組み合わさると沈黙が正常と区別できなくなる
  • 監視の土台は、土台の外から見る。 土台を経由しない通知経路を1本持つ。その結果を土台に書き戻さない
  • 外から見に行く鍵は専用にし、command= と restrict でシェルを取れなくする
  • 誤報は回数で避ける(3回連続)
  • 同じ切り口では同じものしか見つからない。 対象で切って4件、層で切って4件
  • 塞がなかった穴は、理由と一緒に書いておく

アラートは21本から30本に増えた。増えた分の大半は、値の良し悪しではなく「値が届いているか」「評価が動いているか」を見るものだ。