ようへいの日々精進XP

よかろうもん

シェルスクリプトを書く時には set -e をつけた方がいいのかな...どうなんだろう

tl;dr

とあるプロジェクトのデプロイを CircleCI で実行していて, いくつかのコマンドをラップしたBash スクリプト (以後, シェルスクリプトと記載) を実行していたんだけど, コマンドのエラーを意図せずハンドル出来ていない現象に気づいて修正したメモです. 結局のところ, シェルスクリプトを書く時には set -e をつけた方がいいのかなという結論に至った次第です.

for example

以下のようなシェルスクリプトを動かそうとした場合...

#!/bin/bash

echo "イメージをビルドします"
docker build -t foo/bar:build .
echo "イメージタグを設定します"
docker tag foo/bar:build foo/bar:xxxxxxx

docker build で失敗した場合, CI/CD 環境に限らず, シェルスクリプトとしては即座に終了してもらたいのですが, 意図に反して終了せずに次のステップ (docker tag) の処理に進んでしまいます.

解決策

その 1 〜 がんばってエラーハンドリングを実装する 〜

docker build において, Dockerfile に不備があったり, ビルド時に実行されるコマンドにエラーが発生した場合にステータスコード 1 で終了します. これをハンドリングするような処理を入れることを検討としました. ステータスコード 1 以外で終了する場合も想定して, 正常終了 (ステータスコード 0) 以外は全てエラーとしてハンドリングしたいと思います.

#!/bin/bash

echo "イメージをビルドします"
docker build -t foo/bar:build .
[ $? -eq 0 ] && echo "次の処理に進みます" || exit 1
echo "イメージタグを設定します"
docker tag foo/bar:build foo/bar:xxxxxxx
[ $? -eq 0 ] && echo "次の処理に進みます" || exit 1

echo "引続きの処理を行います"

悪くはないですが, 同じような内容を繰り返し記述する必要があり, 可読性が下がりますし, メンテナンスもし辛いと思います.

その 2 〜 set -e を付与する 〜

インターネット上を検索すると, シェルスクリプトShebang-e を付与したり, set -e を付与したりすることで, スクリプト内で実行されるコマンドが正常に終了しなければ, スクリプトを即座に終了することが出来ることが判りました.

パイプライン (1 つの 単純なコマンド からなるものでもよい)、 括弧で囲まれた サブシェル のコマンド、 ブレース (前述の シェルの文法 を参照) で囲まれたコマンドのリストの一部として実行されたコマンドの 1 つ が 0 でないステータスで終了した場合、即座に終了します... (以下, 略)

以下のように書くことで, docker builddocker tag のコマンドが失敗した時点でシェルスクリプトも終了します.

#!/bin/bash

set -e

echo "イメージをビルドします"
docker build -t foo/bar:build .
echo "イメージタグを設定します"
docker tag foo/bar:build foo/bar:xxxxxxx

echo "引続きの処理を行います"

ちょっと, 以下のようなシェルスクリプトで動作確認してみたいと思います.

#!/bin/bash

echo "step 1"
ech "step 1 が終了"

echo "step 2"
echo "step 2 が終了"

echo "step 3"
cho "step 3 が終了"

シェルスクリプトは見ての通り, echotypo した雑なスクリプトですが, これをこのまま実行すると...

$ ./test.sh
step 1
./test.sh: line 4: ech: command not found
step 2
step 2 が終了
step 3
./test.sh: line 10: cho: command not found

上記のように step 1step 3 の分まで実行されていることが判ります. これはこれで良い状況もあるかもしれませんが, CI/CD で実行する場合には, エラーが発生した段階で即座に終了して欲しいです. ということで, set -e を付与してみます.

#!/bin/bash

set -e

echo "step 1"
ech "step 1 が終了"

echo "step 2"
echo "step 2 が終了"

echo "step 3"
cho "step 3 が終了"

実行すると, 以下のように step 1 の段階でエラーとして終了しています.

$ ./test.sh
step 1
./test.sh: line 6: ech: command not found

良い感じです. ちなみに, shebang-e オプションを付与しても同じ結果を得ることが出来ます.

