ある朝、いつものように更新ログを開いたら、前夜に走るはずだった生成ジョブの行が一つだけ抜けていました。エラーログには何も残っていません。終了コードを確認しても 0 です。つまり「成功した」ことになっているのに、記事は一本も増えていない。git の履歴にも、その時刻のコミットがありません。
この「成功したのに何も生まれていない」状態が、いちばん厄介でした。落ちてくれたほうがまだ気づけます。静かに不発に終わり、しかも本人は成功したつもりでいる。個人開発で複数のサイトを夜間に順番に処理していると、こうした無音の不発がたまに混ざります。今日は、それを外側から気づくための仕組みを書いておきます。
「成功」と「成果」は別物だという前提に立つ
私たちはつい、終了コードを成果の証明だと思ってしまいます。けれど exit 0 が保証するのは「最後のコマンドがエラーを返さなかった」ことだけです。途中で生成対象が見つからず、何も作らずに正常終了する経路は、いくらでもあります。
私の場合、原因はだいたい次のどれかでした。参照データの cat が誤ったパスを叩いて空を返し、題材が決まらないまま静かに抜けたこと。ディスクが詰まっていて clone が途中で諦め、それでも後続が形だけ走ったこと。モデルの一時停止で生成が空振りし、push する中身が無かったこと。どれも個別のバグではなく、「不発でも成功扱いになる」という同じ構造を共有しています。
ですから監視の目標は「ジョブが落ちたか」ではありません。「期待した成果物が、本当に生まれたか」です。この一点に絞ると、設計はずいぶん素直になります。
なぜジョブ自身のログを信用できないのか
最初に手を出したくなるのは、ジョブの最後に「完了しました」とログを書くことです。私もそうしました。けれど、これは効きません。
無音の不発が起きるのは、たいてい本流から外れた経路を通ったときです。そして「完了ログを書く」処理は、本流の末尾に置かれています。つまり、ログを取りこぼす状況と、成果物を取りこぼす状況は、同じ根を持っています。失敗したときに限って、失敗を知らせる行も書かれない。自己申告は、いちばん必要な瞬間に黙るのです。
この気づきが設計の出発点になりました。観測する主体を、ジョブの外に出す。ジョブが自分について語る言葉ではなく、ジョブとは独立した第三者が見える事実だけを根拠にする。そう決めると、何を記録すべきかが見えてきます。
外部台帳にハートビートを残す
ジョブには、成功の自己申告ではなく、ハートビート(鼓動)だけを残してもらいます。「私はこの時刻に、この成果物を、この件数だけ作った」という一行を、共有の台帳に追記する。判断はしません。事実の打刻だけです。
台帳は1行1レコードの JSONL にしました。追記しかしないので壊れにくく、あとから機械でも目でも追えます。生成ジョブの最後に、次の数行を足すだけです。
# heartbeat.sh — 生成ジョブの末尾で呼ぶ。判断はせず、起きた事実だけを打刻する
# 使い方: heartbeat.sh <job_id> <repo_dir> <expected_min_articles>
set -uo pipefail
JOB_ID = " $1 "
REPO_DIR = " $2 "
EXPECTED_MIN = " ${3 :- 1} "
LEDGER = "${ HEARTBEAT_LEDGER :- $HOME / ops / heartbeat . jsonl }"
mkdir -p "$( dirname " $LEDGER ")"
# 地の事実: 直近1コミットで追加された ja 記事の数を数える(自己カウントしない)
cd " $REPO_DIR " || exit 0
ADDED = $( git show --name-only --pretty=format: HEAD 2> /dev/null \
| grep -c '^content/articles/ja/.*\.mdx$' )
HEAD_SHA = $( git rev-parse --short HEAD 2> /dev/null || echo "nohead" )
TS = $(TZ = Asia/Tokyo date +%Y-%m-%dT%H:%M:%S%z )
printf '{"job":"%s","ts":"%s","sha":"%s","added":%s,"expected_min":%s}\n' \
" $JOB_ID " " $TS " " $HEAD_SHA " "${ ADDED :- 0 }" " $EXPECTED_MIN " >> " $LEDGER "
ここで一つだけ工夫しています。added の値を、ジョブが頭の中で数えた件数ではなく、git show で実際にコミットへ入ったファイル数から取っていることです。自己申告を排除するという方針を、打刻の段階から徹底しておきます。
不在こそが信号 — デッドマンズスイッチの考え方
ここからが肝心です。ハートビートが「来たこと」ではなく、「来なかったこと」を信号として扱います。これがデッドマンズスイッチです。
仕組みは、こう考えると分かりやすいと思います。電車の運転士が一定時間ハンドルを握り続けないと、装置が自動でブレーキをかける。握り続けている間は何も起きず、握る手が離れた瞬間に作動する。私たちの監視も同じで、ジョブが鼓動を残し続けている間は静かに、鼓動が途切れた瞬間にだけ声を上げます。
実装としては、「今日打刻されるはずだったジョブの集合(期待集合)」と「実際に打刻されたジョブの集合(観測集合)」を用意し、期待集合 ⊆ 観測集合 が崩れていないかを見るだけです。
照合は次の手順で進めます。
その曜日に走る予定のジョブ ID を、期待集合として宣言する
当日分の台帳行を読み、ジョブ ID を観測集合として集める
期待集合から観測集合を引き、差が空であることを確かめる
差が空でなければ、欠けたジョブ ID を不発として報告する
「予定されたものが来ているか」を問うので、ジョブが起動すらしなかった完全な沈黙も捕まえられます。落ちたログを待つ監視では、ここが盲点になります。
ウォッチドッグが見るのは自己申告ではなく地の事実
鼓動が来ていても、まだ安心はできません。打刻はされたのに added が 0、という不発があり得るからです。ですからウォッチドッグは、台帳の鼓動の有無と、リポジトリの地の事実の両方を照らし合わせます。
地の事実とは、その日に対象リポジトリの ja 配下の .mdx が本当に増えたか、です。台帳の自己カウントを信じず、ウォッチドッグ自身が clone 済みのリポジトリで数え直します。二つの数字が食い違ったときは、台帳のほうを疑います。
下のスクリプトが監視の本体です。生成の窓がすべて閉じたあと、1日1回だけ走らせます。
#!/usr/bin/env python3
# watchdog.py — 期待されたジョブの鼓動と、リポジトリの地の事実を突き合わせる
import json, subprocess, sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
JST = ZoneInfo( "Asia/Tokyo" )
LEDGER = Path.home() / "ops" / "heartbeat.jsonl"
# 曜日ごとの期待集合(0=月 .. 6=日)。休止日はここで宣言して誤検知を防ぐ
EXPECTED_BY_WEEKDAY = {
0 : { "claudelab-premium" , "gemilab-premium" , "antigravitylab-premium" , "rorklab-premium" },
# 平日は4サイト共通。土日は別集合に差し替える運用も同じ形で書ける
}
for wd in range ( 1 , 5 ):
EXPECTED_BY_WEEKDAY [wd] = EXPECTED_BY_WEEKDAY [ 0 ]
EXPECTED_BY_WEEKDAY [ 5 ] = { "weekend-content" }
EXPECTED_BY_WEEKDAY [ 6 ] = { "weekend-content" }
REPO_OF = {
"claudelab-premium" : Path.home() / "repos" / "claudelab.net" ,
"gemilab-premium" : Path.home() / "repos" / "gemilab.net" ,
"antigravitylab-premium" : Path.home() / "repos" / "antigravitylab.net" ,
"rorklab-premium" : Path.home() / "repos" / "rorklab.net" ,
}
def today_jst ():
return datetime.now( JST ).date()
def load_today_beats ():
beats = {}
if not LEDGER .exists():
return beats
for line in LEDGER .read_text( encoding = "utf-8" ).splitlines():
try :
rec = json.loads(line)
ts = datetime.fromisoformat(rec[ "ts" ])
except (json.JSONDecodeError, KeyError , ValueError ):
continue
if ts.astimezone( JST ).date() == today_jst():
beats[rec[ "job" ]] = rec # 同一ジョブの最後の打刻を採用
return beats
def ground_truth_added (repo: Path) -> int :
"""ウォッチドッグ自身が数える。台帳の self-count は信用しない。"""
if not (repo / ".git" ).exists():
return - 1 # クローンが無い=検証不能。これも異常として扱う
since = today_jst().isoformat()
out = subprocess.run(
[ "git" , "-C" , str (repo), "log" , "--since" , since,
"--name-only" , "--pretty=format:" ],
capture_output = True , text = True
).stdout
return sum (
1 for ln in out.splitlines()
if ln.startswith( "content/articles/ja/" ) and ln.endswith( ".mdx" )
)
def main ():
weekday = today_jst().weekday()
expected = EXPECTED_BY_WEEKDAY .get(weekday, set ())
beats = load_today_beats()
missing, hollow = [], []
for job in sorted (expected):
rec = beats.get(job)
if rec is None :
missing.append(job) # 鼓動が無い=完全な沈黙
continue
repo = REPO_OF .get(job)
truth = ground_truth_added(repo) if repo else int (rec.get( "added" , 0 ))
if truth <= 0 :
hollow.append((job, rec.get( "added" , 0 ), truth)) # 打刻はあるが地に無い
if not missing and not hollow:
print ( f "[OK] { today_jst() } 期待 { len (expected) } 件すべて成果を確認" )
return 0
if missing:
print ( f "[ALERT] 無音の不発(鼓動なし): { ', ' .join(missing) } " )
for job, claimed, truth in hollow:
print ( f "[ALERT] 空打ち(自己申告= { claimed } / 実測= { truth } ): { job } " )
return 1
if __name__ == "__main__" :
sys.exit(main())
missing と hollow を分けているのは、対処が違うからです。鼓動が無いジョブはスケジューラ側の不発(起動失敗・前段の依存崩れ)を疑い、空打ちのジョブは生成ロジック側(題材枯渇・参照データ欠落)を疑います。症状で原因の当たりがつくと、翌朝の調査が短くなります。
誤検知を減らす — 期待集合をどう定義するか
この監視は、期待集合の正しさがすべてです。期待集合がずれていると、休止日のジョブを「不発だ」と叫び続け、やがて誰も警報を読まなくなります。狼少年にしないことが、監視を生かす条件です。
私は次の3点を守るようにしています。
期待集合は曜日で宣言し、スケジューラの設定変更と同じコミットで更新する。片方だけ直さない
意図的な休止は「空集合の宣言」として明示する。暗黙の不在と区別できるようにする
月初など特異日は、前日に期待集合を手で確認する。例外を黙って通さない
特に2つ目が効きます。「今日は何も走らない予定だ」という情報を監視に教えておくと、不在が正常なのか異常なのかを機械が区別できます。沈黙そのものではなく、「予定と食い違う沈黙」だけを拾えるようになります。
警報の届け方も、誤検知対策の一部だと考えています。私は watchdog.py の終了コードが 1 のときだけ、その日の [ALERT] 行をまとめて手元の通知先へ送るようにしました。正常な日(exit 0)は何も送りません。毎日「異常なし」を送ると、人はやがて中身を読まずに既読にしてしまうからです。届くのは異常の日だけ、という設計にしておくと、一通が来た時点で「今日は何かある」と身構えられます。
運用して見えた数字と、つまずき
3週間ほど回した範囲では、無音の不発を2件、空打ちを1件、いずれも翌朝までに検知できました。以前なら、更新ログの行の欠けに自分で気づくまで気づけなかったものです。検知から原因特定までの時間は、症状の切り分けが効いて、平均で15分ほどに収まりました。
つまずきもありました。最初、台帳の日付を素の date で打っていて、UTC で前日扱いになり、当日分の照合から漏れる事故を起こしました。打刻もウォッチドッグも TZ=Asia/Tokyo で揃えてから安定しています。タイムゾーンは、監視のように「いつ」を主語にする処理では、最初に固定すべき一点だと痛感しました。
もう一つ。ウォッチドッグ自身が落ちたら、すべての監視が無音になります。これは監視の永遠の入れ子問題で、完全には消せません。私は割り切って、ウォッチドッグにだけは外形的なハートビート(実行できたら1行を別ファイルに残す)を持たせ、それが2日途切れたら手で気づけるよう、確認を朝の習慣に組み込みました。最後の一段は人に残す、という線引きです。
もし同じように複数のジョブを夜間に走らせているなら、まずは一番落ちて困るジョブ1本に、この heartbeat の一行を足すところから始めてみてください。期待集合はあとから育てられます。私自身、最初の1本から少しずつ広げていきました。同じ静かな不発に悩んでいる方の、調査の出発点になれば幸いです。