シェルスクリプトでデータ処理を正確に行うエラーハンドリング!保守性の高い自動化スクリプトの書き方

シェルスクリプトのエラーハンドリングをテーマに、ターミナル上で trap や set -eu のコードが表示され、隣にログファイルと監視ダッシュボードが配置された記事アイキャッチ インフラ

シェルスクリプトは、cronやsystemdタイマーと組み合わせることで、ログの集計、バックアップ、レポート生成など、さまざまなデータ処理を自動化する強力な道具です。しかし、その手軽さゆえにエラーハンドリングがおろそかになりがちで、想定外の入力やリソース不足に遭遇したときに無音で失敗したり、中途半端な状態で後続処理を続行したりするスクリプトを多く見かけます。自動化スクリプトの信頼性は、エラーが起きたときの振る舞いで決まると言っても過言ではありません。

本記事では、データ処理を主題に置きながら、シェルスクリプトにおける実践的なエラーハンドリングのパターンを整理します。
目的は、障害発生時に原因を特定しやすく、かつ予測可能な動作をする「保守性の高い」スクリプトを書くことです。
具体的には、終了ステータスとsetオプションの活用、トラップ関数によるクリーンアップ、そしてパイプライン中のエラー伝搬の制御という3つの軸で解説します。

まず基本として、スクリプトの先頭で set -eu を宣言する習慣をつけてください。
-e はコマンドが非ゼロで終了した時点でスクリプトを停止させ、-u は未定義変数を参照した時点でエラーにします。
ただし、パイプラインでは途中のコマンド失敗が検知されない場合があるため、set -o pipefail を併用することが推奨されます。
これにより、パイプ内のどのコマンドが失敗しても全体としてエラー終了します。

  • エラー発生時に実行中の一時ファイルやロックを確実に削除するには、trap コマンドでEXITシグナルを捕捉し、クリーンアップ関数を登録します
  • データ処理で重要なのは、途中経過を構造化ログに出力し、エラー時にはスタックトレース代わりに最終成功行番号を表示する仕組みです。PS4BASH_LINENO を利用するとデバッグ効率が劇的に向上します
  • 外部コマンドのエラーメッセージは標準エラーにそのまま流しつつ、スクリプト独自のエラーコードでラップすることで、呼び出し元が状態を判別しやすくなります

次に、条件分岐におけるエラーハンドリングのパターンを見てみましょう。
単一コマンドであれば if ! コマンド; then で十分ですが、複数コマンドの連続処理ではエラー発生時にロールバックするか、スキップするかの方針を明確にします。
例えば、データベースから抽出したレコードを加工する場合、変換に失敗したレコードだけを別ファイルに退避させ、処理は継続するという戦略も有効です。
その場合は、各変換コマンドの終了ステータスを個別に評価し、|| を使ってフォールバック処理を記述します。

エラーの種類 対応策 終了コード ログレベル
入力ファイル不存在 既定のダミーファイルを使用またはスキップ 0(継続)または2(警告) WARN
変換コマンド失敗 当該レコードをエラーファイルに追記して継続 0(継続) ERROR
ディスク容量不足 即座にリトライせず、後続処理を中止して終了コード64 64 CRITICAL
ネットワークタイムアウト 3回まで指数バックオフリトライ後、終了コード69 69 ERROR

上記のように、終了コードを体系的に割り当てておけば、呼び出し元の監視システムが意味のあるアラートを発報できます。
また、trap の中で終了コードを変数に保持し、EXITハンドラでそのコードを返すことで、本来のエラーコードがクリーンアップ処理によって上書きされるのを防げます。

最後に、エラーハンドリングはスクリプト全体の設計段階で組み込むべきであり、後付けのパッチでは不完全になりがちです。
データのスキーマ変更や入力形式のゆらぎに対応するために、アサーション(事前条件チェック)を各処理の入り口に置き、想定範囲外の値が来たら即座に例外扱いするよう心がけてください。
これにより、予期せぬデータが原因でサイレントに誤った結果が出力されるリスクを大幅に低減できます。

自動化スクリプトは「書いて終わり」ではなく、運用中に何度も修正・拡張される資産です。
エラー時に何が起きたかが明確に記録され、回復手順が自明であるようなスクリプトを目指しましょう。
次回は、実際のログ解析スクリプトを例に、ここで紹介したパターンを適用した完全な実装例を解説します。

自動化スクリプトが想定外のエラーで止まる理由――データ処理特有の落とし穴

シェルスクリプトがエラーで停止する画面と複数のログファイルが積まれたサーバー室のイメージ

シェルスクリプトでデータ処理を自動化する際、多くの開発者が経験するのが「開発環境では正常に動作していたのに、本番環境で突然止まった」という事態です。
この原因は、単なるコーディングミスではなく、データ処理というタスクが本質的に持つ不確実性にあります。
バッチ処理やログ集計、ファイル変換といった業務では、入力データの品質や量、外部リソースの可用性が常に変動するため、想定経路から外れた状況にスクリプトがどう応答するかが信頼性を左右します。

まず理解すべきは、シェルスクリプトのデフォルト動作が「エラーに寛容」であるという事実です。多くのディストリビューションでデフォルトのシェルは、コマンドが非ゼロ終了ステータスを返しても後続の処理を続行します。これは対話的な使用では便利ですが、自動化スクリプトでは致命的です。例えば、grep がパターンに一致しない場合に終了コード1を返しても、スクリプトは次の処理に進み、その結果として空の変数や不完全なファイルを後続の変換コマンドに渡すことになります。この「サイレントなデータ欠損」は、エラーログにすら残らないため、発見が著しく遅れます。

データ処理特有の落とし穴は、さらに複数の層に分かれます。
第一に、入力データのスキーマ変動です。
システム間で受け渡すCSVやJSONは、日時フォーマットの違い、想定外の空文字、文字コードの不一致など、仕様書通りに来ないことが日常的です。
こうした異常値に対して、変換コマンドはしばしば予期しないエラーを吐き、スクリプト全体を停止させます。
第二に、リソース制約の問題です。
大量のログファイルを一度に読み込もうとするとメモリ不足が発生し、sortuniq といった外部コマンドが途中で失敗します。
特にパイプラインで連結された複数コマンドは、途中のプロセスがクラッシュしても、上流のプロセスにはSIGPIPEが送られるだけで、スクリプト自体は異常終了と認識しないケースが多いのです。

第三に、ファイルシステムの状態変化も無視できません。
自動化スクリプトはcronsystemdタイマーで定期的に起動されるため、同時に複数のインスタンスが走る可能性があります。
ロック機構を実装していないと、同じファイルを競合して書き込もうとしてエラーになります。
また、バックアップ用の外部ストレージがマウントされていない、ネットワークドライブが応答しないといった環境依存の障害も、データ処理スクリプトを頻繁に停止させる要因です。

これらの落とし穴に共通するのは、エラーが「例外」ではなく「日常」であるという認識の欠如です。
データ処理において、完全にクリーンな入力はむしろ稀であり、エラー状態を前提とした設計が求められます。
しかし多くのスクリプトは正常系だけをテストし、異常系のテストを軽視しています。
その結果、本番運用で初めてエラーが顕在化し、夜間バッチが止まったというアラートに慌てることになるのです。

さらに、データ処理のパイプラインは中間ファイルを生成することが多く、エラーで停止した場合にこれらのゴミファイルが残り続けます。
次回の実行時にそれらが誤って入力として扱われたり、ディスク容量を圧迫したりする二次障害も頻発します。
このように、一見単純なエラーが連鎖的な問題を引き起こすのがデータ処理スクリプトの怖いところです。

では、どう対処するか。
第一歩は、スクリプトが想定外の入力やリソース不足に出会ったときに、即座に停止するか、明確にログを残してスキップするかを設計時に決めておくことです。
後述する set -etrap といった機構はそのための道具であり、それらを適切に使うことで初めて「止まるべきときに止まり、止まらないべきときには粘る」スクリプトが実現できます。

