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

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

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

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

この記事のポイント

・set -e と set -u でコマンド失敗・未定義変数を即検知できる
・trap でシグナル受信時・スクリプト終了時の後処理を自動実行できる
・一時ファイルの自動削除・エラーログ出力を trap に集約するのが定石
・EXIT シグナルを使えば「正常終了でも異常終了でも後処理」が保証される


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

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

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

たとえば、次のようなスクリプトを想定してください。

#!/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/ にファイルがなかった)、gzipscprm -rf は何もないまま実行されます。特に rm -rf の対象がグロブ展開に失敗したときに何が起こるか、考えるだけで怖いですね。

現場で起きがちな「壊れ方」のパターンを整理します。

サイレント失敗:コマンドがエラーを返しても次の処理が走り続ける
未定義変数の展開:$BACKUP_DIR が空のまま rm -rf $BACKUP_DIR/* を実行してしまう
一時ファイルの残留:スクリプトが中断されると /tmp 以下にゴミが残る
ロックファイルの残留:二重起動防止のロックが残り、次回実行が永遠にブロックされる

これらを防ぐのが、set -eset -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

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 はシェルが提供する特殊変数で、現在実行中の行番号を返します。これをエラーハンドラに渡すことで、ログを確認すればどこで失敗したかが一発でわかります。

実践: バックアップスクリプトに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. メール通知を組み込む

mail コマンドまたは mailx が使える環境では、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コマンドの設定と書き方も参考にしてください。

本記事のまとめ

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

課題 対策 設定・構文
コマンド失敗を無視して処理が続く 失敗で即終了 set -e
未定義変数が空文字として展開される 未定義変数をエラーにする set -u
パイプの途中のエラーを見逃す パイプ内の失敗も検知 set -o pipefail
中断時に一時ファイルが残る 終了時の後処理を保証 trap cleanup EXIT
エラー箇所が特定できない 行番号付きエラーログ trap 'error_handler $LINENO' ERR
Ctrl+C で中断してもゴミが残る シグナル受信時も後処理 trap cleanup INT TERM
二重起動でデータが壊れる ロックファイルで排他制御 ロックファイル + trap で確実削除

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

既存スクリプトにこれらを加えるだけでも、サイレントな障害の大半を防げます。本番環境のスクリプトを見直す際の参考にしてください。

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

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

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

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

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

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

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

登録10秒/合わなければ解除3秒 / 詳細はこちら

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

この記事を書いた人

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

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

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