「一時ファイルが大量に残ってしまい、ディスクがあふれた」
現場でシェルスクリプトを運用していると、こういった事態に一度は遭遇します。スクリプト自体は「動いている」のに、エラーが発生してもそのまま処理が継続してしまい、気づいたときには手遅れ。こうした問題の根本原因は、エラー処理が考慮されていない設計にあります。
この記事では、シェルスクリプト trap コマンドとエラー処理の組み合わせについて、現場で即使えるパターンを解説します。
「set -e の落とし穴」「trap の正しい使い方」「バックアップスクリプトへの実践的な組み込み方」「エラーログと通知設計」「trap が効かないケースのトラブルシュート」まで、壊れないスクリプト設計の全体像をカバーします。
この記事のポイント
・set -e と set -u でコマンド失敗・未定義変数を即検知できる
・trap でシグナル受信時・スクリプト終了時の後処理を自動実行できる
・EXIT トラップを使えばスクリプトがどう終了しても後始末が確実に実行される
・ERR トラップ+$LINENO でコマンド失敗の行番号を正確に記録できる
・SIGKILL やサブシェル内では trap が効かない落とし穴も解説する
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトは「壊れる」のか
シェルスクリプトはデフォルトで非常に寛容な動作をします。あるコマンドが失敗しても、スクリプトはそのまま次の行に進みます。これが「壊れる」原因の大半です。スクリプトが終了するケースは大きく3つのパターンがあります。
・正常終了:最終行まで実行完了、または
exit 0 で明示的に終了・エラー終了:コマンドが非ゼロの終了ステータスを返した(
set -e 有効時はそこで停止)・シグナルによる終了:Ctrl+C(SIGINT)や
kill コマンド(SIGTERM)による強制終了trap を使わない場合、どのパターンで終了しても後始末処理は一切実行されません。たとえば、次のようなスクリプトを想定してください。
#!/bin/bash # 危険なスクリプト例(エラー処理なし) mkdir /tmp/backup_work cp /var/data/*.sql /tmp/backup_work/ gzip /tmp/backup_work/*.sql scp /tmp/backup_work/*.gz user@backup-server:/backups/ rm -rf /tmp/backup_work
cp が失敗した場合(例:/var/data/ にファイルがなかった)、gzip、scp、rm -rf は何もないまま実行されます。特に rm -rf の対象がグロブ展開に失敗したときに何が起こるか、考えるだけで怖いですね。現場で起きがちな「壊れ方」のパターンを整理します。
・サイレント失敗:コマンドがエラーを返しても次の処理が走り続ける
・未定義変数の展開:
$BACKUP_DIR が空のまま rm -rf $BACKUP_DIR/* を実行してしまう・一時ファイルの残留:スクリプトが中断されると
/tmp 以下にゴミが残る・ロックファイルの残留:二重起動防止のロックが残り、次回実行が永遠にブロックされる
これらを防ぐのが、
set -e、set -u、そして trap の組み合わせです。set -e / set -u による基本防御
まずはスクリプト冒頭に設定する3つのオプションを確認します。#!/bin/bash set -e # コマンドが0以外の終了ステータスを返したら即終了 set -u # 未定義変数を参照しようとしたらエラーにする set -o pipefail # パイプライン内のいずれかのコマンドが失敗したら失敗扱いにする
1. set -e の効果と注意点
set -e を有効にすると、コマンドが 0 以外の終了ステータスを返した瞬間にスクリプトが終了します。冒頭の危険な例に set -e を加えるだけで、cp 失敗後の rm -rf 暴走を防げます。ただし、意図的に失敗を許容したいコマンドもあります。そういう場合は次の書き方を使います。
# grep で「見つからない」は正常扱いにしたい場合 grep "pattern" /var/log/app.log || true # コマンドの失敗を if で明示的に制御する場合 if ! cp /var/data/*.sql /tmp/backup_work/; then echo "ERROR: ファイルのコピーに失敗しました" >&2 exit 1 fi
2. set -u で未定義変数を早期検出する
set -u は、定義されていない変数を参照しようとするとエラーになります。$BACKUP_DIR のタイポ($BACKUP_Dir 等)を実行前に検出できます。デフォルト値を持たせたい場合は
${変数名:-デフォルト値} の書き方が使えます。# 未定義の場合にデフォルト値を使う BACKUP_DIR="${BACKUP_DIR:-/tmp/backup_work}" # 未定義の場合にエラーメッセージを出して終了する BACKUP_DIR="${BACKUP_DIR:?'BACKUP_DIR が設定されていません'}"
3. pipefail でパイプの失敗を検知する
set -e だけでは不十分なケースがあります。cmd1 | cmd2 のとき、cmd1 が失敗しても cmd2 が成功していれば、パイプライン全体の終了ステータスは 0 になります。set -o pipefail を加えることで、パイプの途中でどれか一つでも失敗したらパイプライン全体が失敗扱いになります。trap コマンドの仕組みと活用パターン
set -e / set -u は「失敗を検知して止める」仕組みです。一方で「止まったときに後処理をする」仕組みが trap です。trap はシグナル受信時やスクリプト終了時に、あらかじめ定義した処理を自動実行するコマンドです。1. trap の基本構文とシグナル一覧
# 書式 trap '実行するコマンドまたは関数' シグナル名 # EXIT シグナル: スクリプトが終了するとき(正常・異常問わず) trap 'cleanup' EXIT # INT シグナル: Ctrl+C による割り込み trap 'echo "中断されました"; cleanup' INT # TERM シグナル: kill コマンドによる終了シグナル trap 'echo "TERM シグナルを受信"; cleanup' TERM # ERR シグナル: コマンドがエラー終了したとき(set -e と組み合わせて使う) trap 'error_handler' ERR # 現在の trap 設定を確認する trap -p # trap を解除してデフォルト動作に戻す trap - SIGINT # シグナルを無視する(空文字列を設定) trap '' SIGINT
| シグナル名 | 番号 | 発生タイミング | 典型的な使い所 |
|---|---|---|---|
| EXIT | — | スクリプト終了時(正常・エラー・シグナル問わず) | 一時ファイル削除・ロック解放 |
| ERR | — | コマンドが非ゼロで終了した時 | エラーログ記録・通知送信 |
| SIGINT | 2 | Ctrl+C によるユーザー割り込み | graceful shutdown・確認プロンプト |
| SIGTERM | 15 | kill コマンドによる終了要求 | 処理中断・後始末 |
| SIGHUP | 1 | 端末切断・デーモンの設定再読み込み | 設定ファイル再読み込み |
| SIGKILL | 9 | OS レベルの強制終了(kill -9) | 捕捉不可(trap では処理できない) |
SIGKILL(番号 9)は OS レベルで強制終了するため、trap で捕捉することはできません。これはシェルの制限ではなく OS の仕様です。
2. cleanup 関数のパターン
後処理は関数にまとめるのがベストプラクティスです。trap に直接コマンドを書くと可読性が下がります。#!/bin/bash set -euo pipefail # 一時ディレクトリ(スクリプト冒頭で定義) WORK_DIR="" # cleanup 関数: 正常終了・異常終了どちらでも実行される cleanup() { local exit_code=$? echo "$(date '+%Y-%m-%d %H:%M:%S') cleanup 開始 (exit_code=${exit_code})" >&2 # 一時ディレクトリが存在する場合のみ削除 if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then rm -rf "${WORK_DIR}" echo "$(date '+%Y-%m-%d %H:%M:%S') 一時ディレクトリを削除: ${WORK_DIR}" >&2 fi exit "${exit_code}" } trap cleanup EXIT # 一時ディレクトリを作成 WORK_DIR=$(mktemp -d) echo "作業ディレクトリ: ${WORK_DIR}" # ここから処理
3. error_handler 関数でエラー行を記録する
ERR シグナルを使うと、どのコマンド・何行目でエラーが起きたかをログに残せます。error_handler() { local exit_code=$? local line_number=$1 echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: スクリプトが失敗しました" >&2 echo " 終了ステータス: ${exit_code}" >&2 echo " 失敗した行: ${line_number}" >&2 } # ERR トラップで行番号を渡す trap 'error_handler $LINENO' ERR
$LINENO はシェルが提供する特殊変数で、現在実行中の行番号を返します。これをエラーハンドラに渡すことで、ログを確認すればどこで失敗したかが一発でわかります。RHEL 9.4 / Rocky Linux 9.4 で動作確認済みです。4. SIGINT・SIGTERM でシグナル別の終了処理を実装する
EXIT trap は「後始末」を、ERR trap は「エラー記録」を担います。シグナル別の trap は「終了理由に応じた処理の切り分け」に使います。終了ステータスの慣例として、128+シグナル番号を使うことで呼び出し元が終了理由を判断できます。#!/bin/bash set -euo pipefail EXIT_REASON="normal" on_sigint() { EXIT_REASON="interrupted" echo "[WARN] Ctrl+C を受信しました — graceful shutdown 中..." >&2 exit 130 # 128 + 2(SIGINT の番号)が慣例 } on_sigterm() { EXIT_REASON="terminated" echo "[WARN] SIGTERM を受信しました — graceful shutdown 中..." >&2 exit 143 # 128 + 15(SIGTERM の番号)が慣例 } cleanup() { case "${EXIT_REASON}" in interrupted) echo "[INFO] ユーザー割り込みによる後処理を実行します。" ;; terminated) echo "[INFO] SIGTERM 受信後の後処理を実行します。" ;; *) echo "[INFO] 通常後処理を実行します。" ;; esac rm -f "/tmp/work_file.$$" } trap on_sigint SIGINT trap on_sigterm SIGTERM trap cleanup EXIT # 本処理 echo "[INFO] 処理開始 (PID: $$)" for i in $(seq 1 10); do echo "[INFO] ステップ ${i}/10..." sleep 1 done echo "[INFO] 処理完了"
$ bash graceful_shutdown.sh [INFO] 処理開始 (PID: 14827) [INFO] ステップ 1/10... [INFO] ステップ 2/10... ^C [WARN] Ctrl+C を受信しました — graceful shutdown 中... [INFO] ユーザー割り込みによる後処理を実行します。 $ echo $? 130
実践: バックアップスクリプトにtrapを組み込む
ここまでの内容を組み合わせた、実際の現場で使えるバックアップスクリプトを作成します。実行環境: RHEL 9.4 / Rocky Linux 9.4 で動作確認済み。
#!/bin/bash # backup_db.sh — MySQL ダンプをリモートサーバーへ転送するバックアップスクリプト # 作成: 2024-01-10 / bash 5.1 以上 set -euo pipefail # ===== 設定 ===== DB_NAME="${DB_NAME:?'DB_NAME が設定されていません'}" DB_USER="${DB_USER:-root}" REMOTE_HOST="${REMOTE_HOST:?'REMOTE_HOST が設定されていません'}" REMOTE_PATH="${REMOTE_PATH:-/backups}" LOG_FILE="/var/log/backup_db.log" LOCK_FILE="/var/run/backup_db.lock" # ===== 変数初期化 ===== WORK_DIR="" TIMESTAMP=$(date '+%Y%m%d_%H%M%S') DUMP_FILE="${DB_NAME}_${TIMESTAMP}.sql.gz" # ===== ログ出力関数 ===== log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "${LOG_FILE}" } log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: $1" | tee -a "${LOG_FILE}" >&2 } # ===== エラーハンドラ ===== error_handler() { local exit_code=$? local line_number=$1 log_error "スクリプトが失敗しました (line: ${line_number}, exit_code: ${exit_code})" } # ===== クリーンアップ関数 ===== cleanup() { local exit_code=$? # 一時ディレクトリを削除 if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then rm -rf "${WORK_DIR}" log "一時ディレクトリを削除: ${WORK_DIR}" fi # ロックファイルを削除 if [[ -f "${LOCK_FILE}" ]]; then rm -f "${LOCK_FILE}" log "ロックファイルを削除: ${LOCK_FILE}" fi if [[ "${exit_code}" -eq 0 ]]; then log "バックアップ正常完了" else log_error "バックアップ異常終了 (exit_code=${exit_code})" fi exit "${exit_code}" } # ===== trap 設定 ===== trap 'error_handler $LINENO' ERR trap cleanup EXIT trap 'log "シグナルを受信しました (INT/TERM) — 中断処理中..."; exit 130' INT TERM # ===== 二重起動防止 ===== if [[ -f "${LOCK_FILE}" ]]; then log_error "前回の処理がまだ実行中か、異常終了しました。${LOCK_FILE} を確認してください" exit 1 fi touch "${LOCK_FILE}" # ===== メイン処理 ===== log "バックアップ開始: DB=${DB_NAME}" # 一時ディレクトリを作成 WORK_DIR=$(mktemp -d) log "作業ディレクトリ: ${WORK_DIR}" # MySQL ダンプ log "mysqldump 実行中..." mysqldump -u "${DB_USER}" "${DB_NAME}" | gzip > "${WORK_DIR}/${DUMP_FILE}" log "ダンプ完了: ${DUMP_FILE} ($(du -sh "${WORK_DIR}/${DUMP_FILE}" | cut -f1))" # リモートへ転送 log "SCP 転送開始: ${REMOTE_HOST}:${REMOTE_PATH}/" scp "${WORK_DIR}/${DUMP_FILE}" "${REMOTE_HOST}:${REMOTE_PATH}/" log "SCP 転送完了"
/etc/cron.d/ に登録して毎日実行した場合、実際のログ出力は次のようになります。# 正常終了時のログ例(/var/log/backup_db.log) 2024-01-15 03:00:01 バックアップ開始: DB=production_db 2024-01-15 03:00:01 作業ディレクトリ: /tmp/tmp.aB3xK9 2024-01-15 03:00:01 mysqldump 実行中... 2024-01-15 03:00:08 ダンプ完了: production_db_20240115_030001.sql.gz (42M) 2024-01-15 03:00:08 SCP 転送開始: backup-server.example.com:/backups/ 2024-01-15 03:00:23 SCP 転送完了 2024-01-15 03:00:23 一時ディレクトリを削除: /tmp/tmp.aB3xK9 2024-01-15 03:00:23 ロックファイルを削除: /var/run/backup_db.lock 2024-01-15 03:00:23 バックアップ正常完了 # mysqldump が失敗したときのログ例 2024-01-15 03:00:01 バックアップ開始: DB=production_db 2024-01-15 03:00:01 作業ディレクトリ: /tmp/tmp.mN7pQ2 2024-01-15 03:00:01 mysqldump 実行中... 2024-01-15 03:00:02 ERROR: スクリプトが失敗しました (line: 62, exit_code: 1) 2024-01-15 03:00:02 一時ディレクトリを削除: /tmp/tmp.mN7pQ2 2024-01-15 03:00:02 ロックファイルを削除: /var/run/backup_db.lock 2024-01-15 03:00:02 ERROR: バックアップ異常終了 (exit_code=1)
$LINENO を渡す意義です。エラーログ出力と通知設計
バックアップスクリプトの障害は「気づいた時には何週間もバックアップが失敗していた」という事態になりがちです。ログを書くだけでなく、異常発生時に通知する仕組みを加えましょう。1. ERR trap とメール通知を組み合わせる
mail コマンドまたは mailx が使える環境では、ERR trap でエラー行番号付きの通知メールを即時送信できます。Postfix が動作しているサーバーでの実装例です。on_error() { local exit_code=$? local line_number=$1 local host host=$(hostname) local subject="[ERROR] ${host}: スクリプトが失敗しました (line: ${line_number})" local body body="Script: $0 Line : ${line_number} Exit : ${exit_code} Time : $(date '+%Y-%m-%d %H:%M:%S')" echo "${body}" | mail -s "${subject}" admin@example.com echo "[ERROR] アラートを送信しました" >&2 } trap 'on_error $LINENO' ERR
cleanup 関数でも終了ステータスを見て通知を送る方法もあります。# cleanup 関数内のエラー時通知部分 cleanup() { local exit_code=$? # ...(一時ファイル削除等)... # 異常終了時のみメール送信 if [[ "${exit_code}" -ne 0 ]]; then local subject="[ERROR] バックアップ失敗: ${DB_NAME} ($(hostname))" local body body=$(tail -20 "${LOG_FILE}") echo "${body}" | mail -s "${subject}" admin@example.com fi exit "${exit_code}" }
2. stderr を標準ログに流す設計
スクリプト全体の stderr をログファイルにリダイレクトしておくと、エラーハンドラで拾えなかった予期せぬメッセージも記録に残ります。# スクリプトの先頭(set -euo pipefail の直後)で設定 exec 2>>"${LOG_FILE}" # または、stdout と stderr の両方をログファイルと端末に出力する exec 1> >(tee -a "${LOG_FILE}") exec 2>&1
3. cron から実行するときの注意点
cron はメールサーバーが設定されていれば、スクリプトの stdout/stderr をメールで送ってきます。ただし、MAILTO="" を設定して cron のメールを無効にしつつ、スクリプト内で独自の通知設計をするほうがコントロールしやすいです。# /etc/cron.d/backup_db MAILTO="" # 環境変数はここで設定する(スクリプト内で ${VAR:?} を使う場合に必要) DB_NAME=production_db DB_USER=dbbackup REMOTE_HOST=backup-server.example.com # 毎日 03:00 に実行 0 3 * * * root /usr/local/sbin/backup_db.sh
trapが効かないケース(トラブルシュート)
trap を設定しても想定どおりに動かないケースがあります。本番運用前に把握しておきたい落とし穴を解説します。1. サブシェル内のtrapは親シェルに影響しない
パイプやコマンド置換($())はサブシェルで実行されます。サブシェル内で設定した trap は、そのサブシェルの中だけで有効であり、親シェルの trap は変更されません。# サブシェル内でのみ有効(親シェルには影響しない) ( trap 'echo [INFO] subshell cleanup' EXIT echo "inside subshell" ) # 出力: inside subshell # 出力: [INFO] subshell cleanup ← サブシェル終了時に実行される # 親シェルの EXIT trap は未設定のまま trap -p EXIT # 出力なし(親シェルには設定されていない)
set -o pipefail と合わせて PIPESTATUS 配列を使うことで、各コマンドの終了ステータスを個別に確認できます。2. SIGKILL(kill -9)は捕捉できない
SIGKILL はカーネルが直接プロセスを終了させるため、trap で捕捉することは原理的に不可能です。# 以下はエラーになる(bash が拒否する) # trap 'echo caught' SIGKILL # bash: trap: KILL: invalid signal specification # 対処法: SIGTERM で graceful shutdown を実装しておき、 # kill -9 は最終手段とする運用ルールを定める
mktemp で作成した一時ディレクトリを EXIT trap で確実に削除する設計にしておくことが基本です。SIGKILL 後もファイルが残ることを前提に、次回起動時の冪等(べきとう)処理で対応するのが現実的です。3. set -e と || の組み合わせで ERR trap が発火しない
set -e は次の状況では非ゼロ終了コードを無視します。この場合、ERR trap も発火しません。・
if 文の条件式: if grep -q "pattern" file; then・
|| の左辺: command || fallback・
&& の右辺で失敗した場合ERR trap を確実に発火させたいなら、
|| による握り潰しを避け、明示的な条件分岐に書き直します。# NG: || を使うと ERR trap が発火しない cp /nonexistent /tmp/dest || echo "コピー失敗、スキップします" # OK: 失敗を許容する場合は明示的に条件分岐する if cp /nonexistent /tmp/dest; then echo "[INFO] コピー成功" else echo "[WARN] コピー失敗。スキップします" fi
|| を安易に使わず if 文で失敗パターンを明示することを強くお勧めします。本記事のまとめ
壊れないシェルスクリプト設計のポイントを表にまとめます。| 課題 | 対策 | 設定・構文 |
|---|---|---|
| コマンド失敗を無視して処理が続く | 失敗で即終了 | set -e |
| 未定義変数が空文字として展開される | 未定義変数をエラーにする | set -u |
| パイプの途中のエラーを見逃す | パイプ内の失敗も検知 | set -o pipefail |
| 中断時に一時ファイルが残る | 終了時の後処理を保証 | trap cleanup EXIT |
| エラー箇所が特定できない | 行番号付きエラーログ | trap 'error_handler $LINENO' ERR |
| Ctrl+C で中断してもゴミが残る | シグナル受信時も後処理 | trap cleanup INT TERM |
| 二重起動でデータが壊れる | ロックファイルで排他制御 | ロックファイル + trap で確実削除 |
| kill -9 後もロックが残る | SIGKILL は捕捉不可。SIGTERM で graceful shutdown を実装する | trap on_sigterm SIGTERM |
| || 使用時に ERR trap が発火しない | || の左辺は set -e の対象外。if 文で明示的に分岐する | if cmd; then ... else ... fi |
「動けばOK」から「壊れない設計」へのステップアップは、3行(
set -euo pipefail)と trap の組み合わせから始まります。既存スクリプトにこれらを加えるだけでも、サイレントな障害の大半を防げます。本番環境のスクリプトを見直す際の参考にしてください。
シェルスクリプト設計をさらに深めたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使える設計パターンから、テスト・デプロイまで体系的に学べます。
Linux無料マニュアルを受け取る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 次のページへ:シェルスクリプトでバックアップ自動化を作る実践|cronとログ設計まで
- 前のページへ:expectコマンドでLinuxの対話型処理を自動化する方法|パスワード入力・SSHログイン・telnetの自動操作実践例
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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