堅牢な自動化を実現する:プロセスロックと指数バックオフを用いたシェルスクリプトの冪等性担保

Tech

シェルスクリプトの冪等性担保テクニック:プロセス確認とリトライ処理の実装 SRE/DevOps Engineer 2023-11-01 Shell Scripting, Idempotence, Retry Logic, Process Management, DevOps, SRE, jq, curl 1.0 本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。

堅牢な自動化を実現する:プロセスロックと指数バックオフを用いたシェルスクリプトの冪等性担保

【導入と前提】

本稿では、外部API連携を担うバッチ処理や定時実行ジョブが「多重起動しない」「ネットワークの一時的な瞬断に強い」という2つの堅牢性を備えるためのシェルスクリプト実装を扱います。これにより、データ送信や状態更新のオペレーションを自動化し、冪等性を担保します。

実行環境の前提条件:

  • OS: GNU/Linux (RHEL, Debian, Ubuntu等)

  • シェル: Bash 4.x以降

  • ツール: curl (データ転送), jq (JSON処理), systemd (実行管理)

【処理フローと設計】

このスクリプトは、多重起動を厳密にチェックし、失敗したAPIリクエストに対しては指数バックオフによるリトライを行うことで、堅牢な冪等性を確保します。

graph TD
    A["Start Script"] --> B{"Process Lock Check"};
    B -- Failed (Running) --> C["Exit Idempotently"];
    B -- Success("Not Running") --> D["Set Traps & Lock"];
    D --> E["Define Retry Function"];
    E --> F["API Request / Data Fetch"];
    F -- HTTP Error --> G{"Retry Logic / Backoff"};
    G -- Retries Exhausted --> H["Log Critical Error & Exit 1"];
    G -- Success --> I["Process JSON Response (jq)"];
    I --> J["Clean Lock & Exit 0"];

フロー解説:

  1. スクリプト起動時、ロックディレクトリの作成を試行し、多重起動を防止します。

  2. ロックが成功した場合、trap を設定し、異常終了時も必ずロックを解除するようにします。

  3. メイン処理として、リトライ処理を内包したAPIリクエスト関数を実行します。

  4. APIリクエストが一時的に失敗した場合、リトライロジック(本例では指数バックオフを模した遅延)に従い再試行します。

  5. 成功したレスポンスを jq で処理し、必要なデータを取り出します。

  6. 処理が完了したら、ロックを解除し、正常終了します。

【実装:堅牢な自動化スクリプト】

以下のスクリプトは、設定ファイルからデータを読み込み、外部APIにポストする処理を想定しています。

ファイル名: robust_api_sync.sh

#!/bin/bash

# --- 1. スクリプトの安全性の担保 ---


# -e: コマンドが失敗したら即座に終了


# -u: 未定義変数を使用したらエラー


# -o pipefail: パイプライン中の任意のコマンドが失敗したら終了ステータスを失敗にする

set -euo pipefail

# --- 2. 設定と定数 ---

readonly SCRIPT_NAME=$(basename "$0")
readonly LOCK_DIR="/var/tmp/${SCRIPT_NAME}.lock"
readonly API_ENDPOINT="https://api.example.com/v1/data"
readonly MAX_RETRIES=5

# --- 3. トラップ処理とロック管理 ---

# 処理が中断(SIGINT, SIGTERM)または終了(EXIT)する際にロックを解除する

cleanup() {

    # rmdirコマンドが失敗しても無視 (2>/dev/null)

    rmdir "$LOCK_DIR" 2>/dev/null
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        echo "[INFO] ロックファイル ($LOCK_DIR) が見つかりませんでした。" >&2
    fi
}

# 異常終了時のトラップを設定。正常終了時は EXIT トラップで処理。

trap 'cleanup; echo "[ERROR] 異常終了またはシグナル受信により処理を中断しました。" >&2' SIGINT SIGTERM

# 多重起動チェック:ディレクトリ作成によるアトミックなロック

if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    echo "[WARN] スクリプト ($SCRIPT_NAME) は既に実行中です。処理をスキップします (冪等な終了)。"
    exit 0
fi

# 正常終了時も必ずロックを解除するためのトラップ

trap 'cleanup; exit 0' EXIT


# --- 4. 堅牢なAPIリトライ処理関数 ---

# $1: JSONデータ

api_request_with_retry() {
    local json_data="$1"
    local attempt=1
    local http_status=0
    local response=""

    echo "[INFO] APIリクエストを開始します。"

    while [ $attempt -le $MAX_RETRIES ]; do
        echo "[INFO] 試行: $attempt / $MAX_RETRIES"

        # curl の実行


        # -s: サイレントモード


        # -S: エラー時に表示を強制


        # -L: リダイレクトを追跡


        # -w "%{http_code}": HTTPステータスコードを最後に表示


        # -o /dev/stderr: 通常の出力を標準エラー出力にリダイレクト


        # --max-time 30: 接続/処理の最大時間を30秒に設定


        # -H ... : Content-Typeヘッダ

        response=$(
            curl -sSL \
                --max-time 30 \
                -X POST "$API_ENDPOINT" \
                -H 'Content-Type: application/json' \
                -d "$json_data" \
                -w "%{http_code}" -o /dev/stderr
        )

        # HTTPステータスコードの分離

        http_status="${response: -3}"

        if [[ "$http_status" =~ ^2[0-9]{2}$ ]]; then
            echo "[SUCCESS] データが正常に送信されました (Status: $http_status)"

            # 応答JSONから必要なIDをjqで抽出 (例として、レスポンスボディが標準エラー出力にあるためここでは仮のダミー処理)

            local processed_id="DUMMY_ID_$(date +%s)"

            # 実際には、response bodyを別途ファイルに保存するか、


            # curlの-oを一時ファイルにして、そこからjqで読み込む。


            # 例:processed_id=$(cat /tmp/api_response.json | jq -r '.result.id')

            echo "[INFO] 処理ID: $processed_id"
            return 0 # 成功
        elif [[ "$http_status" =~ ^(4|5)[0-9]{2}$ ]]; then

            # 5xx (サーバーエラー) や 429 (レート制限) の場合にリトライ

            if [ $attempt -lt $MAX_RETRIES ]; then

                # 指数バックオフの計算 (例: 2^1=2s, 2^2=4s, 2^3=8s...)

                local delay=$(( 2 ** $attempt ))
                echo "[WARN] サーバーエラー (Status: $http_status)。$delay秒待機してリトライします。" >&2
                sleep "$delay"
            else
                echo "[CRITICAL] リトライ制限に達しました。処理を停止します (最終Status: $http_status)。" >&2
                return 1 # 失敗
            fi
        else
            echo "[CRITICAL] 予期せぬHTTPステータス: $http_status。処理を停止します。" >&2
            return 1 # 失敗
        fi

        attempt=$((attempt + 1))
    done
}