もう一つの重要な視点は、エラーハンドリングを「回復可能性」で分類することです。
ネットワークタイムアウトはリトライで回復できるかもしれませんが、入力ファイルのフォーマット異常は人間の介入が必要です。
この違いを終了コードやログレベルで区別することで、運用チームはアラートの優先度を適切に判断できます。
データ処理では、すべてのエラーを同等に扱うのではなく、ビジネス影響度に応じた段階的な対応が保守性を高める鍵となります。

最後に、エラーが起きたときに何が起きたかを正確にトレースできる仕組みを組み込むことも、落とし穴への対処です。
エラーメッセージに行番号や変数値を含める、処理開始時刻と終了時刻を記録する、途中経過をサマリファイルに出力する――こうした情報がなければ、エラー原因の特定に何時間も費やすことになります。
データ処理は一度の失敗よりも、その失敗からの復旧時間がトータルの信頼性を決めると言っても過言ではありません。

以上の理由から、自動化スクリプトのエラーハンドリングは「おまけ」ではなく、設計の中心に据えるべきテーマです。
次の見出しでは、具体的なシェルオプションの設定方法を見ていきましょう。

set -eu と pipefail でデフォルトの挙動を堅牢にする

ターミナル上で set -eu と set -o pipefail のコマンドが入力されている様子

シェルスクリプトのデフォルト動作は、対話的なコマンドライン操作を想定して設計されているため、バッチ処理にはあまりに寛容です。
この性質を根本から変えるには、スクリプトの先頭で set 組み込みコマンドを適切に設定することが第一歩です。
set -euset -o pipefail の組み合わせは、現代のシェルスクリプトにおける事実上の標準的なプラクティスであり、これだけで多くのサイレントエラーを顕在化できます。

まず set -e(または set -o errexit)は、パイプや条件式ではない単純なコマンドが非ゼロの終了ステータスを返した時点で、スクリプトを即座に終了させます。
これにより、失敗したコマンドの結果を後続が無意識に処理するという危険な状態を防げます。
ただし、if 文の条件部や &&|| で連結されたコマンド、そしてパイプラインの最後のコマンド以外はデフォルトでは -e の影響を受けないという細かい仕様があります。
この曖昧さを補うのが -o pipefail です。

set -o pipefail を有効にすると、パイプライン全体の終了ステータスが、パイプ内のいずれかのコマンドが失敗した場合にその失敗コードになります。
通常はパイプラインの最後のコマンドの終了ステータスしか反映されないため、例えば cat /etc/shadow | grep root | wc -l という処理で、cat が権限不足で失敗しても wc -l が成功すれば全体として終了コード0になってしまいます。
pipefail を設定すれば、cat の失敗がそのままスクリプトの停止を引き起こすため、不正な中間結果を後続に渡すリスクがなくなります。

次に set -u(または set -o nounset)は、未定義の変数を参照しようとしたときにエラーとして扱います。
シェルはデフォルトでは未定義変数を空文字列として展開するため、${FILENAME} が未定義でも何も表示されずに処理が進みます。
これはデータ処理において非常に危険で、例えば rm -rf ${DIR_PATH}/ と書いたときに DIR_PATH が未定義だと rm -rf / が実行される可能性すらあります。
-u を設定しておけば、こうした事故は実行前に検出できます。

ただし、-u は配列や連想配列の一部の参照で想定外の挙動を示すこともあるため、完全に安全というわけではありません。
その場合は ${VAR:-デフォルト値} という構文を使って、未定義時に代替値を明示することで回避します。
この構文は意図的に空文字を許容する場合にも使えるため、エラーを起こすべき場所と許容する場所をコード上で明示できるというメリットもあります。

これらのオプションを一度に設定するには、スクリプトの冒頭で以下のように記述します。

#!/bin/bash
set -euo pipefail

この -o pipefail を含めた形が、多くのプロジェクトで推奨される標準セットです。
ただし、-e には一点注意すべき例外があります。
サブシェル内で発生したエラーは、そのサブシェルが -e を継承していても、親シェルに伝播しない場合があることです。
例えば (cd /nonexistent && echo "done") というサブシェル内で cd が失敗しても、サブシェル自体は終了コード0で返る可能性があります。
この場合は || exit を明示的に付けるか、set -E を追加してエラートラップを継承させるといった補強が必要です。

また、set -e は「コマンドが失敗したら即終了」という原則ですが、あくまで想定外のエラーに対する安全網として捉えるべきです。
意図的に処理を継続したいエラー条件(例えば grep で一致しなかった場合など)は、明示的に条件分岐で処理し、その際には || true を使って終了ステータスを無効化するか、if 文でラップします。
こうすることで、どのエラーが致命的でどのエラーが許容されるかがコード上で自明になります。

さらに、これらの設定は対話的なシェル環境には影響を与えません。
あくまでスクリプトファイル内でのみ有効です。
そのため、cron から呼び出されるスクリプトや systemd のサービスユニットで実行されるバッチ処理にも、必ず先頭にこの設定を書く習慣をつけるとよいでしょう。

最後に、set -x(トレースモード)をデバッグ時に併用するのも有効です。
-x を有効にすると、実行するコマンドとその引数が標準エラーに出力されるため、エラー発生箇所を特定しやすくなります。
ただし本番環境ではログが膨大になるため、set -x は条件付きで有効にするか、DEBUG 変数で切り替える設計が現実的です。
set -eu -o pipefail は堅牢性の土台であり、この上にさらに trap やリトライ機構を積み上げていくことで、本番運用に耐えるスクリプトが完成します。

エラー発生時に確実に後片付けをする trap 活用術

trap コマンドで一時ファイル削除とロック解除を自動化するフローチャート

set -e でスクリプトを即時停止できるようになったとしても、それだけでは不十分です。
エラーが発生してスクリプトが途中終了するとき、それまでに作成した一時ファイル、ロックファイル、あるいは変更中のシステム状態がそのまま残ってしまいます。
これらのゴミは次回の実行時に誤った入力として扱われたり、ディスク容量を圧迫したり、排他制御を破綻させたりする原因になります。
この問題を解決するのが trap コマンドです。
trap はシグナルや終了イベントを捕捉し、あらかじめ登録したクリーンアップ関数を実行する仕組みを提供します。

trap の最も基本的な使い方は、EXIT シグナルを捕捉することです。
EXIT はスクリプトが正常終了した場合も異常終了した場合も、シェルプロセスが終了する直前に発火します。
つまり、trap 'cleanup' EXIT と書いておけば、どんな理由でスクリプトが終了しても cleanup 関数が呼ばれることが保証されます。
この関数内で、rm -f "${TEMP_FILE}"rmdir "${LOCK_DIR}" といった後片付け処理を記述しておくのです。

ただし、EXIT トラップはサブシェルやパイプ内で発生したエラーでは継承されない場合があるため、set -E を併用してエラートラップの継承を有効にするとより堅牢になります。
set -E を設定すると、関数やサブシェル内で発生した ERR シグナルも親シェルで捕捉できるようになります。

具体的な実装例を見てみましょう。
データ処理スクリプトでは、変換の中間ファイルとしてユニークな名前のテンポラリファイルを複数作成することが一般的です。

#!/bin/bash
set -euo pipefail

TEMP_DIR=$(mktemp -d)
trap 'rm -rf "${TEMP_DIR}"; echo "Cleaned up temporary directory"' EXIT

# ここでデータ処理を行う
cp input.csv "${TEMP_DIR}/work.csv"
sort "${TEMP_DIR}/work.csv" > "${TEMP_DIR}/sorted.csv"

この例では、スクリプトが sort で失敗して停止しても、trap がディレクトリごと削除してくれます。
mktemp は安全な一時ファイルを作成するユーティリティであり、trap と組み合わせることで、予期せぬ中断時でもファイルが残りにくくなります。

