【Bash】エラーハンドリング完全設計|set -euo pipefailとtrapによる安全なスクリプト終了処理

スクリプト設計

本番サーバーで自動化シェルスクリプトを運用していて、 「途中でエラーが起きたのに次の行が実行されてデータが消えた」 、あるいは 「Ctrl+Cで中断したのに一時ファイルやロックファイルが残り続けて後続ジョブが全滅した」 という大惨事を経験したことはありませんか?

システム運用・自動化の現場において 「手作業は負け」 ですが、 「エラーで暴走するスクリプトはもっと危険」 です。本番スクリプトの品質を決定づけるのは、正常系の処理ではなく 「異常系(エラーハンドリング)がどれだけ堅牢に設計されているか」 に他なりません。

この記事では、Bashスクリプトを本番運用に耐えうる堅牢な状態に仕上げるための 「set -euo pipefail」 の完全攻略と、 「trap」 コマンドによる安全な終了処理・リソース自動解放の鉄則を、現場目線で余すところなく解説します。

【コピペで即動く】全スクリプトに導入すべき堅牢化ヘッダーテンプレート

まずは、本番運用のすべてのBashスクリプト冒頭にそのままコピペして使える 「堅牢化ヘッダーテンプレート」 を提示します。これを取り入れるだけで、未定義変数の暴走、パイプライン途中のエラー見落とし、異常終了時の一時ファイル取り残しを 100%自動で防止 できます。

#!/usr/bin/env bash
# ==============================================================================
# 堅牢化Bashスクリプト・実務用ヘッダーテンプレート
# ==============================================================================
set -euo pipefail
IFS=$'nt'

