現場で使えるシェルスクリプト入門17:trapとロックファイルを使用した二重起動防止(排他制御)と高度な例外設計

シェルスクリプト IT

前回の記事(入門16)では、Web APIの連携やクラウドインフラ管理において欠かせないJSON形式データをパース・抽出できる「jq」コマンドの基本と、配列の安全なループ処理について解説しました。

今回は、バッチ処理や夜間のcronジョブなどを本番環境で運用する際に極めて重要なテーマである「二重起動防止(排他制御)」について解説します。

時間のかかるスクリプトがまだ実行中であるにもかかわらず、次のスケジュール起動などで重ねて実行されてしまうと、サーバーのCPUやメモリを食いつぶしたり、データベースのデータを破損させたりする重大な障害を引き起こします。これを完全に防ぎ、予期せぬエラーや強制中断時にも安全に復旧できる堅牢なスクリプトの書き方を学びましょう!

二重起動防止(排他制御)の基本とアトミック性

スクリプトの多重実行を防ぐ一般的なアプローチは、「現在実行中であることの印(ロック)」を作ることです。

スクリプトが起動した際に「ロック」を作成し、処理が終わったら「ロック」を解除します。後から起動したスクリプトは、そのロックの有無を調べ、すでにロックが存在する場合は即座にエラー終了して処理をスキップします。

このロックを作成するプロセスにおいて、最も重要なのがアトミック性(不可分性)です。「ロックがあるか確認する処理」と「ロックを作成する処理」の間に少しでもタイムラグがあると、ほぼ同時に起動した複数のプロセスが両方とも「ロックがない」と判定し、同時にロックを作成して動いてしまう「競合状態(レースコンディション)」が発生します。

Linux/Unix環境において、この「確認」と「作成」を完全に同時に、1つのアクションとして実行できる最も簡単なコマンドが mkdir(ディレクトリ作成)です。すでに存在する名前でディレクトリを作成しようとすると、OSレベルで確実にエラー(終了ステータス非0)を返す仕様を利用して、安全な排他制御を実現します。

二重起動防止と自動ロック解除を実現するサンプルスクリプト

以下は、mkdir コマンドを使って一時的なロックディレクトリを作成し、処理中に例外エラーが発生したり、ユーザーが Ctrl+C で強制中断した場合でも、確実にロックを削除し、かつ呼び出し元に正しい終了ステータス(シグナル番号)を伝播させて後片付けを行うサンプルスクリプト(prevent_double_boot.sh)です。

#!/bin/bash

# スクリプトを安全に実行するための基本設定
set -euo pipefail

# 1. ロック用ディレクトリのパスを定義
LOCK_DIR="/tmp/my_batch_process.lock"

# 2. ロック解放のためのクリーンアップ関数を定義
cleanup() {
    # ロックが存在する場合のみ削除する
    if [ -d "$LOCK_DIR" ]; then
        rmdir "$LOCK_DIR"
        echo "[クリーンアップ] ロックを正常に解放しました。"
    fi
}

# 3. 割り込み(Ctrl+C)および終了要求発生時のシグナルハンドラ関数を定義
on_interrupt() {
    cleanup
    echo "[エラー] ユーザーによる強制割り込み(Ctrl+C)を検知しました。" >&2
    # 💡 SIGINT(2) による終了コード 128 + 2 = 130 を明示的に返します
    exit 130
}

on_terminate() {
    cleanup
    echo "[エラー] システムからの終了要求を検知しました。" >&2
    # 💡 SIGTERM(15) による終了コード 128 + 15 = 143 を明示的に返します
    exit 143
}

# 4. シグナル発生時にそれぞれの関数が実行されるよう trap に登録
# 正常終了や通常の例外エラー(EXIT)時は通常のクリーンアップを実行
trap cleanup EXIT
# 強制終了の割り込み(INT, TERM)時は個別のハンドラを実行して適切な終了コードを返却
trap on_interrupt INT
trap on_terminate TERM

# 5. mkdir を使ってアトミックにロックディレクトリを作成する
# すでにディレクトリが存在する場合は作成に失敗し、エラーメッセージを出して終了します
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    echo "エラー: スクリプトは既に実行中、またはロックファイルが存在します。" >&2
    echo "二重起動を防ぐため、処理を終了します。" >&2
    
    # 後続のクリーンアップによる誤削除を防ぐため、trap設定を解除してから異常終了する
    trap - EXIT INT TERM
    exit 1
fi

echo "--- 処理を開始します (排他制御中) ---"

# 6. 実務を想定した重い処理のシミュレーション(15秒待機)
for i in {1..15}; do
    echo "処理中... ($i 秒経過)"
    sleep 1
done

echo "--- すべての処理が正常に完了しました ---"