次に、EXIT 以外にも捕捉すべきシグナルがあります。
SIGINT(Ctrl+C)、SIGTERM(終了要求)、SIGHUP(ハングアップ)などです。
これらのシグナルはユーザーやシステムから送られる中断要求であり、スクリプトが長時間かかるバッチ処理の場合には特に重要です。
trap 'cleanup' INT TERM HUP と複数シグナルを列挙することで、どの中断経路でも同じクリーンアップ処理を実行できます。

さらに、エラー発生時に行番号を出力する仕組みも trap で実現可能です。
ERR シグナルを捕捉すると、コマンドが失敗したタイミングで即座に関数を呼び出せます。

error_handler() {
    echo "Error on line $1" >&2
}
trap 'error_handler $LINENO' ERR

この $LINENO は現在の行番号を表す特殊変数であり、エラーの発生箇所を特定するのに役立ちます。
ただし、ERR トラップは条件式内や &&/|| で処理されたエラーでは発火しないため、あくまで補助的なデバッグ手段として位置づけるとよいでしょう。

クリーンアップ処理を書く際に注意すべき点は、トラップハンドラ内でさらにエラーが発生しないようにすることです。
例えば、削除対象のファイルがすでに存在しない場合に rm がエラーを返すと、set -e が有効ならハンドラ自体が終了してしまいます。
そのため、クリーンアップコマンドには rm -f のようにエラーを無視するオプションを付けるか、|| true を追加して常に成功させる工夫が必要です。

また、複数のトラップを競合させないために、ハンドラは1つに集約し、その中でフラグ変数を使って処理を分岐させる設計が推奨されます。
例えば、正常終了時とエラー終了時で異なるメッセージを出力したい場合は、終了コードを保持する変数を用意し、EXIT ハンドラ内でその値を参照して分岐させます。

finish() {
    local exit_code=$?
    if [ $exit_code -eq 0 ]; then
        echo "Processing completed successfully"
    else
        echo "Processing failed with code $exit_code" >&2
    fi
    rm -rf "${TEMP_DIR}"
    exit $exit_code
}
trap finish EXIT

$? はスクリプト終了直前の終了コードを保持しているため、正常/異常を判別できます。
このパターンを用いれば、クリーンアップ後に本来の終了コードが上書きされることも防げます。

データ処理スクリプトでは、ファイルロックも重要な後片付け対象です。
flockmkdir を使った排他制御を行っている場合、ロックが解除されないと次回の起動時にデッドロックが発生します。
trap で明示的にロックファイルを削除することで、どんな中断でもロックが解放されるように設計することが不可欠です。

さらに、trap はスクリプト内で複数回定義できますが、後で定義したものが前のものを上書きします。
そのため、共通のクリーンアップ処理をライブラリ化する場合は、trap を追加モードで登録するラッパー関数を用意すると保守性が高まります。
trap -- で現在のトラップ一覧を表示できるので、デバッグ時に活用してみてください。

最後に、trap はシグナルだけでなく、DEBUGRETURN といった疑似シグナルも捕捉できます。
これらを活用すると、関数の出入りをトレースしたり、デバッグ情報を細かく出力したりできるため、複雑なデータ処理パイプラインの内部挙動を可視化するのにも役立ちます。
ただし、過剰なトレースはログを肥大化させるため、本番環境では DEBUG トラップを条件付きで有効にするなど、バランスを考慮してください。

パイプライン途中の失敗を漏らさず検知する設計パターン

複数コマンドがパイプで接続され、途中で赤いエラー表示が発生している図解

シェルスクリプトの真髄とも言えるパイプラインは、複数のコマンドを連結してデータをストリーム処理する強力な機能です。しかし、その簡潔さゆえにエラーハンドリングが著しく困難になるというジレンマがあります。set -o pipefail を設定しても、パイプラインの各段階で発生する多様なエラーをすべて捕捉できるわけではありません。パイプライン途中の失敗を漏らさず検知するには、パイプの特性を理解した上で、段階的な設計パターンを適用する必要があります。

まず理解すべきは、パイプラインの各コマンドは独立したプロセスとして実行され、標準出力と標準エラーは別々のストリームで流れるという点です。
command1 | command2 | command3 という構成で、command1 がエラーを標準エラーに出力しても、それはパイプには乗らずにターミナルに直接表示されます。
そして command1 が途中で失敗しても、command2 はすでに読み込んだデータを処理し続けるため、スクリプト全体としてはエラーが検知されにくいのです。

この問題に対処する最初のパターンは、パイプラインの各コマンドの終了ステータスを明示的に検査することです。
Bash では PIPESTATUS という特殊配列変数が用意されており、パイプライン内の各コマンドの終了コードを個別に参照できます。
例えば cat file | grep pattern | wc -l を実行した後、${PIPESTATUS[0]}cat の終了コード、${PIPESTATUS[1]}grep の終了コード、${PIPESTATUS[2]}wc の終了コードを保持します。
この配列をチェックして、いずれかが非ゼロであればエラー処理に遷移するという設計が基本です。

ただし、PIPESTATUS は直近のパイプラインの情報しか保持しないため、複数のパイプラインを連続して実行する場合は、その都度変数に退避させる必要があります。
以下に実装例を示します。

cat data.txt | sort | uniq -c
pipe_errors=("${PIPESTATUS[@]}")
for i in "${!pipe_errors[@]}"; do
    if [ ${pipe_errors[$i]} -ne 0 ]; then
        echo "Pipeline stage $i failed with code ${pipe_errors[$i]}" >&2
        exit 1
    fi
done

このパターンは明示的で理解しやすい反面、パイプラインが長くなるとコードが煩雑になります。
そこで、より簡潔な方法として set -epipefail に加えて shopt -s lastpipe を活用する手もあります。
lastpipe を有効にすると、パイプラインの最後のコマンドが現在のシェル環境で実行されるため、そのコマンド内で変数に代入した値を後続で参照できます。
これにより、最後のコマンドでエラーフラグを立てるという間接的な検知が可能です。

もう一つの強力なパターンは、パイプライン全体をサブシェルでラップし、その終了コードを検査することです。
( command1 | command2 | command3 ) と括弧で囲むと、サブシェル全体の終了コードはパイプラインの最後のコマンドの終了コードになりますが、pipefail が有効ならサブシェル内でいずれかのコマンドが失敗した時点でサブシェル自体がエラー終了します。
これにより、if ! ( command1 | command2 | command3 ); then という簡潔なエラーチェックが実現できます。

ただし、サブシェルを使うと変数のスコープが変わるため、途中の変数代入結果を親シェルで使いたい場合は注意が必要です。
その場合は、パイプラインの代わりにプロセス置換 <() やヒアドキュメントを組み合わせることで、変数を保持しながらエラー検知を両立させる設計も検討できます。

次に、データ処理において特に重要なのが grep の終了コードの扱いです。
grep はパターンに一致する行があった場合は0、なかった場合は1を返しますが、set -e が有効だと一致しなかっただけでスクリプトが停止してしまいます。
これを回避するには、grep -qif 文の条件として使うか、grep pattern || true と明示的に成功扱いにするか、または grep pattern || return_code=$? のように終了コードを変数に格納して後で判定する方法を取ります。
データ処理では「一致なし」はエラーではなく正常な結果であることが多いため、この区別を設計時に明確にしておく必要があります。

さらに、パイプラインで外部コマンドを使う際には、標準エラー出力を別ファイルにリダイレクトすることで、エラーメッセージを後から解析可能な形で残すプラクティスも有効です。
command1 2>err1.log | command2 2>err2.log | command3 とすれば、各コマンドのエラー出力が個別のログに保存されるため、どの段階で何が起きたかを事後的に調査できます。
ただし、この方法ではエラーが発生してもスクリプトは止まらないため、PIPESTATUS と併用する必要があります。

もう一つの高度なパターンとして、名前付きパイプ(FIFO)とバックグラウンドジョブを組み合わせて各段階を個別に制御する方法があります。
各コマンドを別々のプロセスとして起動し、それぞれの終了コードを wait で収集することで、パイプラインの各段階を完全に独立したエラーハンドリング対象にできます。
この方法は複雑ですが、段階ごとにリトライやスキップといった異なる戦略を取りたい場合に柔軟性が高まります。

パターン メリット デメリット 適したユースケース
PIPESTATUS検査 段階ごとに個別判定可能 コードが冗長になる パイプラインが短く、精度が求められる処理
サブシェル+pipefail 簡潔に書ける 変数スコープが制限される 中間結果を親で使わない処理
標準エラー個別出力 デバッグ情報が豊富 エラー検知は別途必要 本番運用でのトレーサビリティ重視
名前付きパイプ制御 段階ごとに細かい制御が可能 実装コストが高い 複雑なETLパイプライン

最後に、パイプラインのエラーハンドリングで忘れてはならないのは、パイプの途中でデータが欠損しても、そのまま後続が空の入力を処理して正常終了するケースです。
例えば curl がタイムアウトして空のレスポンスを返した場合、後続の jq は空入力で正常終了し、スクリプト全体も成功扱いになります。
このようなケースでは、パイプラインの出力サイズや期待されるフォーマットを事後検証するアサーションを組み込むことが有効です。
パイプラインの最後に wc -c でバイト数をチェックするか、head -1 で期待するヘッダー行が存在するかを確認するステップを追加することで、データの実質的な正しさを担保できます。

終了コードとログレベルを体系的に割り振って運用監視に耐える

終了コード一覧表とログレベル(INFO, WARN, ERROR, CRITICAL)が並んだダッシュボード

シェルスクリプトのエラーハンドリングにおいて、終了コードは呼び出し元や監視システムへの唯一の通信手段と言っても過言ではありません。
しかし、多くのスクリプトは成功時に0、失敗時に1という単純な二分法しか使っておらず、「なぜ失敗したのか」という情報が終了コードから一切読み取れないという問題があります。
データ処理のような多様な障害モードを持つタスクでは、終了コードを体系的に設計し、ログレベルと連携させることで、運用監視の質が劇的に向上します。

まず終了コードの設計原則として、Unixの慣習に従い0を成功、それ以外を失敗とします。
ただし、失敗にも複数のカテゴリがあることを認識すべきです。
大きく分けると、入力データに関するエラー外部リソースに関するエラースクリプト内部の論理エラー、そして設定や環境に関するエラーの4つです。
これらを区別するために、終了コードの範囲を意味ごとに割り振ります。

具体的な割り振り例として、以下のような体系を提案します。
1から9は一般的なエラー、10から19を入力データ関連、20から29を外部リソース関連、30から39を内部論理エラー、40から49を環境設定エラー、50以上をカスタム用途として予約します。
このように範囲を区切ることで、終了コードの数値だけでエラーの大分類が把握できるようになります。

終了コード 意味 ログレベル 想定される対応
0 正常終了 INFO 特になし
1 一般的な予期せぬエラー ERROR ログ調査が必要
10 入力ファイルが存在しない WARN ファイルの有無を確認
11 入力フォーマットが不正 ERROR スキーマ検証を実施
12 入力データが空 WARN データソースを確認
20 ネットワークタイムアウト WARN リトライ可能か検討
21 外部APIがエラーを返却 ERROR APIのステータス確認
22 ディスク容量不足 CRITICAL ストレージを増設
30 変換処理で予期せぬ例外 ERROR 処理ロジックをデバッグ
31 アサーション失敗 CRITICAL 前提条件を再評価
40 環境変数が未定義 ERROR 設定ファイルを確認
41 依存コマンドが見つからない CRITICAL パッケージをインストール

この表のように終了コードとログレベルを対応させることで、監視システムはコードに基づいてアラートの重要度を自動判定できます。
例えば、コード22(ディスク容量不足)はCRITICALとして即時対応を促し、コード10(ファイル不存在)はWARNとして夜間のメール通知だけにするといった運用が可能になります。

次に、ログレベルの設計です。
シェルスクリプトでは標準出力と標準エラーを適切に使い分けることが基本ですが、それだけでは粒度が粗すぎます。
そこで、ログレベルを表すプレフィックスを付けたメッセージを出力することをお勧めします。
具体的には [INFO][WARN][ERROR][CRITICAL] といったタグを各行の先頭に付け、かつタイムスタンプも含めることで、後からログをフィルタリングしやすくします。

log_info() {
    echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $*"
}
log_warn() {
    echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}
log_error() {
    echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}
log_critical() {
    echo "[CRITICAL] $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}

このようなラッパー関数を用意しておけば、スクリプト内のどこでも統一されたフォーマットでログを出力できます。
エラー発生時には、終了コードとともにこのログメッセージが出力されるため、オペレータはタイムスタンプと重大度から問題の優先順位を即座に判断できます。

さらに、ログレベルは出力先も変えると効果的です。
INFOレベルは標準出力に、WARN以上は標準エラーに出力することで、通常の処理結果とエラー情報を分離できます。
また、重要なバッチ処理では、WARN以上のログだけを別ファイルにリダイレクトして保存することで、障害時の調査効率が格段に上がります。

終了コードの体系を設計する際に注意すべき点は、既存のコマンドやライブラリが返す終了コードと衝突しないことです。
例えば grep は1を「一致なし」として返すため、スクリプト独自のエラーコードに1を使うと混乱を招きます。
そのため、スクリプト固有のエラーコードは10以上から始めるか、sysexits.h で定義されている標準的なコード(64〜78)を参考にするとよいでしょう。
sysexits.h はBSD系で広く使われており、64が使用法エラー、69が通信エラー、70が内部ソフトウェアエラーなどと定義されています。

運用監視の観点では、終了コードを最終行で明示的に返すことも重要です。
trap を使用している場合、クリーンアップ処理が終了コードを上書きしないように、終了直前に exit $exit_code と明示することで、想定したコードが確実に呼び出し元に伝わります。
また、スクリプトが複数のサブスクリプトを呼び出す場合は、各サブスクリプトの終了コードを集約して、最上位のスクリプトが最終的な終了コードを決定する設計を取るとよいでしょう。

最後に、終了コードとログレベルはドキュメント化することが必須です。
スクリプトのヘッダーコメントやREADMEに、どのコードがどのような状況で返されるかを明記しておけば、後任の開発者や運用チームが迅速に問題を特定できます。
データ処理スクリプトは半年後、一年後にも修正が入ることを考慮し、コードと同様に終了コード体系も保守対象の一部であるという認識を持って設計してください。

想定外データを安全にスキップする継続的エラーハンドリング戦略

変換エラーのレコードだけ別ファイルに退避し、正常データは処理継続するフロー図

データ処理スクリプトにおいて、すべての入力データが仕様通りであるという前提は、本番環境ではほとんど成立しません。
日時フォーマットのゆれ、欠損フィールド、想定外の文字コード、数値範囲の逸脱など、異常データはむしろ日常的に発生します。
こうした状況で「異常データがあればスクリプト全体を停止させる」という厳格な方針は、バッチ処理の可用性を著しく損ないます。
そこで重要になるのが、異常なレコードだけを隔離し、正常なデータの処理は継続するという継続的エラーハンドリング戦略です。

この戦略の核心は、「処理単位」をレコード単位に細分化し、各レコードの処理を独立したトランザクションとして扱うことです。
シェルスクリプトは行指向のツール群と相性が良いため、CSVやログファイルのような行ベースのデータに対しては、一行ごとにバリデーションと変換を行うパイプラインを構築しやすくなります。
具体的には、while read ループの中で各フィールドを検査し、異常があればエラーファイルに書き出して次の行に進むという設計が基本パターンです。

process_record() {
    local record="$1"
    # フィールド数チェック
    if [ $(echo "$record" | awk -F',' '{print NF}') -ne 5 ]; then
        echo "$record" >> error_records.log
        return 1
    fi
    # 日付フォーマットチェック
    local date_field=$(echo "$record" | cut -d',' -f1)
    if ! date -d "$date_field" >/dev/null 2>&1; then
        echo "$record" >> error_records.log
        return 1
    fi
    # 変換処理
    echo "$record" | transform_command >> output.csv
    return 0
}

while IFS= read -r line; do
    process_record "$line" || true
done < input.csv

この例では || true によってループが停止せず、異常レコードはエラーログに追記され、正常レコードだけが出力ファイルに書き込まれます。
process_record 内で return 1 を返しても、while ループの条件式が偽にならないため継続できるのがポイントです。

この戦略をさらに発展させるには、異常レコードの分類を行います。
すべての異常を同じエラーファイルに書き出すのではなく、エラーの種類ごとに別のファイルやディレクトリに振り分けることで、後処理が容易になります。
例えば、フォーマットエラー、型エラー、範囲外エラー、重複エラーなどに分類し、それぞれ errors/format/errors/type/errors/range/errors/duplicate/ というディレクトリに出力します。
こうすることで、運用者はエラーの種類ごとに優先順位をつけて対応できます。

継続的エラーハンドリングを実装する際のもう一つの重要な要素は、異常レコードのカウントと閾値監視です。
いくら継続処理とはいえ、異常レコードの割合が全体の数パーセントを超えるようであれば、データソースそのものに重大な問題がある可能性が高いです。
そこで、異常レコード数が閾値を超えた場合にはスクリプト全体を異常終了させるという二段階の設計を導入します。
これにより、部分的な異常には強く、システム全体の異常には敏感なスクリプトが実現できます。

error_count=0
max_errors=100
while IFS= read -r line; do
    if ! process_record "$line"; then
        ((error_count++))
        if [ $error_count -gt $max_errors ]; then
            log_critical "Error count exceeded threshold ($max_errors). Aborting."
            exit 70
        fi
    fi
done < input.csv

この閾値は処理するデータ量やビジネス要件に応じて調整可能にし、環境変数や設定ファイルから読み込むように設計すると柔軟性が高まります。

次に、スキップしたレコードを後で再処理できる仕組みも重要です。
エラーファイルに書き出したレコードは、後日手動で修正した後に再投入できるように、元の行番号やタイムスタンプを付加して保存することをお勧めします。
行番号を保持しておけば、元のデータソースと照合して修正箇所を特定しやすくなります。
また、再処理用のスクリプトを別途用意し、エラーファイルを入力として受け取れるようにしておくことで、運用の負荷が軽減されます。

継続的エラーハンドリングでは、標準エラー出力の使い分けも見直す必要があります。
異常レコードのログは標準エラーに出しながら、同時にエラーファイルにも書き出す二重出力が効果的です。
これにより、リアルタイムの監視と事後調査の両方を満たせます。

出力先 内容 用途
標準出力 正常処理の結果データ 後続パイプラインへの入力
標準エラー エラー概要(件数のみ) 監視システムでのアラート判定
エラーファイル(個別) 異常レコードの詳細 事後調査と再処理
サマリーファイル 総処理件数、正常件数、エラー種別ごとの件数 日次レポートやトレンド分析

この表のように出力先を階層化することで、監視、調査、分析の各フェーズで最適な情報を提供できます。
サマリーファイルは特に重要で、処理の終了時に総件数とエラー内訳を出力することで、異常のトレンドを把握しやすくなります。

最後に、継続的エラーハンドリングを採用する場合でも、トランザクション整合性が求められる処理には適用できないという制約を認識しておく必要があります。
例えば、データベースの一括更新やファイルのアトミックな置き換えなど、部分的な成功が許されない処理では、継続戦略は逆効果です。
そのようなケースでは、すべてのレコードを事前検証した上で、全体が正常である場合のみ実行する「フェイルファースト」戦略を選択してください。
継続的エラーハンドリングは、あくまで「データの質が完全でないことが許容されるバッチ処理」に限定して適用するのが適切です。

エラー時のデバッグ情報を残すロギングとトレースの実装

エラーログに行番号と変数値が出力され、原因特定が容易なターミナル画面

エラーハンドリングにおいて、エラーが発生したこと自体を検知するのは第一段階に過ぎません。
それ以上に重要なのは、エラーが起きたときに、なぜ起きたのかを迅速に特定できるデバッグ情報が残っているかという点です。
特にデータ処理スクリプトでは、大量のデータを扱うため、再現性が低く、一度エラーが発生すると同じ条件で再実行することが難しい場合が多くあります。
そのため、エラー発生時のコンテキストを詳細にロギングし、トレース情報を残すことが、復旧時間を大幅に短縮する鍵となります。

まず基本となるのは、タイムスタンプ付きの構造化ログです。
単にエラーメッセージを出力するのではなく、処理の開始時刻、終了時刻、現在処理中のファイル名やレコード番号、変数の値を含めることで、エラー発生時の状況が具体的に把握できます。
以下のようなログフォーマットを標準化するとよいでしょう。

log_with_context() {
    local level="$1"
    local message="$2"
    local line_no="${BASH_LINENO[0]}"
    local func_name="${FUNCNAME[1]:-main}"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] [${func_name}:${line_no}] $message" >&2
}

この関数を使えば、log_with_context ERROR "Failed to parse record: $record" と呼び出すだけで、どの関数の何行目でエラーが発生したかが自動的に記録されます。
BASH_LINENO 配列と FUNCNAME 配列を活用することで、コールスタックの情報も取得可能です。

次に、変数のスナップショットを残すことも有効です。
エラー発生時に、関連する変数の値をすべてダンプする関数を用意しておきます。
特にデータ処理では、入力データの先頭数行や、処理中のカウンタ値、ファイルサイズなどが重要な手がかりになります。
set -x を有効にすればすべてのコマンド実行がトレースされますが、本番環境では出力が膨大になるため、エラー時のみトレースを有効にする仕組みが実用的です。

enable_trace_on_error() {
    set -x
}
trap 'enable_trace_on_error' ERR

このように ERR シグナルを捕捉して set -x を有効にすれば、エラー発生直後から詳細なコマンドトレースが開始されます。
ただし、その後のクリーンアップ処理もトレースされるため、ログが意図せず長くなる可能性には注意が必要です。
代わりに、エラー発生時のスタックトレースを出力する専用関数を用意する方法もあります。

