bashのset -eが効かない5つの場面と対処法

set -eを書いたのに、失敗したコマンドの後もスクリプトが進む。|| で呼んだ関数、コマンド置換、local付き代入、&&で終わる関数など、止まらない場面と直し方をbash 5.3の実行結果つきで解説する。

bashのset -eが効かない5つの場面と対処法

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

はじめに:set -e を書いたのに止まらない

set -euo pipefail を書いても、5 つの形では失敗の後も進むか、逆に呼び出し元で止まる

set -euo pipefail を先頭に書いておけば、途中で失敗したら止まる。そう思って書いたスクリプトが、失敗の後も進んでいたことがある。

結論から書くと、set -e には 効かない文脈 があり、しかもその中で呼んだ関数の中身まで丸ごと無効になる。代表的なのは次の 5 つだ。

場面何が起きるか直し方
f || rc=1 のように呼んだ関数関数の中のどの失敗でも止まらない関数の中で || return 1 を書く
echo "$(cmd)"cmd が失敗しても空文字で進むいったん x=$(cmd) で受ける
local x=$(cmd)local の成功 (0) で失敗が消える宣言と代入を分ける
最後が A && B の関数A が偽だと関数が 1 を返し、呼び出し元で止まるif で書くか return 0
x=$(jq ...)壊れた JSON でも先頭の値が入る終了ステータスを必ず見る

以下の実行結果は、どれも GNU bash 5.3.15 で確かめたものだ。

場面1: || や if の中で呼んだ関数

|| の左辺で呼んだ関数は false の後も最後まで走り、rc も 0 のまま

複数のファイルを順に検証し、1 件が失敗しても残りは続けたい。そんなときに書きがちなのがこの形だ。

set -euo pipefail
f() { false; echo "ここまで来る"; }
rc=0
f || rc=1
echo "rc=$rc"
ここまで来る
rc=0

false で止まるどころか、関数は最後まで走り、rc も 0 のままだ。

なぜ止まらないのか

|| の左辺では関数の本体ごと set -e が無視され、最後の echo の 0 が戻り値になる

set -e は、&& / || の途中のコマンドと、if / while の条件では無視される。この無視は、その文脈で呼んだ関数の本体にも及ぶ。関数の中で set -e をし直しても、呼び出しが終わるまで効かない。

関数の終了ステータスは最後に実行したコマンド (ここでは echo) のものなので、|| rc=1 も発火しない。

直し方

関数の中で || return 1 を書けば、失敗が戻り値として呼び出し元に届く

関数の中では、失敗を自分で返す。

validate() {
  local body
  body=$(jq -c . "$1") || return 1
  curl -sS --fail-with-body ... || return 1
}

単体で f と呼べば止まるので、手で試しても気付きにくいのがこの場面の厄介なところだ。

場面2: コマンド置換を別のコマンドの引数に埋める

echo の行は echo の 0 が優先されて進み、代入だけの行はコマンド置換の 1 で止まる

コマンド置換 $(...) の失敗が set -e に届くのは、代入だけの行 のときに限られる。

set -e
echo "[$(false)]"   # 止まらない。[] を出して進む
x=$(false)          # ここで止まる

代入だけの行の終了ステータスは「最後に実行したコマンド置換の終了ステータス」と決まっている。一方、echo のようにコマンドを実行する行では、そのコマンド (echo) の終了ステータスが優先される。

直し方

失敗を検知したいコマンド置換は、いったん変数で受ける。

out=$(cmd)
echo "[$out]"

逆に、失敗を意図して無視したいときは x=$(cmd) || true で回避できる。

場面3: local を付けて代入する

local x=$(false) は local の 0 で失敗が消え、宣言と代入を分ければ止まる

関数の中で local を付けて代入すると、コマンド置換の失敗が消える。

set -e
f() { local x=$(false); echo "ここまで来る"; }
f

local は組み込みコマンドなので、行の終了ステータスは local 自身の成功 (0) で上書きされる。場面2 の「コマンドを実行する行」と同じ理屈だ。

直し方

宣言と代入を分ける。

f() {
  local x
  x=$(false)   # ここで止まる
}

なお、false; local rc=$? のように、直前のコマンドの $? を local で受けるのは問題ない (rc=1 が取れる)。0 に戻るのは local x=$(cmd) の形だけだ。

場面4: 最後の行が A && B の関数

最後が A && B の関数は A が偽だと 1 を返し、呼び出し元の f の行で止まる

「条件が立つときだけ実行」を短く書いた関数は、逆に 止まってほしくない所で止まる。

set -e
v=""
f() { [[ -n "$v" ]] && echo hi; }
f
echo "ここに来ない"

関数の中では && の左辺なので止まらない。ところが関数の戻り値は最後に実行したコマンドの終了ステータスなので、[[ ]] の 1 がそのまま返り、呼び出し元の f の行で set -e が発火する。

直し方

A && B で終わる関数は 1 を返して止まり、if で書けば 0 を返して進む

if で書く。どの分岐も実行しなかった if は 0 を返す。

f() {
  if [[ -n "$v" ]]; then
    echo hi
  fi
}

関数の末尾に return 0 を置いてもよい。

この形は shellcheck (0.11.0) では警告されなかった。静的解析が通っても安心できない例だ。

場面5: jq の出力をコマンド置換で受ける

jq は壊れた JSON でも先頭の値を出力してから失敗し、終了ステータス 5 を返す

set -e そのものの穴ではないが、場面1 と重なると被害が大きくなるのが jq だ。jq は入力を「JSON 値の並び」として先頭から読むので、正しい値の後ろに壊れた文字列があると、先頭の値を出力してから 失敗する。

printf '{"a":1} garbage' > bad.json
jq -c . bad.json; echo $?
{"a":1}
jq: parse error: ...
5

x=$(jq -c . bad.json) だと、x に {"a":1} が入った状態で失敗が返る。これを場面1 のように || rc=1 で呼んだ関数の中でやると、失敗は無視され、壊れた入力の一部だけが後続 (API への送信など) へ渡る。

直し方

終了ステータスを必ず見る。

body=$(jq -c . bad.json) || { echo "壊れた JSON" >&2; exit 1; }

複数の値も部分的な出力も許さないなら、-s で全体を読んでから 1 値かを確かめる。

jq -s -c 'if length == 1 then .[0] else error("not a single value") end' file.json

まとめ:set -e は「保険」であって「保証」ではない

怪しい形はコマンドを false に差し替え、止まるかどうかで確かめる
  • || / && / if の中で呼んだ関数では、set -e は丸ごと効かない。関数の中で || return 1 を書く
  • コマンド置換の失敗が届くのは x=$(cmd) の行だけ。echo "$(cmd)" と local x=$(cmd) では消える
  • 最後が A && B の関数は、逆に呼び出し元を止める。if で書く
  • jq は壊れた入力でも先頭の値を出す。終了ステータスを見る

どれも「手で 1 回試すと動く」ので、実行してみるだけでは気付けない。怪しい形を見つけたら、わざと失敗するコマンド (false) に差し替えて、止まるかどうかを確かめるのが確実だ。

cron などで無人で回すときに踏みやすい、pipefail 下の exit 141 や二重起動は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめた。

bash の文法や終了ステータスの決まりを体系的に押さえておくなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利だ。

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

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

参考