Bash 入門

Bash のエラーハンドリングとロギング

他のソフトウェアと同様に、Bashスクリプトも実行プロセス中にエラーに遭遇することがあります。これらのエラーを効果的に処理し、スクリプトの実行アクティビティを記録することは、信頼性が高くメンテナンスの容易なオートメーションツールの作成において極めて重要です。堅牢なスクリプトは、潜在的な問題を予見し、失敗に対してエレガントに対応し、トラブルシューティングや監査のための明確な記録を提供します。

1. 終了ステータス (Exit Codes) の理解

Bashで実行されるすべてのコマンドは、実行の成功または失敗を示す数字である「終了ステータス(終了コード)」を返します。慣例として、終了ステータス 0 は成功を意味し、ゼロ以外の値はエラーが発生したことを意味します。ゼロ以外の値は通常、特定の種類のエラーを表しますが、具体的な意味はコマンド自体に依存することが多いです。

直前に実行されたコマンドの終了ステータスを取得するには、特殊なバリアブルである $? を使用します。

# 例 1:成功するコマンド
ls /tmp
echo "'ls /tmp' の終了ステータスは: $?" # 出力: 0 (/tmp ディレクトリが存在すると仮定)

# 例 2:失敗するコマンド
ls /nonexistent_directory
echo "'ls /nonexistent_directory' の終了ステータスは: $?" # 出力: 1 または 2 (システムや ls のバージョンに依存)

# 例 3:カスタム終了ステータスを返すシンプルなスクリプト
#!/bin/bash
# script.sh
echo "オペレーションを実行中..."
exit 10 # スクリプトはステータスコード 10 で終了

# スクリプトを実行して終了ステータスを確認
./script.sh
echo "'script.sh' の終了ステータスは: $?" # 出力: 10

1.1 終了ステータスの影響

スクリプトは通常、複数のコマンドを連鎖させて実行します。呼び出しチェーン内のいずれかのコマンドが失敗すると、後続のコマンドが無効なデータや誤った前提の下で実行を続けてしまい、より深刻なエラーや予期しない挙動を引き起こす可能性があります。終了ステータスをチェックすることで、スクリプトはこれらの失敗に反応し、問題が雪だるま式に悪化するのを防ぐことができます。

例えば、スクリプトがファイルをダウンロードし、その後に処理を試みるシナリオを想定します。ダウンロードが失敗した場合、処理ステップでエラーが発生したり、ゴミデータが生成されたりする可能性が高いです。

#!/bin/bash
# download_and_process.sh
DOWNLOAD_URL="http://example.com/some_file.zip" # 実際の URL またはテスト用のダミー URL に置き換えてください
LOCAL_FILE="downloaded_file.zip"

echo "$DOWNLOAD_URL をダウンロード中..."
wget -q "$DOWNLOAD_URL" -O "$LOCAL_FILE"

# wget コマンドの終了ステータスをチェック
if [ $? -ne 0 ]; then
    echo "エラー:$DOWNLOAD_URL のダウンロードに失敗しました"
    exit 1 # エラーステータスでスクリプトを終了
fi

echo "ダウンロード成功。ファイルを処理中..."
# 処理プロセスをシミュレート (例:解凍して成功を確認)
unzip -q "$LOCAL_FILE" -d "extracted_data"

if [ $? -ne 0 ]; then
    echo "エラー:ファイルの解凍に失敗しました。"
    rm -f "$LOCAL_FILE" # 不完全なダウンロードファイルをクリーンアップ
    exit 2 # 別のエラーコードで終了
fi

echo "ファイル処理成功。クリーンアップ中。"
rm -f "$LOCAL_FILE"
rm -rf "extracted_data"
exit 0 # 正常終了

このスクリプトは wget の終了ステータスをチェックする方法を示しています。wget が失敗した場合(URLの間違い、ネットワーク障害など)、スクリプトはエラーメッセージを表示して即座にステータスコード 1 で終了し、存在しない、あるいは不完全なファイルに対して unzip コマンドが実行されるのを防ぎます。

2. set -e:エラー発生時の即時終了

各コマンドの後に手動で $? をチェックすると、スクリプトが冗長になり読みづらくなります。Bashの set -e オプションは非常に強力な機能で、いずれかのコマンドが失敗(ゼロ以外の終了ステータスを返した場合)した際に、自動的かつ即座にスクリプトを終了させます。これにより、一部しか実行されていないコマンドによる予期しない結果を防ぐことができます。

#!/bin/bash
# script_with_set_e.sh
set -e # コマンドが非ゼロステータスで終了した場合、即座にスクリプトを停止

echo "ステップ 1: ディレクトリを作成中..."
mkdir my_temp_dir

echo "ステップ 2: ディレクトリに移動中..."
cd my_temp_dir

echo "ステップ 3: ファイルを作成中..."
touch new_file.txt

echo "ステップ 4: 確実に失敗するコマンドを試行中..."
# /nonexistent_path が存在しないため、このコマンドは失敗します
cp new_file.txt /nonexistent_path/

echo "cp コマンドが失敗した場合、この行は実行されません。"

echo "ステップ 5: クリーンアップ..."
# cp が失敗した場合、これらのクリーンアップコマンドも実行されません
cd ..
rm -rf my_temp_dir
echo "スクリプトが正常に完了しました (cp が失敗した場合、このメッセージは見えないはずです)。"

script_with_set_e.sh を実行すると、スクリプトはディレクトリを作成し、移動し、ファイルを作成しますが、cp コマンドが失敗した時点で即座に終了します。その後の echo やクリーンアップコマンドはスキップされます。

2.1 set -e の例外

set -e は非常に便利ですが、特定の条件下ではスクリプトを終了させません:

  • 条件文(if/while)内のコマンド: コマンドが ifwhile 条件の一部である場合、その失敗は set -e をトリガーしません。なぜなら、if/while ステートメント自体がそのコマンドの成功・失敗を判定するために存在しているからです。
set -e
if ! ls /nonexistent_dir; then # ls は失敗しますが、set -e はトリガーされません
    echo "ディレクトリが存在しないケースを処理しました。"
fi
echo "ディレクトリ不在の処理後、スクリプトは続行されます。"
  • && または || リスト内のコマンド: && または || リストの最後のコマンドが失敗した場合にのみ、set -e がトリガーされます。
set -e
# 最初の false は失敗しますが、後ろの true が成功するため、
# || 構造全体としては「成功」とみなされます。スクリプトはここでは終了しません。
false || true
echo "false || true の後もスクリプトは続行されます。"

# ここでは false が && リストの最後のコマンドであり、失敗します。
# スクリプトはここで終了します。
true && false
echo "この行は実行されません。"
  • 返り値が明示的に無視されるコマンド:  出力が /dev/nullリダイレクトされていたり、コマンド置換 $(command) で結果をバリアブルに代入している場合などが該当します。
set -e
# コマンド置換:ls は失敗しますが、その終了ステータスは無視されます。
# なぜなら出力がキャプチャされているからです。スクリプトは終了しません。
RESULT=$(ls /nonexistent_dir 2>&1)
echo "失敗したコマンドの結果: $RESULT"
echo "コマンド置換の後もスクリプトは続行されます。"
  • パイプライン内の最後のコマンド以外: パイプライン(cmd1 | cmd2)内のいずれかのコマンド(最後以外)が失敗しても、set -e はトリガーされません。パイプライン全体の終了ステータスを決定するのは、最後のコマンドの終了ステータスだからです。これを解消するには set -o pipefail を使用する必要があります。

3. set -o pipefail:パイプラインのエラー処理強化

デフォルトでは、Bashのパイプライン(command1 | command2 | command3)は、最後のコマンドの終了ステータスを返します。これにより、前のコマンドのエラーが隠蔽されてしまうことがあります。例えば、command1 が失敗しても、command2(入力を必要としない、あるいは空入力を許容するもの)が成功すれば、パイプライン全体は成功として報告されます。

set -o pipefail オプション(通常は set -e と組み合わせて set -euo pipefail のように記述されます)はこの挙動を変更します。pipefail を有効にすると、パイプラインの終了ステータスは、非ゼロで終了した最後(最も右側)のコマンドのステータスになります。すべてのコマンドが成功した場合のみ、ステータスは 0 になります。これにより、パイプライン内のいずれかのコマンドが失敗すればパイプライン全体が失敗とみなされ、set -e によって安全にスクリプトを終了させることができます。

#!/bin/bash
# script_with_pipefail.sh

# エラー時に終了し、pipefail を有効化
set -euo pipefail

echo "pipefail の挙動をデモ中..."

# 例 1: cat は失敗するが、grep は空入力の処理に成功する。
# pipefail がなければ、このパイプラインは成功してしまう。
# pipefail 有効時は、cat が失敗したためこの全体も失敗となる。
echo "--- パイプライン 1 (cat /nonexistent | grep pattern) ---"
cat /nonexistent_file.txt | grep "pattern"

# cat が失敗するため、この行は実行されないはずです。
echo "pipefail 有効時に cat が失敗した場合、この行は表示されません。"

echo "--- パイプライン 2 (echo valid | grep -q pattern) ---"
# このパイプラインは成功します。
echo "some text" | grep -q "text"
echo "パイプライン 2 が成功したため、この行は実行されます。"

echo "--- パイプライン 3 (echo valid | grep -q missing) ---"
# grep が失敗 (1 を返す) します。
# grep が最後のコマンドかつ失敗したため、スクリプトはここで終了します。
echo "some text" | grep -q "missing"
echo "パイプライン 3 が失敗したため、この行は実行されません。"

echo "スクリプト完了 (ここまでは到達しないはずです)。"

4. trap コマンドによるクリーンアップ

trap コマンドを使用すると、スクリプトが特定のシグナルを受信した際に、コマンドや関数を実行させることができます。これはクリーンアップ操作(一時ファイルの削除など)において極めて重要で、スクリプトがエラー、ユーザーによる中断(Ctrl+C)、あるいはその他のシグナルで予期せず終了した場合でも、クリーンアップを確実に実行できます。

キャプチャ可能な一般的なシグナル

  • EXIT: スクリプトが終了する際に(成功・失敗問わず)実行されます。
  • ERR: コマンドが非ゼロステータスで終了した際に実行されます(set -e が有効な場合、ERREXIT の前にトリガーされます)。
  • INT: スクリプトが割り込みシグナル(Ctrl+Cなど)を受信した際に実行されます。
  • TERM: スクリプトが終了シグナル(kill コマンドなど)を受信した際に実行されます。
#!/bin/bash
# trap_example.sh
set -euo pipefail
TEMP_DIR=$(mktemp -d) # 一時ディレクトリを作成

# クリーンアップ関数を定義
cleanup() {
    echo "シグナルをキャッチしました!クリーンアップを実行中..."
    if [ -d "$TEMP_DIR" ]; then
        rm -rf "$TEMP_DIR"
        echo "一時ディレクトリを削除しました: $TEMP_DIR"
    fi
}

# EXIT, ERR, INT, TERM シグナル発生時に cleanup 関数を呼び出すよう登録
trap cleanup EXIT
trap cleanup ERR
trap cleanup INT
trap cleanup TERM

echo "一時ディレクトリを作成しました: $TEMP_DIR"
touch "$TEMP_DIR/my_temp_file.txt"
echo "一時ファイルを作成しました: $TEMP_DIR/my_temp_file.txt"

# 重いタスクをシミュレート
echo "重要タスクを実行中、3秒待機..."
sleep 3

# エラーをシミュレート (ERR と EXIT が順にトリガーされます)
echo "エラーを発生させます..."
non_existent_command # このコマンドは失敗します

echo "'set -e' とエラーのため、この行は実行されません。"
# EXIT トラップは、エラーの有無にかかわらず最終的に実行されます。

4.1 trap と関数 vs インラインコマンド

trap 内に直接インラインコマンドを記述することも可能ですが、複雑なクリーンアップ作業の場合は、専用の関数を定義した方がスクリプトの可読性とメンテナンス性が向上します。

# インライン trap の例 (複雑なタスクでは読みづらい)
trap 'echo "スクリプト終了。$TEMP_FILE を削除中"; rm -f "$TEMP_FILE"' EXIT

5. スクリプト活動の記録 (ロギング)

ロギングはスクリプト実行履歴の追跡を可能にし、デバッグ、監査、モニタリングにおいて不可欠です。メッセージを単にコンソールに echo するのではなく、出力をログファイルリダイレクトすることで、重要な情報が適切に保存され、後で参照できるようになります。

5.1 基本的なファイルへのログ出力