print_stack_trace() {
    local i
    echo "Stack trace:" >&2
    for ((i=0; i<${#BASH_SOURCE[@]}; i++)); do
        echo "  ${BASH_SOURCE[$i]}:${BASH_LINENO[$i]} in ${FUNCNAME[$i]}" >&2
    done
}
trap 'print_stack_trace' ERR

この関数は、エラーが発生した時点でのコールスタックを表示するため、どの関数がどのファイルの何行目から呼び出されたかが一目で分かります。
特に複数のスクリプトファイルをソースしている場合に威力を発揮します。

ロギングのもう一つの重要な側面は、出力先の分離とローテーションです。
cron で動作するスクリプトの場合、標準出力と標準エラーはメールで送信されるか、syslog に転送されることが多いですが、大量のログが発生するデータ処理では専用のログファイルを用意することをお勧めします。
exec > >(tee -a script.log) 2>&1 のように tee を併用すれば、コンソール出力とファイル出力を同時に行えます。

さらに、ログファイルには処理のサマリ情報を必ず含めるようにします。
総処理レコード数、成功数、エラー数、スキップ数、処理時間、最大メモリ使用量などのメトリクスを最後に出力することで、エラーが発生しなかった日でもパフォーマンスの推移を把握できます。
これらのサマリ情報は、後述する監視システムと連携させることで、異常の予兆検知にも役立ちます。

デバッグ情報として見落とされがちなのが、入力データのチェックサムやハッシュ値の記録です。
特に外部から取得したデータファイルを処理する場合、ファイルのMD5やSHA-256をログに残しておけば、後日「同じファイルで再実行したのに結果が違う」という事態が発生したときに、ファイルが書き換えられていないかの検証が容易になります。

md5sum input.csv >> script.log

この一行を追加するだけで、処理対象ファイルの同一性が担保できます。

トレース情報を残す際には、個人情報や機密データをログに出力しないというセキュリティ面の配慮も欠かせません。
デバッグのためにレコード全体を出力すると、顧客情報やパスワードがログファイルに残るリスクがあります。
そのため、ログ出力用にはマスキング関数を用意し、特定のフィールドだけを出力するか、ハッシュ化した値を出力するなどの対策を講じてください。

ログレベル 出力内容 保存期間 出力先
INFO 処理開始・終了、サマリ情報 30日間 標準出力 + ログファイル
WARN スキップしたレコード数、軽微な異常 30日間 標準エラー + ログファイル
ERROR エラー詳細、スタックトレース、変数スナップショット 90日間 標準エラー + エラーログファイル
DEBUG 全コマンドトレース(条件付き) 7日間 デバッグ専用ファイル

上記のようなログ保存ポリシーを定めておけば、ディスク容量を圧迫せずに必要な情報を適切な期間保持できます。
ERRORレベルのログだけを別ファイルに分けて長期間保存することで、障害発生時の原因調査に十分な情報を確保できます。

最後に、ログの検索と解析を容易にするために、ログフォーマットを機械可読にすることも検討してください。
JSON形式で出力すれば、ElasticsearchやSplunkなどのログ分析ツールと連携しやすくなります。
シェルスクリプトでJSONを生成するのは少し手間がかかりますが、jq コマンドを利用すれば整形も容易です。
将来の運用拡張を見据えて、ログ設計の段階から構造化を意識しておくことを強くお勧めします。

外部コマンドのエラーをラップして呼び出し元に意味あるコードを返す

外部コマンドのエラーメッセージを独自エラーコードでラップする模式図

シェルスクリプトの強みは、多数の外部コマンドを組み合わせて強力なデータ処理パイプラインを構築できることですが、同時にこれは弱点でもあります。
grepawksedjqcurl といった外部コマンドはそれぞれ独自の終了コード体系を持っており、それらをそのまま呼び出し元に伝播させると、「どの外部コマンドが、なぜ失敗したのか」が曖昧になるという問題が生じます。
この問題を解決するには、外部コマンドのエラーをスクリプト独自のエラーコードにラップし、コンテキスト情報を付加した上で呼び出し元に返す設計が効果的です。

外部コマンドのエラーラッピングの基本パターンは、各コマンドの実行直後に終了コードを検査し、それが非ゼロであれば、元のコードとコマンド名、および関連パラメータを含めたスクリプト独自のエラーコードに変換するというものです。
これにより、呼び出し元は「スクリプト全体が失敗した」という事実だけでなく、「どの処理段階で、どの外部コマンドが、どのような引数で失敗したのか」を把握できます。

run_with_error_wrapping() {
    local cmd="$1"
    local error_code_base="$2"
    shift 2
    local args=("$@")

    log_info "Executing: $cmd ${args[*]}"
    "$cmd" "${args[@]}"
    local exit_code=$?

    if [ $exit_code -ne 0 ]; then
        log_error "Command '$cmd' failed with code $exit_code, args: ${args[*]}"
        return $((error_code_base + exit_code))
    fi
    return 0
}

この関数を使えば、run_with_error_wrapping "curl" 20 --silent --fail https://api.example.com/data という呼び出しで、curl がタイムアウト(終了コード28)した場合、スクリプトは終了コード48(20+28)を返します。
呼び出し元はこの48というコードから、「curlコマンドがタイムアウトした」と推定できます。

さらに踏み込んだ設計として、外部コマンドの標準エラー出力をキャプチャし、スクリプトのログに統合することも有効です。
多くの外部コマンドは詳細なエラーメッセージを標準エラーに出力しますが、これがスクリプトの標準エラーと混ざると可読性が低下します。
そこで、一時ファイルにエラー出力を退避させ、スクリプトのログ関数を経由して出力することで、タイムスタンプやレベルを統一できます。

run_with_captured_error() {
    local cmd="$1"
    local error_code_base="$2"
    shift 2
    local args=("$@")
    local err_file=$(mktemp)

    "$cmd" "${args[@]}" 2>"$err_file"
    local exit_code=$?

    if [ $exit_code -ne 0 ]; then
        local err_msg=$(cat "$err_file")
        log_error "Command '$cmd' failed: $err_msg"
        rm -f "$err_file"
        return $((error_code_base + exit_code))
    fi
    rm -f "$err_file"
    return 0
}

この実装では、エラー発生時に外部コマンドが出力した生のエラーメッセージがスクリプトのログに取り込まれるため、調査時に外部コマンドのマニュアルを参照する手間が省けます。

エラーコードのラッピングにおいて重要なのは、ベースコードの範囲を事前に定義し、外部コマンドごとに固有のベースを割り当てることです。
例えば、curl 関連は20-29、jq 関連は30-39、aws コマンド関連は40-49というように、コマンドのカテゴリごとにベースコードを予約します。
そして、実際の終了コードは0〜255までの値であるため、ベースコードに加算する際にオーバーフローしないよう、ベースコードは10の倍数でかつ十分な余裕を持たせた設計にします。

外部コマンドカテゴリ ベースコード 予約範囲 典型的なエラー例
ファイル操作(cat, rm, cp) 10 10-19 ファイル不存在(1)→ 11
テキスト処理(grep, sed, awk) 20 20-29 パターン不一致(1)→ 21
ネットワーク(curl, wget) 30 30-39 タイムアウト(28)→ 58
JSON処理(jq) 40 40-49 構文エラー(4)→ 44
データベース(psql, mysql) 50 50-59 接続拒否(2)→ 52

この表に従えば、終了コード58は「curlのタイムアウト」であることが即座に判別できます。
呼び出し元のスクリプトや監視システムは、このコードに基づいて適切なリトライ戦略やアラートレベルを決定できます。

ただし、このラッピング戦略には一点の注意が必要です。
外部コマンドが返す終了コードはコマンドごとに意味が異なるため、ベースコードに単純加算するだけでは、元の終了コードの意味が失われる可能性があります。
そのため、スクリプトのドキュメントに各コードの意味を明記することが必須です。
また、エラーログには元の終了コードと変換後のコードの両方を記録しておくことで、後から元のコマンド仕様に照らして調査することも可能にします。

さらに、外部コマンドが正常終了(0)を返すが、その出力内容が期待通りでないケースにも対応する必要があります。
例えば、curl がHTTPステータスコード200を返しても、レスポンスボディが空であったり、JSONとしてパースできない場合があります。
このような場合は、終了コードではなく出力内容の検証を別途行い、その結果に基づいてスクリプト独自のエラーコード(例えば「空レスポンス」を61、「JSONパース失敗」を62)を定義して返すと、より実用的なエラーハンドリングが実現できます。

最後に、ラッピング関数をライブラリ化して複数のスクリプトで共有することをお勧めします。
同じ外部コマンドに対するラッピングロジックをスクリプトごとに再実装すると、保守性が著しく低下します。
共通のシェルライブラリとして error_wrapper.sh を作成し、各スクリプトからソースすることで、終了コード体系がプロジェクト全体で統一され、運用監視の効率も向上します。
このライブラリには、外部コマンドのラッパーだけでなく、エラーコードの定数定義やログ関数も含めておくと、より一貫性のあるスクリプト群を構築できるでしょう。

リトライ処理とバックオフで一時的障害に強くなる

指数バックオフでリトライ間隔が徐々に長くなるグラフと成功/失敗のタイムライン

データ処理スクリプトが直面するエラーのうち、かなりの割合は一時的なものです。ネットワークの瞬断、外部APIのレート制限、データベースのコネクションタイムアウト、ファイルシステムの一時的なビジー状態など、時間の経過とともに解消される障害が数多く存在します。こうしたケースでスクリプトが即座に失敗してしまうのは、過剰な対応であり、リトライ処理を適切に実装することで可用性を大幅に向上させることができます。

リトライ処理の設計で最も重要なのは、リトライ回数とバックオフ戦略の2つです。
単に同じ処理を数回繰り返すだけでは、負荷が集中している状況では逆効果になることもあります。
そこで、リトライ間隔を指数関数的に増加させる「指数バックオフ」を採用することが推奨されます。
これにより、一時的な過負荷状態に対して優雅に減速し、システム全体の安定性に貢献できます。

retry_with_backoff() {
    local max_attempts="$1"
    local base_delay="$2"
    local cmd="$3"
    shift 3
    local args=("$@")
    local attempt=1
    local exit_code=0

    while [ $attempt -le $max_attempts ]; do
        log_info "Attempt $attempt/$max_attempts: $cmd ${args[*]}"
        "$cmd" "${args[@]}"
        exit_code=$?

        if [ $exit_code -eq 0 ]; then
            log_info "Command succeeded on attempt $attempt"
            return 0
        fi

        if [ $attempt -eq $max_attempts ]; then
            log_error "Command failed after $max_attempts attempts, last exit code: $exit_code"
            return $exit_code
        fi

        local delay=$((base_delay * (2 ** (attempt - 1))))
        local jitter=$((RANDOM % 1000))
        delay=$((delay + jitter))
        log_warn "Command failed with code $exit_code, retrying in ${delay}ms"
        sleep $(echo "scale=3; $delay/1000" | bc)

        ((attempt++))
    done
}

この関数では、リトライのたびに待機時間が2倍になり、さらにランダムなジッター(揺らぎ)を加えることで、複数のスクリプトやプロセスが同時にリトライする際の「サンダリングハード問題」を緩和しています。
base_delay はミリ秒単位で指定し、初回リトライまでの待機時間を設定します。
例えば、base_delay=1000 なら初回リトライは1秒後、2回目は2秒後、3回目は4秒後と増加します。

リトライ対象とするエラーは、すべてのエラーではなく、特定の終了コードやエラーメッセージに限定することが重要です。
例えば、curl でタイムアウト(コード28)やサーバーエラー(コード52)はリトライ対象にしますが、認証エラー(コード22)はリトライしても成功しないため、即座に失敗させるべきです。
この区別を実装するには、リトライ可能な終了コードのリストを引数として渡すか、エラーメッセージに特定のパターンが含まれるかを検査するロジックを追加します。

is_retriable_error() {
    local code="$1"
    local retriable_codes=(28 52 56 7)
    for c in "${retriable_codes[@]}"; do
        if [ "$code" -eq "$c" ]; then
            return 0
        fi
    done
    return 1
}

この関数を組み込むことで、リトライ処理はより賢くなります。

リトライ処理を設計する際のもう一つの重要な考慮点は、冪等性(べきとうせい)です。
同じ処理を複数回実行しても結果が同じであることが保証されている場合にのみ、リトライは安全に行えます。
データ処理の文脈では、ファイルの追記書き込みやデータベースのINSERT操作は冪等ではないため、リトライ前に状態を確認するか、トランザクションIDを用いた重複防止機構を別途実装する必要があります。
例えば、APIへのデータ送信であれば、リクエストIDを付与してサーバー側で重複を検出する設計にすることで、リトライがデータの二重登録を引き起こすリスクを回避できます。

ネットワークを介したデータ取得では、部分的な成功にも注意が必要です。
例えば、1000件のレコードを一度に取得するAPIが途中でタイムアウトした場合、一部のデータはすでに受信済みかもしれません。
このようなケースでは、単純なリトライではなく、続きからの再開(レンジリクエストやページネーション)をサポートする方が効率的です。
シェルスクリプトではこの制御が難しいため、外部コマンドのリトライ機能(curl --retry など)を活用することも検討してください。

障害タイプ リトライ可否 推奨バックオフ 上限回数 備考
ネットワークタイムアウト 指数(初回2秒) 5回 ジッターを加える
HTTP 5xx(サーバーエラー) 指数(初回1秒) 3回 503は特に推奨
HTTP 429(レート制限) 指数(初回5秒) 10回 Retry-Afterヘッダを優先
HTTP 4xx(クライアントエラー) 不可 なし 0回 即時失敗
ファイルロック競合 線形(初回0.5秒) 3回 短い間隔で再試行

この表に示すように、障害の種類によって最適なリトライ戦略は異なります。
curl には --retry--retry-delay オプションが用意されているため、外部コマンド自体のリトライ機能を優先的に使い、スクリプト側ではそれを補完する形にするとコードがシンプルになります。

リトライ処理の実装では、リトライ回数の上限と総実行時間の上限の両方を設定することが実運用では求められます。
例えば、最大5回までリトライしても、それぞれの待機時間の合計が30秒を超える場合はタイムアウトとして扱うなど、二重のガードを設けることで、障害が長期化した場合でもスクリプトが無限に待ち続けることを防げます。

最後に、リトライのロギングは特に丁寧に行う必要があります。
何回目のリトライが成功したのか、または最終的に失敗したのかを明確に記録することで、ネットワークの不安定性やAPIのパフォーマンス劣化を事後的に分析できます。
また、リトライごとの待機時間もログに含めることで、バックオフ戦略が意図通りに機能しているかを検証できます。
リトライ処理は「エラーを隠す」のではなく「エラーを可視化しつつ回復を試みる」ものであり、その過程はすべて監視可能な状態にしておくことが、保守性の高い自動化スクリプトの条件です。

スクリプト設計段階で組み込むアサーションと事前条件チェック

データ入力前にスキーマ検証と必須項目チェックを行うチェックリスト

エラーハンドリングの多くは「何かが起きた後」の対応に焦点が当てられがちですが、本当に効果的なエラーハンドリングは、エラーが起きる前に防ぐことにあります。
そのための手法が、スクリプト設計段階で組み込むアサーションと事前条件チェックです。
これは、データ処理の各段階に入る前に、入力が期待される状態を満たしていることを検証し、満たしていない場合は早期に失敗させることで、不整合なデータがパイプラインの奥深くまで進むのを防ぎます。

アサーションとは、プログラムのある時点で必ず真であるべき条件を記述する手法です。
シェルスクリプトでは、専用のアサーション関数を用意し、条件が偽になった場合にエラーログを出力してスクリプトを停止させることで実装できます。
アサーションはデバッグ支援だけでなく、設計上の前提をコードとして明示するという重要な役割も果たします。

assert() {
    local condition="$1"
    local message="$2"
    if ! eval "$condition"; then
        log_critical "Assertion failed: $message (condition: $condition)"
        print_stack_trace
        exit 70
    fi
}

この関数を使えば、assert '[ -f "$input_file" ]' "Input file $input_file does not exist" のように記述でき、ファイルが存在しない段階でスクリプトが停止します。
eval を使っているため、変数展開やテストコマンドを含む任意の条件式を渡せる点が特徴です。

事前条件チェックは、アサーションの一種でありながら、特にデータ処理の各段階の入り口に配置することを指します。
例えば、CSVファイルを処理する前に、ヘッダー行が期待するカラム数と一致しているか、必須フィールドに空文字が含まれていないか、日付フォーマットがISO8601に準拠しているかなどを検証します。
これらのチェックを各変換ステップの直前に置くことで、異常データが次の段階に進む前に除外できます。

validate_csv_headers() {
    local file="$1"
    local expected_columns="$2"
    local header=$(head -n 1 "$file")
    local actual_columns=$(echo "$header" | awk -F',' '{print NF}')
    assert "[ $actual_columns -eq $expected_columns ]" \
           "CSV header has $actual_columns columns, expected $expected_columns"
}

この検証は、データ処理の最初の段階で実施することで、後続の awkcut がフィールド数の不一致で予期せぬ動作をするのを未然に防ぎます。

事前条件チェックの設計で重要なのは、チェックのコストと効果のバランスです。
すべてのレコードに対して複雑なバリデーションを行うと、処理時間が大幅に増加します。
そこで、チェックを以下の3つの層に分けて設計することをお勧めします。

第一層はファイルレベルのチェックです。
ファイルサイズが0でないか、文字コードがUTF-8であるか、BOMが正しく扱えるかなど、ファイル全体に対する軽量な検証を行います。
この層のチェックは高速であり、致命的な問題を早期に発見できます。

第二層はサンプリングベースのチェックです。
全レコードではなく、先頭数行やランダムサンプルに対してスキーマ検証や型チェックを実施します。
これにより、全件チェックに比べてコストを抑えながら、データの傾向を把握できます。

第三層は個別レコードのチェックで、これは実際の変換処理の中で行います。
ただし、この層ではエラーが発生してもスクリプト全体を停止させるのではなく、前述の継続的エラーハンドリング戦略に従って異常レコードをスキップする設計が適切です。

チェック層 対象 コスト 発見できる問題 失敗時の対応
ファイルレベル ファイル全体 空ファイル、文字コード違反、アクセス権限 即時終了(CRITICAL)
サンプリング 先頭数行 カラム数不一致、日付フォーマット異常 警告(WARN)+継続判断
レコード単位 各行 フィールドごとの値域違反、欠損値 スキップ(ERROR)

アサーションを設計する際に忘れてはならないのは、アサーション自体がバグを含まないようにすることです。
条件式に複雑なパイプラインや外部コマンドを含めると、その評価自体が失敗する可能性があります。
アサーションの条件式は可能な限りシンプルに保ち、複雑な検証は専用の関数として分離することを推奨します。

また、アサーションは本番環境でも有効にしておくべきです。
デバッグ時だけ有効にする「開発時アサーション」という考え方もありますが、データ処理スクリプトでは、想定外の入力が本番環境でこそ発生するため、常に有効にしておく方が安全です。
ただし、アサーションが頻繁に発動するようであれば、それはスクリプトの前提条件が実際の運用と乖離している証拠であり、設計の見直しが必要です。

事前条件チェックのもう一つの重要な側面は、外部依存関係の確認です。
スクリプトが利用する外部コマンドがインストールされているか、バージョンが適切か、ネットワーク経由のAPIが到達可能かといった環境条件を、スクリプトの最初期にチェックします。
これらのチェックを main 関数の冒頭に集約することで、処理の途中で「コマンドが見つからない」という初歩的なエラーが発生するのを防げます。

check_dependencies() {
    local required_cmds=("curl" "jq" "awk" "sed")
    for cmd in "${required_cmds[@]}"; do
        if ! command -v "$cmd" >/dev/null 2>&1; then
            log_critical "Required command '$cmd' not found in PATH"
            exit 41
        fi
    done
}

このチェックをスクリプトの先頭で実行することで、依存関係の不足が原因で発生するエラーを、データ処理が始まる前に明確なメッセージとともに停止できます。

最後に、アサーションと事前条件チェックはスクリプトのドキュメントとしても機能するという点を強調しておきます。
コードを読んだ開発者は、アサーションが宣言されている場所を見るだけで、その関数やスクリプトがどのような前提で動作するのかを理解できます。
これは、後任の開発者や自分自身が半年後にスクリプトを修正する際に、非常に大きな助けとなります。
エラーハンドリングはコードの品質を高めるだけでなく、チーム全体の生産性向上にも寄与するのです。

まとめ――エラーハンドリングは運用コストを下げる投資である

エラーがなく安定稼働するサーバーと、明確なログが積み上がるダッシュボードのイメージ

ここまで、シェルスクリプトにおけるエラーハンドリングの設計原則と実装パターンを、データ処理という具体的な文脈で幅広く解説してきました。
set -eupipefail によるデフォルト動作の堅牢化から始まり、trap を活用した確実な後片付け、パイプラインエラーの漏れない検知、終了コードとログレベルの体系化、継続的エラーハンドリング戦略、デバッグ情報の充実、外部コマンドエラーのラッピング、リトライとバックオフ、そしてアサーションによる事前条件チェックまで、多くのトピックを扱いました。
これらはすべて独立したテクニックではなく、相互に補完し合う一貫した設計思想の一部であることをご理解いただけると思います。

エラーハンドリングを軽視したスクリプトは、開発当初は短く書けて一見効率的に見えます。
しかし、本番運用が始まると、原因不明の停止、データ不整合、復旧作業の長時間化といった形で、そのツケが確実に回ってきます。
夜間バッチが止まったというアラートで深夜に起こされ、ログを見ても何が起きたのかさっぱり分からない――そんな経験をお持ちの開発者は少なくないでしょう。
エラーハンドリングは、そうした運用負荷を劇的に軽減するための先行投資なのです。

具体的な投資対効果を考えてみましょう。
適切なエラーハンドリングを実装するのにスクリプト全体の工数が2割増えたとしても、運用開始後の障害対応時間が半分以下になり、データ修正の手間が大幅に減り、さらには障害によるビジネス影響を最小化できます。
特にデータ処理スクリプトは、一度障害が起きると再実行に長時間かかることも多く、その間の損失は工数以上に大きくなります。
そう考えると、エラーハンドリングは「コスト」ではなく「リスクヘッジ」として捉えるべきでしょう。

本記事で紹介した各パターンは、すべてのスクリプトに一度に適用する必要はありません。
まずは set -euo pipefailtrap によるクリーンアップの2つを導入するだけでも、スクリプトの信頼性は格段に向上します。
そこから、ログレベルの導入、終了コードの体系化、リトライ処理と順に拡張していけば、無理なく保守性の高いコードベースが構築できます。
重要なのは、エラーハンドリングを「後付けのオプション」ではなく「設計の必須要件」として位置づけることです。

また、エラーハンドリングはスクリプトごとに独自実装するよりも、共通ライブラリとしてモジュール化することを強く推奨します。
ログ関数、アサーション関数、リトライラッパー、エラーコード定義などを一つのファイルにまとめ、各スクリプトからソースすることで、プロジェクト全体で一貫した品質を維持できます。
このライブラリ自体もバージョン管理し、チーム内でレビューと改善を繰り返すことで、組織全体の自動化スクリプトの成熟度が上がっていきます。

最後に、エラーハンドリングの設計において常に意識すべきは、「人間が読んで理解できる」ことです。
どんなに精巧なエラー検知機構を実装しても、その出力するメッセージや終了コードが運用者に伝わらなければ意味がありません。
エラーメッセージは具体的で、次のアクションが推測できる内容にしましょう。
「何かがおかしい」ではなく、「どのファイルの何行目で、どのような値が想定外だったので、次はこの箇所を確認してください」と示せるのが理想です。

エラーハンドリングは華やかな機能ではありませんが、スクリプトの長期的な寿命と運用者の精神衛生に直結する、最も実践的なスキルの一つです。
本記事が、皆さんの自動化スクリプトをより堅牢で、より保守しやすいものにするための一助となれば幸いです。
次のプロジェクトでは、ぜひエラーハンドリングを設計の中心に据えてみてください。
その先にあるのは、安心して眠れる夜と、問題が起きてもすぐに解決できる朝です。

コメント

タイトルとURLをコピーしました