外部API連携における状態管理の冪等性を担保するCLIスクリプト自動化設計

Tech

  • ユーザーの目的:外部APIとの連携処理をCLIスクリプトで自動化し、重複実行(多重決済や二重登録など)を防ぐために「状態ファイル」を用いた冪等性管理を設計・実装すること。

  • トーン&マナー:実戦経験豊富なSREエンジニアが執筆した、信頼性が高く具体的で、本番環境に耐えうる技術ドキュメント。

  • 技術的正確性:

    • set -euo pipefail によるエラーハンドリング。

    • trap を用いた確実な一時ファイル・ロックファイルのクリーンアップ。

    • mv コマンドを用いたアトミック(原子性)な状態ファイル更新(書き込み中のプロセス競合やクラッシュから状態破損を防ぐため、同一ファイルシステム内でのリネーム)。

    • curl による段階的なリトライ設計(リトライ回数、リレー、タイムアウト)。

    • systemd による定期実行(Timer)とシステムロギング(stdout/stderrからjournaldへのフォワード)。 本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。

外部API連携における状態管理の冪等性を担保するCLIスクリプト自動化設計

【導入と前提】

本スクリプトは、外部APIとのデータ同期において、状態ファイルのアトミック更新と冪等性キーの管理により、二重実行を防止する堅牢なCLI自動化機構です。

実行環境・前提条件

  • 稼働OS: Linux (RHEL 8/9, Ubuntu 22.04 LTS 以降推奨)

  • 必須ツール:

    • curl (バージョン 7.71.0 以上: リトライ制御の最適化)

    • jq (バージョン 1.6 以上: 高度なJSONストリーム処理)

    • uuidgen または shasum (冪等性キーの動的生成)


【処理フローと設計】

graph TD
    A["Start: 実行開始"] --> B["ロック取得 & 状態ファイル読込"]
    B --> C{"既存状態の解析"}
    C -- "未完了/新規" --> D["冪等性キーの生成/ロード"]
    C -- "完了済" --> E["処理スキップ & 終了"]
    D --> F["一時作業ファイルへの中間書き込み"]
    F --> G["APIリクエスト送信: curl"]
    G --> H{"レスポンス判定"}
    H -- "200/201 OK" --> I["状態JSONの更新 & 一時保存"]
    H -- "一時エラー/タイムアウト" --> J["リトライ実施"]
    J --> G
    H -- "致命的エラー (4xx/500)" --> K["エラーハンドリング & 異常終了"]
    I --> L["アトミックなmv置換による状態確定"]
    L --> M["ロック解放 & 正常終了"]
    K --> N["ロック解放 & 異常終了"]

設計上の技術的アプローチ

  1. 排他制御(ロック機構): 多重起動を防止するため、flock コマンドを利用したファイル記述子ベースの排他制御を実施。

  2. アトミック(原子性)な状態更新: 稼働中のディスクフルや強制終了による状態ファイルの破損を防ぐため、状態の書き出しは常に同一ディレクトリ内の一時ファイル(.tmp)に行い、最後に mv コマンドを用いて一瞬で置換します。

  3. 冪等性の担保: APIヘッダーに Idempotency-Key を付与し、APIサーバー側での二重処理を物理的に防止します。


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

1. 状態管理同期スクリプト (/usr/local/bin/sync_state.sh)

#!/usr/bin/env bash


# ==============================================================================


# 外部API連携 状態管理&冪等性担保CLIスクリプト


# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

# --- 構成定義 ---

readonly APP_DIR="/var/lib/api-syncer"
readonly STATE_FILE="${APP_DIR}/state.json"
readonly LOCK_FILE="${APP_DIR}/sync.lock"
readonly API_URL="https://api.example.com/v1/transactions"
readonly TIMEOUT_SEC=15
readonly MAX_RETRIES=3

# 環境変数(API認証キー等)の検証

