お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」では、デプロイのたびにバックエンドとフロントエンドを再起動します。公開ページの中には、表示時にバックエンドから記事を取得するものがあります。その再起動の数秒間にアクセスが重なると、ページが500を返す問題がありました。
同時再起動で、記事を取りに行く公開ページが500になった
以前のデプロイではバックエンドとフロントエンドを同時に再起動しており、先に応答したフロントエンドが、まだ起動していないバックエンドへ記事を取りに行っていました。記事詳細、カテゴリ別一覧、トピック別一覧は、アクセス時に記事データを取得してページを組み立てます。その取得が失敗すると、画面を表示する途中でエラーになります。
当時のデプロイスクリプトの再起動処理は、単純化すると次の1行です。
systemctl restart devpick-backend devpick-frontend
フロントエンドがアクセスを受け付けても、バックエンドはまだ立ち上がっていない場合があります。その瞬間に公開ページを開くと、記事の取得が失敗し、ページのエラー処理から500が返りました。500は、要求されたページの処理に失敗したことを示します。デプロイのための短い停止なのに、訪問者や検索エンジンにはサイトの不具合として見えてしまいます。
問題は、再起動が終わった後の最終的な画面ではありません。フロントエンドがページを組み立てようとしたその時点で、記事の取得先が使えるかどうかです。サーバー側の描画で取得が失敗すると、画面のエラー処理へ進んだ後から「これは計画した一時停止です」と503へ言い直せません。デプロイ完了後にトップページと静的ファイルが200を返すことを確認する手順はありましたが、その確認だけでは途中の公開ページが返した500は捕まえられませんでした。
さらに、サイトマップの一覧もバックエンドの記事一覧を使って生成していました。取得に失敗すれば、記事ページと同じく500になりえます。訪問者がページを開く場合に加え、検索エンジンがサイトのURLを調べる経路も、デプロイ中の短い停止の影響を受けていました。
ここで区別したいのは、フロントエンド自体を再起動している間と、フロントエンドは動いていてバックエンドだけが応答できない間です。前者はWebサーバーがフロントエンドへ接続できず、もともと一時的な503を返します。今回の500は後者で起きました。画面を作る側が生きているためリクエストを受け付け、その先の記事取得に失敗するという組み合わせです。サーバー全体の稼働確認だけでは、この依存関係の切れ目を見落とします。
開発記録によると、当時は1日に5〜21回デプロイしていました。これは500が返った回数ではなく、短い停止が発生しうる機会の数です。私は、再起動中のリクエストに何を返すかも、デプロイ手順の一部として考える必要があると感じました。以前書いたデプロイをスキップして変更が本番に届かなかった話とは別に、今回は変更を配る途中の応答が問題でした。
IT女子 アラ美再起動の順番を変え、描画前に503を返す
修正では、バックエンドを止める前に一時停止の合図を置き、公開ページが描画を始める前に503を返すようにしました。同じ変更で、バックエンドを先に単独で再起動し、軽量な応答確認用のURLが200を返してからフロントエンドを再起動する順番に直しました。新しいフロントエンドが起動した直後に、まだ起動していないバックエンドへ記事を取りに行く状態を避けます。
ただし、再起動の順番を変えるだけでは、古いフロントエンドが動いている間のアクセスを守れません。そこでバックエンドを止める前に、「再起動中」を示すファイルを置きました。すでに描画を始めたリクエストが記事を取得し終えるよう、2秒待ってからバックエンドを再起動します。フロントエンドは対象の公開ページを組み立てる前にこのファイルを確認し、置かれている間は記事を取得せずに503を返します。
正常に進む場合の順序を、実装から抜き出して簡略化すると次の形です。実際のスクリプトには各処理が失敗した場合の分岐もありますが、ここでは公開ページの状態が切り替わる位置を示しています。
touch "$BACKEND_RESTART_FLAG" # 公開ページの503を開始
sleep 2 # 描画中の取得を待つ既定値
systemctl restart devpick-backend
wait_backend_ready # バックエンドの200を確認
rm -f "$BACKEND_RESTART_FLAG" # 通常表示へ戻す
systemctl restart devpick-frontend
応答確認で見ているのは、バックエンドの軽量な入口が200を返すかどうかです。既定では1秒間隔で最大30回確認します。別の監視用URLは集計処理を含み、再起動とは関係のない理由でも503になりうるため、ここでは「バックエンドがリクエストに答えられるか」を直接確かめるURLを選びました。待っても200にならなければ、フロントエンドを新しい版で起動せず、直前の版へ戻します。
503は「いまは一時的に応答できない」ことを表す状態です。応答には Retry-After: 10 も付け、10秒後の再試行を案内します。再起動が終わり、バックエンドの応答を確認できた時点でファイルを外し、通常の表示に戻します。応答確認には、記事取得などの別の状態に左右されにくい軽量なURLを使っています。
再起動中の公開ページが返す情報を、主要なヘッダーだけ抜き出すと次のようになります。これは実装に基づく簡略例です。
HTTP/1.1 503 Service Unavailable
Retry-After: 10
Cache-Control: no-store
本文にも、更新作業中なので待ってから再読み込みしてほしいという案内を出します。機械には状態と再試行の目安を、人には今できる行動を伝える形です。no-store は、この一時停止の応答を保存しないための設定です。バックエンドが復旧しているのに、保存された古い503だけが表示される状態を避けます。
記事詳細などのページとは別に、サイトマップの一覧を返すURLも対応しました。こちらはページを描画する前の判定対象ではないため、記事一覧の取得が失敗した場合に、その処理の中で503と Retry-After を返します。検索エンジンが見る公開URLでも、「サイトが壊れた」のか「更新中で一時的に待ってほしい」のかを分けて伝えるためです。
この判断をページを組み立てる前に置いたのは、記事の取得を試してからでは遅いからです。取得エラーが画面の描画処理まで進むと、フロントエンドのエラー画面が500として返されます。一時停止だと分かっている時点で処理を止めれば、HTTPの状態も本文も一時停止用にそろえられます。応答には「一時的に表示できません」という短い案内を含め、古い503がブラウザなどに残らないようキャッシュも禁止しています。
止める対象も限定しました。記事詳細・カテゴリ別・トピック別ページと分割サイトマップは、表示のためにバックエンドへ問い合わせるので対象です。トップページは同じ取得失敗で画面全体が500になる仕組みではなく、今回の一時停止判定では止めません。再起動中の検知を全ページへ一律に広げず、実際に500へ変わる経路へ置きました。



