GitHub ActionsのCI/CDワークフロー図。ghcr.ioからのイメージpull失敗を指数バックオフで再試行し、PostgreSQLヘルスチェック後に成功する流れが描かれています。開発者がYAMLテスト済みの堅牢なシステムを指し、成功・失敗時ともにDiscordへ通知する様子を示しています。

はじめに

GitHub Actions の services: でコンテナレジストリ(ghcr.io)から Postgres イメージを取得していると、pull はジョブ初期化時に1回だけ実行されます。
再試行の仕組みはありません。

一時的なレート制限(toomanyrequests)に当たると、本体の処理に到達する前にジョブが落ちます。
実運用しているサービスの日次バックアップでは、これが起きました。
しかも失敗時の通知経路がなく、バックアップが丸ごと静かに失敗していました。

この記事では、次の2点で解決した方法を紹介します。

  1. services: をやめて、通常の step で docker pull を指数バックオフ付きで再試行し、docker run で起動する
  2. 通知 job を if: always() にして、失敗・キャンセル時も通知する

こんな人におすすめ

  • GitHub Actions の services: が pull 失敗で落ちた経験がある人
  • 定期実行のバックアップや復元検証を CI で回している人
  • 「失敗時だけ通知」を安全に組みたい人
  • ワークフロー YAML の退行をテストで防ぎたい人

なぜ services: では吸収できないのか

services: はランナーがコンテナを起動してくれる便利な機能です。
一方で、pull の再試行回数や待ち時間を制御する手段がありません。

レジストリ起因の一時障害を吸収したいなら、step に降ろして自前でリトライするのが確実です。
その代わり、services: が裏でやってくれていた次の処理も自分で書く必要があります。

  • ヘルスチェック(起動完了の待機)
  • 後始末(コンテナの削除)

pull を指数バックオフで再試行する step

最大5回、初期待機30秒、失敗ごとに2倍(30→60→120…)にします。
最終試行では待たずに ::error:: を出して即終了します。

- name: Start restore-check Postgres
  env:
    RESTORE_CHECK_IMAGE: ghcr.io/example/postgres:17.x
    RESTORE_CHECK_CONTAINER: restore-check-db
  run: |
    set -euo pipefail
    docker rm -f "$RESTORE_CHECK_CONTAINER" 2>/dev/null || true

    max_attempts=5
    delay_seconds=30
    for attempt in $(seq 1 "$max_attempts"); do
      if docker pull "$RESTORE_CHECK_IMAGE"; then
        break
      fi
      if [ "$attempt" -eq "$max_attempts" ]; then
        echo "::error::image pull failed after ${max_attempts} attempts"
        exit 1
      fi
      sleep "$delay_seconds"
      delay_seconds=$((delay_seconds * 2))
    done

開始前に docker rm -f しておくと、同じランナー上での再実行にも強くなります。
最終試行のあとに無駄に待たないのもポイントです。

ヘルスチェックを自前で再現する

pull のあとは docker run で起動し、--health-* オプションを付けます。
さらに pg_isready を最大60回×5秒でポーリングして、起動完了を待ちます。

ready=0
for wait_attempt in $(seq 1 60); do
  if docker exec "$RESTORE_CHECK_CONTAINER" pg_isready -U postgres -d postgres >/dev/null 2>&1; then
    ready=1
    break
  fi
  sleep 5
done
if [ "$ready" -ne 1 ]; then
  docker logs "$RESTORE_CHECK_CONTAINER" || true
  exit 1
fi

タイムアウト時に docker logs を出しておくと、原因調査がかなり楽になります。
ready を事前に初期化しておくのも忘れないでください。set -u 下では未定義変数の参照でエラーになります。

後始末は if: always() の step で行います。

- name: Cleanup restore-check Postgres
  if: always()
  run: docker rm -f restore-check-db || true

失敗時だけ通知する設計

もう1つの問題は、失敗が誰にも伝わらなかったことです。
通知 job を if: always() にして、job の結果に応じて status を切り替えます。

notify:
  needs: backup
  if: always()
  uses: ./.github/workflows/_notify-discord.yml
  with:
    status: ${{ needs.backup.result == 'success' && needs.backup.outputs.sanity_status || needs.backup.result }}
    notify_on_success: false

成功時は出力値(警告判定など)を、それ以外は job の結果(failure / cancelled)を渡します。
notify_on_success: false を維持すれば、正常時の通知ノイズは増えません。

ワークフロー YAML 自体をテストで固定する

設定の退行を防ぐため、YAML をパースして次の点を検証するテストを置きました。

  • notify.if が always() であること
  • status 式が意図どおりであること
  • services が存在しないこと
  • 再試行 step が存在し、パラメータが想定どおりであること

CI の設定は壊れても気づきにくいので、この種のテストは費用対効果が高いと感じます。

つまづきやすいポイント

  • a && b || c は b が空文字だと c に落ちます。成功時の出力が空になりうる場合、意図しないステータスが通知に渡ります。出力が必ず非空になるようにするか、式を見直してください
  • services: を外すと後始末が自動で行われません。docker rm -f を if: always() の step に入れ忘れると、ランナーにコンテナが残ります
  • 最終試行でも sleep してしまうと、失敗確定までの時間が無駄に延びます。最終試行は待たずに終了させましょう
  • 通知 job の if: always() は、キャンセル時も実行されます。通知先に流したくない場合は条件を調整してください

まとめ

services: の pull は再試行できないため、一時的なレート制限でジョブが落ちることがあります。
step に降ろして指数バックオフで再試行し、ヘルスチェックと後始末も自前で書くことで吸収できます。
あわせて、通知 job を if: always() にして失敗時に確実に気づける状態にしておくと、静かな失敗を防げます。
ワークフロー YAML をテストで固定しておけば、設定の退行も防げます。