ある朝、いつものように更新ログを開いたら、前夜に走るはずだった生成ジョブの行が一つだけ抜けていました。エラーログには何も残っていません。終了コードを確認しても 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本から少しずつ広げていきました。同じ静かな不発に悩んでいる方の、調査の出発点になれば幸いです。