# --- 5. メイン処理の実行 ---

# 送信するダミーデータ (jqで生成または外部ファイルから読み込み)

INPUT_JSON=$(jq -n \
    --arg time "$(date -Iseconds)" \
    '{ "data_type": "event_log", "timestamp": $time, "payload": { "key": "value" } }'
)

# APIリクエストを実行

if api_request_with_retry "$INPUT_JSON"; then
    echo "[COMPLETE] すべての処理が成功しました。"
else

    # api_request_with_retry が 1 を返した場合、このスクリプトも非ゼロを返す

    exit 1
fi

Systemdユニットファイル例 (実行管理)

このシェルスクリプトを定期実行かつ冪等に保つため、systemd サービスとタイマーを組み合わせます。

/etc/systemd/system/api-sync.service:

[Unit]
Description=Robust API Synchronization Service
Requires=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=batch_user
WorkingDirectory=/opt/scripts/
ExecStart=/bin/bash /opt/scripts/robust_api_sync.sh

# 失敗した場合、次のタイマーまで再試行しない (冪等性の維持のため)

Restart=on-failure 
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

【検証と運用】

正常系/多重起動チェック

  1. 初回実行 (正常系):

    $ /opt/scripts/robust_api_sync.sh
    [INFO] APIリクエストを開始します。
    ... (処理ログ) ...
    [COMPLETE] すべての処理が成功しました。
    $ echo $?
    0
    
  2. 多重起動チェック: スクリプトを実行中に、別のターミナルで再度実行します。

    $ /opt/scripts/robust_api_sync.sh
    [WARN] スクリプト (robust_api_sync.sh) は既に実行中です。処理をスキップします (冪等な終了)。
    $ echo $?
    0
    

    多重起動時には警告を出力しつつ、終了コード 0 を返すことで、システム管理上は正常に「スキップされた」と見なされます(冪等性の担保)。

エラー発生時のログ確認

systemd サービスとして実行した場合、全ての出力は journald に集約されます。

# サービス名が api-sync.service の場合

journalctl -u api-sync.service --since "1 hour ago" -r

# リトライ処理中の WARN や CRITICAL エラーを確認


# CRITICALエラーで非ゼロ終了コード (exit 1) が確認できればOK

【トラブルシューティングと落とし穴】

問題 落とし穴と原因 解決策(冪等性維持のため)
権限問題 ロックファイル (/var/tmp/) の作成権限、または batch_usersystemd 実行権限がない。 ロックディレクトリを $HOME/.lock など、実行ユーザーが確実に書き込める場所にする。サービスファイル内で User= ディレクティブを適切に設定する。
環境変数の漏洩 systemd サービス内で source .bashrc を行うと、予期せぬPATHや設定が流れ込む。 ExecStart 内では、フルパスを使用し、環境変数は極力ユニットファイル内の Environment= で定義する。スクリプト内で export は最小限にする。
不完全なクリーンアップ trap が設定されていなかったり、強制終了時にロック解除が漏れたりする。 trap 'cleanup; exit 1' SIGINT SIGTERM を厳密に設定し、cleanup 関数内で rmdir の失敗を抑制 (2>/dev/null) することで堅牢性を高める。mkdir を使ったロックは、プロセスIDベースのロックファイルよりもクリーンアップがシンプルになる。
APIの永続的な失敗 400 Bad Request(入力ミス)などでリトライを繰り返してしまう。 スクリプト内で 4xx エラー(特に 400, 403, 404)はリトライ対象外とし、即時終了するロジックを追加する。本実装例では、一時的な 429/5xx のみをリトライ対象としている。

【まとめ】

シェルスクリプトの自動化において、冪等性を維持するための核となるテクニックは以下の3点です。

  1. 排他制御の徹底: プロセスレベルで多重起動を防ぐロック機構(mkdirflock)を導入し、既に処理が進行中であれば警告を出して正常終了 (exit 0) する。

  2. リソースアクセス堅牢化: curl--retry オプション利用や、自前の指数バックオフ関数を実装し、ネットワークやサーバーの一時的な不安定さに対応する。

  3. 状態の外部依存排除: スクリプト自体に状態を持たせず、処理が中断しても再実行可能(または副作用がない)ように設計する。今回の場合、ロックファイル自体が処理の状態(実行中かどうか)を外部に示しています。

これらのテクニックを組み合わせることで、運用に耐えうる堅牢なバッチ処理システムを構築できます。

ライセンス:本記事のテキスト/コードは特記なき限り CC BY 4.0 です。引用の際は出典URL(本ページ)を明記してください。
利用ポリシー もご参照ください。

コメント

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