cron監視の対象一覧を自動化する方法――鮮度判定と誤報の落とし穴

ガジェット・周辺機器を描いたアイキャッチ画像

手で書いた監視対象一覧は、定期処理を増やした瞬間から古び始めます。某所ラボでは、設置済みのcron定義を都度読み、対象を自動で組み立てる番犬を動かしました。

読後には、cronのログ更新時刻を使って定期処理の鮮度を測る最小構成を再現できます。ただし、最初の本番運転では9件のアラートが出て、9件すべてが誤報でした。監視は、鳴ればよいわけではありません。

今回の線引きは明確です。対象発見は自動化しますが、「異常か」の最終判定はAIに渡さず、固定した機械のロジックに残します。判断役が止まっている時にも動く必要がある、最後の砦だからです。

目次

手書きの監視一覧をやめると、追加漏れを減らせる

定期処理を見張る方法には、対象名を監視設定へ列挙する方法と、実際に置かれた定義を読む方法があります。前者は意図が見えやすい一方、定義を追加・変更した時点で監視一覧との二重管理になります。番犬は/etc/cron.dをスキャンし、そこにある定義を監視対象として扱います。

図1は、番犬が持つ範囲を示した構成図です。定期処理の定義からログ出力先を取り出し、そのmtimeだけを鮮度の証拠にします。

図1: 構成図: cron定義からログmtimeへ至る、対象一覧を持たない鮮度判定
入力 番犬の処理 出力
/etc/cron.dのスケジュール行 周期と>> ログの宛先を読む 新鮮/陳腐化/証拠なし
ログ出力先のmtime 鮮度窓と比較する アラート候補

ここでいう鮮度窓は、期待周期の2倍と3時間の大きい方です。毎日実行は26時間、毎週実行は8日で固定しています。たとえば30分おきの処理なら下限の3時間、24時間周期の処理なら26時間を使います。

ログファイルがあること自体を生存証明にはしません。見るのは、その処理が期待した周期で更新を続けているかです。この考え方は、既出の死活監視は「プロセス」を見よで扱った問題意識ともつながります。

判定をAIに渡さない理由は、番犬の停止条件にある

以前、見張り係の判断をAIへ渡し、手書き1,090行を572行にした回がありました。AIに判断を渡した過去回では、その判断が役に立つ場所を選びました。

一方、この番犬は異なります。番犬は、他の判断役や定期処理が止まった時にこそ働かなければなりません。判定経路を増やすより、決めた証拠と閾値で淡々と判定するほうを選びました。

選択肢 向く場面 この番犬での扱い
静的な対象一覧 対象がほぼ増減せず、手動レビューを優先する場合 採用しない
cron定義の動的スキャン 定期処理の追加漏れを減らしたい場合 採用する
AIによる異常判断 文脈の読解や要約が必要な場合 最終判定には採用しない
固定ロジックによる異常判断 判断役自身が止まっても監視を残したい場合 採用する

本物の番犬が返す状態は5種類です。「新鮮」は問題なし、「陳腐化」は発火記録が途絶えた状態、「起動即死」は発火後に処理本体まで届いていない状態です。加えて、導入直後で判断周期を迎えていない「様子見」と、修正後に次回発火を待つ「修復待ち」を持たせています。

初回の9件全誤報が、二つの状態を必要にした

初回の本番運転では、9件のアラートが出ました。内訳は、導入直後でまだ判断できないものが6件、すでに直っていて次の発火を待つものが3件です。9件すべてが誤報でした。

これは監視対象の失敗ではなく、番犬側の状態設計が不足していた失敗です。そこで「様子見」と「修復待ち」を追加しました。誤報は通知の質を落とすだけでなく、次に本物の異常が起きた時に読まれなくなります。誤検知も監視のバグであると考える理由です。

監視器が自分自身を疑った

もう一つ、少し扱いに困る事故がありました。番犬自身のログに、ほかの対象が出した「アクセス権がない」といった障害メッセージが混ざり、番犬は自分を「起動即死」と判定しました。

原因は、故障の証拠をログ末尾の文言から探す時、番犬自身のログとアラート文の先頭部分を区別していなかったことです。修正では、この二つを探索対象から除外しました。

  • 観測した事実: 他処理の障害文が混ざり、監視器自身が起動即死と誤判定した。
  • 解釈: ログ共有は、証拠の帰属を曖昧にする。
  • 確認できていないこと: 出力を残さず異常終了する処理は、この文言照合だけでは拾えない。

