T-CREATOR

Shell Script の set -e が招く事故を回避:pipefail・サブシェル・条件分岐の落とし穴

Shell Script の set -e が招く事故を回避:pipefail・サブシェル・条件分岐の落とし穴

シェルスクリプトで「エラーが発生したら即座にスクリプトを停止させたい」と考えたとき、多くの開発者が set -e を利用します。この設定は一見便利に思えますが、実は予期しない動作を招く落とし穴がいくつも存在するのです。

特にパイプライン処理、サブシェル、条件分岐といった場面では、set -e が期待通りに機能せず、エラーを見逃してしまうケースがあります。本記事では、set -e の基本から、pipefail オプション、サブシェルや条件分岐における注意点まで、実践的な例とともに詳しく解説していきますね。

この記事を読むことで、シェルスクリプトのエラー処理を確実に行い、本番環境での予期せぬ事故を未然に防げるようになるでしょう。

背景

シェルスクリプトにおけるエラー処理の重要性

シェルスクリプトは、サーバーのセットアップ、デプロイ作業、バッチ処理など、インフラ運用の現場で広く使われています。これらの処理では、一つのコマンドが失敗しても次のコマンドが実行され続けてしまうと、データの不整合や予期しないシステム状態を招く危険性があります。

例えば、データベースのバックアップスクリプトで以下のような処理を考えてみましょう。

bash#!/bin/bash
# データベースバックアップの例(問題あり)

# バックアップディレクトリを作成
mkdir /backup/db

# データベースをダンプ
mysqldump -u root -p mydb > /backup/db/mydb.sql

# 古いバックアップを削除
rm -rf /backup/old/*

このスクリプトでは、mkdir コマンドが失敗(例:ディスク容量不足)しても、次の mysqldump が実行され、意図しない場所にファイルが出力される可能性があります。

デフォルトのシェル動作

Bash などのシェルは、デフォルトでコマンドが失敗してもスクリプトを継続実行します。この動作は柔軟性を提供する一方で、エラーハンドリングを意識しないと重大な問題につながります。

以下の図で、デフォルトのシェル動作を確認しましょう。

mermaidflowchart TD
    start["スクリプト開始"] --> cmd1["コマンド1 実行"]
    cmd1 --> check1{成功?}
    check1 -->|成功| cmd2["コマンド2 実行"]
    check1 -->|失敗| cmd2
    cmd2 --> check2{成功?}
    check2 -->|成功| cmd3["コマンド3 実行"]
    check2 -->|失敗| cmd3
    cmd3 --> finish["スクリプト終了"]

図の要点:

  • コマンドが失敗しても次のコマンドが実行される
  • エラーを明示的に処理しない限り、スクリプトは最後まで実行される
  • 途中のエラーに気付かず、データの不整合が発生するリスクがある

set -e の登場

この問題を解決するために set -e(または set -o errexit)オプションが用意されています。このオプションを有効にすると、コマンドが 0 以外の終了ステータスを返した時点でスクリプトが即座に終了します。

bash#!/bin/bash
set -e  # エラー時に即座に終了

mkdir /backup/db
mysqldump -u root -p mydb > /backup/db/mydb.sql
rm -rf /backup/old/*

この設定により、mkdir が失敗すればその時点でスクリプトが停止し、後続の危険な処理を防げるわけです。

課題

set -e の落とし穴:期待通りに動作しないケース

set -e は便利に見えますが、実際にはいくつかの状況で機能しないという問題があります。これを理解せずに使うと、「エラー処理をしているつもり」が「実際にはエラーを見逃している」状態になってしまいます。

以下の図で、set -e が無効になる主なケースを示します。

mermaidflowchart TB
    sete["set -e 設定"] --> case1["パイプライン処理"]
    sete --> case2["サブシェル実行"]
    sete --> case3["条件分岐"]
    sete --> case4["論理演算子"]

    case1 --> issue1["パイプの途中のエラーを<br/>検出できない"]
    case2 --> issue2["サブシェル内のエラーが<br/>親に伝わらない"]
    case3 --> issue3["if/while文内では<br/>set -e が無効"]
    case4 --> issue4["&& や || と組み合わせると<br/>無効化される"]

図で理解できる要点:

  • set -e には 4 つの主要な落とし穴がある
  • それぞれの状況で異なる回避策が必要
  • これらを理解しないと、エラーハンドリングに穴が生まれる

課題 1:パイプライン処理でのエラー検出漏れ

パイプライン(|)を使ったコマンド連結では、最後のコマンドの終了ステータスのみが評価されます。途中のコマンドが失敗しても、set -e は発動しません。

bash#!/bin/bash
set -e

# 以下のコマンドで grep が何もマッチせず失敗しても
# スクリプトは停止しない
cat non_existent_file.txt | grep "pattern" | wc -l
echo "このメッセージは表示される"

この例では、cat コマンドがファイルを見つけられず失敗(終了ステータス: 1)しますが、wc -l は正常終了(終了ステータス: 0)するため、スクリプトは継続されます。

課題 2:サブシェルでのエラー伝播の問題

サブシェル($(...)(...) で囲まれた部分)内で発生したエラーは、親シェルに伝播しないケースがあります。

bash#!/bin/bash
set -e

# サブシェル内でエラーが発生
result=$(false; echo "executed")
echo "result: $result"  # この行も実行される
echo "スクリプトは継続される"

上記の例では、サブシェル内の false コマンドは失敗しますが、サブシェル全体の終了ステータスは最後のコマンド(echo)のものになるため、スクリプトは停止しません。

課題 3:条件分岐内での set -e の無効化

if 文、while 文、&&|| などの条件判定では、set -e が一時的に無効化されます。これは、条件判定そのものがコマンドの成功/失敗を評価する仕組みだからです。

bash#!/bin/bash
set -e

# if文の条件部ではset -eが無効
if false; then
    echo "実行されない"
fi

echo "スクリプトは継続される(期待と異なる)"

この動作は POSIX 仕様で定義されていますが、多くの開発者が予期しない挙動と感じる部分でしょう。

課題 4:論理演算子との組み合わせ

&&|| と組み合わせた場合も、set -e は機能しません。

bash#!/bin/bash
set -e

# &&の前のコマンドが失敗しても継続
false && echo "実行されない"
echo "スクリプトは継続される"

以下の表で、set -e が有効/無効になる状況をまとめます。

#状況set -e の動作備考
1通常のコマンド実行★ 有効エラー時に即座に終了
2パイプライン cmd1 | cmd2☆ 無効(最後のみ)pipefail が必要
3サブシェル $(cmd)☆ 条件付きサブシェル全体の終了ステータスに依存
4if 文の条件部☆ 無効条件評価のため
5while 文の条件部☆ 無効条件評価のため
6&&|| との組み合わせ☆ 無効論理演算のため

解決策

解決策 1:pipefail オプションの活用

パイプライン処理でのエラー検出漏れを防ぐには、set -o pipefail を使用します。

pipefail の基本設定

pipefail オプションを有効にすると、パイプライン内のいずれかのコマンドが失敗した場合、パイプライン全体が失敗として扱われるようになります。

bash#!/bin/bash
set -e
set -o pipefail  # パイプライン処理のエラー検出を有効化

これにより、以下のような処理でも確実にエラーを検出できます。

bash#!/bin/bash
set -e
set -o pipefail

# cat が失敗すると、パイプライン全体が失敗しスクリプトが停止
cat non_existent_file.txt | grep "pattern" | wc -l

# この行は実行されない
echo "到達しない"

pipefail の詳細な動作

pipefail を設定すると、パイプライン内の最も右にある失敗したコマンドの終了ステータスが返されます。

bash#!/bin/bash
set -e
set -o pipefail

# 以下は失敗する(cat の終了ステータス: 1)
# grepが成功してもcatの失敗が検出される
cat missing.txt | grep "test"

複数のコマンドが失敗した場合の動作も確認しましょう。

bash#!/bin/bash
set -o pipefail

# 複数のコマンドが失敗した場合
false | true | false
echo "終了ステータス: $?"  # 1が表示される(最後のfalseの値)

解決策 2:サブシェルのエラー処理

サブシェルでのエラーを確実に検出するには、以下の方法があります。

方法 1:サブシェルの終了ステータスを明示的にチェック

コマンド置換 $(...) を使う場合、サブシェル内で確実にエラーを伝播させる必要があります。

bash#!/bin/bash
set -e

# サブシェル内で set -e を有効にし、
# 最後にエラーチェックを明示
result=$(
    set -e
    false
    echo "この行は実行されない"
)

# または、サブシェルの後に明示的にチェック
result=$(some_command) || exit 1

方法 2:サブシェル内でも set -e を設定

サブシェル内で独立して set -e を設定することで、エラーハンドリングを強化できます。

bash#!/bin/bash
set -e

# サブシェル内でもset -eを明示
output=$(
    set -e
    set -o pipefail

    curl -f https://api.example.com/data
    jq '.items[] | .name'
)

echo "取得データ: $output"

方法 3:中括弧でのグルーピング

サブシェルではなく、中括弧 { ... } でコマンドをグループ化すると、親シェルと同じコンテキストで実行されます。

bash#!/bin/bash
set -e

# サブシェルではなくコマンドグループを使用
{
    echo "処理開始"
    false  # ここでスクリプトが停止する
    echo "到達しない"
}

解決策 3:条件分岐での安全なエラー処理

条件分岐内で set -e が無効になる問題には、以下のアプローチが有効です。

方法 1:条件の外で終了ステータスを保存

bash#!/bin/bash
set -e

# コマンドを実行して終了ステータスを保存
some_command
status=$?

# 保存した終了ステータスを評価
if [ $status -ne 0 ]; then
    echo "エラー: コマンドが失敗しました(終了コード: $status)"
    exit 1
fi

この方法により、if 文の中でコマンドを直接実行せず、事前に実行結果を評価できます。

方法 2:if 文内で明示的にエラーチェック

if 文内でコマンドを実行する場合は、エラー時に明示的に exit を呼び出します。

bash#!/bin/bash
set -e

if some_condition; then
    # if文内では set -e が効かないため、明示的にチェック
    critical_command || {
        echo "Error: critical_command が失敗しました" >&2
        exit 1
    }
fi

方法 3:command の代わりに ! command を使う

条件判定で成功を期待する場合は、二重否定を使うテクニックもあります。

bash#!/bin/bash
set -e

# 失敗を期待する場合(例: ファイルが存在しないことを確認)
if ! [ -f "/path/to/file" ]; then
    echo "ファイルが存在しません(正常)"
fi

ただし、このアプローチは可読性を下げる可能性があるため、チーム内での規約を決めておくとよいでしょう。

解決策 4:論理演算子との安全な組み合わせ

&&|| を使う場合は、以下のように記述します。

エラー時の即座の停止

bash#!/bin/bash
set -e

# &&を使う場合は、失敗時にexitを明示
command1 && command2 || exit 1

より明示的な記述

bash#!/bin/bash
set -e

# 可読性を重視した記述
if command1; then
    command2
else
    echo "Error: command1 が失敗しました" >&2
    exit 1
fi

ベストプラクティス:推奨される設定セット

以下は、堅牢なシェルスクリプトを書くための推奨設定です。

bash#!/bin/bash

# エラー時に即座に終了
set -e

# パイプライン内のエラーを検出
set -o pipefail

# 未定義変数の使用をエラーとする
set -u

# デバッグ時はコマンドをトレース表示
# set -x

これらの設定により、以下のようなエラーを防げます。

#オプション防げるエラー
1set -eコマンド失敗の見逃しmkdir 失敗後の cd 実行
2set -o pipefailパイプライン内のエラー見逃しcat file | grep patterncat 失敗
3set -u未定義変数の参照$TYPO_VARIABLE のような変数名ミス
4set -xデバッグ情報の不足処理の流れが追跡できない

具体例

実例 1:データベースバックアップスクリプト

以下は、これまでの解決策を組み合わせた実践的なバックアップスクリプトです。

スクリプト全体の設定

bash#!/bin/bash

# 堅牢なエラー処理を設定
set -e          # エラー時に終了
set -o pipefail # パイプラインのエラー検出
set -u          # 未定義変数の使用を禁止

変数定義と初期設定

bash# バックアップ設定
BACKUP_DIR="/backup/mysql"
DB_NAME="production_db"
DB_USER="backup_user"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

ディレクトリ作成(エラーチェック付き)

bash# バックアップディレクトリの作成
# -p オプションで既存ディレクトリでもエラーにならない
mkdir -p "${BACKUP_DIR}" || {
    echo "Error: バックアップディレクトリを作成できません" >&2
    exit 1
}

データベースダンプ(パイプライン処理)

bash# データベースをダンプして圧縮
# pipefailにより、mysqldumpまたはgzipの失敗を検出
mysqldump -u "${DB_USER}" -p"${DB_PASS}" "${DB_NAME}" \
    | gzip -9 \
    > "${BACKUP_FILE}"

上記のパイプライン処理では、mysqldump が失敗した場合も gzip が失敗した場合も、set -o pipefail により確実に検出されます。

バックアップの検証

bash# バックアップファイルが作成されたか確認
if [ ! -f "${BACKUP_FILE}" ]; then
    echo "Error: バックアップファイルが作成されませんでした" >&2
    exit 1
fi

# ファイルサイズが0より大きいか確認
if [ ! -s "${BACKUP_FILE}" ]; then
    echo "Error: バックアップファイルが空です" >&2
    exit 1
fi

古いバックアップの削除(サブシェル使用)

bash# 7日以上古いバックアップを削除
# サブシェル内でもset -eを有効にして安全に実行
old_count=$(
    set -e
    find "${BACKUP_DIR}" -name "*.sql.gz" -mtime +7 -print0 \
        | xargs -0 rm -f \
        | wc -l
) || {
    echo "Warning: 古いバックアップの削除に失敗しました" >&2
}

echo "削除した古いバックアップ: ${old_count}件"

処理完了メッセージ

bash# 成功メッセージ
echo "バックアップが完了しました: ${BACKUP_FILE}"
echo "ファイルサイズ: $(du -h "${BACKUP_FILE}" | cut -f1)"

以下の図で、このスクリプトの処理フローとエラーハンドリングポイントを示します。

mermaidflowchart TD
    start["スクリプト開始<br/>set -e, pipefail, -u"] --> mkdir["mkdir -p でディレクトリ作成"]
    mkdir --> check1{成功?}
    check1 -->|失敗| error1["エラーメッセージ出力<br/>exit 1"]
    check1 -->|成功| dump["mysqldump | gzip<br/>でバックアップ"]
    dump --> check2{成功?}
    check2 -->|失敗| error2["pipefail により<br/>即座に終了"]
    check2 -->|成功| verify["バックアップファイル検証"]
    verify --> check3{ファイル存在?<br/>サイズ > 0?}
    check3 -->|失敗| error3["エラーメッセージ出力<br/>exit 1"]
    check3 -->|成功| cleanup["古いバックアップ削除"]
    cleanup --> done["完了メッセージ表示"]

    error1 --> terminate["スクリプト終了"]
    error2 --> terminate
    error3 --> terminate
    done --> terminate

図で理解できる要点:

  • 各ステップでエラーチェックが行われる
  • エラー発生時は即座に終了し、後続の危険な処理を防ぐ
  • pipefail によりパイプライン内のエラーも検出
  • 検証ステップで、バックアップの成功を確認

実例 2:デプロイスクリプトでの条件分岐

以下は、条件分岐を含むデプロイスクリプトの例です。

環境変数のチェック

bash#!/bin/bash
set -e
set -o pipefail
set -u

# 必須環境変数のチェック
# if文内ではset -eが効かないため、事前にチェック
: "${DEPLOY_ENV:?Error: DEPLOY_ENV が設定されていません}"
: "${APP_NAME:?Error: APP_NAME が設定されていません}"

: "${VAR:?message}" は、変数が未定義または空の場合にエラーメッセージを表示して終了する Bash の機能です。

環境に応じた処理分岐

bash# 環境に応じた設定
if [ "${DEPLOY_ENV}" = "production" ]; then
    SERVER_HOST="prod.example.com"
    # 本番環境では追加の確認を要求
    read -p "本番環境へデプロイします。続行しますか? (yes/no): " confirm

    if [ "${confirm}" != "yes" ]; then
        echo "デプロイをキャンセルしました"
        exit 0
    fi
elif [ "${DEPLOY_ENV}" = "staging" ]; then
    SERVER_HOST="staging.example.com"
else
    echo "Error: 不明な環境: ${DEPLOY_ENV}" >&2
    exit 1
fi

ビルドとテストの実行

bash# ビルドの実行(パイプラインでログを保存)
echo "ビルドを開始します..."
yarn build 2>&1 | tee build.log

# ビルド結果の確認
# PIPESTATUSで最初のコマンドの終了ステータスを取得
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
    echo "Error: ビルドに失敗しました" >&2
    exit 1
fi

PIPESTATUS は、パイプライン内の各コマンドの終了ステータスを配列で保持する Bash の特殊変数です。

テストの実行(失敗時の詳細情報表示)

bash# テストの実行
echo "テストを実行します..."
if ! yarn test; then
    # テスト失敗時のエラーコード例
    # Error: Test suite failed to run
    echo "Error: テストに失敗しました" >&2
    echo "詳細: yarn test で1つ以上のテストケースが失敗しました" >&2
    exit 1
fi

ファイル転送(サブシェルでの安全な処理)

bash# 成果物を転送
echo "ファイルを転送します: ${SERVER_HOST}"
transfer_result=$(
    set -e
    set -o pipefail

    # rsyncで転送(-aは archive モード、-v は verbose)
    rsync -av --delete \
        ./build/ \
        "deploy@${SERVER_HOST}:/var/www/${APP_NAME}/"

    # 転送後のファイル数を確認
    ssh "deploy@${SERVER_HOST}" \
        "ls -1 /var/www/${APP_NAME}/ | wc -l"
) || {
    echo "Error: ファイル転送に失敗しました" >&2
    echo "エラーコード: $?" >&2
    exit 1
}

echo "転送完了: ${transfer_result}件のファイル"

サービスの再起動

bash# アプリケーションの再起動
echo "アプリケーションを再起動します..."
ssh "deploy@${SERVER_HOST}" \
    "sudo systemctl restart ${APP_NAME}" || {
    echo "Error: systemctl restart ${APP_NAME} が失敗しました" >&2
    echo "リモートサーバーでサービスの状態を確認してください" >&2
    exit 1
}

# 再起動後のヘルスチェック
sleep 5
health_status=$(
    curl -f -s "http://${SERVER_HOST}/health" || echo "unhealthy"
)

if [ "${health_status}" != "ok" ]; then
    echo "Error: ヘルスチェックに失敗しました" >&2
    echo "レスポンス: ${health_status}" >&2
    exit 1
fi

echo "デプロイが完了しました!"

実例 3:エラー処理のまとめとトラップ活用

より高度なエラー処理として、trap コマンドを使った後処理の例を示します。

トラップの設定

bash#!/bin/bash
set -e
set -o pipefail
set -u

# 一時ファイルの初期化
TEMP_DIR=$(mktemp -d)
LOCK_FILE="/var/lock/myapp.lock"

クリーンアップ関数の定義

bash# クリーンアップ関数
# スクリプト終了時(正常/異常問わず)に実行される
cleanup() {
    exit_code=$?

    echo "クリーンアップを実行中..."

    # 一時ファイルの削除
    if [ -d "${TEMP_DIR}" ]; then
        rm -rf "${TEMP_DIR}"
        echo "一時ディレクトリを削除: ${TEMP_DIR}"
    fi

    # ロックファイルの削除
    if [ -f "${LOCK_FILE}" ]; then
        rm -f "${LOCK_FILE}"
        echo "ロックファイルを削除: ${LOCK_FILE}"
    fi

    # 終了コードに応じたメッセージ
    if [ $exit_code -eq 0 ]; then
        echo "正常終了しました"
    else
        echo "エラーで終了しました(終了コード: $exit_code)" >&2
    fi
}

トラップの登録

bash# EXIT シグナル(スクリプト終了時)でクリーンアップを実行
trap cleanup EXIT

# INT シグナル(Ctrl+C)でも適切に終了
trap 'echo "中断されました"; exit 130' INT

メイン処理

bash# ロックファイルの作成(二重起動防止)
if [ -f "${LOCK_FILE}" ]; then
    echo "Error: 既に実行中です(ロックファイル: ${LOCK_FILE})" >&2
    exit 1
fi

touch "${LOCK_FILE}"

# メイン処理
echo "処理を開始します..."
# ... 実際の処理 ...
echo "処理が完了しました"

# 正常終了(cleanup が自動で実行される)
exit 0

この構造により、スクリプトがどのように終了しても(正常終了、エラー、Ctrl+C による中断など)、必ずクリーンアップ処理が実行されます。

以下の表で、各エラー処理テクニックの使い分けをまとめます。

#テクニック使用場面効果
1set -eすべてのスクリプトコマンド失敗時の即座の終了
2set -o pipefailパイプライン使用時パイプライン内のエラー検出
3set -u変数を多用する場合未定義変数の参照を防止
4command || exit 1条件分岐内set -e が効かない場所でのエラー処理
5trap cleanup EXITリソース管理が必要な場合終了時の確実なクリーンアップ
6PIPESTATUSパイプラインの詳細チェック各コマンドの終了ステータス取得
7: ${VAR:?message}必須環境変数のチェック変数未設定時の即座の終了

まとめ

本記事では、シェルスクリプトにおける set -e の落とし穴と、その回避方法について詳しく解説しました。

set -e は確かに便利なオプションですが、パイプライン処理、サブシェル、条件分岐といった場面では期待通りに動作しないことがあります。これらの状況を理解し、適切な対策を講じることで、堅牢なスクリプトを書けるようになりますね。

特に重要なポイントをおさらいしましょう。

重要ポイント:

  1. set -o pipefail は必須 - パイプライン処理では set -e だけでは不十分です
  2. サブシェルには注意 - コマンド置換 $(...) 内では、エラーが伝播しないケースがあります
  3. 条件分岐では set -e が無効 - if 文や &&/|| では明示的なエラーチェックが必要です
  4. 推奨設定セット - set -euo pipefail を基本として採用しましょう
  5. trap でクリーンアップ - リソース管理が必要な場合は、trap を活用して確実にクリーンアップしましょう

これらのテクニックを実践することで、本番環境での予期せぬエラーや、データの不整合を未然に防げます。特にデプロイスクリプトやバックアップスクリプトなど、失敗が許されない場面では、今回紹介したエラー処理を徹底することが重要です。

シェルスクリプトは一見シンプルに見えますが、エラー処理を適切に行うには深い知識が求められます。本記事で紹介した内容を参考に、より安全で信頼性の高いスクリプトを書いていただければ幸いです。

関連リンク