「ログ集計コマンドでパイプ途中にエラーが出ていたのに、スクリプトは終了コード0を返していた」
こういったトラブルの多くは、bashのパイプラインが持つデフォルト動作に起因しています。bashは標準設定では パイプラインの最後のコマンドの終了コードだけを参照します。途中のコマンドが失敗していても、スクリプトはそのまま次の処理に進んでしまいます。
この記事では、bashの組み込み変数
$PIPESTATUS と set -o pipefail を使って、パイプライン途中の失敗を確実に検知する方法を解説します。RHEL 9.4 / Ubuntu 24.04 LTS で動作確認した実行例とともに、set -u(未定義変数検出)・IFS設定・バックアップスクリプト・ログ処理パイプライン・SIGPIPEとの衝突回避・trapを組み合わせた本番設計まで網羅します。この記事のポイント
・set -o pipefail を設定するとパイプ途中の失敗でパイプライン全体が非ゼロを返す
・$PIPESTATUS 配列で各コマンドの終了コードを個別に参照できる
・$PIPESTATUS はすぐに上書きされるため、実行直後に別変数にコピーするのが定石
・set -u(nounset)で未定義変数参照をエラーにし、タイポによる意図しない処理を防ぐ
・set -e・set -u・set -o pipefail の3つを組み合わせた set -euo pipefail が堅牢な出発点
・grep のマッチなし(exit 1)や head との組み合わせ(SIGPIPE/exit 141)には専用の回避設計が必要
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜ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(コマンド失敗時に即停止するオプション)を使っていても、この問題は解決しません。set -e はパイプライン全体の終了コードを評価するため、最後のコマンドが成功している限りスクリプトは中断しません。#!/bin/bash set -e # これだけでは不十分 # catが失敗しても、wc -l が成功(0)を返すためスクリプトは継続する cat /var/log/nonexistent.log | grep "Error" | wc -l echo "ここに到達してしまう"
この問題が特に危険なのは、バックアップスクリプトで
mysqldump | gzip > backup.gz のようなパイプを使う場合です。mysqldump が権限エラーや接続エラーで失敗しても、空の gzip ファイルが正常に生成されてしまい、バックアップが取れていないまま日が経過してしまいます。同様に tar czf - /var/data | ssh backup-server "cat > /backup/data.tar.gz" では、tar がディスク容量不足で途中終了しても ssh が正常に書き込みを完了すれば $? は0になります。この問題に対処するのが
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 の各オプションが担う役割を確認しておきましょう。なお -o pipefail は -e や -u と異なりショートオプション(1文字形式)が存在しないため、最後に長い形式でつなげて書きます。・-e(errexit):コマンドが0以外の終了コードを返したときにスクリプトを即停止する
・-u(nounset):未定義の変数を参照したときにエラーを発生させる
・-o pipefail:パイプライン途中のコマンド失敗でパイプライン全体を失敗とする
さらに
IFS=$'
'(Internal Field Separator)をセットで設定しておくと、スペースを含むファイル名を for ループで扱う場合の意図しない分割を防げます。#!/bin/bash set -euo pipefail IFS=$' ' # スペースをフィールド区切りから除外(ファイル名にスペースがある環境で有効)
set -o pipefail は bash 専用のオプションです。#!/bin/sh(dash 等)ではサポートされないため、スクリプトの shebang は必ず #!/bin/bash にしてください。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で安定運用する書き方も参照してください。4. set -u(nounset)で未定義変数エラーを検出する
set -u(または set -o nounset)は、未定義の変数を参照した時点でエラーにするオプションです。bashのデフォルト動作では、未定義変数は空文字列として扱われます。これが想定外の動作を引き起こすことがあります。#!/bin/bash # set -u を設定していない危険な例 BACKUP_DIR="/var/backup" # タイポで変数名を間違えた場合 rm -rf "${BACKUP_DUR}/" # BACKUP_DUR は未定義 → 空文字列として展開される # 展開後は rm -rf "/" と同等になる可能性がある
set -u を有効にすれば、未定義変数への参照を即座にエラーにできます。#!/bin/bash set -u BACKUP_DIR="/var/backup" rm -rf "${BACKUP_DUR}/" # ← ここで即停止 # 実行結果(RHEL 9.4) # /path/to/script.sh: line 5: BACKUP_DUR: unbound variable
${VAR:-デフォルト値} の記法を使います。#!/bin/bash set -u # 環境変数が未定義のときはデフォルト値を使う LOG_DIR="${MYAPP_LOG_DIR:-/var/log/myapp}" MAX_RETRY="${MAX_RETRY:-3}" DRY_RUN="${DRY_RUN:-false}" echo "ログ出力先 : ${LOG_DIR}" echo "最大リトライ: ${MAX_RETRY}" echo "ドライラン : ${DRY_RUN}"
:- 記法は「変数が未定義または空文字列の場合にデフォルト値を使う」という意味です。set -u 有効化後でも、オプション動作をシンプルに実装できます。5. 特定の処理だけpipefailを一時的に無効化する
set -o pipefail を有効にしても、どうしても途中コマンドの失敗を無視したい場面があります。後述の grep のマッチなし問題や SIGPIPE が発生する処理が典型例です。そのような箇所だけ set +o pipefail で一時的に無効化し、処理後に set -o pipefail で再有効化するパターンが使えます。#!/bin/bash set -euo pipefail # 通常のパイプ処理(pipefailが有効) cat /etc/passwd | wc -l # grepのマッチなし(exit 1)を無視したい場合は一時無効化 set +o pipefail MATCH_COUNT=$(grep "対象パターン" /var/log/app.log | wc -l) set -o pipefail echo "マッチ件数: ${MATCH_COUNT}"
set +o pipefail で無効化し、処理が終わったら set -o pipefail で再度有効化します。このパターンを意図した場所にだけ使うことで、設計の意図をコード上で明確にできます。$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=${PIPESTATUS[@]} のように括弧なしで代入すると文字列になり、添字 [0] [1] での個別アクセスができなくなります。コピー後は PIPE_STATUS を使って任意のタイミングで判定できます。条件式の書き方については、bashのif文とtest条件式の使い方|ファイル・文字列・数値の比較から実践例までも参考にしてください。
4. 関数でパイプラインを包むパターン
実際の運用スクリプトでは、パイプラインを関数に包んで$PIPESTATUS を確認する設計が使いやすいことがあります。#!/bin/bash set -euo pipefail check_pipeline() { local logfile="$1" local pattern="$2" local pipe_status grep "$pattern" "$logfile" | sort | uniq -c pipe_status=("${PIPESTATUS[@]}") # grep の終了コード2(ファイルアクセスエラー)のみ異常とする if [ "${pipe_status[0]}" -eq 2 ]; then echo "ERROR: grep でファイルアクセスエラーが発生しました" >&2 return 1 fi # 終了コード1(マッチなし)は正常として許容 return 0 } check_pipeline "/var/log/app.log" "ERROR"
・0:マッチあり(正常)
・1:マッチなし(正常。エラーではない)
・2:エラー(ファイルなし・権限なし等)
バックアップスクリプトへの実践的な適用例
バックアップスクリプトは、パイプラインの失敗を最も見逃しやすい典型的なケースです。tar | ssh や mysqldump | gzip のようなパイプラインでは、途中のコマンドが失敗しても最後のコマンドが正常終了すれば $? は0になります。1. tar | ssh パイプラインの設計
次のスクリプトは$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
2. mysqldump | gzip パイプラインの設計
データベースのバックアップでも同様のパターンが必須です。pipefail なしでは mysqldump が接続エラーで失敗しても空の gzip ファイルが正常に生成されてしまいます。$PIPESTATUS でダンプの成否を確認し、失敗時には壊れたファイルを削除することが重要です。#!/bin/bash set -uo pipefail DB_HOST="db01.internal" DB_USER="backup_user" DB_NAME="myapp" BACKUP_DIR="/backup/mysql" BACKUP_FILE="${BACKUP_DIR}/$(date '+%Y%m%d_%H%M%S')_${DB_NAME}.sql.gz" mkdir -p "$BACKUP_DIR" # ダンプ → gzip のパイプ mysqldump --host="$DB_HOST" --user="$DB_USER" --single-transaction --quick "$DB_NAME" | gzip -c > "$BACKUP_FILE" PIPE_STATUS=("${PIPESTATUS[@]}") # mysqldump(ステージ0)の確認 if [ "${PIPE_STATUS[0]}" -ne 0 ]; then echo "ERROR: mysqldump が失敗しました(終了コード: ${PIPE_STATUS[0]})" >&2 rm -f "$BACKUP_FILE" # 破損バックアップを削除 exit 1 fi # gzip(ステージ1)の確認 if [ "${PIPE_STATUS[1]}" -ne 0 ]; then echo "ERROR: gzip 圧縮が失敗しました(終了コード: ${PIPE_STATUS[1]})" >&2 rm -f "$BACKUP_FILE" exit 1 fi echo "バックアップ完了: $BACKUP_FILE"
・PIPE_STATUS へのコピー:パイプライン実行直後の行で
PIPE_STATUS=("${PIPESTATUS[@]}") とコピーする・コマンドごとの確認:各コマンドの終了コードを独立して確認する
・破損ファイルの削除:失敗時には不完全なバックアップファイルを削除してから終了する
・明示的な 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
#!/bin/bash set -uo pipefail LOG_FILE="/var/log/nginx/access.log" REPORT="/tmp/access_report_$(date '+%Y%m%d').txt" # IP別アクセス数の集計(4段パイプライン) awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -20 > "$REPORT" PIPE_STATUS=("${PIPESTATUS[@]}") # 全ステージを一括検証(インデックスは0始まり) for i in 0 1 2 3 4; do if [ "${PIPE_STATUS[$i]:-0}" -ne 0 ]; then echo "ERROR: パイプのステージ $(($i+1)) が失敗(終了コード: ${PIPE_STATUS[$i]})" >&2 exit 1 fi done echo "レポート生成完了: $REPORT"
grep の終了コードには2種類の意味があり、この区別がログ処理では特に重要です。| 終了コード | 意味 | 対処 |
|---|---|---|
| 0 | マッチする行が見つかった(正常処理) | 処理を続行 |
| 1 | マッチする行がなかった(ファイルは正常に読めた) | エラーログなし = 正常状態として扱う |
| 2以上 | エラー(ファイルが読めないなど) | 異常として処理を中止する |
if [ "${PIPE_STATUS[0]}" -ge 2 ] として終了コード2以上だけを「エラー」と判定しています。「マッチなし(1)」と「ファイル読み取りエラー(2以上)」を区別することが、安定したログ処理の設計につながります。集計結果をteeで二重出力するパターン
集計結果を標準出力とファイルの両方に流したい場合はtee を使います。pipefail があれば tee の書き込み失敗も検知できます。#!/bin/bash set -uo pipefail LOGFILE="/var/log/app/production.log" OUTFILE="/var/report/$(date +%Y%m%d)-errors.txt" # tee で標準出力とファイルに同時出力 grep "ERROR" "$LOGFILE" | sort | uniq -c | tee "$OUTFILE" PIPE_STATUS=("${PIPESTATUS[@]}") # grepとteeそれぞれの終了コードを確認 if [ "${PIPE_STATUS[0]}" -ge 2 ]; then echo "ERROR: ログファイルにアクセスできません" >&2 exit 1 fi if [ "${PIPE_STATUS[3]}" -ne 0 ]; then echo "ERROR: ファイルへの書き込みに失敗しました: $OUTFILE" >&2 exit 1 fi echo "レポート保存完了: $OUTFILE"
SIGPIPEとpipefailの衝突を回避する設計
set -o pipefail を実際の運用スクリプトに適用すると、head や tail のような「途中でパイプを閉じるコマンド」との組み合わせで意図しないスクリプト停止が起きることがあります。1. SIGPIPE(exit 141)が発生する仕組み
# ログの先頭5行だけ表示したい cat /var/log/syslog | head -n 5 echo "終了コード: $?"
・pipefail あり:終了コード 141(SIGPIPEによる異常終了)
head -n 5 が5行読み終えてパイプを閉じると、cat はまだデータを書き込もうとしてパイプへの書き込みに失敗し、SIGPIPE(シグナル番号13)を受信します。bash の終了コードは 128 + シグナル番号 = 141 となり、pipefail がこれを検知してスクリプトを停止させてしまいます。2. 回避パターン3選
パターン1:該当箇所だけ pipefail を一時無効化する(最も安全)# SIGPIPEが発生することが分かっている処理だけ pipefail を一時無効化 set +o pipefail cat /var/log/syslog | head -n 5 set -o pipefail
パターン2:PIPESTATUSでSIGPIPE(141)だけを許容する
cat /var/log/syslog | head -n 5 cat_exit="${PIPESTATUS[0]}" # SIGPIPE(141)は正常な打ち切りとして許容する if [ "$cat_exit" -ne 0 ] && [ "$cat_exit" -ne 141 ]; then echo "ERROR: cat が失敗しました(exit $cat_exit)" >&2 exit 1 fi
# sed -n '1,5p' は5行処理後に自ら正常終了するので SIGPIPE が発生しない cat /var/log/syslog | sed -n '1,5p' echo "終了コード: $?" # 0
sed は指定した行まで処理して正常終了するため、上流コマンドに SIGPIPE が届きません。ログの先頭 N 行を取得する処理では、head よりもこのパターンが安定しています。trapと組み合わせた完全なエラーハンドリング設計
set -euo pipefail と trap を組み合わせると、パイプライン失敗時にも後始末処理(ロックファイル削除・エラーログ記録など)を確実に実行できる設計になります。次のテンプレートをベースにカスタマイズしてください。#!/bin/bash set -euo pipefail LOCK_FILE="/tmp/$(basename "$0").lock" LOG_FILE="/var/log/myapp/process.log" TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') # EXIT シグナルで後始末を確実に実行(正常・異常問わず) cleanup() { local exit_code=$? rm -f "$LOCK_FILE" if [ $exit_code -ne 0 ]; then echo "[$TIMESTAMP] ERROR: 異常終了しました(exit=$exit_code)" >> "$LOG_FILE" fi } trap cleanup EXIT # 二重起動防止 if [ -e "$LOCK_FILE" ]; then echo "[$TIMESTAMP] 既に実行中のプロセスがあります" >> "$LOG_FILE" exit 1 fi touch "$LOCK_FILE" # パイプラインを実行して直後に PIPESTATUS を保存 cat /var/log/app/access.log | grep "ERROR" | sort | uniq -c | sort -rn > /tmp/error_summary.txt PIPE_STATUS=("${PIPESTATUS[@]}") # ステージごとの検証 if [ "${PIPE_STATUS[0]}" -ne 0 ]; then echo "[$TIMESTAMP] ERROR: ログファイルの読み込み失敗(exit=${PIPE_STATUS[0]})" >> "$LOG_FILE" exit 2 fi # grep マッチなし(exit 1)は業務的に正常として扱う if [ "${PIPE_STATUS[1]}" -eq 1 ]; then echo "[$TIMESTAMP] INFO: ERRORパターンなし(正常)" >> "$LOG_FILE" exit 0 fi echo "[$TIMESTAMP] INFO: エラー集計完了" >> "$LOG_FILE"
trap cleanup EXIT で EXIT シグナルを捕捉し、ロックファイルの削除を確実に実行している点です。set -e でパイプライン失敗からスクリプトが中断されても、cleanup は必ず実行されます。2つ目は grep の exit 1 を「マッチなし=正常」として明示的に処理している点で、次のトラブルシュートセクションで詳しく取り上げます。trap ERR を使うと、エラーが発生した 行番号と終了コード をログに自動記録できます。#!/bin/bash set -euo pipefail # エラー発生時に行番号と終了コードをログに記録する on_error() { local exit_code=$? local line_no=$1 echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') 行 ${line_no} で失敗 (終了コード: ${exit_code})" >&2 rm -f /tmp/myapp_lock.pid || true } trap 'on_error $LINENO' ERR echo "[INFO] バックアップ開始" rsync -av --delete /var/www/html/ /mnt/backup/www/ echo "[INFO] バックアップ完了"
トラブルシュート・よくある落とし穴
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"(各コマンドの終了コード)
なお
set -o pipefail はサブシェル(())には継承されますが、bash -c '...' の内部では独立したシェルが起動するため、親シェルの設定が引き継がれません(後述)。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. bash -c 内ではpipefailが引き継がれない
pipefail の設定はサブシェル(())には継承されますが、bash -c '...' の内部では独立したシェルが起動するため、親シェルの設定が引き継がれません。別スクリプトを bash script.sh で呼び出した場合も同様に継承されません。#!/bin/bash set -euo pipefail # OK: サブシェル () 内では pipefail が継承される ( cat /tmp/nonexistent | wc -l # サブシェルが exit 1 で終了 → 親シェルも停止 ) # 注意: bash -c 内は独立したシェル → pipefail は引き継がれない bash -c 'cat /tmp/nonexistent | wc -l; echo done' # 安全策: bash -c でも明示的に設定を渡す bash -c 'set -euo pipefail; cat /tmp/nonexistent | wc -l; echo done' # 別スクリプトへの呼び出しも同様: 呼び出し先にも set -euo pipefail を書く bash /path/to/sub_script.sh
set -euo pipefail を書くことで、呼び出し元の設定に依存しない独立した安全設計にできます。4. pipefailとgrepを組み合わせるときの注意点
set -eo pipefail を使っているスクリプトで grep を使うと、マッチなし(終了コード1)でスクリプトが意図せず停止することがあります。grep は仕様として、マッチする行がゼロ件のとき exit 1 を返します。これはエラーではなく「マッチなし」という正常な情報ですが、pipefail はこれを「失敗」として扱います。この問題の回避策は状況に応じて3パターンあります。
パターン1:if grep -q で事前にマッチの有無を確認する
#!/bin/bash set -euo pipefail # 安全なパターン: if 文で先にマッチ有無を確認してから件数取得 if grep -q "ERROR" /var/log/app.log; then COUNT=$(grep -c "ERROR" /var/log/app.log) echo "エラー件数: $COUNT" else echo "エラーログなし" fi
# grepのexit 1を|| trueで打ち消す(マッチなしを正常として扱う場合) INVALID_COUNT=$(grep "INVALID USER" /var/log/auth.log | wc -l || true) echo "不正アクセス試行: ${INVALID_COUNT}件"
|| true は最もシンプルですが、本来はエラーである失敗まで握りつぶすリスクがあります。意図した場所にだけ限定して使うことが重要です。パターン3:set +e / set -e で局所的にerrexitを制御する
# set +e を局所的に使い、マッチなし(exit 1)を正常として扱う set +e COUNT=$(grep -c "ERROR" /var/log/app.log) set -e echo "エラー件数: $COUNT"
trap ERR を使ったエラー通知の設計については、trapコマンドでbashスクリプトのシグナルを捕捉・処理する方法|一時ファイル削除やエラー終了処理の実践例もも参考にしてください。5. if文の条件式ではset -eが発動しない
bash では、if / while / until の条件式の中ではコマンドの失敗が set -e のトリガーになりません(条件の評価として使われるためです)。ただし pipefail はパイプライン全体の終了コードの計算に影響するため、条件の true/false 判定は正しく行われます。set -euo pipefail # pipefailあり: grepが失敗してもif文の条件評価なのでset -eは発動しない # ただしpipefailにより条件式がfalseになりelseブランチに入る if grep "ERROR" /var/log/app.log | head -n 1; then echo "エラーログあり" else echo "エラーなし(またはログ取得失敗)" fi
if 文の条件式でパイプラインを使う場合は、必ず $PIPESTATUS を条件式の外で確認する設計にしてください。6. set -xとbash -nでスクリプトの動作を確認する
set -euo pipefail を有効にしたスクリプトで予期しない場所が停止する場合、2つのデバッグ手法が役立ちます。set -x(実行トレース):実行するコマンドをステップ表示します。
#!/bin/bash set -euo pipefail set -x # デバッグ時のみ: 実行するコマンドをステップ表示 # スクリプト本体 tar czf - /var/data | ssh backup-server "cat > /backup/data.tar.gz" PIPE_STATUS=("${PIPESTATUS[@]}") echo "pipe_status: ${PIPE_STATUS[@]}" set +x # トレース終了
set -x を有効にすると、実行した各コマンドを + のプレフィックス付きで標準エラーに出力します。どの行で停止しているか確認できたら set +x でトレースを止めてください。bash -n(シンタックスチェック):スクリプトを実際に実行せずに構文だけ検査します。
# 構文エラーがないか事前に確認する(実際には実行されない) $ bash -n /path/to/backup.sh # 構文エラーが見つかった場合の出力例 $ bash -n /path/to/backup.sh /path/to/backup.sh: line 15: unexpected token `fi' # エラーがなければ何も出力されない(終了コード0)
bash -n で構文チェックをかける習慣をつけると、実行時の構文エラーによる停止を未然に防げます。7. zshスクリプトでPIPESTATUSが参照できない場合
PIPESTATUS は bash 固有の配列です。zsh では pipestatus(小文字)という別名の配列が同様の機能を持ちます。# bash: PIPESTATUS(大文字) bash -c 'cat /etc/passwd | grep "foo" | wc -l; echo "${PIPESTATUS[@]}"' # → 0 1 0 # zsh: pipestatus(小文字) zsh -c 'cat /etc/passwd | grep "foo" | wc -l; echo "${pipestatus[@]}"' # → 0 1 0
#!/bin/bash に固定し、PIPESTATUS(大文字)で統一するのが安全です。8. localとコマンド置換の組み合わせでset -eが無効になる
local や declare とコマンド置換を同じ行で使うと、set -e が期待通りに働かないケースがあります。これは bash の仕様で、local コマンド自体の終了コードが代入の終了コードを上書きするためです。#!/bin/bash set -euo pipefail get_value() { # NG: local と代入を同時に行うと set -e が発動しない local result=$(some_failing_command) # some_failing_command が失敗しても止まらない # OK: 宣言と代入を分けることで set -e が正しく働く local result2 result2=$(some_failing_command) # これは set -e が有効 }
local と代入を同じ行に書くのは一見シンプルですが、エラー検知の観点では危険です。関数内でコマンド置換の結果を代入するときは、必ず宣言と代入を2行に分けてください。本記事のまとめ
| やりたいこと | コマンド/設定 |
|---|---|
| パイプ途中の失敗でパイプライン全体を失敗とする | set -o pipefail |
| set -e・set -u とセットで堅牢な基本防御を設定する | set -euo pipefail |
| スペースを含むファイル名の for ループを安全にする | IFS=$'
' |
| pipefailの設定状態を確認する | set -o | grep pipefail |
| pipefailを解除する | set +o pipefail |
| 未定義変数参照をエラーにする | set -u(または set -o nounset) |
| 未定義変数にデフォルト値を設定する | ${VAR:-デフォルト値} |
| パイプライン全コマンドの終了コードを確認する | echo "${PIPESTATUS[@]}" |
| 特定のコマンド(n番目)の終了コードを取得する | echo "${PIPESTATUS[n]}"(0始まり) |
| $PIPESTATUSを安全に参照する(上書き防止) | PIPE_STATUS=("${PIPESTATUS[@]}") |
| パイプ失敗時の後始末を確実に実行する | trap cleanup EXIT(cleanup 関数内で rm / ログ記録) |
| エラー行番号と終了コードをログに記録する | trap 'on_error $LINENO' ERR |
| head との組み合わせで SIGPIPE(141)が出る場合 | set +o pipefail で一時無効化、または sed -n '1,Np' で代替 |
| grepのマッチなし(exit 1)を無視する | grep ... || true または set +o pipefail で挟む |
| スクリプト停止の原因をトレースする | set -x で実行コマンドを表示(終了後は set +x) |
| 実行前に構文エラーだけ確認する | bash -n スクリプト名 |
| zshでも同等の機能を使う | ${pipestatus[@]}(小文字) |
$PIPESTATUS と set -o pipefail はどちらか一方だけでは不完全です。set -o pipefail はパイプライン全体の失敗を検知し、$PIPESTATUS はどのコマンドが失敗したかを特定します。この2つを組み合わせることで、「静かな失敗」を確実に検知できる設計になります。既存のバックアップスクリプトがあれば、まずスクリプト冒頭に
set -uo pipefail を追加し、パイプライン実行直後に PIPE_STATUS=("${PIPESTATUS[@]}") を入れることから始めてみてください。パイプラインのエラー処理をさらに深めたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使えるシェルスクリプト設計を体系的に学べます。
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 次のページへ:自作シェルスクリプトをsystemdサービス化して常駐運用する方法|Unitファイル作成・Restart設定・ログ確認
- 前のページへ:シェルスクリプトのセキュリティ設計|クォート徹底・コマンドインジェクション対策・evalを避ける書き方
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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