45395 - シコウサクゴ -

launchdヘルスチェックスクリプトの段階的拡張:set -e と日付フィルタで「気づかない停止」をなくす

2026-04-28
AI駆動開発
AI駆動開発
launchd
bash
監視
運用設計
macOS
Last updated:2026-05-07
11 Minutes
2191 Words

macOS の launchd で動かしているバッチジョブが「気づかないうちに止まっていた」ことが何度かあり、ヘルスチェックスクリプトを段階的に拡張してきました。

最初は launchctl list | grep だけの一行スクリプトだったものが、今は 6種類のチェックを束ねた検証スクリプトになっています。その進化の過程と、途中で踏んだ罠をまとめます。

出発点:「動いているはず」を信じない

launchd の plist は KeepAliveStartCalendarInterval で起動条件を指定しますが、以下のようなケースでは「黙って失敗」します。

  • plist の syntax error で launchctl bootstrap 自体が失敗していた
  • スクリプト本体が command not found で即死し、ログだけ更新されている
  • 出力先ディレクトリが消えていて、生成物が一切できていない
  • スケジュールは発火しているが、内部の例外で何も処理されていない

exit code 0 を返しても、実際には何も生成されていないケースがあります。「動いている」と「意図通りに動いている」を区別する仕組みが必要でした。

v1: launchctl list だけ(5行)

最初のバージョンです。

1
#!/bin/bash
2
JOB_LABEL="com.example.daily_pipeline"
3
launchctl 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 で返します。

Terminal window
1
last_exit=$(launchctl list "$JOB_LABEL" | awk '/LastExitStatus/ {print $3}' | tr -d ';')
2
if [[ "$last_exit" != "0" ]]; then
3
echo "NG: $JOB_LABEL last exit = $last_exit"
4
fi

問題: exit code 0 でも、ジョブの中身が何も処理せず即終了しているケースを拾えません。

v3: ログファイルの鮮度をチェック

「最後に書き込まれた時刻」が古ければ、ジョブが実際には走っていない、という判定です。

Terminal window
1
LOG_FILE="/var/log/daily_pipeline.log"
2
MAX_AGE_HOURS=25 # 日次ジョブなので25h
3
4
if [[ ! -f "$LOG_FILE" ]]; then
5
echo "NG: log file missing"
6
exit 1
7
fi
8
9
mtime=$(stat -f %m "$LOG_FILE") # macOS
10
now=$(date +%s)
11
age_hours=$(( (now - mtime) / 3600 ))
12
13
if (( age_hours > MAX_AGE_HOURS )); then
14
echo "NG: log is $age_hours hours old"
15
fi

踏んだ罠: macOS の stat-f %m、Linux は -c %Y。両OSで動かす場合は分岐が要ります。

v4: set -e を入れたら全部死んだ

シェルスクリプトの定石として set -euo pipefail を冒頭に書いたところ、正常系でもスクリプト全体が異常終了するようになりました。

何が起きたか

Terminal window
1
set -euo pipefail
2
3
# launchctl list は対象が無いと exit 1 を返す
4
last_exit=$(launchctl list "$JOB_LABEL" | awk '/LastExitStatus/ {print $3}')

launchctl list <存在しないラベル> が exit 1 を返した瞬間、set -e でスクリプトが死にます。「ラベルが見つからない」こと自体を検査したいのに、検査前にスクリプトが落ちてしまうのです。

grep も同様で、マッチしないと exit 1 を返します。

修正方針: チェック対象のコマンドは || true で守る

Terminal window
1
set -euo pipefail
2
3
# || true でエラーを握りつぶしつつ、戻り値を別で判定
4
list_output=$(launchctl list "$JOB_LABEL" 2>/dev/null || true)
5
6
if [[ -z "$list_output" ]]; then
7
echo "NG: $JOB_LABEL not loaded"
8
exit 0 # スクリプトは正常終了。NGメッセージで通知側が判定
9
fi

ここで重要な判断: 「チェック対象が NG な状態」と「スクリプト自体のバグ」を分離することです。

  • スクリプトのバグ(コマンド typo、変数未定義)→ exit 非ゼロで止める(set -e の本来の用途)
  • チェック対象が NG → スクリプトは exit 0、メッセージで NG 件数を出す

混ぜると「監視が止まっているのか、対象がNGなのか」が区別できなくなります。

v5: 日付フィルタで「同日のログだけ」見る

ログファイルが存在しても、直近実行のログに ERROR が出ているかを見たい。前日以前のエラーは無視したい。