if [[ -z "${API_TOKEN:-}" ]]; then
    echo "[ERROR] API_TOKEN が環境変数に設定されていません。" >&2
    exit 1
fi

# 実行環境のセットアップ

mkdir -p "${APP_DIR}"
touch "${STATE_FILE}"

# 状態ファイルが空の場合は初期JSONを出力

if [[ ! -s "${STATE_FILE}" ]]; then
    echo '{"last_status": "INIT", "idempotency_key": "", "updated_at": ""}' > "${STATE_FILE}"
fi

# --- 排他制御 (ファイルロック) ---

exec 9>"${LOCK_FILE}"
if ! flock -n 9; then
    echo "[WARNING] 他の同期プロセスが現在実行中です。処理をスキップします。" >&2
    exit 0
fi

# 一時ファイルのクリーンアップ関数

cleanup() {
    local exit_code=$?

    # スクリプト終了時に一時ファイルを確実に削除する

    if [[ -f "${TEMP_STATE:-}" ]]; then
        rm -f "${TEMP_STATE}"
    fi

    # ロック解除

    flock -u 9
    exit "${exit_code}"
}
trap cleanup EXIT INT TERM

# --- メインロジック ---

# 現在の状態をロード

CURRENT_STATUS=$(jq -r '.last_status' "${STATE_FILE}")
SAVED_KEY=$(jq -r '.idempotency_key' "${STATE_FILE}")

# ステータス判定(前回完了している場合はスキップ)

if [[ "${CURRENT_STATUS}" == "SUCCESS" ]]; then
    echo "[INFO] 前回のトランザクションは既に完了しています。新規実行をスキップします。"
    exit 0
fi

# 冪等性キーの設定(未処理からのリトライ時は前回のキーを再利用)

if [[ -z "${SAVED_KEY}" ]]; then
    IDEMPOTENCY_KEY=$(uuidgen)
    echo "[INFO] 新規冪等性キーを発行しました: ${IDEMPOTENCY_KEY}"
else
    IDEMPOTENCY_KEY="${SAVED_KEY}"
    echo "[INFO] 既存の冪等性キーを再利用してリトライします: ${IDEMPOTENCY_KEY}"
fi

# 同一ファイルシステム内の一時ファイル(アトミック更新の担保)

TEMP_STATE=$(mktemp "${STATE_FILE}.XXXXXX")

# 実行中状態を一時的に保存(クラッシュ時の回復用)

jq --arg key "${IDEMPOTENCY_KEY}" \
   --arg time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
   '.last_status = "RUNNING" | .idempotency_key = $key | .updated_at = $time' \
   "${STATE_FILE}" > "${TEMP_STATE}"

# 一時ファイルを本番の状態ファイルへアトミックに置換

mv "${TEMP_STATE}" "${STATE_FILE}"

# API送信用ペイロードの構築

PAYLOAD=$(jq -n \
  --arg id "${IDEMPOTENCY_KEY}" \
  '{"transaction_id": $id, "amount": 1000, "currency": "JPY"}')

echo "[INFO] APIリクエストを送信中..."

# curlによるHTTP通信の実行


# -s: 進捗バー非表示 (Silent)


# -S: エラー時のみエラーメッセージ出力


# -f: HTTPステータス4xx/500番台で異常終了ステータスを返す


# --retry: 通信エラー時の一時的リトライ回数


# --max-time: 最大タイムアウト設定

HTTP_RESPONSE=$(curl -sS -f \
    -X POST \
    -H "Authorization: Bearer ${API_TOKEN}" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
    -d "${PAYLOAD}" \
    --max-time "${TIMEOUT_SEC}" \
    --retry "${MAX_RETRIES}" \
    --retry-delay 2 \
    --retry-connrefused \
    "${API_URL}")

# APIレスポンス解析

RESPONSE_STATUS=$(echo "${HTTP_RESPONSE}" | jq -r '.status // empty')

