set -euo pipefail、そしてそれだけでは足りない理由
一言でいうと
set -euo pipefailは、スクリプトが静かに誤った結果を出すことを防ぐ最小限の仕組みです。しかしそれだけでは、一時ファイルの後始末も、重複実行の防止もできません。
なぜ必要なのか
バックアップスクリプトが毎日明け方に動き、毎日成功と報告していました。復旧が必要な日になって、バックアップファイルが0バイトだったことがわかりました。
tar czf "$DEST" "$SRC_DIR"
SRC_DIRがタイプミスで空になっており、シェルは空文字列をそのまま通し、tarは空のアーカイブを作り、終了コードは0でした。3か所で、それぞれ「静かに」通過した結果です。
set -uが1つあれば、最初の段階で止まっていたでしょう。これらのオプションの価値は、問題を発見した地点で止めることにあります。
どう動くのか
4行の標準ヘッダーは、次のとおりです。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
-e: コマンドが失敗すると、即座に終了します。ただし、ifの条件や||の後ろのように、「失敗が予想される文脈」では発動しません。-u: 未定義の変数を読むと、エラーで終了します。タイプミスをすぐに捕まえます。-o pipefail: パイプラインの中のどれか1つでも失敗すると、全体を失敗にします。これがないと、curl ... | jq ... | wc -lでcurlが死んでも成功に見えます。IFS: 単語分割の基準から空白を外して、空白を含むファイル名の事故を減らします。
ところが、set -eですべてが解決するわけではありません。むしろ落とし穴があります。
- 関数の中で失敗しても、その関数を
||やifの文脈で呼び出すと、-eが発動しません。 -eで終了する経路でも、一時ファイルは残ります。-eは「何が失敗したのか」を教えてくれません。
そのため、trapが対で必要です。
cleanup() {
local rc=$?
rm -f "$TMPFILE"
exit "$rc"
}
trap cleanup EXIT
ここで、2つが核心です。1つ目は、EXITトラップが正常終了・エラー終了・シグナル終了のすべてで動くことです。2つ目は、最初に$?をつかんでおいて、最後に返してやらないと、トラップが終了コードを飲み込んでしまうことです。
一時ファイルは、必ずmktempで作ります。固定された名前は同時実行のときに衝突し、/tmpではシンボリックリンク攻撃の標的になります。
現場での姿
重複実行の防止。cronが5分ごとに動かす作業が6分かかるようになると、2つが重なって動きます。ロックがないと、同じファイルに同時に書き込んでデータが壊れます。flockやmkdirベースのロックで2回目の実行はすぐに終了させますが、終了コードで「なぜ動かなかったのか」を区別してやることが重要です。そうすれば、モニタリングが「失敗」と「すでに動いているのでスキップ」を区別できます。
終了コードの規約。0が成功、1が一般的な失敗までは、誰もが知っています。それ以上は、プロジェクトが決めます。sysexitsの慣例を借りるなら、64は使い方のエラー、66は入力ファイルなしです。値そのものより、一貫して使い、文書に書いておくことが重要です。
リトライは条件付きで。ネットワーク呼び出しは、失敗してもやり直せば成功することがあります。ただしリトライには、3つの条件が付きます。最大回数があること、試行の間に休むこと(休まないと、相手をさらに追い込みます)、そして成功したらすぐに止まることです。さらに、リトライしてもよい作業なのか(冪等なのか)を、先に確認する必要があります。決済リクエストをリトライすると、2回決済されます。
shellcheckは無料のレビュアーです。クォートの漏れ、使っていない変数、危険なパターンを見つけてくれます。CIに入れておくと、レビュー時間が目に見えて減ります。
set -eが見てくれない場面
set -euo pipefailを一番上に書いておけば安全だと考えがちです。ところがset -eには、仕様で決まっている例外がいくつもあり、事故はいつもその場面で起こります。
条件として使われたコマンドは、失敗しても止まりません。if、while、&&、||、!の左側にあるコマンドは、失敗がそのまま判断材料なので、終了条件ではありません。ここまでは意図された動作です。問題は、関数をその場所に置いたときです。
setup() {
mkdir -p /srv/data # 여기서 실패해도
cp config.yml /srv/data # 이 줄이 실행된다
}
if setup; then echo "준비 완료"; fi
関数がifの条件として呼ばれた瞬間、その関数の内側全体でset -eがオフになります。関数は最後のコマンドの終了コードを返すので、前で何が失敗しても、cpだけ成功すれば「準備完了」が出力されます。
コマンド置換の失敗は、代入が飲み込みます。
VERSION=$(cat /etc/app/version) # 파일이 없어도 스크립트는 계속 간다
VERSION=...という代入文が成功したからです。終了コードは代入のもので、catのものではありません。前にlocalやexportを付けると、さらに確実に隠れます。値を必ず得なければならないなら、分けて書きます。
VERSION=$(cat /etc/app/version) || exit 1
: "${VERSION:?버전 파일이 비어 있습니다}"
pipefailがないと、パイプの前側はないも同然です。set -eだけでは、curl ... | jq .でcurlが死んでも、jqが成功すれば通過します。ただし、pipefailをオンにすると、headで切るパイプがSIGPIPEで失敗するようになるので、そのような場面は|| trueで意図を書いておきます。
後始末はtrapに任せます。途中で止まるスクリプトほど、一時ファイルが残ります。
tmp=$(mktemp -d) || exit 1
trap 'rm -rf "$tmp"' EXIT
EXITは、正常終了・set -eによる終了・exitの呼び出しのすべてで実行されます。INT/TERMまで捕まえたいなら一緒に書きますが、その中で、再び失敗しうるコマンドを使いません。後始末の関数が失敗すると、残るのは一時ファイルではなく無限ループです。
次のラボですること
厳格モードが実際に動作することを行動で証明し、pipefailでパイプの失敗を伝播させ、trapで一時ファイルを片付け、ロックで重複実行を防ぎ、空白を含むパスを安全に処理し、終了コードの規約を守るバックアップスクリプトとリトライヘルパーを作った後、最後には他のスクリプトを検査する監査ツールを作成します。