#!/bin/bash -e

echo "step 1"
ech "step 1 が終了"

echo "step 2"
echo "step 2 が終了"

echo "step 3"
cho "step 3 が終了"

実行すると, 結果は先述と同様に step 1 の段階で終了します.

$ ./test.sh
step 1
./test.sh: line 6: ech: command not found

注意点

再掲

パイプライン (1 つの 単純なコマンド からなるものでもよい)、 括弧で囲まれた サブシェル のコマンド、 ブレース (前述の シェルの文法 を参照) で囲まれたコマンドのリストの一部として実行されたコマンドの 1 つ が 0 でないステータスで終了した場合、即座に終了します。 ただし、失敗したコマンドが、キーワード while または until の直後のコマンドの一部である場合、予約語 if または elif に続く条件式の一部である場合、 && または || によるコマンドのリストの一部である場合 (最後の && や || の後のコマンドを除く)、 パイプラインの中の最後のコマンド以外である、 コマンドの返り値が ! で反転されている場合、のいずれかであれば、シェルは終了しません。 ERR に対するトラップが設定されていれば、シェルが終了する前に実行されます。 このオプションはシェルの環境と各サブシェルの環境に別々に適用され (前述の コマンド実行環境 を参照)、 サブシェルはサブシェル内の全てのコマンドを実行する前に終了するかもしれません。

set -e オプションの挙動について, man ページの抜粋を再掲させて頂きます.

個人的に分かりづらい (読みにくい) ですが, set -e オプションが付与されていても, 幾つかの条件では, 実行されるコマンドがエラーとなっても, シェルスクリプトを終了しないようです.

実例 (1)

以下のようなシェルスクリプトだと, set -e が付与されていても最後まで処理が行われました.

#!/bin/bash

set -e

if [ "$(cho 'test')" == "0" ];then
  echo "success"
else
  echo "failure"
fi

例のごとく, echochotypo しています. 実際に実行してみると, 以下のように出力されて, シェルスクリプト全体の挙動としては正しいのかなと思います.

$ ./test.sh
./test.sh: line 5: cho: command not found
failure

実例 (2) 〜 grep コマンド 〜

例えば, シェルスクリプト内で, 何かしらの条件判断に grep を使っている場合, set -e を利用していると意図しない結果になることがあります.

grep コマンドにおいて, 引数で指定した文字列が含まれている場合, ステータスコード 0 が返ってきます. 逆に含まれていない場合には, ステータスコード 1 が返ってきます.

$ bash -c 'echo "foo" | grep "foo"; echo $?'
foo
0
$ bash -c 'echo "foo" | grep "bar"; echo $?'
1

この挙動を利用して, 以下のようなシェルスクリプトを書きました.

#!/bin/bash

echo "foofoo" | grep "$1"
if [ $? == 0 ];then
  echo "処理を継続します..."
else
  echo "処理を終了します"
fi

これを, このまま実行すると, 以下のように「処理を終了します」が出力されます.

bash -c './test.sh bar; echo $?'
処理を終了します
0

set -e オプションを付与すると, 前回とは異なる結果となります.

$ bash -c './test.sh bar; echo $?'
1

これを回避する為 (set -e が付与されていても, 正しく条件判断が行われるようにする) には, 以下のようにシェルスクリプトを書きます.

set -e

if echo "foo" | grep -q "$1" ;then
  echo "処理を継続します..."
else
  echo "処理を終了します"
fi

実行すると, 以下のように出力されました.

$ bash -c './test.sh foo; echo $?'
処理を継続します...
0
$ bash -c './test.sh bar; echo $?'
処理を終了します
0

尚, set -e を使っている際の意図しない挙動への対処については, 以下のブログ記事が参考になりました. ありがとうございます.

sousaku-memo.net

以上

set -e#!/bin/bash -e について, ちゃんと使ったことなかったのですが, 実際にトラブったり, 触ってみたりすることで挙動を把握出来て良かったです. また, bash の奥深さの少しだけ垣間見ることが出来た気がします.

以上, ドキュメントをちゃんと読め案件でした.