Bashで処理が失敗したのに成功扱い? pipefail・PIPESTATUSで終了コードを確かめる

窓辺の書斎で、ノートパソコンと二つの小さな表示灯を見比べる桜色の長髪の女性。

こんにちは、おうどんです🍜

処理のログにはエラー。でも、終了コードは0。

……待って。成功したのは誰?

今回はBashのパイプ処理です。処理 | tee ログのように、画面表示とログ保存を両立させたつもりが、処理本体の失敗を見逃すことがあるんですよね。

結論は「パイプ全体の終了コード」と「各コマンドの終了コード」を分けて確認すること。さらに、その失敗を受けて止めるのか、続けるのかを決めます。確認係と判断係、兼務させると話がややこしくなるので。。。

目次

試す前に:Bash専用の説明です

対象はLinux上のBash。この記事の小さな再現例は、作成時の隔離された環境(GNU Bash 5.2.21)で確認しています。読者の実環境、既存ジョブ、他のシェルでは未検証です。

sh script.shではなく、bash script.shで実行してください。配列とPIPESTATUSを使うので、他のシェルへそのまま移植する前提ではありません。

以下の再現例はbash <<'BASH'から最後のBASHまでまとめて実行します。子のBashで動くため、親の端末へ設定を残しません。管理者権限は不要です。例ではteeの書き込み先を/dev/nullにし、業務ファイルの読み書きや外部通信はしません。

まず、失敗が0になるところを見る

bash <<'BASH'
set +e
set +o pipefail
false | tee /dev/null
printf 'pipeline=%s\n' "$?"
BASH

確認結果はpipeline=0です。

falseは意図的に失敗を返すコマンド。でも、その右側のteeは正常に終わっています。

通常、Bashがパイプ全体の終了コードに採用するのは最後のコマンドの値です。つまり「本体も成功」と判定したのではなく、最後尾の返事を採用していたんですね。GNU Bash公式:Pipelines

例えるなら、料理ができていなくても、伝票を渡せた係が「完了です」と答える感じ。説明用の比喩ですが、伝票だけ来てもお腹は満たされません。。。

pipefailは「全体の返事」の決め方を変える

bash <<'BASH'
set +e
set -o pipefail
false | tee /dev/null
printf 'pipeline=%s\n' "$?"
BASH

今度はpipeline=1になります。

ただし、pipefailは「最初に失敗したコマンドの値」ではありません。複数が非0なら、並び順で最も右にある非0の値を採用します。終了した時刻の早い・遅いではないので、そこは分けましょう。GNU Bash公式:Pipelines

そして、この例は失敗後もprintfまで進みます。pipefailは停止命令でも、途中まで進んだ処理を元に戻す命令でもありません。

「エラーを見つけた」と「安全に止めた」は別。ここ、さらっと一緒にしたくなるんですよね。

PIPESTATUSで、誰が失敗したのか残す

bash <<'BASH'
set +e
set -o pipefail
(exit 7) | (exit 3) | true
rc=$? stages=("${PIPESTATUS[@]}")
printf 'pipeline=%s stages=%s\n' "$rc" "${stages[*]}"
BASH

確認結果はpipeline=3 stages=7 3 0です。

説明用に7、3、0を返しているので、全体の3だけでは左端の7が見えません。配列なら左から順に各段を追えます。

保存行を少し変わった書き方にしたのには理由があります。$?PIPESTATUSを、同じ代入だけのコマンドでまとめて退避しています。間にechoや別の代入を挟むと、調べたかった状態が更新されてしまうんです。

「まずログを出して、それから終了コードを確認しよう」。

そのログ出力が、証拠を更新する側だった。。。この例でも、間にprintfを挟むとPIPESTATUS0へ変わることを確認しました。変数の定義はGNU Bash公式:Bash Variablesを参照してください。

実装例:本体とログ保存を別々に判定する

次は、あえて本体を失敗させる完成形です。set -eに任せず、この区間では各終了コードを明示的に扱います。

bash <<'BASH'
set +e
set -u
set -o pipefail

demo_job() {
    printf 'sample output\n'
    printf 'intentional failure\n' >&2
    return 7
}

demo_job 2>&1 | tee /dev/null
rc=$? stages=("${PIPESTATUS[@]}")

printf 'pipeline=%s job=%s log=%s\n' \
    "$rc" "${stages[0]}" "${stages[1]}" >&2

if (( stages[0] != 0 )); then
    printf 'job failed; do not retry blindly\n' >&2
    exit "${stages[0]}"
fi
if (( stages[1] != 0 )); then
    printf 'log output failed\n' >&2
    exit "${stages[1]}"
fi
exit 0
BASH

確認結果はpipeline=7 job=7 log=0で、子のBashは7を返します。2>&1により本体の標準エラーもパイプへ渡しています。ここでは本体の失敗を優先して返し、両方の値を診断行へ残す設計です。

本番用に置き換えるときは、次の確認が必要です。

  • demo_jobの代わりに呼ぶ処理自身が、失敗時に非0を返すか。
  • ログ保存だけ失敗した場合も、ジョブ全体を失敗にするか。
  • 再実行でデータの二重登録や二重送信にならないか。
  • 途中まで作られた出力を、後続処理が完成品として読まないか。

tee /dev/nullを実ファイルへ変えると、書き込みの影響が発生します。通常のteeは既存ファイルを上書きするので、専用の保存先と容量・権限を確認してください。追記を選ぶ場合も、実行ごとのログが混ざらない設計が必要です。GNU Coreutils公式:tee

このサンプルは仕組みの確認用です。保存領域の障害や容量不足を起こすテスト、実ジョブへの組み込みは行っていません。

set -eを付ければ全部解決、とはならない

set -eは非0終了でシェルを終了させる設定ですが、ifの条件や&&||の途中など、適用されない文脈があります。GNU Bash公式:The Set Builtin

単純なfalse | trueでは、-eだけだと全体が0なので次へ進む。pipefailも有効にすると、その単純な例では次へ進まず終了する。両方の挙動を作成環境で確認しました。

ただし、止まることだけを狙うと、終了コードを保存する行まで届かないことがあります。だから上の実装例はset +eで、自分で退避・判定・終了する形にしています。既存の長いスクリプトへset +eだけを足すのは避けてください。後続区間の失敗まで見逃す変更になり得ます。

もう一つ、関数を丸ごとifの条件に置いたら、関数内部でも期待したように-eが効かない場合があります。「この関数は-eで途中停止するから大丈夫」と決めつけず、必要な失敗を関数自身で返す設計にしましょう。

非0を全部「障害」と呼ぶ前に

例えばGNU grepは、通常「一致あり」が0、「一致なし」が1、「エラー」が2です。GNU grep公式:Exit Status

「エラー行がなかった」を正常としたい検索なら、1も想定内ですよね。ただ、|| trueを足して一律に成功へ変えると、読み取り失敗まで隠しかねません。

また、grep -qは一致を見つけると早く終了し、エラーがあっても0を返す場合があります。途中で読むのをやめる処理をパイプに置く設計は、単純な「全段0なら成功」と相性を確認しておきたいところです。この記事の完成例は、入力を途中で打ち切る処理を含めていません。

終了コードは、判断の入口

まずパイプの並びと設定を確認する。次に、直後の終了コードを退避する。最後に、失敗の意味と再実行の条件を決める。

この順番なら「成功したのは誰?」を追いやすくなります。

0が出たから安心、ではなく、何の0かまで見る。 ログ係だけ元気でも、本体が倒れていたら困りますからね🍜

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次