プログラムコードの詳細解説

スクリプト内の重要なステップを順番に解説します。

  • クリーンアップ関数の定義(cleanup)
    スクリプト終了時にロックを解放するための関数です。ロックディレクトリ($LOCK_DIR)が存在するかどうかを確認した上で、rmdir コマンドで安全に削除します。
  • シグナルハンドラ関数(on_interrupt / on_terminate)の定義
    ユーザーによる強制割り込み(Ctrl+C = SIGINT)やシステムからの終了命令(SIGTERM)を受け取った際、単にロックを消すだけでなく、呼び出し元のシステム等に対して「何が原因で異常終了したか」を正確に伝えるために、クリーンアップ実行後に exit 130exit 143 を返して明示的に終了しています。
  • trapによるイベントハンドラの登録
    正常終了および内部例外での終了(EXITシグナル)時は cleanup 関数を登録し、外部からの強制終了シグナル(INT, TERM)には上記の専用のハンドラ(on_interrupt, on_terminate)を個別に紐付けています。これによって、どのような停止方法であっても確実にロックが解除され、かつ正しい終了ステータスが外部に伝わります。
  • mkdirによるアトミックなロック判定
    if ! mkdir "$LOCK_DIR" 2>/dev/null; then の部分です。ディレクトリ作成が成功すればそのまま処理へ進みます。もし既に別のプロセスが実行中であり、ディレクトリが存在していれば、mkdir はエラーを返します。この結果を if の否定条件で捕らえ、重複起動を防ぎます。
  • 多重実行側のtrap解除と終了
    すでに実行中だと判定されてスクリプトを終了する際、そのまま exit 1 してしまうと、登録された cleanup 関数が動き、先行して実行されているプロセスのロックディレクトリを誤って削除してしまいます。 これを防ぐため、trap - EXIT INT TERM コマンドを実行してイベント監視設定を解除した上で exit 1 で終了しています。

シェルスクリプト排他制御時の落とし穴とエラーハンドリング

シェルスクリプトで排他制御や例外ハンドリングを自作する際、不具合やセキュリティ障害に繋がりやすい「2つの落とし穴」に注意する必要があります。

① 「ファイル存在チェック(if [ ! -f ])」による排他制御がNGな理由

陥りがちなのが、以下のような「ファイルの存在確認」と「ファイル作成」を分けた書き方です。

# ❌ 競合状態が発生する危険な書き方
if [ ! -f "/tmp/lockfile" ]; then
    # この瞬間に他のプロセスが割り込む余地がある
    touch "/tmp/lockfile"
    # メイン処理...
fi

このロジックは、if で「ファイルがないこと」をチェックしたあと、実際に touch で「ファイルを生成する」までの間に、OSの実行プロセス切り替えによるタイムラグが存在します。cronや急激な負荷によって2つのスクリプトが同時に数ミリ秒の差で立ち上がった場合、「両方のプロセスが同時にif文を通過し、結果として多重実行されてしまう」という障害が発生します。

そのため、本記事のサンプルのように「存在確認」と「作成(確保)」が絶対に割り込まれずに行われる(アトミックな) mkdirln コマンド、あるいは flock コマンドを使用しなければなりません。

② Ctrl+C で中断された際の終了ステータス伝播マナー

ユーザーがスクリプトの実行中に Ctrl+C を押して中断した場合、スクリプトの外部(親プロセスや監視ツール)に対して「シグナルによって中断された」ことを正しく伝え、外部ツールの処理を分岐させることがあります。

Bashなどの標準では、Ctrl+C(SIGINT = シグナル番号2)で終了した場合、その終了ステータスは 128 + シグナル番号 である 130 になる必要があります。

サンプルスクリプトでは、ユーザーが Ctrl+C を押した際、trap経由で on_interrupt 関数が呼び出され、最終的に exit 130 で終了するように設計されています。もし単に exit 1 で終わらせたり、何の設定もせずに関数の処理だけを抜けてしまうと、終了ステータスが 0(正常終了)になり、呼び出し元のジョブ管理システムなどが「バッチ処理は最後まで正常に完了した」と誤認し、エラー検知が機能しなくなってしまいます。例外をトラップする際は、何シグナルによる中断であるかに応じた適切な終了コードを返却するように設計してください。

まとめ

今回は、バッチ処理やcronジョブの暴走を防ぐために不可欠な「二重起動防止(排他制御)」の実装方法と、それを支える trap コマンドによる例外クリーンアップ設計について解説しました。

アトミックな作成を保証する mkdirtrap を組み合わせることで、中断やエラーが起きても自動復帰できるシェルスクリプトを記述することができます。ぜひ実務の環境でも役立ててください!

コメント

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