自動実行のbashが突然落ちる・失敗を見逃す4つの罠

cronやCIで回すbashが、ある日exit 141で落ちる。テストの失敗を数えない。evalで意図しないコマンドが走る。二重起動でファイルを壊す。筆者が踏んだ4つの罠と、どう直したかをbash 5.3の実行結果つきで解説します。

自動実行のbashが突然落ちる・失敗を見逃す4つの罠

本記事はプロモーションを含みます。

はじめに:手で動かすと通るのに、無人だと落ちる

無人で回す bash で踏んだ 4 つの罠: exit 141、消える失敗数、eval の実行、二重起動の上書き

毎日 cron で回しているスクリプトが、ある日から落ち始める。テストは全件 pass なのに、壊れたコードが通っている。手で 1 回動かすと問題なく見えるので、原因を探すのに時間がかかります。

筆者がブログの記事 PR を作る自動化や、Claude Code の hook (コマンドを実行前に検査するスクリプト) を書く中で踏んだのは、次の 4 つでした。

場面何が起きたかどう直したか
grep ... | head -n 15入力が増えた日から exit 141 で落ちるgrep -m 15 で grep 自身に切り詰めさせる
パイプの中で失敗数を数える失敗が数えられず、常に全件 passプロセス置換で回し、先に red を確かめる
eval echo "$f" で ~ を展開文字列の中の $(...) まで実行される先頭の ~ と $HOME だけを文字列で置き換える
cron と手動が同時に走る同じ作業ディレクトリのファイルを互いに上書きflock で囲み、競合の終了コードを分ける

set -e が効かない場面は bashのset -eが効かない5つの場面と対処法 にまとめています。この記事はその続きで、「止まるべきでない所で止まる」「失敗を見逃す」側の話です。以下の実行結果は GNU bash 5.3.15 と util-linux 2.42.3 の flock で確かめました。

罠1: | head が入力の増えた日に exit 141 で落ちる

head が 15 行で抜けた後に上流が書き込むと SIGPIPE で 141 になり、pipefail がそれを全体の結果にする

記事 PR を毎日作るジョブが、ある日から exit 141 で落ち、PR が出ない日が続きました。原因は、保存した記事の一覧から新しい 15 件を取り出す、次のような行でした。

set -o pipefail
line=$(printf 'a%.0s' $(seq 2000))
for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null
echo "rc=$?"
rc=141

同じ行に 2 行しか流さなければ rc=0 です。手元で少ない入力を流して試すと通ってしまいます。

なぜ 141 になるのか

head -n 15 は 15 行を読んだら終了し、パイプの読み口を閉じます。その後に上流の grep がパイプへ書き込むと、カーネルが grep に SIGPIPE を送ります (pipe(7))。シグナルで終わったプロセスの終了ステータスは 128 + シグナル番号なので、SIGPIPE (13) なら 141 です。

pipefail は、パイプラインの右端ではなく「最後に非 0 で終わったコマンド」の値を全体の終了ステータスにします。そのため head が 0 で終わっても、全体は 141 になり、set -e ならそこで止まります。

上流が書き終える前に head が抜けるかどうかは、データ量とパイプの容量で決まります。Linux のパイプの容量は既定で 65,536 バイトです。入力が小さいうちは上流が先に書き終えるので再現せず、件数が積み上がったある日に落ち始めます。しかもタイミング次第なので、同じ入力でも毎回落ちるとは限りません。grep a big.txt | head -n 15 を 8 回試すと、7 回が 141、1 回が 0 でした。

直し方: 切り詰めを grep 自身にやらせる

grep -m 15 なら grep が自分で読むのをやめるので 0 で終わる。ただし grep の上流があると再び 141 になる

| head -n 15 をやめ、grep -m 15 で grep 自身に 15 件で止まってもらいました。grep が読むのをやめて正常に終わるので、閉じたパイプへ書き込むプロセスがいなくなります。

grep -m 15 a big.txt >/dev/null; echo "rc=$?"
rc=0

ただし、これで直るのは grep がパイプラインの先頭で、ファイルを直接読んでいるときだけです。grep の前にさらにコマンドがあると、今度はその上流が SIGPIPE を受けます。

seq 200000 | grep -m 15 . >/dev/null; echo "rc=$?"
rc=141

pipefail を付けたスクリプトで | head や grep -m を見かけたら、「上流が書き終える前に読み手が抜けないか」を確かめます。

罠2: パイプの中で数えた失敗数が消える

パイプの右側は subshell で動くので、中で増やした failed は親シェルに戻らず 0 のまま

Claude Code の hook に、止めるべきコマンドと通すべきコマンドを並べたテーブル駆動のテストを書きました。最初に書いたテスト用のハーネスは、失敗数をパイプの中で数えていたため、失敗が 1 件も集計されませんでした。

failed=0
check() { [[ $1 == ok ]] || failed=$((failed + 1)); }
printf '%s\n' ng ng ok | while read -r v; do check "$v"; done
echo "failed=$failed"
failed=0

ng が 2 件あるのに 0 です。このハーネスは、どんな実装に対しても「全件 pass」を出します。

なぜ消えるのか

bash では、パイプラインの各コマンドはそれぞれの subshell で実行されます (Bash Reference Manual: Pipelines)。subshell は親の変数をコピーした別プロセスなので、while の中で failed を増やしても、親シェルの failed は 0 のままです。$(...) の中で数えた場合も同じです。

直し方: while をプロセス置換で回す

入力をプロセス置換 < <(...) で渡すと、while は親シェルで動きます。

while read -r v; do check "$v"; done < <(printf '%s\n' ng ng ok)
echo "failed=$failed"
failed=2