同じ定義ファイルでも、鮮度は行ごとに見る

図解: 同じファイルでも行ごとに判定(09:20の日次処理 / 30分おき処理 / 以前の判定 / 正常に見える / 改修後の判定 / 対応するログ)
図解: 同じファイルでも行ごとに判定(AI生成)

定期処理ファイル内の複数スケジュール行を、以前はOR判定で一つの鮮度に畳んでいました。このため、09:20に動く日次処理が前日から止まっていても、同じファイル内の30分おき処理が動いていれば、全体は正常に見え続けました。

修正後は、行ごとにログ宛先という個別の証拠を特定できる場合に限り、行単位で鮮度を見ます。生きている行が死んでいる行を隠さないためです。

図2: 構成図: 同一ファイル内の09:20日次処理と30分おき処理は別々のログ宛先で判定する
スケジュール行 以前の判定 改修後の判定
09:20の日次処理 30分おき処理が動くと正常に見える 対応するログの鮮度を個別に見る
30分おき処理 同上 対応するログの鮮度を個別に見る

図2で読むべきなのは、30分おきの行が新鮮でも、09:20の行は救われないことです。ログ宛先を分けられない場合、番犬は行単位の根拠を持てません。

cronだけ見ていると、タイマー停止を見落とす

定期実行にはcronだけでなく、OS標準のタイマー機構もあります。当初、番犬からタイマー側が漏れていました。タイマーは「失敗」として残らず、ただ発火しなくなることがあります。この漏れにより、5つの処理が1日以上無音のまま止まりました。

タイマー監視を追加した後には、NAS監視用のタイマーが導入・有効化済みなのに一度も起動しておらず、監視材料の収集が19時間止まっていた例も見つかりました。導入翌日から翌々日にかけて起きた事例です。

「一度も発火していない」だけでは原則アラートしません。導入直後なら自然だからです。ただし、有効化されているのに次回発火予定すらない場合は例外としてアラートします。改修後はOS標準以外のタイマーを7本認識し、定期バックアップ処理では直近発火が約1.1時間前、周期24.0時間であることを確認しています。

致命的終了だけを追加し、一時的な警告は鳴らさない

図解: 致命的終了だけを追加(ERROR: で始まる文言 / 強制終了 / メモリ不足によるエラー / 致命的終了 / 照合対象 / 一時的な警告)
図解: 致命的終了だけを追加(AI生成)

当初は、ERROR: で始まる文言、強制終了、メモリ不足によるエラーという3種類の致命的終了を照合対象に入れておらず、見逃しがありました。これらを追加しました。

一方で、一時的な警告に相当する文言は追加していません。一時的なエラーで大きく鳴らすと、監視対象の処理そのものを止めかねないためです。番犬は1日2回、決まった時刻に動きます。だからこそ、証拠の強さと通知の重さを混ぜないようにしています。

再現パック: 2026-08-15に82エントリを判定した最小版

2026-08-15に、このホストで本物の番犬からcron定義の動的スキャンと鮮度窓だけを抜き出した最小版を実走しました。82エントリを判定し、stale=10でした。これは本物の番犬の最終的な通知件数ではありません。最小版には「様子見」「修復待ち」がないためです。

図3: 実画面: 2026-08-15実行出力・82エントリ中stale=10
lane-adsense               daily  新鮮
lane-app-orchestrator      15min  新鮮
lane-blog-pdca             daily  新鮮
lane-broker-reconcile       5min  新鮮
lane-funnel                   3h  新鮮
lane-publish               daily  新鮮
lane-watchdog              daily  新鮮
(中略)
-- 82 entries, stale=10

この結果は「一覧を持たずに対象を見つけられる」ことの証拠です。同時に、鮮度だけの最小版では導入直後や修復直後を区別できず、初回の9件全誤報に近い問題を再現することも示しています。

まだ見張れていないものが二つある

番犬は稼働中です。誤報を踏み、「様子見」「修復待ち」、行単位の鮮度判定、タイマー監視、致命的終了文言を一つずつ追加してきました。

ただし、正常終了していても成果物、たとえばバックアップ実体が古いケースはまだ見ていません。また、何も出力を残さず異常終了するケースも、ログ末尾の文言照合では拾えません。今すぐ全てを一つの監視へ寄せるより、まず自分の定期処理が成功時にどの証拠を残すかを整理するのが安全です。