レビューで見つかった「失敗時に503が消える」穴
この修正のレビュー中に、正常に再起動できた場合だけを考えていた穴が見つかりました。最初の実装では、バックエンドの再起動に失敗しても終了時の後片付けで「再起動中」のファイルを消していました。フロントエンドを直前の版へ戻しても、バックエンドが応答しないままなら記事の取得は失敗します。ファイルまで消すと、公開ページはまた500を返す状態に戻ります。これはマージ前のレビューで指摘された条件であり、修正後に本番で再発した話ではありません。
そこで、ファイルはバックエンドの応答を確認できたときだけ消す形に直しました。再起動に失敗した場合はファイルを残し、古いフロントエンドへ戻した後も、バックエンドが応答しない間は503を返します。テストでも、再起動失敗時にファイルが残ることと、成功時にはフロントエンドを再起動する前に消えることを確認しています。
一方、デプロイ処理が強制終了してファイルだけ残った場合、復旧後も503を返し続けるのは困ります。そのため、ファイルが置かれてから2分を超えたら、バックエンドが実際に応答するかを調べます。応答が無ければ503を続け、200を返していれば通常表示へ戻します。この条件なら、再起動失敗中の500と、復旧済みなのに503が続く状態の両方を避けられます。
2分という境界は、通常の再起動を待つ時間より長く取りました。デプロイスクリプトは、描画中の処理を待つ2秒の後、既定ではバックエンドの応答を最大30回確認します。この間にファイルを「古い」と判定して問い合わせを始めると、正常なデプロイ中にも余計な応答確認が走ります。通常の切り替えにはファイルの存在を信じ、それより長く残った場合にだけ、実際の応答を使って状態を決めるようにしています。
ここでは、ファイルが「あるか」だけを見続けないことが大事でした。ファイルはデプロイ処理が残した記録であって、現在のバックエンドの状態そのものではありません。置いた直後は再起動中として扱い、長く残った場合だけ実際の応答を確かめます。古いファイルを見つけるたびに問い合わせると公開ページへのアクセスがそのままバックエンドへの追加アクセスになるため、確認結果は短時間使い回す設計です。
実装では、古いファイルに対するバックエンドへの問い合わせに1秒の時間制限を付け、結果を5秒間使い回します。同時に複数の公開ページへアクセスが来ても、同じ確認を1回にまとめます。バックエンドが復旧したと分かったら、デプロイスクリプトがファイルを消し損ねていても通常のページを表示できます。逆に接続に失敗したら、ファイルが古くても503を続けます。時間が経っただけで「復旧した」と決めないためです。
このロールバックも、サービス全体を変更前に戻す操作ではありません。バックエンドのコードとデータの変更はすでに新しくなっているため、戻すのはフロントエンドの直前の版だけです。デプロイスクリプトは失敗を示して終了し、人が状態を確認できるようにします。古いフロントエンドを再起動できたことだけで「全部復旧した」と判断しないことが、ここでは大切です。特にバックエンドが応答しない間は、一時停止の合図を残す意味があります。
このファイルは公開ページの状態を伝えるための補助です。ファイルを作れなかった場合は警告を出してデプロイを続けるため、その間の公開ページが500になりうる点は残ります。バックエンドの更新を途中で止めると、先に適用したデータ変更と古いコードの組み合わせが残るためです。私は、この制約も含めて、応答を返す条件と解除する条件を一緒に検証しておくべきだと考えました。



