Linux & IT ノート

堅牢なシェルスクリプト:set -euo pipefail とエラー処理

管理人 約15分で読めます

前回は関数と引数処理を学び、再利用しやすいスクリプトの書き方を身につけました。今回はその仕上げとして、エラー処理と堅牢性を扱います。

動くスクリプトと「安全に動くスクリプト」には大きな差があります。手元での動作確認はパスしても、想定外の入力や環境でコマンドが失敗したとき、素朴なスクリプトは黙って処理を続けてしまいます。その結果、本来止まるべきところで止まらず、誤ったデータを書き込んだりファイルを消したりします。

素朴なスクリプトが危険な理由

具体例を見てみましょう。

#!/bin/bash

cp important.txt /backup/
rm important.txt
echo "バックアップ完了"

/backup/ ディレクトリが存在しない、あるいは権限がなければ cp は失敗します。しかし bash は既定でエラーが起きても次の行を実行するため、コピーに失敗した直後に rm でオリジナルを削除してしまいます。

未定義変数の問題も同様です。

#!/bin/bash

echo "削除対象: $TAREGT_DIR"   # タイポ(TAREGT → TARGET)
rm -rf "$TAREGT_DIR"/*

TAREGT_DIR は未定義なので空文字として展開され、rm -rf /* に化けます。bash の既定では未定義変数を空文字として扱うため、タイポがそのまま実行されてしまいます。

これらの問題を防ぐのが set -euo pipefail です。

set -euo pipefail の意味と効果

set はシェルオプションを切り替える組み込みコマンドです。-e-u-o pipefail の3つを組み合わせて使います。

set -e:エラーで即終了

コマンドが0以外の終了ステータスを返した瞬間にスクリプトを終了させます。

set -e

cp important.txt /backup/   # ここで失敗したら
rm important.txt             # ← 実行されない

-e があれば、cp が失敗した時点でスクリプトが止まるため、rm は実行されません。

set -u:未定義変数をエラーにする

未定義の変数を参照したときにエラーとして扱います。

set -u

echo "$TAREGT_DIR"   # unbound variable エラーで終了

-u があれば、先ほどの rm -rf 問題は変数参照の時点で止まります。

set -o pipefail:パイプ途中の失敗を検知

-e だけではパイプライン途中の失敗を捕捉できません。

set -e

grep "pattern" huge.log | sort | uniq -c

grep がパターンを見つけられず終了ステータス1を返しても、-e だけではパイプ全体の終了ステータスは最後のコマンド(uniq)の値になるため、失敗が見えなくなります。-o pipefail を付けると、パイプラインのどれかが失敗した時点でパイプ全体の終了ステータスが非ゼロになります。

まとめて宣言する

スクリプトの先頭に一行書くのが標準的な書き方です。

#!/usr/bin/env bash
set -euo pipefail

-euo pipefail-e -u -o pipefail を省略した形です。以降は「安全なデフォルト」として動作します。

set -e の落とし穴

-e は万能ではありません。いくつかの例外的な挙動を知っておく必要があります。

条件式の中では -e が適用されない

set -e

# これはエラーにならない(条件式の文脈)
if grep -q "pattern" file.txt; then
  echo "見つかった"
fi

# || の右辺も同様
grep -q "pattern" file.txt || echo "見つからなかった"

if/while の条件部分や ||/&& の右辺は、終了ステータスが非ゼロでも -e によるスクリプト終了が起きません。これは仕様通りの動作で、条件チェックに使うコマンドを書く場所だからです。

関数内での挙動

関数を呼び出している文脈が条件式の中だと、関数内のエラーも -e でキャッチされません。

check() {
  false    # 失敗するコマンド
  echo "ここも実行される"
}

# 条件式の文脈なので check 内の false が -e を無効化
if check; then echo "ok"; fi

複雑な関数を書くときは、このあたりの挙動を意識しておく必要があります。

trap でクリーンアップ処理を書く

スクリプトが途中で止まったとき、一時ファイルや途中状態が残ると困ります。trap を使うと、スクリプトが終了するタイミングでクリーンアップ処理を実行できます。

EXIT トラップ(後始末の定番)

#!/usr/bin/env bash
set -euo pipefail

TMPFILE=$(mktemp)   # 安全な一時ファイルを作成

cleanup() {
  rm -f "$TMPFILE"
}
trap cleanup EXIT   # EXIT: 正常終了・エラー終了・Ctrl+C どれでも実行される

# 一時ファイルを使った処理
some-command > "$TMPFILE"
process "$TMPFILE"

echo "処理完了"
# スクリプト終了時に cleanup が呼ばれ $TMPFILE が削除される

trap cleanup EXIT は EXIT シグナルに対してフックを設定します。正常終了・エラーによる中断・kill シグナルによる終了(SIGTERMなど)どれでも cleanup が呼ばれるため、「一時ファイルを必ず消す」用途に最適です。ただし SIGKILLkill -9)と SIGSTOP だけは trap で捕捉できないため、これらで強制終了された場合は cleanup が実行されません。移植性を高めつつ割り込み時のクリーンアップをより確実にしたい場合は、trap cleanup EXIT INT TERM のように INT・TERM も併せて指定するとよいでしょう(純粋な POSIX sh では EXIT trap がシグナルで走らない実装もあるためです)。

ERR トラップ(エラー発生時のログ)

on_error() {
  local exit_code=$?
  local line_number=$1
  echo "エラー: 行 ${line_number} で終了ステータス ${exit_code}" >&2
}
trap 'on_error $LINENO' ERR

ERR トラップはエラーが起きた行番号($LINENO)とともにエラーを記録したいときに使います。ただし EXITERR を両方設定する場合、cleanup が2回呼ばれないよう注意が必要です。シンプルなスクリプトであれば EXIT だけで十分なことが多いです。

終了ステータスの活用

$? で終了ステータスを確認する

コマンドの成否は終了ステータス(0=成功、非0=失敗)で表されます。直前のコマンドの終了ステータスは $? で参照できます。

cp source.txt dest.txt
if [[ $? -ne 0 ]]; then
  echo "コピー失敗" >&2
  exit 1
fi

ただし、if command の形で直接コマンドを条件にするほうがシンプルで読みやすいです。

if ! cp source.txt dest.txt; then
  echo "コピー失敗" >&2
  exit 1
fi

exit で終了コードを明示する

関数やスクリプトを終了させるときは、終了コードを明示します。

if [[ ! -f "$1" ]]; then
  echo "エラー: ファイル '$1' が見つかりません" >&2
  exit 1   # 呼び出し元に失敗を伝える
fi

exit 0 は成功、exit 1 は汎用的な失敗を意味します。より細かい区別が必要な場合は 2〜125 の値を使います(126 以降はシェルが予約しているため使わない)。

安全な実践:クオート・IFS・mktemp

変数は必ずダブルクォートで囲む

-u を付けていても、クオートの省略は予期しない動作を招きます。

filename="my file.txt"

# 悪い例:スペースで単語分割され2つの引数になる
rm $filename

# 良い例:1つの引数として正しく渡る
rm "${filename}"

変数展開は "${var}" の形で常にダブルクォートで囲む習慣をつけましょう。波括弧 {} は必須ではありませんが、変数名の境界が明確になるため付けておくと読みやすいです。

IFS について一言

IFS(Internal Field Separator)はシェルが単語分割を行うときの区切り文字を定義する変数です。デフォルトはスペース・タブ・改行です。for line in $(cat file) のような書き方でファイルを読むと IFS による単語分割が起きてしまいます。ファイルを行単位で処理するなら while IFS= read -r line を使うのが正しいパターンです。

# 良い例:行単位で安全に読む
while IFS= read -r line; do
  echo "処理: $line"
done < input.txt

mktemp で安全な一時ファイルを作る

TMPFILE=$(mktemp)          # /tmp/tmp.XXXXXXXXXX のような一意なパス
TMPDIR=$(mktemp -d)        # ディレクトリを作りたい場合

/tmp/myscript.tmp のような固定名は、同名ファイルがすでに存在した場合の上書きやシンボリックリンク攻撃(TOCTOU)のリスクがあります。mktemp を使えば一意なファイル名が保証され、こうしたリスクを避けられます。

デバッグ:set -x で実行トレース

スクリプトが期待通り動かないとき、set -x を使うと実行されたコマンドが + 付きで標準エラー出力に表示されます。

set -x   # トレース開始
cp source.txt dest.txt
set +x   # トレース終了(特定の範囲だけ有効にしたい場合)
+ cp source.txt dest.txt

スクリプト全体をデバッグしたい場合は、実行時に -x フラグを渡す方法も使えます。

bash -x myscript.sh

set -euo pipefailset -x を組み合わせると、どのコマンドで止まったかが一目でわかります。本番では set -x をコメントアウトし、デバッグ時だけ有効にするのが一般的です。

堅牢なスクリプトのテンプレート

これまでの内容をまとめた雛形です。新しいスクリプトを書くときの出発点として使えます。

#!/usr/bin/env bash
set -euo pipefail

# --- 定数・変数 ---
SCRIPT_NAME="$(basename "$0")"
TMPFILE=""

# --- クリーンアップ ---
cleanup() {
  [[ -n "$TMPFILE" ]] && rm -f "$TMPFILE"
}
trap cleanup EXIT

# --- ヘルプ ---
usage() {
  cat <<EOF
使い方: ${SCRIPT_NAME} [オプション] <引数>

オプション:
  -h    このヘルプを表示する

EOF
  exit 0
}

# --- メイン処理 ---
main() {
  # 引数チェック
  if [[ $# -lt 1 ]]; then
    echo "エラー: 引数が必要です" >&2
    usage
  fi

  local input="$1"

  if [[ ! -f "$input" ]]; then
    echo "エラー: ファイル '${input}' が見つかりません" >&2
    exit 1
  fi

  TMPFILE=$(mktemp)

  # 処理
  cp "${input}" "${TMPFILE}"
  # ... 以降の処理 ...

  echo "完了"
}

# オプション解析
while getopts "h" opt; do
  case "$opt" in
    h) usage ;;
    *) usage ;;
  esac
done
shift $((OPTIND - 1))

main "$@"

この雛形のポイントは次の通りです。

  • #!/usr/bin/env bash で bash を明示(/bin/sh は POSIX sh になる場合があり、bash 固有の構文が使えない)
  • set -euo pipefail を先頭近くに置く
  • cleanup + trap ... EXIT で一時ファイルを確実に後始末
  • 処理を main 関数にまとめ、末尾で main "$@" と呼ぶ(関数定義より前に呼び出さない)
  • エラーメッセージはすべて >&2

まとめ

  • set -euo pipefail の3つを組み合わせてスクリプトの安全性を底上げする
    • -e: コマンド失敗で即終了
    • -u: 未定義変数をエラーに
    • -o pipefail: パイプ途中の失敗を検知
  • set -e は条件式や || の文脈では適用されない点に注意
  • trap cleanup EXIT で一時ファイルなどを確実に後始末する
  • 終了ステータスは if command; then で直接評価し、明示的に exit 1 で失敗を伝える
  • 変数は "${var}" でクオート、一時ファイルは mktemp で作成する
  • set -x / bash -x でデバッグトレースを有効にする

これで連載「シェルスクリプト入門」は完結です。3回を通じて、bash スクリプトの基礎から実用的なテクニックまでを一通り学びました。

  • #1 変数・条件分岐・ループ:変数の扱い方とクオート、if 文・for/while ループでスクリプトの基本構造を作った
  • #2 関数・引数処理local 変数・戻り値・getopts によるオプション解析で、再利用できるスクリプトの書き方を身につけた
  • #3 堅牢なエラー処理set -euo pipefailtrap・終了ステータスの活用で、本番環境でも安心して使えるスクリプトを書く技術を押さえた

ここで紹介したテンプレートを手元に置き、実際のタスクを自動化しながら使っていくことで定着します。「まず動くものを書き、set -euo pipefail を加えて安全にする」という順序で進めるのが、最初の一歩として取り組みやすいかと思います。