終了コード設計|失敗の定義と伝播

運用と安全設計

この記事の狙い

Bash スクリプトの失敗の定義を明確化し、終了コード(exit status)を一貫して扱えるようにします。
0/非0 の基本から、予約コード、関数・パイプライン・サブシェルでの伝播、set -e/pipefail/trap ERR の連携、CLI のコード設計指針
までを、コピペで使える最小実装とともに示します。

前提と対象

  • Bash 4+(set -Eeuo pipefail 推奨)
  • 終了コードは 0–255(8bit)。0=成功、非0=失敗(種類を表現)

TL;DR(最小実装・コピペ可)

#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail   # パイプ途中の失敗も検知
trap 'code=$?; echo "ERROR ($code) at ${BASH_SOURCE[0]}:${LINENO}" >&2' ERR

# 終了コードの規約(例)
E_USAGE=2   # 使い方エラー
E_IO=3      # I/O/外部依存
E_TMP=4     # 一時/ロック
E_DATA=5    # 入力データ不正

usage(){ echo "Usage: $(basename "$0") <file>" >&2; }

main(){
  local f="${1-}"            || return $E_USAGE
  [[ -n "$f" ]] || { usage; return $E_USAGE; }
  [[ -r "$f" ]] || { echo "cannot read: $f" >&2; return $E_IO; }

  mapfile -t lines <"$f"     || return $E_IO
  (( ${#lines[@]} )) || { echo "empty input" >&2; return $E_DATA; }

  printf '%s\n' "${#lines[@]}"   # 正常系は stdout
}

main "$@" || exit $?             # 戻り値を最終 exit に

設計の要点(原則)

  1. 0=成功 / 非0=失敗を徹底。正常系で非0を返さない
  2. 失敗の分類をコードにマッピング(定数で可読化)
  3. stdout=結果 / stderr=メッセージ(ログは stderr)
  4. 伝播の仕組みを固定|| return/exitset -o pipefail、必要に応じて trap ERR
  5. 関数は return、スクリプトは exitexit を関数から直接呼ばない(ライブラリ可用性)

予約・慣例の終了コード

  • 0 成功
  • 1 一般的な失敗(未分類)
  • 2 使い方/引数エラー(多くの CLI 慣例)
  • 126 実行権なし/実行不可
  • 127 コマンド未発見
  • 128+n シグナル n により終了(130=SIGINT、137=SIGKILL 等)
  • 255 範囲外(exit 256 は 0 に巻き戻らず 0–255 に丸め)

予約・慣例は上書きしない。自前の分類は 3–125 あたりで定義するのが無難。

失敗の伝播パターン

単独コマンド

cp -- "$src" "$dst" || { echo "copy failed" >&2; return $E_IO; }
  • 「失敗したら即 return/exit」を明示set -e 任せにしない

関数チェーン

step1 || return $?
step2 || return $?
  • 直前の失敗コードをそのまま返すなら return $?
  • 分類を付け替えるなら定数で返す

パイプライン

set -o pipefail
grep -E '^\d+$' "$f" | awk '{s+=$1} END{print s}' || return $E_DATA
  • pipefail がないと最後のコマンドのコードだけが残る
  • 個々のコードが必要なら ${PIPESTATUS[@]} を参照
cmd1 | cmd2 | cmd3
st=("${PIPESTATUS[@]}")      # 例: 0 2 0
(( st[0]==0 && st[1]==0 && st[2]==0 )) || return $E_DATA

サブシェル / コマンド置換

sum="$(awk '{s+=$1} END{print s}' <"$f")" || return $E_DATA
( cd "$dir" && make ) || return $E_IO
  • $(...) 内の失敗は外側の $? で検知
  • ( ... )子プロセス。副作用(cd 等)が親に影響しない

バックグラウンドと wait

taskA & p1=$!
taskB & p2=$!
ok=0
wait "$p1" || ok=1
wait "$p2" || ok=1
(( ok==0 )) || exit $E_IO
  • wait の終了コードで個別の失敗を拾う

trap ERR と -E

set -E            # ERR を関数/置換/サブシェルにも伝播
trap 'echo "ERR ($?) at $BASH_SOURCE:$LINENO" >&2' ERR
  • ログには便利。ただし制御は明示の || return/exit が基本
  • trap ERR成功/失敗の分岐を置き換えるものではない

CLI の終了コード設計(実務例)

例:小さな CLI の分類

E_OK=0
E_USAGE=2
E_NOTFOUND=3    # 入力対象が無い
E_INVALID=4     # フォーマット不正
E_IO=5
  • README/–help に明記(「Exit codes: 0/2/3/4/5 …」)
  • 使い方エラーは常に 2(grep など多くの CLI に倣う)
  • 外部依存(ネットワーク/権限/ロック)は E_IO に集約し、ログで詳細

サブコマンド別けとの相性

  • サブコマンドごとに内部分類を持っても、最終的に共通分類へ畳み込む
  • 機械連携(make/systemd/cron/CI)では非0だけで十分な場合が多い。詳細はログへ

ログと出力の分離(再掲)

  • **結果(成功時の値)**は stdout。人間向け説明/エラーは stderr
  • 上流から下流へ結果をパイプする設計で、stderr 混入は致命傷
result="$(do_thing)" || exit $E_IO
printf '%s\n' "$result"          # ← 結果

“コピペ可”テストブロック(最小)

#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

E_USAGE=2; E_IO=3; E_DATA=5

sut='
set -Eeuo pipefail; set -o pipefail
E_USAGE=2; E_IO=3; E_DATA=5
main(){ local f="${1-}"||return 2; [[ -n "$f" ]]||return 2; [[ -r "$f" ]]||return 3; mapfile -t L <"$f"||return 3; (( ${#L[@]} ))||return 5; printf "%s\n" "${#L[@]}"; }
main "$@" || exit $?
'

# 1) 使い方エラー
bash -c "$sut" >/dev/null 2>&1; st=$?; [[ $st -eq $E_USAGE ]] || { echo "usage:$st"; exit 1; }

# 2) I/O エラー
bash -c "$sut" _no_such_ >/dev/null 2>&1; st=$?; [[ $st -eq $E_IO ]] || { echo "io:$st"; exit 1; }

# 3) データ不正
: >"$TMP/empty"
bash -c "$sut" "$TMP/empty" >/dev/null 2>&1; st=$?; [[ $st -eq $E_DATA ]] || { echo "data:$st"; exit 1; }

# 4) 正常(行数を返す)
printf 'a\nb\n' >"$TMP/in"
out="$(bash -c "$sut" "$TMP/in")"; st=$?
[[ $st -eq 0 && "$out" = "2" ]] || { echo "ok:$st/$out"; exit 1; }

echo "PASS"

失敗しやすい点(アンチパターン)

  • set -e 依存:条件式・パイプ・|| true などで落ち方が不定
    → 重要箇所は 明示の || return/exitpipefail
  • 関数から exit:ライブラリ再利用性を殺す
    return で上位へ委ね、エントリポイントだけ exit $?
  • stdout にエラー文:下流が壊れる
    stderr へ(>&2
  • 予約コードの再定義127 などを自前分類に使う
    3–125 を割当

運用メモ(実務)

  • CI/cron/systemd で非0検知→ログ参照の運用を前提にする
  • 並列・分散ジョブは「部分失敗をどう扱うか」を決め、最終集約コードを設計(例: 1件でも失敗→非0)
  • trap '...' EXIT で一時資源を必ず掃除しつつ、失敗時のみ詳細ログを出す

互換性と移植性

  • Bash 以外(dash 等)は PIPESTATUSERR 伝播が異なる
  • POSIX sh 互換を狙う場合は、パイプラインの各段で明示判定し、中間ファイルも検討

セキュリティと安全設計

  • 失敗時のメッセージに機密情報を含めない
  • 外部コマンドの終了コードをそのまま上流へ(隠蔽しない)
  • trap異常終了時の後始末(ロック解除・一時ファイル削除)を徹底

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする