set -euo pipefail とは何か - Bashスクリプト冒頭の「おまじない」を正しく理解する

set -euo pipefail とは何か - Bashスクリプト冒頭の「おまじない」を正しく理解する

作成日:
更新日:

Bashスクリプトの冒頭で、こういう一行を見たことがあるはずです。

よく見る冒頭の一行
#!/usr/bin/env bash
set -euo pipefail

「とりあえず付けておくおまじない」として書かれがちですが、これはスクリプトを「エラーで黙って進まない」安全な動作に切り替える大事な設定です。一方で set -e には独特の「効かない場面」があり、知らないと「付けたのにエラーをすり抜けた」とハマります。

この記事では set -euo pipefail を3つに分解し、それぞれの意味・落とし穴・実践的な書き方を、bash 5.3 での実挙動を確認しながら整理します。

結論: 何をしている一行なのか

set -euo pipefail は、3つのオプションをまとめて有効にしています。

記述別名効果
-eerrexitコマンドが失敗(非ゼロ終了)したら、スクリプトを即終了する
-unounset未定義の変数を参照したらエラーにする
-o pipefailpipefailパイプライン全体の終了コードを「失敗したコマンド」基準にする

デフォルトの Bash は、コマンドが失敗しても次の行へ進み、未定義変数は空文字として扱い、パイプは最後のコマンドの結果だけを見ます。これは対話シェルでは便利ですが、スクリプトでは「途中で失敗したのに最後まで走って中途半端な結果を残す」事故の元です。set -euo pipefail はこれを「失敗したら止まる」方向に倒します。

-e(errexit): 失敗したら止まる

set -e を付けると、コマンドが非ゼロで終了した時点でスクリプトが終了します。

set -e なし(デフォルト)
cp not_exist.txt /tmp/   # 失敗するが…
echo "ここまで実行される"  # 実行されてしまう
set -e あり
set -e
cp not_exist.txt /tmp/   # 失敗した時点で
echo "ここは実行されない"  # スクリプト終了

「前の処理が失敗したのに次へ進む」を防げるので、デプロイやビルドのスクリプトでは特に重要です。

set -e の落とし穴(ここが本題)

set -e は万能ではありません。Bash の仕様上、「失敗しても終了しない」例外が複数あります。man bash の記述に沿って、実際に bash 5.3 で確認した挙動を挙げます。

1. && / || でつないだ左側

set -e
false || echo "左が失敗しても止まらない"   # 止まらない
echo "続行される"

正確には、&& / || のリストでは最後のコマンドを除く失敗で終了しません。false || echo ...false のように、後ろに ||/&& が続くコマンドの失敗は無視されます(一方 true && false の最後の false は終了対象です)。if の条件、while/until の条件、! で反転した結果も同様に「終了しない」対象です。これは仕様であり、if false; then ... でいちいち落ちられたら困るからです。

2. 関数を条件文脈で呼ぶと、内部の set -e が無効化される

これがいちばん危険な落とし穴です。

set -e
f() {
  false              # ここで止まってほしいのに…
  echo "関数内: false の後も実行された"
}
f || true            # 関数を || の左に置くと…

実行すると「関数内: false の後も実行された」が表示されます。関数を ||if などの条件文脈で呼ぶと、その関数の中の set -e まで無効になるのです。「関数の戻り値だけ見たい」つもりが、関数内部のエラーチェックごと殺してしまいます。

3. コマンド置換 $(...) には伝播しない

set -e
x=$(false; echo "置換内: false 後も実行")
echo "x=[$x]"   # x=[置換内: false 後も実行] と出てしまう

デフォルトでは、コマンド置換用のサブシェル内では errexit が継承されないため、置換内の途中の失敗(後続コマンドで終了コードが上書きされるケース)が無視されます。なお x=$(false) のように置換全体が非ゼロを返す場合は、外側の代入コマンドも非ゼロになり set -e で終了します。途中失敗も拾いたいときは inherit_errexit を有効にします(bash 4.4 以降)。

コマンド置換にも errexit を伝播させる
set -e
shopt -s inherit_errexit
x=$(false; echo "ここは実行されない")   # 置換内の false で止まる

WARNING

set -e を付けたからといって「あらゆる失敗で必ず止まる」わけではありません。特に「関数を条件文脈で呼ぶ」「コマンド置換」は盲点です。重要な失敗チェックは set -e 任せにせず、明示的に終了コードを見る(cmd || exit 1 など)方が確実な場面もあります。

-u(nounset): 未定義変数をエラーにする

set -u を付けると、定義していない変数を参照した時点でエラーになります。

set -u あり
set -u
echo "[$UNDEFINED_VAR]"   # エラーで終了(未割り当ての変数です)

タイプミスした変数名や、渡し忘れた引数を、空文字として静かに使ってしまう事故を防げます。rm -rf "$DIR/"$DIR が未定義で空だったら…という典型的な事故への保険になります。

-u の実践: 「未定義かもしれない変数」の扱い

set -u 下で「未定義なら既定値」を安全に書くには、パラメータ展開を使います。

未定義でも安全に既定値を使う
set -u
name="${1:-ゲスト}"        # $1 が無ければ「ゲスト」
echo "こんにちは、${name}さん"

${var:-default} は「未定義または空なら default」、${var-default} は「未定義なら default(空文字はそのまま)」です。set -u と組み合わせるときの必須テクニックです。

配列のループには注意が必要です。bash 4.4 以降では、空配列でも "${args[@]}"set -u のエラーになりません(配列展開は例外扱い)。

空配列でも安全(bash 4.4+)
set -u
args=("$@")
for a in "${args[@]}"; do echo "$a"; done   # 空配列なら 0 回。エラーにならない

逆に "${args[@]:-}" と書くと、空配列のときに「空文字1要素」に展開され、ループが1回余計に回ってしまいます。配列では :- を付けないのが正解です。

-o pipefail: パイプの失敗を見逃さない

デフォルトの Bash では、パイプライン a | b | c の終了コードは最後のコマンド c の結果だけです。途中の ab が失敗しても、c が成功すればパイプ全体は成功扱いになります。

pipefail なし
set -e
false | true       # false は失敗だが、true が成功するので…
echo "到達してしまう"

set -o pipefail を付けると、man bash の定義どおり「パイプラインの戻り値は、非ゼロで終了した最後(最も右)のコマンドの値、すべて成功なら0」になります。

pipefail あり
set -eo pipefail
false | true       # 途中の false で全体が失敗扱いに
echo "ここには到達しない"

grep pattern file | sort のように、途中のコマンドの失敗を見逃したくないパイプで効きます。ログ処理やデータ整形のパイプラインでは、ほぼ必須です。

NOTE

ただし ... | head のように途中でパイプを閉じるコマンドを使うと、上流が SIGPIPE で非ゼロ終了し、pipefail 下では意図せず失敗扱いになることがあります。head を含むパイプではこの点に注意してください。

おまけ: IFS も一緒に設定する流儀

堅牢なスクリプトでは、set -euo pipefail に加えて IFS を設定する流儀があります。

より堅牢なテンプレート
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

IFS(Internal Field Separator)は、単語分割に使う区切り文字です。デフォルトは「スペース・タブ・改行」で、これがファイル名にスペースが含まれる場合などに意図しない分割を起こします。IFS=$'\n\t' にすると区切りを「改行とタブ」だけに絞れ、スペース由来の事故を減らせます。

ただし IFS の変更は挙動を変えるため、既存スクリプトに後付けすると逆に壊れることもあります。新規スクリプトで最初から設計するときに採り入れるのがおすすめです。

実践テンプレート

ここまでを踏まえた、新規スクリプト向けの冒頭テンプレートです。

実践的なBashスクリプトの冒頭
#!/usr/bin/env bash
# -E を加えると ERR トラップが関数・サブシェルにも継承される
set -Eeuo pipefail
IFS=$'\n\t'
 
# コマンド置換内の失敗も拾う(bash 4.4+)
shopt -s inherit_errexit 2>/dev/null || true
 
# エラー時に行番号を表示するトラップ(任意)
trap 'echo "エラー: line $LINENO で終了 (exit $?)" >&2' ERR
 
main() {
  # 本処理
  :
}
 
main "$@"

-Eerrtrace)を付けるのがポイントです。これがないと ERR トラップは関数やサブシェルに継承されず、関数内で落ちたときに発火しないことがあります。trap ... ERR を併せると、set -e で止まったときにどこで落ちたかが分かり、デバッグが楽になります。

まとめ

  • set -euo pipefail は3点セット: -e(失敗で止まる)・-u(未定義変数でエラー)・-o pipefail(パイプ途中の失敗も拾う)
  • デフォルトの「失敗しても進む・未定義は空・パイプは最後だけ」を、スクリプト向けの安全側に倒す設定
  • set -e には落とし穴がある: &&/||(最後を除く)・if/while の条件、関数を条件文脈で呼ぶと内部の errexit が無効、コマンド置換の途中失敗は伝播しない(inherit_errexit で対処)
  • -u 下では ${var:-default} などのパラメータ展開で未定義変数を安全に扱う
  • より堅牢にするなら IFS=$'\n\t'trap ... ERR を併用

「おまじない」で済ませず、特に set -e の例外を理解しておくと、「付けたのにエラーをすり抜けた」事故を防げます。デプロイやCIで動かすスクリプトほど、この一行の意味を正しく押さえておく価値があります。

なお、本記事の挙動はすべて bash 5.3 で確認しています。zsh など他のシェルでは挙動が異なる点があるので注意してください。コミットメッセージやスクリプトの整え方は Conventional Commits の解説も参考になります。

参考リンク