最もシンプルな形式は、スクリプトの標準出力と標準エラー出力をファイルにリダイレクトすることです。

#!/bin/bash
# basic_logger.sh
LOG_FILE="/var/log/my_script.log" # または /tmp などユーザーが書き込み可能なパス
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")

# すべてのスクリプト出力 (stdout と stderr) をログファイルにリダイレクトし、追記
exec > >(tee -a "$LOG_FILE") 2>&1

echo "[$TIMESTAMP] スクリプトを開始しました。"
echo "[$TIMESTAMP] タスク 1 を実行中..."
# 成功するコマンドの例
ls /tmp
echo "[$TIMESTAMP] タスク 2 を実行中..."
# 失敗する可能性のあるコマンドの例
# cp /nonexistent_source /tmp/destination
echo "[$TIMESTAMP] スクリプトの実行が完了しました。"

exec > >(tee -a "$LOG_FILE") 2>&1 という強力なコマンドは、以下のことを行っています:

  1. exec >(...): スクリプト全体の標準出力をプロセス置換 (...) にリダイレクトします。
  2. tee -a "$LOG_FILE": tee コマンドは標準入力を読み込み、標準出力と指定されたファイルの両方に書き込みます。-a は追記モードを意味します。
  3. 2>&1: 標準エラー出力(ファイル記述子 2)を標準出力(ファイル記述子 1)と同じ場所にリダイレクトし、エラーメッセージもログに記録されるようにします。

5.2 関数による構造化ログの実装

より仕様に沿ったログを得るために、各メッセージにタイムスタンプ、ログレベル(INFO, WARN, ERROR)、スクリプト名、プロセスID(PID)などのプレフィックスを付与する専用の関数を作成できます。

#!/bin/bash
# structured_logger.sh
LOG_FILE="/var/log/my_structured_script.log"
SCRIPT_NAME=$(basename "$0")
PID=$$ # スクリプトのプロセス ID

# ログディレクトリが存在し、書き込み可能であることを確認
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE" || { echo "エラー:ログファイル $LOG_FILE を作成できません。終了します。" >&2; exit 1; }

# コア・ロギング関数
log_message() {
    local LEVEL="$1"
    local MESSAGE="$2"
    local TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
    echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE"
}

# 各ログレベルのラッパー関数
log_info() {
    log_message "INFO" "$1"
}

log_warn() {
    log_message "WARN" "$1" >&2 # 警告は stderr にも送信
}

log_error() {
    log_message "ERROR" "$1" >&2 # エラーは stderr にも送信
    exit 1 # デフォルトで ERROR 時にスクリプトを終了
}

# --- メインロジック ---
log_info "スクリプトを開始しました。"
TEMP_DIR=$(mktemp -d)
log_info "一時ディレクトリを作成しました: $TEMP_DIR"

cleanup() {
    local exit_status=$? # log_error などが $? を変える前に元のステータスをキャプチャ
    log_info "一時ディレクトリをクリーンアップ中: $TEMP_DIR"
    if [ -d "$TEMP_DIR" ]; then
        rm -rf "$TEMP_DIR"
        log_info "一時ディレクトリを削除しました: $TEMP_DIR"
    fi
    exit "$exit_status"
}
trap cleanup EXIT

log_info "$TEMP_DIR 内にファイルを作成中..."
touch "$TEMP_DIR/file1.txt" "$TEMP_DIR/file2.log"
if [ $? -ne 0 ]; then
    log_error "$TEMP_DIR 内でのファイル作成に失敗しました。"
fi
log_info "ファイルの作成に成功しました。"

log_warn "潜在的な問題:ディスク使用率が高くなっています。"
# log_error "データベース接続に失敗しました。" # テスト時はコメント解除

log_info "スクリプトが完了しました。"

5.3 ログローテーション (Log Rotation)

時間の経過とともにログファイルは肥大化し、ディスク容量を圧迫したり閲覧しづらくなったりします。ログローテーションは、古いログをアーカイブ、圧縮し、最終的に削除してシステムをクリーンに保つプロセスです。Bash自体にこの機能はありませんが、Linuxでは通常 logrotate などのユーティリティで処理されます。

