「ログ集計コマンドでパイプ途中にエラーが出ていたのに、スクリプトは終了コード0を返していた」
こういったトラブルの多くは、bashのパイプラインが持つデフォルト動作に起因しています。bashは標準設定では パイプラインの最後のコマンドの終了コードだけを参照します。途中のコマンドが失敗していても、スクリプトはそのまま次の処理に進んでしまいます。
この記事では、bashの組み込み変数
$PIPESTATUS と set -o pipefail を使って、パイプライン途中の失敗を確実に検知する方法を解説します。RHEL 9.4 / Ubuntu 24.04 LTS で動作確認した実行例とともに、バックアップスクリプト・ログ処理パイプラインへの実践的な適用方法まで網羅します。この記事のポイント
・set -o pipefail を設定するとパイプ途中の失敗でパイプライン全体が非ゼロを返す
・$PIPESTATUS 配列で各コマンドの終了コードを個別に参照できる
・$PIPESTATUS はすぐに上書きされるため、実行直後に別変数にコピーするのが定石
・set -e・set -u・set -o pipefail の3つを組み合わせると堅牢なエラー検知になる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜbashはパイプ途中の失敗を見逃すのか
bashのパイプライン(cmd1 | cmd2 | cmd3 形式)では、デフォルトで変数 $? には 最後のコマンド(cmd3)の終了コードが格納されます。cmd1 や cmd2 がエラーを返していても、$? は cmd3 の結果だけを反映します。次の実行例を見てください。
# デフォルトのbash動作(RHEL 9.4 での実行例) [admin@rhel94-sv01 ~]$ cat /var/log/nonexistent.log | grep "Error" | wc -l cat: /var/log/nonexistent.log: No such file or directory 0 # catは失敗しているが、$? は wc -l の終了コード(0)を返す [admin@rhel94-sv01 ~]$ echo $? 0
cat コマンドはファイルが存在しないためエラー(終了コード1)を返しています。ところが $? は最後の wc -l(0行をカウントして正常終了)の終了コードを返すため、スクリプト上は「成功」と見なされてしまいます。set -e(コマンド失敗時に即停止するオプション)を使っていても、パイプライン全体の終了コードが0であればスクリプトは中断しません。これが「静かな失敗(Silent Failure)」と呼ばれる問題の本質です。この問題に対処するのが
set -o pipefail と $PIPESTATUS の2つです。それぞれの役割を整理しておきます。| 機能 | 役割 | 使いどころ |
|---|---|---|
set -o pipefail |
パイプ途中のエラーでパイプライン全体を失敗とする | set -e との連携・パイプ失敗時のスクリプト停止 |
$PIPESTATUS |
パイプラインの各コマンドの終了コードを配列で保持 | どのコマンドが失敗したかを特定する詳細診断 |
set -o pipefailでパイプ全体の失敗を検知する
1. set -o pipefailの設定方法
set -o pipefail はスクリプト冒頭またはインタラクティブシェルで設定します。# コマンドラインから設定(現在のシェルセッションに適用) set -o pipefail # スクリプト内での典型的な記述(shebang の直後) #!/bin/bash set -o pipefail # set -e・set -u とセットで使うのが現場での定番 #!/bin/bash set -euo pipefail
-euo pipefail の各オプションが担う役割を確認しておきましょう。・-e(errexit):コマンドが0以外の終了コードを返したときにスクリプトを即停止する
・-u(nounset):未定義の変数を参照したときにエラーを発生させる
・-o pipefail:パイプライン途中のコマンド失敗でパイプライン全体を失敗とする
2. set -o pipefailの動作確認
先ほどと同じコマンドをpipefail 設定後に実行すると、動作が変わります。# pipefail 設定後の動作(RHEL 9.4) [admin@rhel94-sv01 ~]$ set -o pipefail [admin@rhel94-sv01 ~]$ cat /var/log/nonexistent.log | grep "Error" | wc -l cat: /var/log/nonexistent.log: No such file or directory 0 # 今度は $? が 1 を返す(catの失敗がパイプライン全体に伝播) [admin@rhel94-sv01 ~]$ echo $? 1 # pipefailの設定状態を確認する [admin@rhel94-sv01 ~]$ set -o | grep pipefail pipefail on
set -o pipefail を設定すると、パイプラインの $? には 最後に失敗したコマンドの終了コード が格納されます。これにより set -e と組み合わせたときに、パイプ途中の失敗でスクリプトを停止させることができます。設定を解除したい場合は
set +o pipefail で無効化できます。3. set -eとの組み合わせ
#!/bin/bash set -euo pipefail echo "ログ集計を開始します" # pipefail がなければ cat の失敗を見逃す RESULT=$(cat /var/log/application.log | grep "ERROR" | wc -l) echo "エラー件数: $RESULT" echo "完了"
set -eo pipefail の設定があれば、/var/log/application.log が存在しない場合にスクリプトはその時点で停止します。pipefail なしでは、RESULT に0が入ったまま正常終了してしまいます。シェルスクリプトの
set -e・set -u・trap ERR を組み合わせた設計全体については、現場で壊れないシェルスクリプト設計|エラー処理とtrapで安定運用する書き方も参照してください。$PIPESTATUSでどのコマンドが失敗したか特定する
set -o pipefail はパイプラインが失敗したことを検知しますが、「どのコマンドが失敗したか」は分かりません。そこで使うのが $PIPESTATUS 配列です。1. $PIPESTATUSの基本的な使い方
$PIPESTATUS は、直前に実行したパイプラインの各コマンドの終了コードを格納した配列です。パイプラインの左から0始まりのインデックスで対応します。# パイプラインを実行 [admin@rhel94-sv01 ~]$ cat /var/log/nonexistent.log | grep "Error" | wc -l cat: /var/log/nonexistent.log: No such file or directory 0 # 各コマンドの終了コードを確認する [admin@rhel94-sv01 ~]$ echo "${PIPESTATUS[@]}" 1 1 0 # インデックスで個別にアクセスする # ${PIPESTATUS[0]} = cat の終了コード(1: ファイルなし) # ${PIPESTATUS[1]} = grep の終了コード(1: マッチなし) # ${PIPESTATUS[2]} = wc -l の終了コード(0: 正常) [admin@rhel94-sv01 ~]$ echo "cat:${PIPESTATUS[0]} grep:${PIPESTATUS[1]} wc:${PIPESTATUS[2]}" cat:1 grep:1 wc:0
2. $PIPESTATUSは次のコマンドで上書きされる
$PIPESTATUS を使う際の最大の落とし穴が、「次のコマンドを実行した瞬間に上書きされる」点です。# NG例: if 文の条件式評価時に $PIPESTATUS が上書きされる cat /var/log/nonexistent.log | grep "Error" | wc -l # この if 文の [ ] が実行された瞬間に $PIPESTATUS は [ ] の結果で上書きされる if [ "${PIPESTATUS[0]}" -ne 0 ]; then echo "catが失敗しました" fi # → 正しく動作しない
if 文の条件評価([ ])もコマンドの一種です。パイプラインの後に if を書くと、条件評価の時点で $PIPESTATUS はすでに [ ] の結果で上書きされています。3. $PIPESTATUSを変数にコピーするパターン
正しい使い方は、パイプラインを実行した 直後の1行で別の変数にコピーする ことです。#!/bin/bash # パイプラインを実行 cat /var/log/nonexistent.log | grep "Error" | wc -l # 実行直後に配列全体をコピー(これが唯一安全な方法) PIPE_STATUS=("${PIPESTATUS[@]}") # コピーした変数で判定する if [ "${PIPE_STATUS[0]}" -ne 0 ]; then echo "ERROR: cat コマンドが失敗しました(終了コード: ${PIPE_STATUS[0]})" >&2 fi if [ "${PIPE_STATUS[1]}" -ne 0 ]; then echo "WARN: grep にマッチする行がありませんでした(終了コード: ${PIPE_STATUS[1]})" >&2 fi # 全コマンドの失敗をループで確認する for i in "${!PIPE_STATUS[@]}"; do if [ "${PIPE_STATUS[$i]}" -ne 0 ]; then echo "コマンド $((i+1)) が失敗(終了コード: ${PIPE_STATUS[$i]})" >&2 fi done
PIPE_STATUS=("${PIPESTATUS[@]}") で配列全体をコピーしています。コピー後は PIPE_STATUS を使って任意のタイミングで判定できます。条件式の書き方については、bashのif文とtest条件式の使い方|ファイル・文字列・数値の比較から実践例までも参考にしてください。
バックアップスクリプトへの実践的な適用例
バックアップスクリプトは、パイプラインの失敗を最も見逃しやすい典型的なケースです。tar | ssh のようなパイプラインでは、途中の tar が失敗しても最後の ssh が正常終了すれば $? は0になります。次のスクリプトは
$PIPESTATUS と set -uo pipefail を組み合わせた安全な設計です。#!/bin/bash set -uo pipefail # ---- 設定 ---- BACKUP_SRC="/var/data/important" BACKUP_HOST="backup@192.168.x.x" BACKUP_DEST="/backup/$(date +%Y%m%d).tar.gz" LOG="/var/log/backup.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" } # ---- バックアップ実行 ---- log "バックアップ開始: $BACKUP_SRC" tar czf - "$BACKUP_SRC" | ssh -q "$BACKUP_HOST" "cat > '$BACKUP_DEST'" PIPE_STATUS=("${PIPESTATUS[@]}") # ---- 終了コードを確認 ---- if [ "${PIPE_STATUS[0]}" -ne 0 ]; then log "ERROR: tar が失敗(終了コード: ${PIPE_STATUS[0]})" exit 1 fi if [ "${PIPE_STATUS[1]}" -ne 0 ]; then log "ERROR: ssh 転送が失敗(終了コード: ${PIPE_STATUS[1]})" exit 1 fi log "バックアップ正常終了" exit 0
・PIPE_STATUS へのコピー:パイプライン実行直後の行で
PIPE_STATUS=("${PIPESTATUS[@]}") とコピーする・コマンドごとの確認:tar と ssh の終了コードをそれぞれ独立して確認する
・ログ記録:エラー発生時刻・コマンド名・終了コードをログファイルに残す
・明示的な exit:エラー時は必ず
exit 1 でスクリプトを停止する【注意】このスクリプトでは
set -e をあえて外しています。set -e を使うと、PIPE_STATUS のコピーより前にスクリプトが停止するケースがあるためです。代わりに $PIPESTATUS を使った手動確認を行っています。パイプラインのエラー処理をさらに深めたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使えるシェルスクリプト設計を体系的に学べます。
ログ処理パイプラインへの適用例
ログ集計スクリプトでも同様のパターンが有効です。ここではgrep | awk | sort のようなパイプラインへの適用を解説します。#!/bin/bash set -uo pipefail LOG_FILE="/var/log/application/access.log" REPORT="/var/tmp/error_report_$(date +%Y%m%d).txt" # エラーログを集計してレポートファイルに出力 grep "ERROR" "$LOG_FILE" | awk '{print $1, $4}' | sort | uniq -c | sort -rn > "$REPORT" PIPE_STATUS=("${PIPESTATUS[@]}") # grep が失敗した場合(ファイルが読めないなど)は異常終了 if [ "${PIPE_STATUS[0]}" -ge 2 ]; then echo "ERROR: ログファイルの読み込みに失敗しました: $LOG_FILE" >&2 exit 1 fi # grep 終了コード1(マッチなし)は正常状態として扱う if [ "${PIPE_STATUS[0]}" -eq 0 ]; then echo "レポートを出力しました: $REPORT" else echo "エラーログは検出されませんでした" fi
grep の終了コードには2種類の意味があり、この区別がログ処理では特に重要です。| 終了コード | 意味 | 対処 |
|---|---|---|
| 0 | マッチする行が見つかった(正常処理) | 処理を続行 |
| 1 | マッチする行がなかった(ファイルは正常に読めた) | エラーログなし = 正常状態として扱う |
| 2以上 | エラー(ファイルが読めないなど) | 異常として処理を中止する |
if [ "${PIPE_STATUS[0]}" -ge 2 ] として終了コード2以上だけを「エラー」と判定しています。「マッチなし(1)」と「ファイル読み取りエラー(2以上)」を区別することが、安定したログ処理の設計につながります。トラブルシュート・よくある落とし穴
1. サブシェル内では$PIPESTATUSが届かない
$()(コマンド置換)内でパイプラインを実行すると、$PIPESTATUS はサブシェル内で完結し、外のシェルには届きません。#!/bin/bash set -uo pipefail # コマンド置換内のパイプラインを実行 RESULT=$(cat /nonexistent | grep "foo" | wc -l) PIPE_STATUS=("${PIPESTATUS[@]}") # $PIPESTATUS は $() 自体の終了コードのみ(要素が1つ) echo "${PIPE_STATUS[@]}" # → "1" のみ(コマンド置換全体の失敗を示す) echo "${#PIPE_STATUS[@]}" # → 1(要素数は1) # 正しい対処: パイプラインをコマンド置換の外に出す cat /nonexistent | grep "foo" | wc -l PIPE_STATUS=("${PIPESTATUS[@]}") echo "${PIPE_STATUS[@]}" # → "1 1 0"(各コマンドの終了コード)
2. while readループとpipefailの相互作用
while read ループをパイプラインに含める場合、while 側の終了コードに注意が必要です。#!/bin/bash set -uo pipefail # パイプの右側が while read の場合 # while が exit 0 で終了するため、cat の失敗が pipefail で検知されないケースがある cat /var/log/app.log | while read -r line; do echo "処理: $line" done PIPE_STATUS=("${PIPESTATUS[@]}") # while は通常 exit 0 を返すため PIPE_STATUS[1] が 0 になる場合がある # 推奨: process substitution を使う while read -r line; do echo "処理: $line" done < <(cat /var/log/app.log) # この形式であれば cat の終了コードを $? で確認できる
while read ループはパイプラインの右辺に置かず、プロセス置換(< <())を使うとより安全です。3. pipefailとgrepを組み合わせるときの注意点
set -eo pipefail を使っているスクリプトで grep を使うと、マッチなし(終了コード1)でスクリプトが意図せず停止することがあります。#!/bin/bash set -euo pipefail # 問題のあるパターン: マッチなしで set -e がスクリプトを停止させる COUNT=$(grep -c "ERROR" /var/log/app.log) # 安全なパターン1: -e を局所的に無効にする set +e COUNT=$(grep -c "ERROR" /var/log/app.log) set -e # 安全なパターン2: if 文で先にファイルと内容を確認する if grep -q "ERROR" /var/log/app.log; then COUNT=$(grep -c "ERROR" /var/log/app.log) echo "エラー件数: $COUNT" else echo "エラーログなし" fi
if grep -q で事前確認するか、set +e/set -e で局所的にエラー検知を制御するのが堅牢な設計です。trap ERR を使ったエラー通知の設計については、trapコマンドでbashスクリプトのシグナルを捕捉・処理する方法|一時ファイル削除やエラー終了処理の実践例もも参考にしてください。本記事のまとめ
| やりたいこと | コマンド/設定 |
|---|---|
| パイプ途中の失敗でパイプライン全体を失敗とする | set -o pipefail |
| set -e・set -u とセットで堅牢な基本防御を設定する | set -euo pipefail |
| pipefailの設定状態を確認する | set -o | grep pipefail |
| pipefailを解除する | set +o pipefail |
| パイプライン全コマンドの終了コードを確認する | echo "${PIPESTATUS[@]}" |
| 特定のコマンド(n番目)の終了コードを取得する | echo "${PIPESTATUS[n]}"(0始まり) |
| $PIPESTATUSを安全に参照する(上書き防止) | PIPE_STATUS=("${PIPESTATUS[@]}") |
$PIPESTATUS と set -o pipefail はどちらか一方だけでは不完全です。set -o pipefail はパイプライン全体の失敗を検知し、$PIPESTATUS はどのコマンドが失敗したかを特定します。この2つを組み合わせることで、「静かな失敗」を確実に検知できる設計になります。既存のバックアップスクリプトがあれば、まずスクリプト冒頭に
set -uo pipefail を追加し、パイプライン実行直後に PIPE_STATUS=("${PIPESTATUS[@]}") を入れることから始めてみてください。パイプラインのエラー処理をさらに深めたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使えるシェルスクリプト設計を体系的に学べます。
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 次のページへ:自作シェルスクリプトをsystemdサービス化して常駐運用する方法|Unitファイル作成・Restart設定・ログ確認
- 前のページへ:シェルスクリプトのセキュリティ設計|クォート徹底・コマンドインジェクション対策・evalを避ける書き方
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます