壊れにくいシェルスクリプト:ロック・trap・ログ・cron の落とし穴

strict モード、mkdir ロック、後始末の trap、時刻付きログ、--dry-run を備えたテンプレートを kill -9 と SIGTERM で実際に検証。cron で壊れる理由も再現。

シリーズ:コマンドライン講座 6/6(上級)。 スクリプトを定期実行に載せる、あるいは同僚に引き継ぐ直前の開発者が、誰も見ていないときに起きること(二重起動、途中での kill、違う環境、深夜に誰かが読むログ)に耐えられるスクリプトを書けるようになるための回です。

「昨日までは動いていた」スクリプトに悩む人のための記事です。同時に2つ動いてファイルを壊した、途中で止められてロックが残り、以降の実行がすべて起動を拒否した、cron から動かしたら黙って何もしていなかった。ここでは小さくても完結したスクリプト(ログの圧縮)を1つ作り、わざと攻撃します。二重起動、kill -9、kill -TERM、cron に似た空の環境。以下の出力はすべて実際のものです。

環境: macOS 26、Apple Silicon Mac(M1 Pro)、GNU bash 3.2.57(#!/bin/bash)、BSD のコマンド群。タイムスタンプを比べやすいよう TZ=UTC にしました。パスは ~/demo/... と表記します。crontab のエントリも launchd のジョブも登録していません。 cron の部分は env -i で再現し、crontab の行は説明のみです。GNU/Linux の書き方は「ここでは未実行」と明記します。

前提は第4回(strict モードとその穴)と第5回(シグナルと trap)です。シリーズの全体像は学習ロードマップにあります。

テンプレート

やることは、ディレクトリ内の N 日より古い *.log を gzip するだけです。この1ファイルをコピーして chmod +x すれば動きます。

#!/bin/bash
# rotate-logs.sh - gzip *.log files older than N days in one directory.
# Template: strict mode, fixed PATH, args + --dry-run, mkdir lock, cleanup trap, timestamped log.
set -euo pipefail
IFS=$'\n\t'
export PATH=/usr/bin:/bin:/usr/sbin:/sbin

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOCK_DIR="${TMPDIR:-/tmp}/rotate-logs.lock"
LOG_FILE="$SCRIPT_DIR/rotate-logs.log"
DRY_RUN=0
DAYS=7
TARGET=""
WORK=""
CHILD=""

log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "$2" >>"$LOG_FILE"; }
die() { log ERROR "$1"; echo "error: $1" >&2; exit "${2:-1}"; }
usage() { echo "usage: $0 [--dry-run] [--days N] DIR" >&2; exit 2; }

while [ $# -gt 0 ]; do
  case "$1" in
    -n|--dry-run) DRY_RUN=1 ;;
    --days) [ $# -ge 2 ] || usage; DAYS="$2"; shift ;;
    -h|--help) usage ;;
    -*) echo "unknown option: $1" >&2; usage ;;
    *) TARGET="$1" ;;
  esac
  shift
done
[ -n "$TARGET" ] || usage
[ -d "$TARGET" ] || die "not a directory: $TARGET" 3
case "$DAYS" in ''|*[!0-9]*) die "--days needs a number, got '$DAYS'" 2 ;; esac

# Lock: mkdir is atomic. Keep our PID inside so a stale lock can be detected.
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  old=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
  if [ -n "$old" ] && kill -0 "$old" 2>/dev/null; then
    log WARN "already running as pid $old, exiting"
    exit 0
  fi
  log WARN "removing stale lock (pid '$old' is gone)"
  rm -rf "$LOCK_DIR"
  mkdir "$LOCK_DIR" || die "could not take the lock" 4
fi
echo $$ >"$LOCK_DIR/pid"

