はじめに
個人開発者向けに、AWS上へWebアプリを公開するための販売基盤を作っています。
構成は、ざっくりこんな感じです。
本番環境へフロントを反映したあと、ALBのTarget Healthを確認するとhealthy。
/へアクセスしてもHTTP 200。
「よし、問題なさそうだな!」
この時点では「勝ったな、風呂入ってくる」状態でした。
ブラウザを開くと、そこに表示されたのは販売サイトではなく、真っ白な画面でした。
| 確認対象 | 状態 |
|---|---|
| ALB Target Health | healthy |
/ |
HTTP 200 |
| React | 真っ白 |
「ALBさん、Healthyって言いましたよね?」
今回は、ALBもNginxも動いているのに、Reactだけが起動できなかった障害について書きます。
今回の構成
販売サイトはReactとViteで作成し、ビルド成果物をEC2上のNginxから配信しています。
EC2上の配信ディレクトリは次のようになっています。
/usr/share/nginx/html/
├── index.html
├── runtime-config.json
└── assets/
├── main-xxxxx.js
└── index-xxxxx.css
index.htmlだけでReactが動くわけではありません。
ブラウザはindex.htmlを取得したあと、そこに記載されたJavaScriptやCSSを追加で取得し、Reactアプリを起動します。
起きたこと
本番反映後、販売サイトのTOPページが白画面になりました。
一方、AWSとサーバー側では次のように見えていました。
| 確認項目 | 状態 |
|---|---|
| ALB Target Health | healthy |
/のHTTP Status |
200 |
| Nginx | 起動中 |
| EC2 | 稼働中 |
| ブラウザ表示 | 真っ白 |
最初は、
「ALBもEC2もNginxも生きているのに、なぜ画面だけ死んでいるんだ?」
という状態でした。
ALBは何を見てHealthyと判断していたのか
今回のALB Target Groupでは、次のヘルスチェックを設定しています。
| 設定項目 | 値 |
|---|---|
| Protocol | HTTP |
| Port | 80 |
| Path | / |
| Success codes | 200 |
かなり簡略化すると、ALBが確認していたのはここです。
今回も/は正常にHTTP 200を返していました。
そのため、ALBのhealthy判定自体は間違っていません。
ただし、ALBはブラウザ上でReactが起動できたかまでは確認していません。
| 確認対象 | 意味 |
|---|---|
| ALB Healthy | ALBから指定パスへ到達し、期待したStatus Codeが返る |
| React画面が正常 | HTML、JavaScript、CSS、実行時設定がすべて読み込める |
この2つは別の話でした。
Nginxのアクセスログに答えがあった
Nginxのaccess.logを確認したところ、見覚えのない404がありました。
GET /assets/main-Che1Uowk.js 404
/は200ですが、JavaScriptは404です。
| リクエスト | HTTP Status |
|---|---|
/ |
200 |
/assets/main-Che1Uowk.js |
404 |
この時点で、ようやく状況がつながりました。
ALBもNginxも動いていました。
Reactを起動するためのJavaScriptだけが、サーバー上から消えていたのです。
原因はViteのハッシュ付きassetsと全削除型デプロイ
Viteで本番ビルドすると、JavaScriptやCSSには内容に応じたハッシュ付きファイル名が付きます。
例えば、旧ビルドと新ビルドでは次のようになります。
旧ビルド:
index.html
assets/main-Che1Uowk.js
新ビルド:
index.html
assets/main-NewHash.js
JavaScriptの内容が変わると、ファイル名も変わります。
今回のデプロイ処理では、新しいfront.zipを展開する前に、Nginxの配信ディレクトリを全削除していました。
rm -rf /usr/share/nginx/html/*
cp -a /tmp/sales-site-front/. /usr/share/nginx/html/
そのため、新しい成果物を配置した時点で、旧main-Che1Uowk.jsはサーバー上から消えました。
一方、一部のブラウザには、適切なCache-Controlが設定されていなかった旧index.htmlが残っていました。
つまり、ブラウザとサーバーで世代がずれていました。
| 場所 | 保持していた世代 |
|---|---|
| ブラウザ | 旧index.html
|
| サーバー | 新index.htmlと新assets |
| サーバー上の旧JavaScript | 削除済み |
スーパーで例えるなら、
- 入口では「営業中」と言われた
- 少し古い店内マップを渡された
- マップに書かれた棚へ行った
- その棚の商品は撤去済みだった
という状態です。
ALBは入口を見て「営業中」と判断しました。
しかし、ブラウザが必要としていたJavaScriptという商品は、すでに棚から消えていました。
S3バージョニングから旧JavaScriptを救出
フロントの配布用front.zipは、バージョニングを有効にしたS3バケットで管理していました。
そこで、直前バージョンのfront.zipを取得し、その中から旧JavaScriptを取り出しました。
復旧後は、新旧両方のJavaScriptがHTTP 200を返す状態にしました。
| JavaScript | HTTP Status |
|---|---|
/assets/main-NewHash.js |
200 |
/assets/main-Che1Uowk.js |
200 |
これにより、ブラウザが持っているHTMLの世代にかかわらず、必要なJavaScriptを取得できます。
旧ビルド全体へロールバックしたわけではありません。
現行機能を残したまま、旧HTMLとの互換性を一時的に持たせる復旧方法を取りました。
S3バージョニングを有効にしていた過去の自分には、ちょっと感謝しました。
恒久対応としてキャッシュ方針を変更
今回の根本原因は、HTMLとassetsを同じ感覚で扱っていたことでした。
それぞれ役割が異なるため、キャッシュ方針も分ける必要があります。
index.htmlとclean URL
index.htmlは、どのJavaScriptやCSSを読み込むかを指定する入口です。
古いHTMLがブラウザに残り続けると、削除済みのassetを参照する可能性があります。
そのため、HTMLとclean URLには次のHeaderを設定しました。
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
runtime-config.json
runtime-config.jsonには、実行時に利用するAPI Endpointを設定しています。
古い設定が残ると、すでに使っていないAPIへ接続する可能性があります。
Cache-Control: no-store
assets配下のJavaScriptとCSS
一方、Viteのassetsはハッシュ付きファイル名です。
内容が変わるとURLも変わるため、同じURLの内容は変わらない前提で扱えます。
そのため、長期キャッシュを設定しました。
Cache-Control: public, max-age=31536000, immutable
整理すると次のようになります。
| 対象 | キャッシュ方針 | 理由 |
|---|---|---|
index.html |
キャッシュさせない | 参照するassetが変わる |
| clean URL | キャッシュさせない | 実体としてHTMLを返す |
runtime-config.json |
キャッシュさせない | API設定が変わる |
assets/*.js |
長期キャッシュ | 内容変更時にハッシュとURLが変わる |
assets/*.css |
長期キャッシュ | 内容変更時にハッシュとURLが変わる |
実際のNginx設定は次のようにしました。
location = /runtime-config.json {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri $uri.html $uri/ =404;
}
キャッシュの役割分担は次のイメージです。
HTMLと実行時設定は新しいものを確認し、ハッシュ付きassetsは長期間利用する方針です。
Terraformへ反映してEC2を更新
Nginxの設定を手作業で直すだけでは、EC2が再作成されたときに元へ戻ってしまいます。
そこで、キャッシュ設定をTerraformで管理しているuser_dataへ反映しました。
その後、環境ごとに次の対応を行っています。
Prdでは、更新後に次の状態を確認しました。
| 確認項目 | 状態 |
|---|---|
| ASG Instance | InService |
| ALB Target Health | healthy |
| Nginx | active |
| 現行JavaScript | HTTP 200 |
| 旧JavaScript | HTTP 200 |
最後にterraform planを実行し、意図しない差分が残っていないことも確認しました。
ALB HealthyはE2Eテストではない
今回、一番大きかった学びです。
| 確認方法 | 確認できる範囲 |
|---|---|
| ALB Target Health | ALBからEC2の指定パスまで |
| HTTP 200確認 | 対象ファイルのHTTP応答 |
| ブラウザ表示確認 | HTML、assets、実行時設定、Reactの動作 |
| E2Eテスト | ユーザーが利用する一連の操作 |
ALBのTarget Healthは、ALBからEC2へアクセスし、設定したパスが期待したStatus Codeを返すかを確認しています。
一方、React/ViteのようなSPAでは、その後にブラウザが行う処理も成功する必要があります。
/が200でも、JavaScriptが404ならReactは起動しません。
インフラのヘルスチェックが正常でも、ユーザー視点のE2Eが成功しているとは限らないことを、白い画面から教わりました。
次に白画面が起きたら、この順番で確認する
今回の経験を踏まえて、次回は次の順番で確認します。
- ブラウザのDevToolsを開く
- ConsoleでJavaScriptエラーを確認する
- NetworkでJS、CSS、
runtime-config.jsonの404を確認する - Nginxの
access.logで実際のリクエストを確認する - Nginxの
error.logで配信エラーを確認する -
index.htmlが参照しているasset名を確認する - EC2上に対象assetが存在するか確認する
- S3上の
front.zipとEC2上の成果物を比較する - ALB Target Healthを確認する
- Terraformの差分を確認する
今回の決め手は、次の組み合わせでした。
| リクエスト | HTTP Status |
|---|---|
/ |
200 |
/assets/main-Che1Uowk.js |
404 |
この状態なら、ALBやNginx全体が停止しているのではなく、HTMLとassetsの世代不一致を疑えます。
正直、最初にブラウザのNetworkタブを見れば、もう少し早く気づけました。
次からはちゃんと最初に見ます。たぶん。
まとめ
本番反映後、ALBのTarget Healthはhealthy、/もHTTP 200なのに、Reactの画面だけが真っ白になる障害が発生しました。
原因は、ブラウザに残っていた旧index.htmlが、デプロイ時に削除した旧JavaScriptを参照していたことです。
復旧では、S3バージョニングから旧JavaScriptを取り出し、現行成果物へ互換用assetとして追加しました。
恒久対応として、キャッシュ方針も分離しました。
| 対象 | キャッシュ方針 |
|---|---|
index.html / clean URL |
no-cache, no-store |
runtime-config.json |
no-store |
assets/*.js / assets/*.css
|
public, max-age=31536000, immutable |
ALBは嘘をついていませんでした。
私が確認していた「正常」の範囲が、ユーザー画面まで届いていなかっただけです。
ALBくん、君は何も悪くない。
今回の件で、ブラウザDevTools、Nginx access log、S3バージョニングのありがたさをかなり実感しました。
同じように「ALBはHealthyなのに画面が出ない!」となった方の参考になれば幸いです。
おまけ
この販売基盤は、個人開発者向けにAWS公開環境をTerraformで作れるようにする「ヒトリ開発」の一部として作っています。
アプリは作れるけど、AWS公開、ALB、HTTPS、SSM、CloudWatchあたりで詰まりやすい方に向けて、少しずつ整備しています。
今回のように失敗することもありますが、失敗した内容まで含めて設計と手順へ反映しています。
失敗しない基盤ではなく、失敗しても戻せる基盤を目指しています。
少しでも興味を持ってくれた方がいれば、サイトのぞいてみてください!
白い画面はもう見れませんが...w