macOS の launchd で動かしているバッチジョブが「気づかないうちに止まっていた」ことが何度かあり、ヘルスチェックスクリプトを段階的に拡張してきました。
最初は launchctl list | grep だけの一行スクリプトだったものが、今は 6種類のチェックを束ねた検証スクリプトになっています。その進化の過程と、途中で踏んだ罠をまとめます。
出発点:「動いているはず」を信じない
launchd の plist は KeepAlive や StartCalendarInterval で起動条件を指定しますが、以下のようなケースでは「黙って失敗」します。
- plist の syntax error で
launchctl bootstrap自体が失敗していた - スクリプト本体が
command not foundで即死し、ログだけ更新されている - 出力先ディレクトリが消えていて、生成物が一切できていない
- スケジュールは発火しているが、内部の例外で何も処理されていない
exit code 0 を返しても、実際には何も生成されていないケースがあります。「動いている」と「意図通りに動いている」を区別する仕組みが必要でした。
v1: launchctl list だけ(5行)
最初のバージョンです。
1#!/bin/bash2JOB_LABEL="com.example.daily_pipeline"3launchctl list | grep -q "$JOB_LABEL" \4 && echo "OK: $JOB_LABEL is loaded" \5 || echo "NG: $JOB_LABEL not found"問題: launchctl list に出ていても、最後の実行で失敗していたら気づけません。loaded されているだけで安心してしまいます。
v2: 終了コードのチェックを追加
launchctl list <label> は、最後の終了コードを LastExitStatus で返します。
1last_exit=$(launchctl list "$JOB_LABEL" | awk '/LastExitStatus/ {print $3}' | tr -d ';')2if [[ "$last_exit" != "0" ]]; then3 echo "NG: $JOB_LABEL last exit = $last_exit"4fi問題: exit code 0 でも、ジョブの中身が何も処理せず即終了しているケースを拾えません。
v3: ログファイルの鮮度をチェック
「最後に書き込まれた時刻」が古ければ、ジョブが実際には走っていない、という判定です。
1LOG_FILE="/var/log/daily_pipeline.log"2MAX_AGE_HOURS=25 # 日次ジョブなので25h3
4if [[ ! -f "$LOG_FILE" ]]; then5 echo "NG: log file missing"6 exit 17fi8
9mtime=$(stat -f %m "$LOG_FILE") # macOS10now=$(date +%s)11age_hours=$(( (now - mtime) / 3600 ))12
13if (( age_hours > MAX_AGE_HOURS )); then14 echo "NG: log is $age_hours hours old"15fi踏んだ罠: macOS の stat は -f %m、Linux は -c %Y。両OSで動かす場合は分岐が要ります。
v4: set -e を入れたら全部死んだ
シェルスクリプトの定石として set -euo pipefail を冒頭に書いたところ、正常系でもスクリプト全体が異常終了するようになりました。
何が起きたか
1set -euo pipefail2
3# launchctl list は対象が無いと exit 1 を返す4last_exit=$(launchctl list "$JOB_LABEL" | awk '/LastExitStatus/ {print $3}')launchctl list <存在しないラベル> が exit 1 を返した瞬間、set -e でスクリプトが死にます。「ラベルが見つからない」こと自体を検査したいのに、検査前にスクリプトが落ちてしまうのです。
grep も同様で、マッチしないと exit 1 を返します。
修正方針: チェック対象のコマンドは || true で守る
1set -euo pipefail2
3# || true でエラーを握りつぶしつつ、戻り値を別で判定4list_output=$(launchctl list "$JOB_LABEL" 2>/dev/null || true)5
6if [[ -z "$list_output" ]]; then7 echo "NG: $JOB_LABEL not loaded"8 exit 0 # スクリプトは正常終了。NGメッセージで通知側が判定9fiここで重要な判断: 「チェック対象が NG な状態」と「スクリプト自体のバグ」を分離することです。
- スクリプトのバグ(コマンド typo、変数未定義)→ exit 非ゼロで止める(
set -eの本来の用途) - チェック対象が NG → スクリプトは exit 0、メッセージで NG 件数を出す
混ぜると「監視が止まっているのか、対象がNGなのか」が区別できなくなります。
v5: 日付フィルタで「同日のログだけ」見る
ログファイルが存在しても、直近実行のログに ERROR が出ているかを見たい。前日以前のエラーは無視したい。
1TODAY=$(date +%Y-%m-%d)2error_count=$(grep -c "ERROR" "$LOG_FILE" | head -1 || true)これだと 過去のエラー全部 がカウントされます。日付フィルタを足します。
1error_count=$(grep "$TODAY" "$LOG_FILE" | grep -c "ERROR" || true)より厳密に: 直近 N 時間で絞る
ログのタイムスタンプ形式に依存しますが、awk で時刻比較できます。
1SINCE=$(date -v-25H "+%Y-%m-%d %H:%M:%S") # macOS2
3awk -v since="$SINCE" '$0 >= since && /ERROR/ {count++} END {print count+0}' "$LOG_FILE"ログ行が 2026-04-28 10:15:00 ERROR ... のように先頭に時刻があれば、文字列比較で OK です。
v6: 生成物の存在・鮮度・件数まで見る
ここが一番効きました。ログにエラーが出ていなくても、生成物が一切できていないケースが現実にあったからです。
1OUTPUT_DIR="/var/data/daily_output"2EXPECTED_PATTERN="report_*.parquet"3MIN_FILES=34
5# 当日生成されたファイル数6today_files=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 2>/dev/null | wc -l | tr -d ' ')7
8if (( today_files < MIN_FILES )); then9 echo "NG: only $today_files files generated (expected >= $MIN_FILES)"10fiポイント: find -mtime -1 で「過去24時間以内に更新されたファイル」を絞り込みます。古いファイルが残っていても誤検知しません。
件数だけでなくサイズも見る
ファイルが生成されていても、0バイトで失敗しているケースを別途検知します。
1empty_files=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 -size 0 | wc -l | tr -d ' ')2if (( empty_files > 0 )); then3 echo "WARN: $empty_files empty files detected"4fi現状のスクリプト構造
最終的に、以下の6チェックを束ねた構造に落ち着きました。
1#!/bin/bash2set -euo pipefail3
4JOB_LABEL="com.example.daily_pipeline"5LOG_FILE="/var/log/daily_pipeline.log"6OUTPUT_DIR="/var/data/daily_output"7EXPECTED_PATTERN="report_*.parquet"8MIN_FILES=39
10ng_count=011warn_count=012
13check() {14 local result=$115 local message=$244 collapsed lines
16 case "$result" in17 NG) echo "[NG] $message"; ((ng_count++));;18 WARN) echo "[WARN] $message"; ((warn_count++));;19 OK) echo "[OK] $message";;20 esac21}22
23# Check 1: plist がロードされているか24if launchctl list "$JOB_LABEL" >/dev/null 2>&1; then25 check OK "$JOB_LABEL is loaded"26else27 check NG "$JOB_LABEL not loaded"28fi29
30# Check 2: 最後の終了コード31last_exit=$(launchctl list "$JOB_LABEL" 2>/dev/null \32 | awk '/LastExitStatus/ {print $3}' | tr -d ';' || echo "?")33[[ "$last_exit" == "0" ]] && check OK "exit=0" || check NG "last_exit=$last_exit"34
35# Check 3: ログ鮮度36if [[ -f "$LOG_FILE" ]]; then37 mtime=$(stat -f %m "$LOG_FILE")38 age_h=$(( ($(date +%s) - mtime) / 3600 ))39 (( age_h <= 25 )) && check OK "log age=${age_h}h" || check NG "log too old: ${age_h}h"40else41 check NG "log file missing"42fi43
44# Check 4: 当日のログにERRORがないか45today=$(date +%Y-%m-%d)46errors=$(grep "$today" "$LOG_FILE" 2>/dev/null | grep -c "ERROR" || true)47(( errors == 0 )) && check OK "no errors today" || check NG "$errors errors today"48
49# Check 5: 生成物の件数50files=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 2>/dev/null | wc -l | tr -d ' ')51(( files >= MIN_FILES )) && check OK "$files files generated" || check NG "only $files files"52
53# Check 6: 空ファイルの検出54empty=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 -size 0 2>/dev/null | wc -l | tr -d ' ')55(( empty == 0 )) && check OK "no empty files" || check WARN "$empty empty files"56
57echo ""58echo "Summary: NG=$ng_count WARN=$warn_count"59exit 0 # スクリプトは常に正常終了、判定はメッセージで拡張時に効いた考え方
1. 「正常」の定義を増やしていく
最初は「loaded されている = 正常」でした。今は「loaded されている AND 最後の exit が 0 AND ログが新しい AND エラーが無い AND 生成物がある AND 空ファイルが無い」が正常です。
増やすたびに過去にすり抜けた事故が1つずつ消えていきました。正常の定義は事故から学んで増やしていくのが現実的です。
2. set -e は「スクリプト自体のバグ」用
チェック対象の状態を判定する箇所では || true で守る、それ以外は set -e で守る。役割を分けるとデバッグが楽になります。
3. exit code 0 を信じない
ジョブが「成功」しても、生成物がゼロのケースは現実にあります。exit code とは別に「意図した生成物が出ているか」を見るチェックを必ず入れます。
4. 日付フィルタは早めに入れる
過去のログを引きずって誤検知すると、「またあのアラートか」となって本物のエラーを見逃します。日付フィルタ(mtime も含めて)は早めに仕込むほどよいです。
まとめ
| バージョン | 追加した検知能力 | 学び |
|---|---|---|
| v1 | loaded されているか | 「動いている」だけでは不十分 |
| v2 | 最後の終了コード | exit code は最低限の手がかり |
| v3 | ログ鮮度 | mtime チェックで「黙って止まる」を検知 |
| v4 | set -e の罠を回避 | チェック対象の NG とスクリプトのバグを分離 |
| v5 | 日付フィルタ | 過去のエラーで誤検知しない |
| v6 | 生成物の存在・鮮度・件数 | exit 0 でも生成物ゼロは現実にある |
ヘルスチェックは「最初から完璧に書く」ものではなく、事故のたびに 1 行ずつ増やしていくものでした。今もまだ完成形ではないですが、追加するたびに「あの事故は来年も起きない」という安心が積み上がります。