デプロイスクリプトと公開URLの両側で確かめた
修正後は、デプロイスクリプトが正しい順番で動くことと、公開ページが正しいHTTPの状態を返すことを、別々にテストしました。スクリプトだけが成功しても、公開ページ側の判定が効いていなければ、利用者や検索エンジンに届くのはまた500です。逆にページ側だけが503を返しても、バックエンドの復旧後に合図を消せなければ、表示は戻りません。
デプロイスクリプトのテストでは、バックエンドの再起動が失敗した場合と、再起動はできても応答が戻らない場合を分けました。どちらもフロントエンドを直前の版へ戻しつつ、一時停止の合図を残します。バックエンドが応答した後でフロントエンドの再起動だけ失敗した場合は、合図を外した状態で直前の版へ戻します。単に「デプロイに失敗した」でまとめると、バックエンドが使えるかどうかという肝心の違いを見落とします。
公開ページ側のテストでは、再起動中に記事詳細、カテゴリ別、トピック別、分割サイトマップがいずれも503と再試行の案内を返すことを確認しました。対象外のトップページは、この判定では止めません。また、再起動中の503はアクセス数の制限に数えないようにしています。メンテナンスのために返した503が、復旧直後のアクセスをさらに制限しては困るからです。
最後に、合図のファイルが古く残った状態も試しました。バックエンドが応答しなければ503を続け、200へ戻れば通常の表示へ進みます。同時に複数のリクエストが来ても、バックエンドへの確認は1回にまとめます。成功、再起動失敗、復旧後のそれぞれで、合図と実際の応答が食い違ったときの振る舞いを確かめました。



まとめ
DevPickでは、バックエンドとフロントエンドを同時に再起動していたため、再起動中にアクセスされた公開ページが、記事を取得できず500を返していました。修正では、バックエンドを先に再起動して応答を確かめる順番に変更し、その間は公開ページを組み立てる前に503と再試行の案内を返すようにしました。サイトマップの一覧も、取得失敗時には一時停止を伝える応答にしています。
レビューで確認できたのは、成功時だけでなく、再起動失敗時と復旧後の扱いも決める必要があることです。応答を確認する前に一時停止の合図を消せば500へ戻り、復旧後も合図を信じ続ければ503が残ります。私は、デプロイの成功判定に加えて、その途中に来たアクセスへ何を伝えるかを設計することが大切だと感じました。











