シェルスクリプトで外部リソース(API・データベース・ファイルシステム)を扱う場合、一時的なエラーが起きることは避けられません。そういった「一時的な失敗」に対して、単純に
set -e でスクリプトを止めるのではなく、リトライ(再実行)で自動回復させる設計が実務では重要です。この記事では、シェルスクリプトでのリトライ設計の基本から、指数バックオフ・ジッター・タイムアウトとの組み合わせまで、RHEL 9.4(bash 5.1)で動作確認した実践的なパターンを解説します。
この記事のポイント
・固定間隔リトライより指数バックオフで、サーバーへの集中アクセスを避けられる
・retry関数として実装することで、どのコマンドにもリトライを適用できる
・ジッターを加えると並行実行時の「連鎖障害」を防げる
・timeoutと組み合わせると、1回のリトライに上限時間を設けられる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜリトライ設計がシェルスクリプトに必要なのか
外部リソースを扱うコマンドが失敗する原因には「永続的な失敗」と「一時的な失敗」があります。・永続的な失敗: 設定ミス・パスの誤り・権限不足など → リトライしても意味がない
・一時的な失敗: ネットワーク遅延・DB接続ビジー・APIのレート制限 → リトライで回復できる
リトライ設計が意味を持つのは後者です。cronで深夜に動くバッチスクリプトが一時的なDB接続タイムアウトで止まり、翌朝データが欠損していた——そんな運用トラブルをリトライ設計で防げます。
リトライを実装しないスクリプトは、
set -e によって最初の失敗で即終了します。これは「永続的な失敗」には正しい挙動ですが、「一時的な失敗」に対しては過剰反応です。リトライロジックで「一時的な失敗は再実行で吸収し、永続的な失敗だけ即終了」と区別することが、堅牢なシェルスクリプト設計の核心です。基本的なリトライループの書き方
1. retry関数として実装する設計方針
リトライロジックをインラインで書くと、スクリプトの本体処理が見えにくくなります。retry関数として独立させ、コマンドを引数で渡す設計が再利用性と可読性の両立につながります。#!/bin/bash set -eu # retry <最大リトライ回数> <間隔秒> <コマンド> [引数...] retry() { local max_retry=$1 local interval=$2 shift 2 local cmd=("$@") local retry_count=0 until "${cmd[@]}"; do retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then echo "[ERROR] コマンド '${cmd[*]}' が ${max_retry}回失敗しました" >&2 return 1 fi echo "[WARN] 失敗 (${retry_count}/${max_retry}回目)。${interval}秒後にリトライ..." >&2 sleep "$interval" done } # 使用例: 5秒間隔で最大5回リトライ retry 5 5 curl -sf "https://api.example.com/health" -o /dev/null echo "[INFO] APIヘルスチェック成功"
local cmd=("$@") で配列に格納し、"${cmd[@]}" で展開することで、スペースを含む引数も安全に扱えます。文字列変数に格納して eval で展開する方法はコマンドインジェクションのリスクがあるため使いません。RHEL 9.4の実環境で存在しないホストへcurlを実行した際の動作確認です。
$ bash retry_sample.sh [WARN] 失敗 (1/5回目)。5秒後にリトライ... [WARN] 失敗 (2/5回目)。5秒後にリトライ... [WARN] 失敗 (3/5回目)。5秒後にリトライ... [WARN] 失敗 (4/5回目)。5秒後にリトライ... [ERROR] コマンド 'curl -sf https://api.invalid.example/health -o /dev/null' が 5回失敗しました $ echo $? 1
2. 最大試行回数と終了コードを正しく設計する
retry関数からreturn 1 するとき、呼び出し元スクリプトの set -e が有効であれば自動的にスクリプトが終了します。意図的にretry失敗後に別処理をしたい場合は、|| true の代わりに終了コードを変数に受けて分岐させてください。#!/bin/bash set -eu retry() { local max_retry=$1 local interval=$2 shift 2 local cmd=("$@") local retry_count=0 until "${cmd[@]}"; do retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then return 1 fi sleep "$interval" done } # set -eを一時的に無効にして失敗を自分で処理する set +e retry 3 2 curl -sf "https://api.example.com/health" -o /dev/null RETRY_RESULT=$? set -e if [ "$RETRY_RESULT" -ne 0 ]; then echo "[ERROR] APIに到達できません。フォールバック処理を実行します" >&2 # フォールバック処理... fi
set +e/set -e で一時的にエラー終了を無効にする範囲を最小限にすることがポイントです。スクリプト全体で set -e を外すと、意図しない失敗を見逃します。3. 固定間隔リトライの限界(Thundering Herd問題)
固定間隔リトライは実装がシンプルですが、複数のスクリプトやプロセスが同時にリトライすると「同じタイミングでアクセスが集中する」問題(Thundering Herd)が発生します。DBやAPIが高負荷で失敗した状況で、複数スクリプトが一斉に5秒後・10秒後と同期してリトライすると、回復しかけたサービスを再び過負荷に追い込みます。この問題を解決するのが次節の指数バックオフです。
指数バックオフ(Exponential Backoff)の実装
1. 指数バックオフとは何か
指数バックオフは「リトライするたびにスリープ時間を指数的に増やす」設計です。・1回目のリトライ: 2秒後
・2回目のリトライ: 4秒後
・3回目のリトライ: 8秒後
・4回目のリトライ: 16秒後
AWS SDKやGoogle Cloud SDKも内部でこのパターンを使っています。短時間の一時的障害はすぐに回復し、長引く障害には徐々に間隔を空けてサーバー負荷を自然に下げられます。
2. 指数バックオフリトライ関数の実装
bashで指数バックオフを実装するにはビットシフト演算子を使います。#!/bin/bash set -eu # retry_backoff <最大リトライ回数> <基底秒数> <コマンド> [引数...] retry_backoff() { local max_retry=$1 local base_delay=$2 shift 2 local cmd=("$@") local retry_count=0 local delay until "${cmd[@]}"; do retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then echo "[ERROR] '${cmd[*]}' が ${max_retry}回失敗。終了します" >&2 return 1 fi # 2^retry_count * base_delay(最大60秒でクランプ) delay=$((base_delay * (1 << retry_count))) [ "$delay" -gt 60 ] && delay=60 echo "[WARN] リトライ ${retry_count}/${max_retry}回目。${delay}秒後に再試行..." >&2 sleep "$delay" done } # 基底2秒の指数バックオフで最大6回リトライ retry_backoff 6 2 curl -sf "https://api.example.com/data" -o /dev/null
(1 << retry_count) はビット左シフトで、1 << 1 = 2、1 << 2 = 4、1 << 3 = 8 と2の指数倍になります。base_delay を掛けることで基底秒数を調整できます。上限を60秒にクランプしているのは、障害が長引いた場合でもリトライ間隔が際限なく延び続けないようにするためです。3. ジッター(乱数)を加えて同時実行の衝突を避ける
指数バックオフだけでは、同時に起動した複数のスクリプトが「同じ指数で同期してリトライ」する問題が残ります。これをジッター(ランダムな揺らぎ)で解決します。#!/bin/bash set -eu retry_backoff_jitter() { local max_retry=$1 local base_delay=$2 shift 2 local cmd=("$@") local retry_count=0 local delay local jitter until "${cmd[@]}"; do retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then echo "[ERROR] '${cmd[*]}' が ${max_retry}回失敗。終了します" >&2 return 1 fi delay=$((base_delay * (1 << retry_count))) [ "$delay" -gt 60 ] && delay=60 # 0~delay秒の範囲でランダムなジッターを追加 jitter=$((RANDOM % (delay + 1))) echo "[WARN] リトライ ${retry_count}/${max_retry}回目。$((delay + jitter))秒後に再試行..." >&2 sleep $((delay + jitter)) done } # 指数バックオフ+ジッターで最大6回リトライ retry_backoff_jitter 6 2 curl -sf "https://api.example.com/data" -o /dev/null
$RANDOM はbashの組み込み変数で、0~32767の乱数を返します。$((RANDOM % (delay + 1))) で0からdelay秒の範囲のジッターを生成し、指数バックオフの待機時間に加算します。これにより、複数スクリプトのリトライタイミングが分散して連鎖障害を防げます。実務で使うリトライ設計パターン
1. APIリクエストのリトライ(curlでHTTPステータス分岐)
HTTPリクエストをリトライする際は、HTTPステータスコードも確認する必要があります。5xx(サーバーエラー)はリトライ対象ですが、4xx(クライアントエラー)はリトライしても意味がありません。Linuxコマンドの基本的な終了コードの仕組みと同様に、HTTPステータスによる適切な分岐が設計の肝です。#!/bin/bash set -eu API_URL="https://api.example.com/v1/data" MAX_RETRY=5 BASE_DELAY=2 api_call_with_retry() { local retry_count=0 local http_status local delay while true; do set +e http_status=$(curl -sf -o /dev/null -w "%{http_code}" "$API_URL" 2>/dev/null) set -e case "$http_status" in 2*) echo "[INFO] APIリクエスト成功 (HTTP ${http_status})" return 0 ;; 429|503|504) # レート制限・一時的サービス不可・ゲートウェイタイムアウト → リトライ対象 ;; 4*) # クライアントエラー → リトライしても意味がない echo "[ERROR] クライアントエラー (HTTP ${http_status})。リトライしません" >&2 return 1 ;; *) # 接続エラー(http_statusが空)もリトライ対象 ;; esac retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$MAX_RETRY" ]; then echo "[ERROR] 最大リトライ回数超過 (HTTP ${http_status:-connection_error})" >&2 return 1 fi delay=$((BASE_DELAY * (1 << retry_count))) [ "$delay" -gt 60 ] && delay=60 echo "[WARN] HTTP ${http_status:-接続エラー}。リトライ ${retry_count}/${MAX_RETRY}回目(${delay}秒後)" >&2 sleep "$delay" done } api_call_with_retry
curl -w "%{http_code}" でHTTPステータスコードを取得し、case 文でリトライ対象かどうかを分岐させています。接続自体に失敗した場合は http_status が空文字列になるため、*) でキャッチしてリトライします。2. データベース接続待ちのリトライ
コンテナ環境やミドルウェア起動直後に、DB接続を待ちながらリトライするパターンです。ポート疎通確認にはnc(netcat)コマンドを使います。ポート確認コマンドの詳細はLinux ポート確認の全コマンドも参考にしてください。#!/bin/bash set -eu DB_HOST="db.internal" DB_PORT=5432 DB_MAX_RETRY=30 DB_RETRY_INTERVAL=2 wait_for_db() { local retry_count=0 echo "[INFO] DB接続待機中: ${DB_HOST}:${DB_PORT}" until nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; do retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$DB_MAX_RETRY" ]; then echo "[ERROR] DB接続タイムアウト(${DB_MAX_RETRY}回試行後)" >&2 exit 1 fi echo "[INFO] DB待機中... (${retry_count}/${DB_MAX_RETRY})" >&2 sleep "$DB_RETRY_INTERVAL" done echo "[INFO] DB接続確認完了" } wait_for_db # DB接続確認後に本処理を実行 # psql -h "$DB_HOST" -U myuser -d mydb -c "SELECT 1"
nc -z はTCPポートが開いているかを確認するコマンドです。DBプロセスが起動してポートをLISTENし始めるまで待機する「起動待ちループ」として使えます。この場合は固定間隔リトライで十分です。DB起動待ちは「長期障害でなく起動タイムラグ」であり、複数プロセスが同時実行する状況でないためThundering Herd問題が起きにくいからです。3. タイムアウト付きリトライ(timeoutとの組み合わせ)
リトライする各コマンドに時間制限を設けたい場合、timeout コマンドと組み合わせます。#!/bin/bash set -eu retry_with_timeout() { local max_retry=$1 local timeout_sec=$2 local base_delay=$3 shift 3 local cmd=("$@") local retry_count=0 local exit_code local delay while true; do set +e timeout "$timeout_sec" "${cmd[@]}" exit_code=$? set -e [ "$exit_code" -eq 0 ] && return 0 retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then echo "[ERROR] '${cmd[*]}' が ${max_retry}回失敗。終了します" >&2 return 1 fi if [ "$exit_code" -eq 124 ]; then echo "[WARN] タイムアウト(${timeout_sec}秒)。リトライ ${retry_count}/${max_retry}回目" >&2 else echo "[WARN] 失敗(終了コード ${exit_code})。リトライ ${retry_count}/${max_retry}回目" >&2 fi delay=$((base_delay * (1 << retry_count))) [ "$delay" -gt 60 ] && delay=60 sleep "$delay" done } # 各試行を30秒でタイムアウト、指数バックオフで最大4回リトライ retry_with_timeout 4 30 2 curl -f "https://api.example.com/heavy-process"
timeout コマンドがタイムアウトした場合の終了コードは 124 です。exit_code でこれを検出してメッセージを変えることで、「コマンドが失敗した」のか「時間切れになった」のかをログで区別できます。よくあるエラーとリトライ設計の注意点
1. 冪等でないコマンドをリトライする危険性
リトライ設計の前提は「同じコマンドを複数回実行しても結果が変わらない(冪等性がある)」ことです。冪等でない操作をリトライすると重複実行による問題が起きます。・NGな例: DBにINSERTするSQLをリトライ → レコードが重複して挿入される
・NGな例: APIに注文を送信するPOSTリクエストをリトライ → 同じ注文が複数回送られる
・OKな例: データを読み取るSELECTやGETリクエスト → リトライしても副作用がない
・OKな例: ファイルを特定パスにコピーする操作 → 同じ内容なら何度実行しても結果が同じ
INSERT/POSTのような書き込み操作をリトライする場合は、DBのUPSERT(INSERT ON CONFLICT)やAPIの冪等性キー(Idempotency-Key)を使う設計が必要です。
2. 永続的なエラーとリトライ可能なエラーを見極める
すべての失敗をリトライすべきではありません。永続的なエラーはリトライしても無駄なだけでなく、問題の発見を遅らせます。| エラーの種類 | リトライ | 具体例 |
|---|---|---|
| 一時的なネットワーク障害 | する | 接続タイムアウト・DNS一時的失敗 |
| レート制限(HTTP 429) | する(間隔を長くする) | API呼び出し過多 |
| サーバー一時障害(HTTP 503) | する | メンテナンス中・高負荷状態 |
| 権限エラー(HTTP 403) | しない | 認証失敗・アクセス拒否 |
| 構文エラー・設定ミス | しない | 不正なJSONリクエスト(HTTP 400) |
| ファイル・パスが存在しない | しない(通常) | スクリプトの設定ミス |
3. リトライ中の出力を適切に制御する
リトライループの中でコマンドが大量の出力を出す場合、ログが汚れます。通常は失敗時のみメッセージを出力し、コマンド本体の出力はリトライ成功後に流す設計が読みやすいログになります。#!/bin/bash set -eu retry_quiet() { local max_retry=$1 local interval=$2 shift 2 local cmd=("$@") local retry_count=0 local exit_code local output while true; do set +e output=$("${cmd[@]}" 2>&1) exit_code=$? set -e if [ "$exit_code" -eq 0 ]; then # 成功時だけ出力を流す echo "$output" return 0 fi retry_count=$((retry_count + 1)) if [ "$retry_count" -ge "$max_retry" ]; then echo "[ERROR] '${cmd[*]}' が ${max_retry}回失敗" >&2 echo "$output" >&2 return 1 fi echo "[WARN] リトライ ${retry_count}/${max_retry}回目" >&2 sleep "$interval" done }
output=$("${cmd[@]}" 2>&1) でコマンドの標準出力と標準エラーを変数にキャプチャし、失敗が続いているうちは抑制、成功した場合だけ echo "$output" で流します。cronジョブでメール通知が飛ぶ環境では、リトライ途中の不要なstderrが大量のアラートメールを引き起こすため、この制御が実務では重要です。本記事のまとめ
リトライ設計のパターンをまとめます。| 場面 | 推奨パターン | ポイント |
|---|---|---|
| 汎用コマンドのリトライ | retry <max> <interval> <cmd> |
関数化して再利用。配列で引数を安全に渡す |
| 外部API・クラウドAPI | 指数バックオフ+ジッター | HTTPステータスで条件分岐。4xxはリトライしない |
| コンテナ起動後のDB待機 | 固定間隔リトライ(nc -z) | 起動タイムラグには固定間隔で十分 |
| 1コマンドの時間制限 | timeoutとの組み合わせ | 終了コード124でタイムアウトを検出して分岐 |
| 複数プロセスの並行実行 | 指数バックオフ+ジッター | Thundering Herd対策にジッター必須 |
注意点まとめ
・リトライ前に「冪等性があるか」を必ず確認する
・永続的なエラー(設定ミス・権限エラー)はリトライせず即終了する
・複数スクリプトが並行実行する場合はジッターを加えて衝突を分散する
・バックオフ上限(例: 60秒)を設けてリトライ間隔が際限なく延びないようにする
・
set +e/set -e でエラー終了を一時的に無効にする範囲は最小限にする一時的な障害に対して手動で対応するのではなく、スクリプト自身が自動回復する設計が、深夜・休日の無人運用を支えます。
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 前のページへ:bashのexecとファイルディスクリプタ操作|スクリプト全体のログをリダイレクトする設計
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます