📄

Bashスクリプトのエラーハンドリング

2023/01/19に公開

まえがき

シェルスクリプトを書いている途中でテスト実行やエラーハンドリングに試行錯誤したのでメモ。シェルはGoogleシェルスタイルガイドに従ってbashを使う。

TL;DR

開発用(ユーザー向けたメッセージでなくて良い)でかつbashのバージョンが4.4以降であれば以下の雛形を使う予定。

#!/bin/bash -eEu
shopt -s inherit_errexit
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

本文

エラー時点で終了する様にする

まず怖いのが途中のコマンドが異常終了したともスクリプトは全部実行されてしまう事。開発中のスクリプトは何をしだすか分かったもんじゃない。

error_test1.sh
#!/bin/bash

(失敗するsample.txt生成コマンド)
for i in {1..5}; do
    cat sample.txt | ...
done
実行例
$ ./error_test1.sh
cat: sample.txt: そのようなファイルやディレクトリはありません
cat: sample.txt: そのようなファイルやディレクトリはありません
cat: sample.txt: そのようなファイルやディレクトリはありません
cat: sample.txt: そのようなファイルやディレクトリはありません
cat: sample.txt: そのようなファイルやディレクトリはありません

これを防ぐために片っ端から以下の様なスクリプトを挟むのは大変だし見づらくなる。

古来のスクリプト
(コマンド)
if [[ $? != 0 ]]; then
    echo "Error!" >&2
    exit 1
fi

どうすればいいかというとset -euを先頭に入れて、コマンドの異常終了でスクリプトが異常終了するようにすれば良いとか。それぞれ-eはerrexit、-uはnounsetの意味。

Google Shell Style Guide から学ぶ保守性の高いシェルスクリプトの書き方|NAVITIME_Tech|note

なお、set -*はシェバングにしてるbashにオプションを与えても同じ意味になるので行数を減らすためにそうする。

error_test2.sh
#!/bin/bash -eu

echo "script start"
false
echo "script end"
実行例
$ ./error_test2.sh
script start
$

確かにecho "script start"で止まる。でもfalseみたいにエラー出力を吐かないで異常終了するコマンドだと原因が分かりにくい。

エラーハンドラをしかける

trapでエラーハンドリングして(開発者に)分かりやすいメッセージを出力する。trapや使用しているシェル変数についてはman参照。

Man page of BASH

error_test3.sh
#!/bin/bash -eu
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

echo "script start"
false
echo "script end"
実行例
$ ./error_test3.sh
script start
[Err] ./error_test3.sh:8 - 'false' failed
$

これで良し…と思ったら関数内ではうまくいかず、エラーハンドラのon_error()を呼んでくれない。

error_test4.sh
#!/bin/bash -eu

on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

raise_error() {
    echo "raise_error() start"
    false
    echo "raise_error() end"
}

echo "script start"
raise_error()
echo "script end"
実行例
$ ./error_test4.sh
script start
raise_error() start
$

調べてみるとここの事情はけっこうややこしい模様。

  • bash -eのエラー時終了は関数・サブシェルまで継承されるがtrapで登録したエラーハンドラは継承されない
  • bash -Eでエラーハンドラを関数・サブシェル・コマンド置換に継承出来る
  • 一方でbash -eはコマンド置換には継承されない

bashのtrap(ERR)が関数内に継承されないわけがない - Qiita

3つ目が曲者でエラーハンドラを継承させようとしてbash -eEとすると、以下の様にコマンド置換を使用した場合にエラーハンドラは呼ばれるものの、今度はbash -eの方が継承されておらずスクリプトが走り続けてしまう。(raise_error() start/endが無いのはtmpに代入されているため)

error_test5.sh
#!/bin/bash -eEu
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

raise_error() {
    echo "raise_error() start"
    false
    echo "raise_error() end"
}

echo "script start"
tmp=$(raise_error)
echo "script end"
実行例
$ ./error_test5.sh
script start
[Err] ./error_test5.sh:9 - 'false' failed
script end
$

ここの解決で散々うなっていたが、この挙動はbash特有(POSIX非互換)な様で更にbashのバージョンが4.4以降であればshopt -s inherit_errexitで対応可能とのこと。

シェルスクリプトのset -eを罠を避けて使う方法 - Qiita

error_test6.sh
#!/bin/bash -eEu
shopt -s inherit_errexit
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

raise_error() {
    echo "raise_error() start"
    false
    echo "raise_error() end"
}

echo "script start"
tmp=$(raise_error)
echo "script end"
実行例
$ ./error_test6.sh
script start
[Err] ./error_test6.sh:10 - 'false' failed
[Err] ./error_test6.sh:15 - 'tmp=$(raise_error)' failed
$

これでエラー時終了とエラーハンドラの挙動が一致した。コマンド置換はサブシェルで実行するので関数が異常終了→スクリプトが異常終了してエラー出力が2重になってしまっているが、スタックトレースみたいなのでまぁよしとする。

なお、自分でファイル存在確認や変数チェックをしたい場合はtest系コマンドを条件文としてではなくコマンドとして使用する。

error_test7.sh
#!/bin/bash -eEu
shopt -s inherit_errexit
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR

raise_error() {
    echo "raise_error() start"
    [[ -f nowherefile ]]
    echo "raise_error() end"
}

echo "script start"
raise_error
echo "script end"
実行例
$ ./error_test7.sh
script start
raise_error() start
[Err] ./error_test7.sh:10 - '[[ -f nowherefile ]]' failed
$

課題

エラーハンドラの処理から外れて自分でエラーを判定して独自メッセージを出そうとすると、if文中の条件式や||及び&&といったパイプラインの途中ではエラー時終了もエラーハンドラも継承されないので困ってしまう。

下記ではraise_error()の終了ステータスを判定して、独自メッセージを出力して異常終了するerror()を実行しようとしているが、raise_error()にエラー時終了が継承されていないのでecho "raise_error() end"まで実行され、スクリプトが最後まで走ってしまう。

error_test8.sh
#!/bin/bash -eEu
shopt -s inherit_errexit
on_error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - '${BASH_COMMAND}' failed">&2
}
trap on_error ERR
error() {
    echo "[Err] ${BASH_SOURCE[1]}:${BASH_LINENO} - ${*}">&2
    exit 1
 }

raise_error() {
    echo "raise_error() start"
    false
    echo "raise_error() end"
}

echo "script start"
raise_error || error "raise_error() failed with some reason"
echo "script end"
実行例
$ ./error_test8.sh
script start
raise_error() start
raise_error() end
script end
$

これに対処しようとすると関数内ではエラー時にreturn 1を返す必要があるが、エラー時終了
が継承されてない以上関数内のエラー判定を全て自分で実装しなければならなくなり本末転倒。
この問題を簡便に解決する手法は未だ持ち合わせておらず、上述のリンクにある様にわざわざ自分でエラーチェックをしないことが一番の解決策だろう。

あとがき

ちゃんと読んだことが無かったけどbashのmanページはすごい有用なことが整理して書いてあったのでぜひご一読を。出来れば英語の、ちゃんと更新されているmanページを読むこと。inherit_errexitはUbuntuのmanページには書いてあって、mapages-ja-devで利用出来る日本語manページには書いてなかった。

Discussion