コピペで再現

前提条件: Linux、Python 3、/etc/cron.dの読み取り権限が必要です。標準ライブラリだけで動き、rootは不要な環境が多い構成です。判定するのは>> pathで指定されたログ出力先のmtimeだけです。

次の内容をcron_freshness.pyとして保存し、実行してください。

#!/usr/bin/env python3
"""cron鮮度チェック最小版 — /etc/cron.d をスキャンし、監視対象を動的に組み立てる。

監視対象の一覧は持たない。設置済みの cron 定義そのものが一覧である。
判定は「各エントリのログ出力先(>> path)の最終更新が、期待周期から導いた
鮮度窓に収まっているか」だけ。追加パッケージ不要、read-only。
"""
import glob
import os
import re
import sys
import time

FLOOR_MIN = 3 * 60
DAILY_MIN = 26 * 60
WEEKLY_MIN = 8 * 24 * 60

CRON_RE = re.compile(
    r"^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+\S+\s+(.*)$"
)
LOG_RE = re.compile(r">>\s*(\S+)")


def classify(minute, hour, dom, mon, dow):
    """cron 式 → (期待周期[分], ラベル)。粗い導出で現役定義には十分。"""
    if dow.strip() != "*":
        return WEEKLY_MIN, "weekly"
    m = re.fullmatch(r"\*/(\d+)", minute.strip())
    if m:
        return int(m.group(1)), f"{m.group(1)}min"
    h = re.fullmatch(r"\*/(\d+)", hour.strip())
    if h:
        return int(h.group(1)) * 60, f"{h.group(1)}h"
    if hour.strip() == "*":
        return 60, "hourly"
    return 24 * 60, "daily"


def window_minutes(interval_min, label):
    """鮮度窓 = max(2×期待周期, 3時間)。daily/weekly は 26h/8d 固定。"""
    if label == "weekly":
        return WEEKLY_MIN
    if label == "daily":
        return DAILY_MIN
    return max(2 * interval_min, FLOOR_MIN)


def main():
    now = time.time()
    rows = []
    for path in sorted(glob.glob("/etc/cron.d/*")):
        try:
            text = open(path, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        for line in text.splitlines():
            if line.lstrip().startswith("#") or not line.strip():
                continue
            m = CRON_RE.match(line)
            if not m:
                continue
            minute, hour, dom, mon, dow, cmd = m.groups()
            interval, label = classify(minute, hour, dom, mon, dow)
            win = window_minutes(interval, label)
            logm = LOG_RE.search(cmd)
            if not logm:
                rows.append((os.path.basename(path), label, "証拠なし(ログ出力先が無い)"))
                continue
            log_path = logm.group(1)
            try:
                age_min = (now - os.path.getmtime(log_path)) / 60
            except OSError:
                rows.append((os.path.basename(path), label, "様子見(まだログが無い)"))
                continue
            status = "新鮮" if age_min <= win else f"陳腐化({age_min/60:.1f}h 更新なし)"
            rows.append((os.path.basename(path), label, status))
    width = max((len(r[0]) for r in rows), default=10)
    for name, label, status in rows:
        print(f"{name:<{width}}  {label:>7}  {status}")
    stale = [r for r in rows if r[2].startswith("陳腐化")]
    print(f"-- {len(rows)} entries, stale={len(stale)}")
    return 1 if stale else 0


if __name__ == "__main__":
    sys.exit(main())
$ python3 cron_freshness.py

ハマり所:

  • この最小版のstale=10には、本物の番犬が「様子見」「修復待ち」としてアラートから除外する状態も含まれます。最初から通知へ直結させず、導入直後と修復直後を別状態にしてください。
  • >> ログがない行は「証拠なし」です。成功時に何も出力しない処理は死んで見えるため、成功も1行残す設計にします。
  • 同じ定義ファイルに複数行があり、ログ宛先が異なるなら、行単位で判定します。生きている行が死んだ行を隠さないためです。

この最小版は、成果物が古いケースと、何も出力せずに異常終了するケースを判定しません。まずは自分のcron定義と成功ログをそろえ、その後に通知の状態設計を足してください。

この検証を回している環境

この検証は、自宅の常設ラボ(使い捨てVM/LXCを回す母艦+GPU+VLAN分離ネットワーク)で動かしています。使っている機材と選定理由、全体構成は1本にまとめています。

ラボ構成のまとめを見る →
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次