現場で壊れないシェルスクリプト設計|エラー処理とtrapで安定運用する書き方

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > 現場で壊れないシェルスクリプト設計|エラー処理とtrapで安定運用する書き方
「バックアップスクリプトが途中で失敗していたのに、ずっと気づかなかった」
「一時ファイルが大量に残ってしまい、ディスクがあふれた」
「設定変更スクリプトが途中で止まって、サーバーが中途半端な状態になった」

現場でシェルスクリプトを運用していると、こういった事態に一度は遭遇します。スクリプト自体は「動いている」のに、エラーが発生してもそのまま処理が継続してしまい、気づいたときには手遅れ。あるいは途中で止まっても後始末が走らず、環境が壊れたまま残る。こうした問題の根本原因は、エラー処理が考慮されていない設計にあります。

この記事では、シェルスクリプト trap コマンドとエラー処理の組み合わせについて、現場で即使えるパターンを解説します。
「set -e の落とし穴」「trap の正しい使い方」「ロールバック設計(完了済みステップを逆順に取り消す方法)」「バックアップスクリプトへの実践的な組み込み方」「エラーログと通知設計」「trap が効かないケースのトラブルシュート」まで、壊れないスクリプト設計の全体像をカバーします。

動作確認環境: RHEL 9.4 / Rocky Linux 9.4(bash 5.1.8)

この記事のポイント

・set -e と set -u でコマンド失敗・未定義変数を即検知できる
・trap でシグナル受信時・スクリプト終了時の後処理を自動実行できる
・EXIT トラップを使えばスクリプトがどう終了しても後始末が確実に実行される
・ROLLBACK_STACK 配列に取り消し関数を積めばステップ単位のロールバックが実現できる
・ERR トラップ+$LINENO でコマンド失敗の行番号を正確に記録できる
・SIGKILL やサブシェル内では trap が効かない落とし穴も解説する


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

なぜシェルスクリプトは「壊れる」のか

シェルスクリプトはデフォルトで非常に寛容な動作をします。あるコマンドが失敗しても、スクリプトはそのまま次の行に進みます。これが「壊れる」原因の大半です。

スクリプトが終了するケースは大きく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

trap に設定できる主なシグナルは次のとおりです。

シグナル名 番号 発生タイミング 典型的な使い所
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] 処理完了"

Ctrl+C で中断した場合の出力例(RHEL 9.4 / bash 5.1.8 で確認):

$ bash graceful_shutdown.sh [INFO] 処理開始 (PID: 14827) [INFO] ステップ 1/10... [INFO] ステップ 2/10... ^C [WARN] Ctrl+C を受信しました — graceful shutdown 中... [INFO] ユーザー割り込みによる後処理を実行します。 $ echo $? 130

終了ステータス 130(128 + SIGINT 番号 2)が返るため、呼び出し元スクリプトや監視ツールが「ユーザーが中断した」と判断できます。

5. ロールバック設計パターン: 完了済みステップを逆順に取り消す

set -e のおかげでスクリプトはエラー箇所で止まりますが、それまでに実行したファイル作成・ディレクトリ構成・設定変更はサーバーに残ります。複数ステップを持つスクリプトが途中で止まると、環境が中途半端な状態になり、手動クリーンアップが必要です。

この問題を解決するのが「ロールバック設計」です。完了したステップごとに取り消し関数を配列(スタック)に積んでおき、エラー発生時に逆順で実行します。