if [[ "${RESPONSE_STATUS}" == "completed" ]]; then
    echo "[SUCCESS] API処理が正常に完了しました。"

    # 成功ステータスで状態をアトミックに書き換え

    TEMP_STATE=$(mktemp "${STATE_FILE}.XXXXXX")
    jq --arg time "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
       '.last_status = "SUCCESS" | .updated_at = $time' \
       "${STATE_FILE}" > "${TEMP_STATE}"
    mv "${TEMP_STATE}" "${STATE_FILE}"
else
    echo "[ERROR] API応答のステータスが不正です: ${HTTP_RESPONSE}" >&2
    exit 1
fi

2. 永続化のための systemd ユニット設定

定期実行( cron の代替)および実行環境をカプセル化するために、systemdタイマーを配置します。

① サービス定義ファイル (/etc/systemd/system/api-syncer.service)

[Unit]
Description=External API Idempotent Sync Service
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=api-worker
Group=api-worker
EnvironmentFile=/etc/api-syncer/env
ExecStart=/usr/local/bin/sync_state.sh
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

② タイマー定義ファイル (/etc/systemd/system/api-syncer.timer)

[Unit]
Description=Run API Sync Service every 5 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=1s

[Install]
WantedBy=timers.target

【検証と運用】

1. 正常系の動作確認

手動でスクリプトを実行し、状態ファイルの変化とログを確認します。

# 環境変数をロードして手動テスト実行

export API_TOKEN="test_token_xyz123"
sudo -u api-worker /usr/local/bin/sync_state.sh

期待される初期実行後の state.json の出力:

{
  "last_status": "SUCCESS",
  "idempotency_key": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d",
  "updated_at": "2023-10-27T08:30:00Z"
}

この状態で再度スクリプトを実行した場合に、「新規実行をスキップします」と表示されることを確認します。

2. systemd タイマーの有効化とログ確認

# タイマー設定の有効化と即時反映

sudo systemctl daemon-reload
sudo systemctl enable --now api-syncer.timer

# タイマーの稼働状況一覧確認

systemctl list-timers --all | grep api-syncer

# systemd 経由の実行ログ監視

journalctl -u api-syncer.service -f

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

① 状態ファイル更新時の「パーミッションエラー」と「デバイス境界不整合」

  • 事象: mv: cannot move '...' to '...': Device or resource busy や権限エラー。

  • 原因: 一時ファイルを生成するデフォルトの /tmp ディレクトリと、/var/lib/api-syncer が異なるファイルシステム(マウントポイント)上に存在する場合、mv コマンドがOSレイヤーで「物理的なコピー&削除」にフォールバックし、アトミックな更新ではなくなります

  • 対策: mktemp 実行時に、必ず状態ファイルと同一のディレクトリ内に一時ファイルを生成するようにパスを指定します(本スクリプトの実装例:mktemp "${STATE_FILE}.XXXXXX")。

② APIトークン等、秘匿環境変数の漏洩防止

  • 事象: ps aux やプロセスの環境変数ダンプから認証キーが漏洩する。

  • 対策: systemd サービス経由で実行する際は、コマンド引数にトークンを含めず、パーミッション 0600 に制限した環境変数ファイル(/etc/api-syncer/env)から読み込ませます。

# 環境変数ファイルの適切なパーミッション設定

sudo chown api-worker:api-worker /etc/api-syncer/env
sudo chmod 0600 /etc/api-syncer/env

【まとめ】

運用の冪等性を維持するための3大重要ポイント:

  1. 書き込み完了の原子性(Atomicity): データをファイルへ直接追記・編集せず、一時ファイルへの完全書き出し後に mv で上書きする。

  2. 冪等性キーの一貫性保持: 送信失敗やタイムアウト発生時、同一データパケットに対して「同一のUUID」をリトライ時にも一貫して使い回す。

  3. シグナルハンドリングの徹底: trap による不意のスクリプト中断(SIGINT/SIGTERM)時におけるゴミファイルの自動クリーンアップ。

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

コメント

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