運用

起動直後のヘルスチェックで誤ロールバックしたので、再試行を入れて安全にリリースする

起動完了前の単発ヘルスチェックが引き起こした誤ロールバックを、5回のloopback再試行で解決した事例。自分のサービスで再試行回数と間隔をどう決めるかの判断軸と、概念的な実装例も示す。

自動ロールバック付きのリリースの仕組みを運用していて、新しいリリースを有効化した直後にヘルスチェックが失敗し、リリースが正常なのにロールバックしてしまったことがある。原因は「リリースが壊れていた」のではなく、単発のヘルスチェックが「サービスの起動が完了する前」に実行されていたことだった。この記事では、実際に起きたこの誤ロールバックの原因と、自動ロールバックの仕組み自体は外さずに直した方法、そして同じ問題が自分のデプロイパイプラインで起きたときにどう考えればよいかを説明する。

起きたこと

新しいリリースを有効化する処理は、次の順序で動く。

  1. 新しいリリースへの切り替え(current の切り替え)
  2. サービスの再起動
  3. ヘルスチェックで起動確認
  4. 失敗していれば自動的に前のリリースへロールバック

最初にこの新しいリリースを昇格させたとき、サービスの起動が完了する前にヘルスチェックが実行され、「まだ応答していない」という一時的な状態を「リリースが壊れている」と判定してしまった。その結果、リリース自体には問題がないにもかかわらず自動ロールバックが発生し、直前のアクティブなリリースは変更されずに保持された。

原因は「壊れていた」ではなく「まだ起動していなかった」

昇格処理の判定が、単発・即時のヘルスチェック1回だけで構成されていたことが原因だった。サービスの起動には(プロセス起動、依存先への接続確立などのために)一定の時間差がありうる。リリースの有効化と同時に1回だけヘルスチェックを行う設計では、この一時的な未起動状態と、本当に起動に失敗したリリースを区別できない。

自動rollbackを外さず、bounded retryを入れる

修正の方針は、自動ロールバックの安全装置はそのまま残し、判定の入り口だけを「単発の即時チェック」から「上限回数を決めた再試行ウィンドウ」に変えることだった。実際の修正では、loopback(サービス自身のホスト内)へのヘルスチェックを最大5回まで再試行するようにした。再試行の間は「まだ起動していないだけかもしれない」として待ち、上限回数に達してもヘルシーにならなければ、これまでどおり自動ロールバックする。

実装(概念例)

以下は、この判断ロジックの本質部分だけを取り出した概念的な例で、実際のコードそのものではない(サービス名やポート番号などの非本質的な部分は一般化している)。上の「起きたこと」「対処」で説明した順序・回数・振る舞いは実装から確認できたものだが、変数名や関数の分割方法などの細部は実装と一致するとは限らない。

# ヘルスチェック本体: プロセスが動いていて、かつ health エンドポイントが
# 応答することを確認する
health_check() {
  systemctl is-active --quiet myapp.service \
    && curl --fail --silent --max-time 5 http://127.0.0.1:PORT/health >/dev/null
}

# 起動直後の一時的な未起動を、恒久的な失敗と区別するための再試行ウィンドウ
wait_for_health() {
  for attempt in 1 2 3 4 5; do
    health_check && return 0
    sleep 1
  done
  return 1
}

# 昇格処理: 再起動してから再試行ウィンドウ内でヘルシーになるのを待つ。
# ウィンドウ内で健全にならなければ、直前のリリースへ戻して再確認する。
if systemctl restart myapp.service && wait_for_health; then
  echo "promoted"
else
  point_current_to_previous_release   # current を直前のリリースへ戻す
  if systemctl restart myapp.service && wait_for_health; then
    echo "rolled_back"
  fi
fi

ポイントは、wait_for_health が「失敗を見逃す」ための仕組みではないことだ。再試行ウィンドウの中でも一度もヘルシーにならなければ、そのまま失敗として扱われ、自動ロールバックの分岐に進む。再試行が変えるのは「いつ失敗と判定するか」というタイミングだけで、「失敗したら自動でロールバックする」という安全側の振る舞いは変えていない。

修正後に何を確認したか

再試行ウィンドウを導入したあと、次の状態を確認した。

  • サービスが healthy を報告した
  • loopback のヘルスチェックが OK になった
  • 公開している7つのルートすべてが HTTP 200 を返した
  • 公開された成果物のダイジェストとプロジェクト数が、意図したリリースと一致した

これらはこの1回のリリースで観測した結果であり、複数回のリリースにわたる成功率や、負荷がかかった状態での挙動を測定したものではない。

retry回数と間隔をどう決めるか

この記事で使った「5回・1秒間隔」という値が、どんなサービスにも当てはまる正解というわけではない。自分のサービスで再試行回数と間隔を決めるときは、次の要素を確認するとよい。

  • サービスの通常時のstartup時間(依存先への接続確立などを含む)
  • cold start(キャッシュが空の状態など)での最大startup時間
  • 1回あたりのretry interval(チェックの間隔)
  • 合計のgrace window(再試行を許容する合計時間)
  • 本物の障害(実際に起動できない状態)を検知するまでに、自分たちがどれだけの遅延を許容できるか

大まかな関係としては、

grace window ≒ retry count × retry interval

となる。grace window は「cold startでの最大startup時間」を十分にカバーしつつ、「本物の障害を検知するまで許容できる時間」を超えないように、retry count と retry interval を調整する、という考え方になる。ここで具体的な最適値(例えば「10秒がちょうどいい」など)を示すには、自分のサービスの起動時間や運用条件での測定が必要で、この記事の観測範囲だけでは判断できない。

自動rollbackを残す理由

再試行を入れる目的は、失敗を無視することではない。あくまで「起動直後の一時的な未起動」だけを、恒久的な失敗と区別して扱うためのものだ。再試行ウィンドウの中で一度もヘルシーにならなければ、それは今までどおり実際の失敗として扱われ、自動ロールバックが働く。この安全装置を外さずに、判定のタイミングだけを調整したことが、今回の対処の要点になる。

限界

今回確認できたのは、このリリースにおける5回の再試行と、その後の正常な有効化までである。再試行回数や間隔の最適値を、複数のサービスや負荷条件で比較した測定は行っていない。5回という回数がすべてのサービスで最適であること、高負荷時にも同じ結果になること、この方式がダウンタイムを完全にゼロにすることは、いずれもこの記事の範囲では示していない。自分の環境に適用する際は、実際の起動時間と許容できる遅延を基に、回数と間隔を検証してから決める必要がある。