#!/bin/bash set -euo pipefail # ロールバック関数のスタック ROLLBACK_STACK=() SCRIPT_STATUS="failed" # ロールバック関数を登録する register_rollback() { ROLLBACK_STACK+=("$1") } # スタックを逆順に実行する do_rollback() { trap - ERR # ロールバック中の ERR 再帰呼び出しを防ぐ [[ ${#ROLLBACK_STACK[@]} -eq 0 ]] && return 0 echo "[ROLLBACK] ${#ROLLBACK_STACK[@]}ステップをロールバックします" >&2 local i for (( i=${#ROLLBACK_STACK[@]}-1; i>=0; i-- )); do echo "[ROLLBACK] 実行: ${ROLLBACK_STACK[$i]}" >&2 "${ROLLBACK_STACK[$i]}" || echo "[ROLLBACK] 警告: ${ROLLBACK_STACK[$i]} が失敗しました" >&2 done echo "[ROLLBACK] 完了" >&2 } # 正常終了・異常終了を統合管理する EXIT ハンドラ on_exit() { if [[ "${SCRIPT_STATUS}" == "failed" ]]; then do_rollback echo "[ERROR] スクリプトが失敗しました" >&2 else echo "[INFO] スクリプトが正常に完了しました" fi } # エラー発生時に行番号を記録する ERR ハンドラ on_error() { local exit_code=$? local line_num=$1 echo "[ERROR] ${line_num}行目でエラーが発生しました(終了コード: ${exit_code})" >&2 } trap 'on_exit' EXIT trap 'on_error $LINENO' ERR # ===== メイン処理(ここからステップを書く)===== # ステップ1: 作業ディレクトリを作成する WORK_DIR="/tmp/myapp-setup-$$" mkdir -p "${WORK_DIR}" rollback_step1() { rm -rf "${WORK_DIR}"; } register_rollback rollback_step1 echo "[STEP1] 作業ディレクトリを作成しました: ${WORK_DIR}" # ステップ2: 設定ファイルをコピーする cp /etc/hosts "${WORK_DIR}/hosts.bak" rollback_step2() { rm -f "${WORK_DIR}/hosts.bak"; } register_rollback rollback_step2 echo "[STEP2] 設定ファイルをコピーしました" # ステップ3: 存在しないファイルをコピー(わざと失敗させるテスト) cp /tmp/not-exist.conf "${WORK_DIR}/myapp.conf" rollback_step3() { rm -f "${WORK_DIR}/myapp.conf"; } register_rollback rollback_step3 echo "[STEP3] この行は実行されない" # 全ステップ成功 SCRIPT_STATUS="success" trap - ERR

実行結果(RHEL 9.4 で確認):

$ bash rollback-demo.sh [STEP1] 作業ディレクトリを作成しました: /tmp/myapp-setup-12345 [STEP2] 設定ファイルをコピーしました cp: cannot stat '/tmp/not-exist.conf': No such file or directory [ERROR] 42行目でエラーが発生しました(終了コード: 1) [ROLLBACK] 2ステップをロールバックします [ROLLBACK] 実行: rollback_step2 [ROLLBACK] 実行: rollback_step1 [ROLLBACK] 完了 [ERROR] スクリプトが失敗しました # ステップ1で作ったディレクトリが削除されているか確認 $ ls /tmp/myapp-setup-12345 ls: cannot access '/tmp/myapp-setup-12345': No such file or directory

ステップ3のロールバック関数はまだ登録されていないため、ステップ2→ステップ1の順(逆順)でロールバックが実行されました。/tmp/myapp-setup-12345 が確実に削除されています。

設計のポイントを整理します。

・ROLLBACK_STACK 配列:ロールバック関数名を後ろに追加(+=)することで実行順が保存される
・register_rollback:ステップ完了直後に呼び出す。文字列でなく関数名を渡すので eval 不要
・do_rollback:配列を末尾から先頭へループ処理し、後に完了したステップから順に取り消す
・SCRIPT_STATUS 変数:成功時に "success" へ更新し、EXIT 時にロールバックするか判定する
・trap - ERR を do_rollback 冒頭で解除:ロールバック中の ERR 再帰呼び出しを防ぐ

実践: バックアップスクリプトに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)

「line: 62」という情報が残るので、スクリプトを開いて62行目を確認するだけで原因箇所が特定できます。これが $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

cron の環境変数制限については、crontabコマンドの設定と書き方も参考にしてください。

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