logrotate と連携させるには、スクリプトが固定のパスに書き込みを続けるようにするだけです。logrotate は設定ファイル(例:/etc/logrotate.d/your_script)を読み込み、ファイルの管理を引き継ぎます。

設定例:

/var/log/my_structured_script.log {
    daily         # 毎日実行
    missingok     # ファイルがなくてもエラーにしない
    rotate 7      # 7日分保持
    compress      # 圧縮する
    notifempty    # 空ならローテーションしない
    create 0640 root adm # 権限と所有者を指定
}

6. 実戦ケース:システム管理スクリプトの強化

これまで学んだエラーハンドリングとロギングの技術を、日常のシステム管理スクリプトに応用してみましょう。バックアップ、パッケージの更新、一時ファイルのクリーンアップを行うスクリプトを想定します。

6.1 元のシステム管理スクリプト(未強化版)

#!/bin/bash
# sys_admin_script_v1.sh
echo "システム管理タスクを開始します。"

echo "バックアップを実行中..."
# rsync -avz /data /mnt/backup_drive/

echo "パッケージを更新中..."
sudo apt update && sudo apt upgrade -y

echo "一時ファイルをクリーンアップ中..."
sudo rm -rf /tmp/*

echo "システム管理タスク完了。"

6.2 エラーハンドリングとロギングを組み込んだ強化版スクリプト

#!/bin/bash
# sys_admin_script_v2_robust.sh

# --- 設定エリア ---
LOG_FILE="/var/log/sys_admin_script.log"
BACKUP_SOURCE="/var/www/html"
BACKUP_DEST="/mnt/backup_drive/web_data_$(date +%Y%m%d%H%M%S)"

# --- 初期化 ---
set -euo pipefail # 厳格なエラーチェックモード
SCRIPT_NAME=$(basename "$0")
PID=$$

mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE" || { echo "エラー:$LOG_FILE を作成できません。終了。" >&2; exit 1; }

# --- ログ関数 ---
log_message() {
    local LEVEL="$1"
    local MESSAGE="$2"
    local TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
    if [[ "$LEVEL" == "ERROR" || "$LEVEL" == "WARN" ]]; then
        echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE" >&2
    else
        echo "[$TIMESTAMP] [$SCRIPT_NAME:$PID] [$LEVEL] $MESSAGE" | tee -a "$LOG_FILE"
    fi
}

log_info() { log_message "INFO" "$1"; }
log_warn() { log_message "WARN" "$1"; }
log_error() {
    log_message "ERROR" "$1"
    exit 1
}

# --- クリーンアップ ---
cleanup() {
    local exit_status=$?
    if [ "$exit_status" -ne 0 ]; then
        log_error "スクリプトが異常終了しました。ステータスコード: $exit_status"
    else
        log_info "スクリプトが正常に終了しました。"
    fi
}
trap cleanup EXIT

# --- メインタスク ---
log_info "システム管理タスクを開始します。"

# 1. バックアップ
log_info "'$BACKUP_SOURCE' から '$BACKUP_DEST' へのバックアップを試行中..."
if ! mountpoint -q "$(dirname "$BACKUP_DEST")"; then
    log_error "バックアップ先ディレクトリが見つからないかアクセスできません。中断します。"
fi

mkdir -p "$BACKUP_DEST"
cp -r "$BACKUP_SOURCE" "$BACKUP_DEST/" # デモ用。本番は rsync 推奨
log_info "バックアップが完了しました: '$BACKUP_DEST'"

# 2. パッケージ更新
log_info "システムパッケージを更新中..."
sudo apt update
sudo apt upgrade -y
log_info "システムパッケージが更新されました。"

# 3. 一時ファイル削除
log_info "/tmp/ 内の一時ファイルをクリーンアップ中..."
sudo find /tmp -mindepth 1 -delete
log_info "一時ファイルのクリーンアップが完了しました。"

この強化版スクリプトのポイント:

  1. set -euo pipefail: 非常に厳格なエラー防止基準を確立。
  2. 構造化ログ: コンソールとファイルの両方に出力し、エラー時には stderr を使用。
  3. trap EXIT: スクリプトの成否を問わず、最終的な実行結果をログに記録。
  4. 安全チェック: バックアップなどの重要操作の前に mountpoint を確認し、早期にエラーを阻止。
  5. 安全な削除: rm -rf よりも安全な find -delete を採用。