Terminal window
1
TODAY=$(date +%Y-%m-%d)
2
error_count=$(grep -c "ERROR" "$LOG_FILE" | head -1 || true)

これだと 過去のエラー全部 がカウントされます。日付フィルタを足します。

Terminal window
1
error_count=$(grep "$TODAY" "$LOG_FILE" | grep -c "ERROR" || true)

より厳密に: 直近 N 時間で絞る

ログのタイムスタンプ形式に依存しますが、awk で時刻比較できます。

Terminal window
1
SINCE=$(date -v-25H "+%Y-%m-%d %H:%M:%S") # macOS
2
3
awk -v since="$SINCE" '$0 >= since && /ERROR/ {count++} END {print count+0}' "$LOG_FILE"

ログ行が 2026-04-28 10:15:00 ERROR ... のように先頭に時刻があれば、文字列比較で OK です。

v6: 生成物の存在・鮮度・件数まで見る

ここが一番効きました。ログにエラーが出ていなくても、生成物が一切できていないケースが現実にあったからです。

Terminal window
1
OUTPUT_DIR="/var/data/daily_output"
2
EXPECTED_PATTERN="report_*.parquet"
3
MIN_FILES=3
4
5
# 当日生成されたファイル数
6
today_files=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 2>/dev/null | wc -l | tr -d ' ')
7
8
if (( today_files < MIN_FILES )); then
9
echo "NG: only $today_files files generated (expected >= $MIN_FILES)"
10
fi

ポイント: find -mtime -1 で「過去24時間以内に更新されたファイル」を絞り込みます。古いファイルが残っていても誤検知しません。

件数だけでなくサイズも見る

ファイルが生成されていても、0バイトで失敗しているケースを別途検知します。

Terminal window
1
empty_files=$(find "$OUTPUT_DIR" -name "$EXPECTED_PATTERN" -mtime -1 -size 0 | wc -l | tr -d ' ')
2
if (( empty_files > 0 )); then
3
echo "WARN: $empty_files empty files detected"
4
fi

現状のスクリプト構造

最終的に、以下の6チェックを束ねた構造に落ち着きました。

1
#!/bin/bash
2
set -euo pipefail
3
4
JOB_LABEL="com.example.daily_pipeline"
5
LOG_FILE="/var/log/daily_pipeline.log"
6
OUTPUT_DIR="/var/data/daily_output"
7
EXPECTED_PATTERN="report_*.parquet"
8
MIN_FILES=3
9
10
ng_count=0
11
warn_count=0
12
13
check() {
14
local result=$1
15
local message=$2
44 collapsed lines
16
case "$result" in
17
NG) echo "[NG] $message"; ((ng_count++));;
18
WARN) echo "[WARN] $message"; ((warn_count++));;
19
OK) echo "[OK] $message";;
20
esac
21
}
22
23
# Check 1: plist がロードされているか
24
if launchctl list "$JOB_LABEL" >/dev/null 2>&1; then
25
check OK "$JOB_LABEL is loaded"
26
else
27
check NG "$JOB_LABEL not loaded"
28
fi
29
30
# Check 2: 最後の終了コード
31
last_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: ログ鮮度
36
if [[ -f "$LOG_FILE" ]]; then
37
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"
40
else
41
check NG "log file missing"
42
fi
43
44
# Check 4: 当日のログにERRORがないか
45
today=$(date +%Y-%m-%d)
46
errors=$(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: 生成物の件数
50
files=$(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: 空ファイルの検出
54
empty=$(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
57
echo ""
58
echo "Summary: NG=$ng_count WARN=$warn_count"
59
exit 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 も含めて)は早めに仕込むほどよいです。

まとめ

バージョン追加した検知能力学び
v1loaded されているか「動いている」だけでは不十分
v2最後の終了コードexit code は最低限の手がかり
v3ログ鮮度mtime チェックで「黙って止まる」を検知
v4set -e の罠を回避チェック対象の NG とスクリプトのバグを分離
v5日付フィルタ過去のエラーで誤検知しない
v6生成物の存在・鮮度・件数exit 0 でも生成物ゼロは現実にある

ヘルスチェックは「最初から完璧に書く」ものではなく、事故のたびに 1 行ずつ増やしていくものでした。今もまだ完成形ではないですが、追加するたびに「あの事故は来年も起きない」という安心が積み上がります。

Article title:launchdヘルスチェックスクリプトの段階的拡張:set -e と日付フィルタで「気づかない停止」をなくす
Article author:45395
Release time:2026-04-28

記事へのご質問・ご感想をお聞かせください

フィードバックを送る