ERR trap に気づいてほしい処理を書く場合は、|| を安易に使わず if 文で失敗パターンを明示することを強くお勧めします。

4. ロールバックが2回実行される: ROLLBACK_DONE フラグで防ぐ

trap 'on_error $LINENO' ERR と trap on_exit EXIT を両方設定すると、ERR ハンドラ内で exit を呼んだとき EXIT ハンドラも走り、ロールバックが2回実行されることがあります。

# 対策: 二重実行防止フラグを do_rollback 内に入れる ROLLBACK_DONE=false do_rollback() { if [[ "${ROLLBACK_DONE}" == "true" ]]; then return 0 # 2回目の呼び出しは何もしない fi ROLLBACK_DONE=true trap - ERR # ロールバック中の再帰を防ぐ # ...ロールバック処理... }

あるいは、前節のテンプレートのように EXIT ハンドラ1つに統合(SCRIPT_STATUS 変数で正常・異常を判定)することで、ERR trap では行番号の記録だけ行い、ロールバックは EXIT でのみ実行するシンプルな設計にする方法もあります。

5. ロールバック関数の中でさらにエラーが起きる

ロールバック中に rm -rf や systemctl stop が失敗すると、on_error が再帰的に呼ばれて無限ループになるリスクがあります。

# do_rollback の冒頭で ERR トラップを解除するのが鉄則 do_rollback() { trap - ERR # これを最初に書く [[ ${#ROLLBACK_STACK[@]} -eq 0 ]] && return 0 local i for (( i=${#ROLLBACK_STACK[@]}-1; i>=0; i-- )); do # || true は書かない(警告は残す) "${ROLLBACK_STACK[$i]}" || echo "[ROLLBACK] 警告: ${ROLLBACK_STACK[$i]} が失敗しました" >&2 done }

ロールバック関数の失敗は || echo "警告" で記録しつつ続行するのが正しい設計です。|| true では失敗が記録されないため、運用上問題の原因追跡が困難になります。

本記事のまとめ

壊れないシェルスクリプト設計のポイントを表にまとめます。

課題 対策 設定・構文
コマンド失敗を無視して処理が続く 失敗で即終了 set -e
未定義変数が空文字として展開される 未定義変数をエラーにする set -u
パイプの途中のエラーを見逃す パイプ内の失敗も検知 set -o pipefail
中断時に一時ファイルが残る 終了時の後処理を保証 trap cleanup EXIT
エラー箇所が特定できない 行番号付きエラーログ trap 'error_handler $LINENO' ERR
Ctrl+C で中断してもゴミが残る シグナル受信時も後処理 trap cleanup INT TERM
複数ステップが途中で止まり環境が壊れる 完了済みステップを逆順ロールバック ROLLBACK_STACK 配列 + register_rollback
二重起動でデータが壊れる ロックファイルで排他制御 ロックファイル + trap で確実削除
kill -9 後もロックが残る SIGKILL は捕捉不可。SIGTERM で graceful shutdown を実装する trap on_sigterm SIGTERM
|| 使用時に ERR trap が発火しない || の左辺は set -e の対象外。if 文で明示的に分岐する if cmd; then ... else ... fi
ロールバックが2回実行される ROLLBACK_DONE フラグで二重実行を防ぐ do_rollback 冒頭で trap - ERR を解除

「動けばOK」から「壊れない設計」へのステップアップは、3行(set -euo pipefail)と trap の組み合わせから始まります。

既存スクリプトにこれらを加えるだけでも、サイレントな障害の大半を防げます。複数ステップを持つ設定スクリプトにはロールバック設計を加えることで、本番環境での安全性がさらに高まります。本番環境のスクリプトを見直す際の参考にしてください。

シェルスクリプト設計をさらに深めたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使える設計パターンから、テスト・デプロイまで体系的に学べます。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
Linux無料マニュアルを受け取る >>

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。