cleanup() {
  rc=$?
  [ -z "$CHILD" ] || kill "$CHILD" 2>/dev/null || true
  [ -z "$WORK" ] || rm -rf "$WORK"
  rm -rf "$LOCK_DIR"
  log INFO "exit $rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

WORK=$(mktemp -d "${TMPDIR:-/tmp}/rotate-logs.XXXXXX")
log INFO "start dir=$TARGET days=$DAYS dry_run=$DRY_RUN"
find "$TARGET" -type f -name '*.log' -mtime +"$DAYS" -print0 >"$WORK/list"
count=0
while IFS= read -r -d '' f; do
  if [ "$DRY_RUN" -eq 1 ]; then
    log INFO "would gzip: $f"
  else
    gzip -9 -- "$f"
    log INFO "gzipped: $f"
  fi
  count=$((count + 1))
done <"$WORK/list"
log INFO "done, $count file(s)"

# Test hook: hold the lock for SLOW seconds. Run long commands as '& wait'
# so a TERM is handled at once instead of after the command finishes.
if [ "${SLOW:-0}" -gt 0 ]; then sleep "$SLOW" & CHILD=$!; wait "$CHILD"; fi
流れ図。引数を解析し、不正なら 2 か 3 で終了。次にロック用ディレクトリを mkdir する。すでにあれば保存された PID を kill -0 で確認し、生きていれば already running を記録して 0 で終了、死んでいれば古いロックを消して取り直す。ロックを取ったら EXIT の trap を設定し、本処理または dry-run を行う。trap が一時フォルダとロックを消し、終了コードを記録する。
テンプレートの処理順。ロックを取る前に検証し、取った直後に EXIT の trap を設定する。kill -9 以外のどの終わり方でも後始末が動く。

各部分がある理由:

  • set -euo pipefail:処理されない失敗・未設定変数・壊れたパイプラインで止まる(何がすり抜けるかは第4回)。
  • IFS=$'\n\t':クォートし忘れた展開が空白で分割されなくなる。第二の防衛線で、展開はすべてクォートしたままです。
  • export PATH=...:誰が(何が)起動しても同じコマンドを使う。cron 対策として最も重要な1行です。
  • SCRIPT_DIR:ログはスクリプトの隣に書き、その時のカレントディレクトリに依存しない。
  • 終了コードの使い分け:使い方の誤りは 2、ディレクトリ不正は 3、ロック失敗は 4。呼び出し側や監視が区別できます。
  • find ... -print0 と read -r -d '':空白や改行を含むファイル名でも壊れない(テストデータにわざと web access.log を入れています)。
  • gzip -9 -- "$f":-- があれば -rf.log という名前のファイルがオプション扱いされません。

テスト1:引数・dry-run・本実行

テストデータは、更新日時を 1・3・10・30 日前にした app-Nd.log 4つ(touch -t で設定)と、40日前の web access.log と old notes.txt です。

$ ./rotate-logs.sh; echo "[exit $?]"
usage: ./rotate-logs.sh [--dry-run] [--days N] DIR
[exit 2]
$ ./rotate-logs.sh --days x "logs dir"; echo "[exit $?]"
error: --days needs a number, got 'x'
[exit 2]
$ ./rotate-logs.sh nope; echo "[exit $?]"
error: not a directory: nope
[exit 3]
$ ./rotate-logs.sh --dry-run "logs dir"; echo "[exit $?]"
[exit 0]
$ cat rotate-logs.log
2026-11-12T08:27:44+0000 [ERROR] --days needs a number, got 'x'
2026-11-12T08:27:45+0000 [ERROR] not a directory: nope
2026-11-12T08:27:45+0000 [INFO] start dir=logs dir days=7 dry_run=1
2026-11-12T08:27:45+0000 [INFO] would gzip: logs dir/app-30d.log
2026-11-12T08:27:45+0000 [INFO] would gzip: logs dir/app-10d.log
2026-11-12T08:27:45+0000 [INFO] would gzip: logs dir/web access.log
2026-11-12T08:27:45+0000 [INFO] done, 3 file(s)
2026-11-12T08:27:45+0000 [INFO] exit 0

dry-run は端末では静かで、ログにははっきり残ります。触る予定の3ファイルを挙げ、1日・3日のログは対象外、.txt は無視しています。次に本実行を2回。

$ ./rotate-logs.sh "logs dir"; ls -1 "logs dir"
app-10d.log.gz
app-1d.log
app-30d.log.gz
app-3d.log
old notes.txt
web access.log.gz
$ ./rotate-logs.sh "logs dir"; tail -3 rotate-logs.log
2026-11-12T08:28:34+0000 [INFO] start dir=logs dir days=7 dry_run=0
2026-11-12T08:28:34+0000 [INFO] done, 0 file(s)
2026-11-12T08:28:34+0000 [INFO] exit 0

2回目はやることがなく、そう記録しました。定期実行のスクリプトは冪等(何度実行しても安全)であるべきです。削除・移動・圧縮をするものには必ず --dry-run を付けます。いちばん安上がりなレビューです(稼働中のホストから WordPress を安全に消したときも同じ考え方でした)。

テスト2:二重起動と mkdir ロック

macOS には flock コマンドがありません(command -v flock は何も返しませんでした)。そこでテンプレートは mkdir を使います。ディレクトリを作るか失敗するかが不可分に決まるからです。テスト用フックで3秒間ロックを持たせ、その間に2つ目を起動しました。

$ SLOW=3 ./rotate-logs.sh --dry-run "logs dir" & first=$!; sleep 0.5
$ ./rotate-logs.sh --dry-run "logs dir"; echo "[second exit $?]"
[second exit 0]
$ wait $first; echo "[first exit $?]"
[first exit 0]
$ cat rotate-logs.log
2026-11-12T08:27:53+0000 [INFO] start dir=logs dir days=7 dry_run=1
...
2026-11-12T08:27:53+0000 [INFO] done, 3 file(s)
2026-11-12T08:27:53+0000 [WARN] already running as pid 5859, exiting
2026-11-12T08:27:56+0000 [INFO] exit 0

2つ目は警告を記録して去り、1つ目は3秒後に終わりました。ロックで弾かれた実行を 0 で終えるか非 0 で終えるかは設計判断です。数分おきに動くジョブでは「実行中」は普通のことなので、ここでは 0 にしてログの警告で十分としました。

よくある間違い:古いロックを確認しないロック

mkdir と trap の rm だけのロックは、-9 で殺されるまでは動きます。実際に起きることをテンプレートで測りました。

$ SLOW=30 ./rotate-logs.sh --dry-run "logs dir" & p=$!; sleep 0.7
$ kill -KILL $p; wait $p; echo "[killed: $?]"
[killed: 137]
$ ls tmp
rotate-logs.iCoz1W
rotate-logs.lock

SIGKILL は捕まえられないので、ログに exit の行は出ず、ロック用ディレクトリと一時フォルダが残りました(子の sleep 30 も第5回と同じく親を失って生き残ったので、PID を指定して止めました)。素朴なロックだと、この後永遠に起動を拒否します。テンプレートは PID を保存し、kill -0 で確かめます。

$ ./rotate-logs.sh --dry-run "logs dir"; echo "[exit $?]"
[exit 0]
$ cat rotate-logs.log
2026-11-12T08:27:57+0000 [WARN] removing stale lock (pid '5940' is gone)
2026-11-12T08:27:57+0000 [INFO] start dir=logs dir days=7 dry_run=1
...
2026-11-12T08:27:57+0000 [INFO] exit 0

対策のまとめ:ロックに PID を書き、その PID が消えていれば古いロックとみなし、取り直したことをログに残す。残った一時フォルダ rotate-logs.iCoz1W はそのままなので、一時フォルダは片付けやすい1つの親の下に置くか、kill -9 の後の残骸は許容します。

テスト3:SIGTERM と & wait が必要な理由

サービス管理、CI ランナー、kill はまず TERM を送ります。テンプレートはそれを trap し、後始末の後に 143 で終わります。では trap はいつ動くのか。kill -TERM から終了までの遅れを、長い処理の書き方2通りで各3回測りました。

長い処理の書き方 終了コード TERM 後の遅れ(3回)
sleep "$SLOW"(フォアグラウンド) 143 4.378秒、4.045秒、4.022秒
sleep "$SLOW" & CHILD=$!; wait "$CHILD" 143 0.038秒、0.038秒、0.040秒

フォアグラウンドのコマンドがあると、bash はそのコマンドが終わるまで trap を保留します。sleep 5 の開始1秒後に TERM が届いたので、終了は約4秒後でした。sleep なら無害ですが、20分かかる rsync で、サービス管理が KILL まで10秒しか待たない場合は本当の問題です。& wait なら wait がシグナルですぐ戻り、trap が動き、後始末が $CHILD で子も止めます。

cron:スクリプトが想定していない環境

cron はほぼ空の環境でコマンドを動かします。env -i で再現しました(HOME・LOGNAME・SHELL と PATH=/usr/bin:/bin だけ、ホームディレクトリから起動)。macOS の man 5 crontab では SHELL は /bin/sh、LOGNAME と HOME はユーザーのアカウントから設定されるとあります。既定の PATH は cron の実装次第で、たいていは /usr/bin:/bin です(実際の cron 実行は確認していません)。スクリプトから見える環境はこれだけです。

$ env -i HOME=~ LOGNAME=demo SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c env
SHELL=/bin/sh
PATH=/usr/bin:/bin
PWD=~
SHLVL=1
HOME=~
LOGNAME=demo
_=/usr/bin/env

次に、~/demo/bin に置いた補助ツール(対話シェルでは PATH に入っている)と相対パスを使う素朴なスクリプトです。

#!/bin/bash
# naive.sh
echo "PATH=$PATH"
echo "cwd=$(pwd)"
lines=$(report-tool ./data.txt)
echo "$(date +%F) lines=$lines" >> report.txt
echo "exit so far: $?"
## 対話シェルに近い状態(~/demo/cronjob から)
PATH=~/demo/bin:/usr/bin:/bin
cwd=~/demo/cronjob
exit so far: 0
## cron に近い状態
PATH=/usr/bin:/bin
cwd=~
~/demo/cronjob/naive.sh: line 4: report-tool: command not found
exit so far: 0
cron-like exit=0
$ cat ~/report.txt ~/demo/cronjob/report.txt
2026-11-12 lines=
2026-11-12 lines=3

失敗が3つ、エラーコードはゼロ。ツールが見つからず、./data.txt はホームで探され、レポートはホームに新しく作られた report.txt に空の値で追記されました。cron はその1行の stderr を、メールが設定されていれば誰も読まないメールボックスに送るだけです。テンプレートは3つとも避けます。PATH を自分で設定し、引数を受けて確認し、ログを自分の隣に書きます。cron に近い環境で相対パスを渡すと、今度ははっきり失敗しました。

error: not a directory: logs dir
[exit 3]

「対話では動き、無人では壊れる」という同じ型は、ホスティングのアカウント移行の後にも見つかりました。ジョブはエラーを出さず、違う環境で静かに動いていたのです。

crontab の書き方(説明のみ、登録はしていません)

# m  h  dom mon dow  command
  15 3  *   *   *    /home/demo/bin/rotate-logs.sh --days 14 "/home/demo/app/logs" >> /home/demo/log/rotate-cron.log 2>&1

ハマりどころ:

落とし穴 起きること 対策
コマンド中の %(例:date +%F) エスケープしない % は改行に変わり、以降はコマンドの標準入力になる(man 5 crontab) \% とエスケープするか、スクリプトに移す
PATH に頼る command not found(上で再現) スクリプトで export PATH=...、または crontab 先頭に PATH= 行
相対パス ホームディレクトリ基準で解決される 絶対パス、またはスクリプト内で cd "$(dirname "$0")"
出力をリダイレクトしない メールで送られるか、捨てられる >> file 2>&1、スクリプト内でもログを書く
タイムゾーン cron はシステムのタイムゾーン テンプレートのように %z 付きで記録
実行の重なり 遅い実行+短い間隔=2重起動 上のロック

macOS では Apple は cron より launchd を推奨しており、保護されたフォルダに触れる cron ジョブには cron へのフルディスクアクセス付与が必要です。どちらもここでは試していません。Linux では systemd タイマーが現代的な選択肢で、ログは journal に残ります(ここでは未実行)。日本のレンタルサーバーの管理画面から登録する cron も、中身は同じ crontab であることが多いので、上の表はそのまま当てはまります。

ログ:何を書き、いくらかかるか

深夜に役立つログ1行は、オフセット付きの時刻・レベル・1つの事実でできています。2026-11-12T08:27:57+0000 [WARN] removing stale lock (pid '5940' is gone)。開始時にパラメータを、変更のたびにその内容を、最後に終了コードを記録します(最後の1行は EXIT の trap が保証します。kill -9 以外は)。

ただし $(date ...) は毎回プロセスを起動します。2,000 行のログを4通りで書き、各3回の中央値を取りました。

方法(2,000行) 中央値 1行あたり
bash:毎行 $(date ...)、毎行 >> 5.84秒 2.9 ms
bash:時刻なし、毎行 >> 0.08秒 0.04 ms
zsh:組み込み strftime(zsh/datetime)を毎行、毎行 >> 0.08秒 0.04 ms
bash:時刻は1回、ループ全体を1回だけ { ...; } >> log 0.02秒 0.01 ms
棒グラフ。1行ごとに date を呼ぶ bash:5.84秒。タイムスタンプなしの bash:0.08秒。組み込みの strftime を使う zsh:0.08秒。タイムスタンプ1回でループ全体を1回リダイレクトする bash:0.02秒。
ログ 2,000 行を書く時間。他の処理で負荷のある M1 Pro で3回の中央値。1行ごとに date を起動するコストが他を圧倒する。

計測中のマシンは他の処理も動いていた(ロードアベレージ 6〜9)ので、date のコストは高めに出ています。20行しか書かないスクリプトなら 1行 3 ms は無関係ですが、10万ファイルのループなら5分です。対策は、ファイルごとではなくまとまりごとに記録する、zsh の strftime を使う、bash 4.2 以降なら printf '%(%Y-%m-%dT%H:%M:%S%z)T' を使う、のいずれかです。bash 3.2 では printf: '(': invalid format character で拒否されました(4.2 という版は bash の NEWS によるもので、ここでは未実行)。ファイルではなくシステムログに送るなら、macOS にも Linux にも logger -t rotate-logs "message" があります(ここでは未実行)。

正直な限界とバージョンの注意

  • すべて macOS の bash 3.2.57 で実行しました。テンプレートは bash 4 以降の機能(declare -A、mapfile、wait -n、${x,,})をわざと避けているので、Linux の bash 5 でもそのまま動くはずです(ここでは未実行)。
  • mkdir ロックには小さな競合があります。1つ目の mkdir と echo $$ > pid の間に2つ目が起動すると、空の PID を見て古いロックと判断します。数分間隔の実行ならこの隙間(マイクロ秒単位)は許容範囲としました。Linux ならファイル記述子に flock -n 9 を使えば避けられます(ここでは未実行)。
  • kill -9 の後はロックと一時フォルダが残ります。古いロックの確認で戻るのはロックだけです。
  • find -mtime +7 は日数を切り捨てて数えます。この Mac では 7.5 日前のファイルが -mtime +7 に一致せず、-mtime +6 で一致しました。境界は自分のデータで確かめてください。
  • この記事のために crontab のエントリや launchd のジョブは作っていません。crontab の構文と % の規則は man 5 crontab、cron の環境は env -i による再現です。

練習問題

  1. テストデータを作り直し(TZ=UTC なので「1日前」のファイルは前日の 09:00 になります)、今作った fresh.log も加えて --dry-run --days 0 を実行します。どのファイルが報告され、なぜ app-1d.log が入らないのでしょう。
  2. 同僚が trap cleanup EXIT を trap cleanup TERM に置き換えました。ロックが残ってしまう状況を2つ挙げてください。

解答

  1. 報告されたのは app-3d.log・app-30d.log・app-10d.log・web access.log の4つでした。-mtime +0 は経過が丸1日以上必要です。実行は 08:28 UTC だったので app-1d.log は23.5時間前で切り捨てると 0、fresh.log も 0 です。.txt は -name '*.log' に一致しません。日数の境界は午前0時ではなく find の開始時刻から数えます。
  2. 正常終了(最後まで進んで 0 で終わる)と、set -e による失敗や die の呼び出しです。どちらも TERM ではありません。EXIT はこれらすべてを含み、TERM だけではシグナルしか拾えません(kill -9 はどちらの版でも残ります)。

前回: 第5回 プロセス・ジョブ・シグナル ・ シリーズの最初:第1回 移動とファイル操作 ・ 第4回 変数と終了コード ・ 学習ロードマップへ戻る

よくある質問

ターミナルでは動くのに cron だと失敗するのはなぜですか?

cron はほとんど空の環境で起動するからです。PATH はたいてい /usr/bin:/bin、作業ディレクトリはホーム、シェルのプロファイルは読まれません。再現では ~/demo/bin のツールを呼ぶスクリプトが 'report-tool: command not found' を出し、別フォルダのレポートに空の値を書き、それでも終了コード 0 でした。スクリプト内で PATH を設定し、絶対パスを使います。

flock のない macOS で、スクリプトの二重起動を防ぐには?

mkdir をロックに使います。ディレクトリ作成はアトミックなので成功するのは1プロセスだけです。中に PID を書き、EXIT の trap で消し、その PID に kill -0 が失敗したら古いロックとみなします。2つ目の起動('already running' を記録して終了)と kill -9(次の実行が古いロックを除去)の両方を試しました。

後始末の trap が遅れて動く、または動かないのはなぜですか?

kill -9 は捕まえられないので何も動きません。強制終了した実行ではロックと一時フォルダが残りました。また bash は TERM の trap を、実行中のフォアグラウンドコマンドが終わってから動かします。フォアグラウンドの sleep 5 ではシグナルから終了まで 4.0 秒、'sleep 5 & wait $!' では 0.04 秒でした。

  • #シェル
  • #bash
  • #cron
  • #ログ
  • #シェルスクリプト
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