自宅サーバーのバックアップ、資格情報の期限チェック、クラウド費用の日次確認。どれもsystemdのタイマーで動く小さなスクリプトだ。「今日は成功したか」をPrometheusとGrafanaで見たい。

そのためにexporterを書く必要はない。node-exporterには、ディレクトリに置いたテキストファイルをそのままメトリクスとして出す機能がある。 textfileコレクタという。

設定は1行、書き込みは1ファイル

node-exporterの起動オプションにディレクトリを渡す。

--collector.textfile.directory=/var/lib/node-exporter/textfile

Docker Swarmで動かしているなら、ホスト側のディレクトリを :ro でマウントしておく。読むだけなので書き込み権限は要らない。

あとはスクリプトが、そのディレクトリに .prom ファイルを書くだけだ。

# HELP forge_backup_vps_success 1 なら直近のバックアップが成功
# TYPE forge_backup_vps_success gauge
forge_backup_vps_success 1
forge_backup_vps_last_success_timestamp_seconds 1757980800
forge_backup_vps_size_bytes 1234567890

書式はPrometheusのexposition formatそのもの。次のスクレイプで、これらが node_exporter のメトリクスに混ざって出てくる。

書くときの3つの作法

小さいことだが、踏むと地味に困るものを先に書く。

1. 一時ファイルに書いてから mv する。 スクレイプはいつ来るか分からない。書きかけのファイルを読まれると、その回のスクレイプ全体が壊れる。

tmp="$out.$$"
{ echo "..."; } > "$tmp"
mv "$tmp" "$out"

同じファイルシステム内の mv は原子的なので、途中の状態は見えない。

2. ディレクトリの所有者を、書く側のユーザーに合わせる。 systemdのunitを一般ユーザーで動かしているなら、そのユーザーが書けるようにする。root のまま作って、unitの User= が別だと黙って書けない。

3. node_textfile_scrape_error を見る。 書式が壊れていると、node-exporterはそのファイルを無視して、代わりにこのメトリクスを1にする。設置直後に一度、0であることを確認しておく。

値のメトリクスだけでは足りない

ここからが本題だ。

資格情報の期限チェックを、この仕組みに載せていた。CloudflareのAPIトークンなどの有効期限を毎日読んで、残り日数 をメトリクスに出す。Grafanaのアラートは「残り30日を切ったら鳴らす」。

ある日、そのスクリプトが壊れた。読み込み元の形式が変わって、期限を取れなくなった。

アラートは鳴らなかった。それどころか、「失効まであと320日」と正常を返し続けた。

理由は単純だ。スクリプトは失敗すると .prom を書き直さない。前回書いたファイルがそのまま残る。node-exporterはそれを毎回読んで、毎回同じ値を出す。壊れる前日の値が、壊れたあとも永遠に「今日の値」として流れ続けた。

Grafanaから見れば、320日はどこまでも320日だ。「まだ余裕がある」。異常は何も無い。

監視が死んでいる状態と、監視して正常な状態が、外から見て完全に同じ形をしていた。

これに気づいたのは、監視漏れを棚卸ししたときだった。その時点で、この状態が17日間続いていた。気づかなければ、320日後に「あと320日」と言いながらトークンが失効していた。

鮮度を別建てで見る

対処は、値とは別に「この値はいつ更新されたか」を出して、そちらにアラートを掛けることだ。

forge_secret_expiry_last_success_timestamp_seconds 1757980800

スクリプトが成功したときだけ、そのときの時刻を書く。失敗したら書かない(古いファイルが残る)。そしてアラートはこうする。

(time() - forge_secret_expiry_last_success_timestamp_seconds) / 3600 > 48

最後の成功から48時間経ったら鳴る。 日次ジョブなので、1回落としただけでは鳴らず、2回続けて落ちたら鳴る。

このメトリクス自体は、実は最初から出していた。出していたのに、それを見るアラートが無かった。 本質的な穴はそこだった。データがあることと、それを見ていることは別だ。

一般化: 「失敗したら古い値を残す」型のスクリプトは、必ず *_last_success_timestamp の鮮度を別のアラートで見る。値そのもののアラートは、古い値を見て黙る。

「系列が来ていない」は absent_over_time で

鮮度のアラートにはもう一つ形がある。メトリクスそのものが届かなくなった場合だ。

たとえばマシンごと落ちている、あるいはtextfileディレクトリが消えている。このとき last_success_timestamp は「古い」のではなく「存在しない」。time() - X はXが無いので評価できず、アラートはNoDataになる。GrafanaはNoDataを既定でアラート扱いにするが、誤報を避けるために noDataState を OK にしているルールでは、これが沈黙に化ける。

こちらは absent_over_time で書く。

absent_over_time(forge_backup_vps_last_success_timestamp_seconds{job="forge"}[48h])
  • 系列があるとき → 空を返す → NoData → noDataState="OK" にしておけば沈黙する(誤報にならない)
  • 系列が48時間無いとき → 1を返す → しきい値 gt 0.5 で発火

窓を48時間にしたのは、日次で1回落としても鳴らさないため。両方向を実データで確かめた。 存在する系列に対して空が返ること、存在しない系列に対して1が返ること。アラートを足したときは、一度は発火させて到達を確認する。足しただけで安心すると、さっきの320日と同じことになる。

3つのアラートで1組

結局、1つの定期ジョブにつき3つの見方が要る。

見るものアラート捕まえるもの
値そのもの残り日数 < 30本当に期限が近い
値の鮮度time() - last_success > 48hスクリプトが失敗し続けている
系列の存在absent_over_time(...[48h])マシンかexporterごと止まっている

1つ目だけだと、2つ目と3つ目の状況で黙る。しかも黙り方が「正常」と同じなので、見に行っても気づけない。

同じ構造の事故は、太陽光の記録を取るexporterでも起きていて、51日間同じ値を出し続けた。あれもスクレイプは成功していて up は1のままだった。「動いている」と「正しい値を出している」は別のことで、監視を組むときはいつもこの2つを分けて考えるようになった。

まとめ

  • 定期ジョブの結果はtextfileコレクタで載せる。exporterは要らない。.prom を置くだけ
  • 書くときは一時ファイルに書いて mv。所有者を書く側に合わせる。node_textfile_scrape_error が0か見る
  • 値のメトリクスだけでは、ジョブが死んでも古い値が「正常」として流れ続ける。 実例: 失効まで「あと320日」を言い続けた期限チェック
  • 成功時刻を *_last_success_timestamp_seconds で別に出し、鮮度にアラートを掛ける
  • 系列ごと消えた場合は absent_over_time で。noDataState を OK にして誤報を避ける
  • 1ジョブにつき「値・鮮度・存在」の3つで1組
  • アラートを足したら一度は発火させて到達を確認する

監視を足すたびに「これが黙っているとき、正常なのか死んでいるのか、区別できるか」を考える。区別できない監視は、無いのと同じか、無いより悪い。