# --- ロガー関数の定義(標準エラー出力へ分離) ---
log_info() { echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $*" ; }
log_error() { echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2 ; }

# --- エラー発生時のデバッグトラップ(行番号と失敗コマンドを通知) ---
trap 'log_error "スクリプトが異常終了しました (行番号: ${LINENO}, コマンド: "${BASH_COMMAND}", 終了コード: $?)"' ERR

# --- 終了時の一時リソース自動クリーンアップ(正常・異常・中断すべてに対応) ---
cleanup() {
 local exit_code=$?
 log_info "終了処理(クリーンアップ)を実行します (終了コード: ${exit_code})"
 if [[ -n "${TMP_DIR:-}" && -d "${TMP_DIR}" ]]; then
 rm -rf "${TMP_DIR}"
 log_info "一時作業ディレクトリを削除しました: ${TMP_DIR}"
 fi
}
trap cleanup EXIT

# --- 安全な一時作業ディレクトリの作成 ---
TMP_DIR="$(mktemp -d -t myapp.XXXXXX)"
log_info "一時作業ディレクトリを作成しました: ${TMP_DIR}"

# ==============================================================================
# ここからメイン処理
# ==============================================================================
log_info "メイン処理を開始します..."

# 処理例
echo "安全に実行中..." > "${TMP_DIR}/work.txt"

log_info "すべての処理が正常に完了しました。"

このテンプレートを導入するだけで、万が一スクリプトの途中でエラーが発生したり、管理者が Ctrl+C で強制終了した場合でも、 EXIT トラップによって一時ディレクトリが確実に自動破棄されます。


なぜデフォルトのBashスクリプトは危険なのか?

エラーが起きても次の行を実行してしまう危険性(実例: cd失敗後のrm -rf)

Bashのデフォルト動作における最大の落とし穴は、 「途中のコマンドがどれほど重大なエラーで失敗しても、何食わぬ顔で次の行を実行し続ける」 という仕様です。

この仕様が引き起こす最も恐ろしい実例が、ディレクトリ移動後のファイル一括削除です。

#!/usr/bin/env bash
# 【極めて危険なコード例】デフォルト設定のまま実行した場合
WORKDIR="/var/tmp/app_cache_dir"

# 指定ディレクトリへ移動(何らかの理由でこのディレクトリが存在せず失敗したとする)
cd "${WORKDIR}"

# 一時ファイルを全削除しようとする
rm -rf *

もし権限不足やディレクトリ名の変更によって cd "${WORKDIR}" が失敗した場合、何が起きるでしょうか?

スクリプトはエラーメッセージを1行出力するだけで停止せず、 「元のディレクトリ(最悪の場合はルートやホームディレクトリ)」に留まったまま rm -rf * を実行 します。これにより、消してはいけない重要ファイルが一瞬で抹消されてしまいます。これこそが、多くのインフラエンジニアが背筋を凍らせてきた事故の典型パターンです。

終了ステータス($?)の仕組みと判定

LinuxおよびBashにおいて、すべてのコマンドは終了時に 「終了ステータス(終了コード / Exit Code)」 を返します。直前に実行されたコマンドの終了ステータスは、特殊変数 $? に格納されます。

  • 0 : 正常終了(Success)
  • 1〜255 : 異常終了(何らかのエラー)

手動でエラー判定を行う場合、通常は以下のように $? をチェックします。

# コマンド実行
curl -sSf "https://api.example.com/data.json" -o "/tmp/data.json"

# 終了ステータスの手動判定
if [[ $? -ne 0 ]]; then
 echo "データのダウンロードに失敗しました。" >&2
 exit 1
fi

しかし、スクリプト内のすべてのコマンドの直後に if [[ $? -ne 0 ]] を手動で書き続けるのは現実的ではありません。コードが冗長化し、書き忘れも発生します。終了ステータスの詳細な設計原則については 終了コード設計|失敗の定義と伝播 でも詳しく解説していますが、 「エラー検知は手動ではなくシェルに自動化させる」 のが現場の鉄則です。


スクリプト冒頭に書くべきおまじない「set -euo pipefail」徹底解説

スクリプトを自動停止させ、安全性を一撃で担保するのが set -euo pipefail です。現場では「Bashの安全装置」「スクリプト冒頭のおまじない」と呼ばれますが、中身を正確に理解せずに使うと予期せぬ中断に悩まされることになります。それぞれのオプションの挙動を深く理解しておきましょう。

【早見表】setオプションの機能とエラー検知の仕組み

オプション 正式名称 機能・検知対象 未設定時のリスク
-e errexit 終了ステータスが非ゼロのコマンドがあったら直ちに中断 エラーを無視して後続コマンドが暴走する
-u nounset 未定義の変数を参照した際にエラーとして中断 未定義変数が空文字扱いされ、誤削除やパラメータ不整合が起きる
-o pipefail pipefail パイプライン途中の失敗を全体の終了コードに反映 最後のコマンドさえ成功すればパイプ途中の失敗が見逃される

set -e: コマンドが失敗したら直ちにスクリプトを中断する

set -e を指定すると、実行したコマンドが 非ゼロ(エラー)で終了した瞬間にスクリプト全体の実行が停止 します。

#!/usr/bin/env bash
set -e

echo "処理1: 開始"
false # ここで非ゼロ(1)を返すため、スクリプトは即座に停止する
echo "処理2: この行は決して実行されない"

先ほどの cd "${WORKDIR}" && rm -rf * の例でも、 set -e が有効になっていれば cd が失敗した瞬間にスクリプトが強制終了するため、 rm -rf * が誤爆する事故を確実に防ぐことができます。

なお、 if 文の条件判定式( if command; then ... )や while ループの判定式、 || 演算子で処理がハンドリングされているコマンドでは、 set -e による自動停止は作動しません。

set -u: 未定義変数の参照をエラーとして検知する

Bashはデフォルトでは、 未定義の変数(代入されていない変数)を参照してもエラーを出さず、単に「空文字列」として展開 します。これが致命的なバグを生む温床です。

#!/usr/bin/env bash
# set -u なしの場合
TARGET_DIR="/opt/myapp/temp"
# 変数名をタイポ(TARGET_DR になっている)
rm -rf "${TARGET_DR}/*"

上記の場合、 ${TARGET_DR} は未定義なので空文字となり、実際に実行されるコマンドは rm -rf /* に化けてしまいます。

set -u を有効にしておけば、未定義変数を参照した瞬間に以下のようにエラーを出力して停止します。

./deploy.sh: line 4: TARGET_DR: unbound variable

【Tips: 未定義の可能性がある変数を安全に扱う方法】
環境変数などで「未定義の場合はデフォルト値を使いたい」ケースでは、パラメータ展開 ${VAR:-default} または ${VAR:-} を使用します。これを使えば set -u のチェックを安全にすり抜けることができます。

# 未定義の場合は空文字として扱う
LOG_FILE="${USER_LOG_FILE:-/var/log/app.log}"

set -o pipefail: パイプ途中のエラーを見逃さない

set -e だけでは防げない代表的な罠が 「パイプライン(|)でのエラー」 です。

Bashのデフォルトでは、複数のコマンドをパイプで繋いだ場合、 「一番最後のコマンドの終了ステータス」のみがパイプライン全体のステータス とみなされます。

#!/usr/bin/env bash
set -e

# 存在しないファイルをcatしてgrepする
cat /path/to/missing_file.log | grep "ERROR"
echo "ステータス: $?"

この場合、 cat は「ファイルが見つからない」とエラー(ステータス1)を出しますが、後続の grep は「標準入力が空で何も見つからなかった」として処理を終えます(マッチなしなら1、もし他の出力と繋がって正常終了すれば0)。

set -o pipefail を設定すると、 「パイプライン内のいずれか1つでもコマンドが失敗した場合、その非ゼロステータスが全体の終了ステータスとして採用」 されるようになります。これにより、データの抽出元が失敗しているのに後続の整形処理だけが走って空ファイルが生成されるといったサイレントエラーを根絶できます。

一時的にエラー無視したい場合の回避テクニック(command || true)

set -e を導入した際に最もよく直面するのが、 「失敗しても処理を継続させたいコマンド」 があるケースです。たとえば、対象プロセスが存在しない場合の pgrep や、すでにディレクトリが存在するかもしれない mkdir などです。

この場合、以下のいずれかの回避テクニックを使います。

# パターン1: || true または || : を付与(最も一般的で簡潔)
grep "TARGET" /var/log/app.log > /tmp/matches.txt || true

# パターン2: 局所的に set +e で解除して再設定
set +e
grep "TARGET" /var/log/app.log > /tmp/matches.txt
local_status=$?
set -e
log_info "grepの終了コード: ${local_status} (処理を継続します)"

基本的には command || true を使うのがコードの可読性を損なわず、最もシンプルでおすすめです。setオプションのさらなる応用は Bashのsetコマンドを使いこなす方法とオプション一覧 | set -euo pipefail の効き方 も参照してください。


異常終了時も安心!trapコマンドによる後処理自動化

スクリプトが途中でエラー停止したり、強制終了されたときに問題になるのが 「残骸の処理」 です。作成した一時ファイル、排他制御用のロックファイル(PIDファイル)、起動したバックグラウンドプロセスが残り続けると、次回実行時に二重起動エラーやディスク逼迫を引き起こします。

この後処理を完全に自動化するのが組み込みコマンド trap です。

trapの基本構文: trap [実行する処理] [シグナル(EXIT / ERR / INT / TERM)]

trap は、指定したシグナルをシェルが受信した際に、あらかじめ登録しておいた処理(コマンドまたは関数)を自動実行する機能です。詳細な仕様は trap – シグナル受信時や終了時に実行するハンドラを登録する(シェル組み込み) で解説しています。

# 基本構文
trap '実行する処理' シグナル名...

実務で頻繁に監視対象とするシグナル・疑似シグナルは以下の4つです。

シグナル名 番号 発生タイミング・契機 用途
EXIT 0 (疑似) スクリプトが終了するとき(正常終了、エラー終了、exit呼び出し問わず) 一時ファイルの削除、ロック解放、終了ログ
ERR – (疑似) コマンドが非ゼロステータスで失敗したとき(set -e相当の契機) エラー発生行番号・失敗コマンドの通知
INT 2 (SIGINT) ユーザーがキーボードから Ctrl+C を送信したとき 強制中断時のクリーンアップと終了
TERM 15 (SIGTERM) 外部から kill コマンド等で終了要求が送られたとき プロセスキル時の正常な後始末

特に重要なのが EXIT 疑似シグナル」 です。 EXIT を指定すると、正常終了はもちろん、 set -e によるエラー中断、途中の exit 1 での終了など、 「いかなる理由でスクリプトが終了しても100%必ず実行」 されます。

実務テンプレート: スクリプト終了時に一時ディレクトリ/ロックファイルを確実に消す

実務で頻出する「一時ディレクトリの安全な自動削除」と「排他ロックファイルの解放」を両立した決定版テンプレートです。一時ファイルの作成には安全なランダム名を生成する mktemp コマンド を組み合わせます。

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

LOCK_FILE="/var/run/myapp.lock"
WORK_DIR=""

# --- クリーンアップ関数(必ず冪等に書く) ---
cleanup() {
 local exit_code=$?
 # ロックファイルの削除
 if [[ -f "${LOCK_FILE}" ]]; then
 rm -f "${LOCK_FILE}"
 fi
 # 一時ディレクトリの削除
 if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then
 rm -rf "${WORK_DIR}"
 fi
 exit "${exit_code}"
}

# EXITシグナルに後始末関数をバインド
trap cleanup EXIT INT TERM

# --- 多重起動防止のロック獲得 ---
if ! (set -C; echo "$$" > "${LOCK_FILE}") 2>/dev/null; then
 echo "[ERROR] すでに別のインスタンスが実行中です: ${LOCK_FILE}" >&2
 exit 1
fi

# --- 安全な作業ディレクトリの作成 ---
WORK_DIR="$(mktemp -d -t myapp_work.XXXXXX)"

# メイン処理
echo "バッチ処理を実行中..."
sleep 2

【現場の設計ポイント】
クリーンアップ関数の中身は、何度呼ばれても問題が起きない 「冪等性(べきとうせい)」 を保つように書きます( if [[ -d ... ]] で存在チェックを行ってから rm するなど)。

エラー発生時に行番号とコマンドを出力するデバッグトラップ

set -e を有効にしていると、スクリプトが途中で止まった際に「一体何行目で、何のコマンドが失敗したのか」が分からず調査に時間がかかることがあります。

これを瞬時に解決するのが trap ... ERR によるデバッグハンドラです。

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

# エラー発生時に行番号、実行コマンド、終了ステータスを出力する
trap 'echo "[ERROR] $(date +%T) - エラー発生! 行: ${LINENO} | コマンド: "${BASH_COMMAND}" | ステータス: $?" >&2' ERR

echo "ステップ1: 準備完了"
# 存在しないディレクトリへのアクセス(意図的エラー)
ls /non_existent_directory_xyz
echo "ステップ2: 完了"

実行結果:

ステップ1: 準備完了
ls: /non_existent_directory_xyz にアクセスできません: そのようなファイルやディレクトリはありません
[ERROR] 14:30:15 - エラー発生! 行: 8 | コマンド: "ls /non_existent_directory_xyz" | ステータス: 2

特殊変数 ${LINENO} (失敗行番号)と ${BASH_COMMAND} (直前に実行されたコマンド)を組み合わせることで、ログを見るだけで即座に根本原因を特定できるようになります。


現場で役立つエラー通知・ログ記録のベストプラクティス

標準エラー出力(>&2)とロガー関数(log_error / log_info)の設計

スクリプト内でエラーメッセージを出力する際、単に echo "エラーです" と書いてしまうと 「標準出力(stdout)」 に出力されてしまいます。

スクリプトの実行結果をパイプで別のコマンドに渡したり、ファイルにリダイレクトしている場合、エラーメッセージが正常データの中に混入して二次災害を引き起こします。エラーメッセージは必ず 「標準エラー出力(stderr)」 に流すのが鉄則です。

# NG: 標準出力に出してしまう(リダイレクト時にデータと混ざる)
echo "[ERROR] 設定ファイルが見つかりません"

# OK: 標準エラー出力(FD 2)へリダイレクト
echo "[ERROR] 設定ファイルが見つかりません" >&2

実務では、以下のようなロガー関数をスクリプト共通部品として定義しておくことを強く推奨します。

#!/usr/bin/env bash

# タイムスタンプ付きロガー関数
log_info() {
 echo -e "33[32m[INFO]33[0m $(date '+%Y-%m-%d %H:%M:%S') - $*"
}

log_warn() {
 echo -e "33[33m[WARN]33[0m $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}

log_error() {
 echo -e "33[31m[ERROR]33[0m $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}

# 使用例
log_info "バックアップ処理を開始します"
log_warn "ディスク空き容量が少なくなっています (残 15%)"
log_error "データベースのダンプに失敗しました"

なお、動的なコマンド構築を安全に行う際、危険な eval コマンドを無闇に使うとインジェクション攻撃や予期せぬ構文エラーを引き起こします。動的実行の安全対策については bash eval コマンドの使い方|シェルスクリプトでの活用例と注意点 も合わせて確認してください。


まとめ & 関連記事

Bashスクリプトを安全かつ確実に運用するためのエラーハンドリングの要点を振り返りましょう。

  • set -euo pipefail をスクリプト冒頭に記述する : コマンド失敗、未定義変数、パイプ途中のエラーを自動検知して即座に安全停止させる。
  • 一時的なエラー許容は「command || true」を使う : set -e の安全性を維持したまま、失敗が許容されるコマンドを安全に実行する。
  • trap … EXIT で後始末を完全自動化する : 正常終了でも強制中断でも、一時ファイルやロックファイルを100%確実に削除する。
  • エラーメッセージは必ず標準エラー出力(>&2)へ流す : ログ関数を共通化し、行番号通知( trap ... ERR )と組み合わせて調査コストを最小化する。

「とりあえず動くスクリプト」から「本番で安心して放置できるスクリプト」への脱却は、これら数行の安全設計から始まります。ぜひ日々の開発・運用のテンプレートとしてご活用ください。

関連記事

Bash玄

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

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

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

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

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

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

Bash玄をフォローする

コメント