スクリプトでは shopt -s lastpipe も使えます。ジョブ制御が無効なとき、パイプラインの最後のコマンドを今のシェルで実行する設定です (The Shopt Builtin)。対話シェルではジョブ制御が有効なので効かず、手で試したときと結果が変わる点に注意します。

実装の前に、テストが落ちることを確かめる

わざと落ちるケースで FAIL と非 0 の終了を見てから実装に進む。全件 pass しか出ないハーネスは何も確かめていない

この罠は、テストが緑のまま気付けないのが一番の問題です。筆者はハーネスを直した後、実装より先に、テストが落ちる (red になる) ことを確かめてから進めました。

  • わざと期待と違うケースを 1 件足す
  • そのケースが FAIL と表示され、ハーネス全体が非 0 で終わるかを見る
  • 見えたらケースを戻し、実装に進む

自作のハーネスに限らず、「全件 pass」しか見たことのないテストは、失敗を報告できるかどうかがまだ分かっていない状態です。

罠3: eval で ~ を展開すると $(…) も実行される

~ を展開するつもりの eval echo が、文字列の中の $(touch pwned) まで実行する

同じ hook には、sed -i の対象ファイルが symlink かどうかを判定する処理がありました。コマンド文字列から取り出したファイル名の ~ を展開するために、eval echo を使っていました。

f='$(touch pwned)~/x'
eval echo "$f" >/dev/null
ls pwned
pwned

~ を展開したつもりが、touch pwned が実行されています。

なぜ実行されるのか

eval は引数をシェルのコードとして読み直します。展開の種類は選べず、チルダ展開・変数展開・コマンド置換がすべて行われます。

hook はユーザがコマンドを承認する前に動きます。ファイル名に $(...) が混ざったコマンドを検査しただけで、承認より前にその中身が実行されることになります。

直し方: 先頭の ~ と $HOME だけを文字列で置き換える

パラメータ展開の置換で先頭の ~ だけを $HOME に置き換え、$(...) は文字列のまま残す

eval をやめ、パラメータ展開の置換で先頭の ~ と $HOME だけを展開しました。

f='~/x'
f=${f/#\~/$HOME}
f=${f/#\$\{HOME\}/$HOME}
f=${f/#\$HOME/$HOME}
echo "$f"
/home/<user>/x

その代わり、~user/x や、途中に $VAR を含むパスは展開されなくなります。それでも、ここでの用途は「symlink かどうか」の判定です。展開できないパスは「symlink ではない」側に倒れるだけで、承認前にコードが実行されるよりは安全です。そう判断して、展開できる範囲を狭める方を選びました。

罠4: cron と手動実行が重なって、互いのファイルを上書きする

cron と手動の実行が同じ作業ディレクトリで重なると互いに上書きし、flock で囲めば後から来た方は起動しない

毎日の動画を作るジョブで、cron の make daily と、手で打った make daily が同じ作業ディレクトリで同時に動き、互いのファイルを上書きしていました。手で試すときは cron の時刻を意識しないので、重なっても気付きにくい場面です。

make daily を flock で囲み、同時には 1 つしか動かないようにしました。

flock -n tmp/daily.lock make daily-run

-n は、ロックが取れなければ待たずにすぐ終わる指定です。

直し方: 競合の終了コードを -E で分ける

flock -n は競合もコマンドの失敗も 1 を返す。-E 75 を付けると競合だけ 75 になり見分けられる

flock -n は、ロックの競合を終了コード 1 で返します。中のコマンドが失敗したときの 1 と区別できません。

flock -n lock sleep 3 & sleep 1   # 先にロックを取らせておく
flock -n lock true;        echo "競合 rc=$?"
flock -n lock2 false;      echo "コマンド失敗 rc=$?"
flock -n -E 75 lock true;  echo "競合 (-E 75) rc=$?"
競合 rc=1
コマンド失敗 rc=1
競合 (-E 75) rc=75

-E (--conflict-exit-code) で、競合のときの終了コードを変えられます (flock(1))。make から呼ぶなら、次のように競合だけを「実行中」として扱えます。

daily:
	@flock -n -E 75 tmp/daily.lock $(MAKE) daily-run || { rc=$$?; [ $$rc -eq 75 ] && echo "実行中" || exit $$rc; }

flock -n lock cmd || echo 実行中 と書くと、cmd の本当の失敗まで「実行中」として握りつぶします。

まとめ:手で 1 回動かすだけでは見えない

4 つの罠は、入力を増やす、わざと落とす、$(...) を混ぜる、2 つ同時に起動する、で確かめられる
  • | head と pipefail は、入力がパイプの容量 (既定 64KiB) を超えた日に 141 で落ちる。grep -m で切り詰めるなら、grep が先頭のときだけ直る
  • パイプの右側と $(...) の中で変えた変数は戻らない。数えるならプロセス置換で回し、先にテストが落ちることを見る
  • eval は展開の種類を選べない。~ だけが要るならパラメータ展開で置き換える
  • cron と手動の二重起動は flock で防ぐ。-E で競合と失敗を分ける

4 つとも、手で小さな入力を 1 回流すだけでは問題なく見えます。入力を増やす、わざと落ちるケースを足す、$(...) を混ぜた値を渡す、2 つ同時に起動する。こうして無人で起きる状況を手元で作ってから確かめるのが確実です。

パイプと subshell、展開の順序といったシェルの仕組みを体系的に押さえておくなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利です。

新しいシェルプログラミングの教科書 (三宅英明、電子書籍) 画像: 楽天市場

新しいシェルプログラミングの教科書 (三宅英明、電子書籍)

参考