きっかけは、社内で「このサイト、思ったより表示が速くないですね」と言われたことでした。
自社サイトは Astro でビルドした静的ファイルを S3 に置き、CloudFront から配信しています。全部静的なので、遅くなる要素はほとんどないはずなんですよね。それなのに、記事ページを開くたびに毎回同じ画像やフォントを取りに行っているような挙動が見える。
キャッシュ設定は自分で書いた記憶があるので、まずそこを疑いました。結果、書いた本人が四ヶ月気づいていなかった不具合が出てきたので、備忘も兼ねて残しておきます。
「出し分けているつもり」だった二段構成
デプロイの該当箇所はこうなっていました。HTML は常に最新を返したいので no-store、アセットはファイル名にハッシュが入るので一年キャッシュ。二回に分けて同期する、よくある形です。
# HTML(キャッシュなし)のつもり
- run: |
aws s3 sync ./dist s3://$PROD_BUCKET/ \
--delete \
--include "*.html" \
--cache-control "no-cache, no-store, must-revalidate"
# アセット(長期キャッシュ)のつもり
- run: |
aws s3 sync ./dist s3://$PROD_BUCKET/ \
--exclude "*.html" \
--cache-control "public, max-age=31536000, immutable"
意図としては読めます。上が HTML、下がそれ以外。
問題は上の --include "*.html" です。AWS CLI の S3 系コマンドでは、フィルタは「デフォルトで全ファイルが対象」という状態から始まります。--include は、すでに --exclude で除外されたものを拾い直すためのオプションなんですよね。つまり --exclude が一つもない状態で --include だけ書いても、対象は何も絞られない。
正しくはこう書く必要があります。
# NG: 全ファイルが対象のまま(--include が効かない)
aws s3 sync ./dist s3://$BUCKET/ --include "*.html"
# OK: いったん全部除外してから拾い直す
aws s3 sync ./dist s3://$BUCKET/ --exclude "*" --include "*.html"
ここまでは仕様を知っていれば分かる話です。想定外だったのは、その先でした。
二段目が丸ごと空振りしていた
フィルタが効いていないなら、一段目で全ファイルが no-store で上がる。それは分かります。でも二段目でアセットを上書きするから、最終的には正しいヘッダーになるのでは、と最初は思ったんです。
なりませんでした。
aws s3 sync は、その名の通り「差分同期」です。転送するかどうかはファイルサイズと更新時刻で判断します。一段目で全ファイルをアップロードした直後なので、S3 側のオブジェクトの更新時刻はローカルのビルド成果物より新しい。サイズも当然同じ。結果、二段目の sync は「同期済み」と判断して一件も転送しません。
--cache-control はあくまで「転送するときに付けるメタデータ」であって、これが違うことは差分の判定材料になっていない。だから二段目は毎回、何もせずに成功していました。ログ上は緑なので、誰も疑わない。
実際に本番のオブジェクトを見にいくと、はっきりします。
# ハッシュ付きアセットのはずが no-store になっていた
$ aws s3api head-object --bucket $PROD_BUCKET \
--key "_astro/index.DkY1pQzT.css" \
--query "CacheControl" --output text
no-cache, no-store, must-revalidate
/_astro/ 配下は Astro がファイル名にコンテンツハッシュを埋め込んで出力するもので、内容が変われば URL ごと変わります。一年キャッシュして完全に安全な種類のファイルです。それが全件 no-store、つまり「保存すらするな」で配信されていました。
CloudFront 側は /_astro/* に対して長めの TTL のマネージドキャッシュポリシーを当てています。ただしこのポリシーはオリジンの Cache-Control を尊重する設計なので、オリジンが no-store と言えばエッジも素直に諦める。インフラ側で正しく設計していても、デプロイスクリプトの一行で無効化されるという、運用を考えるとなかなか嫌な壊れ方でした。
直す前に、そもそもの分類を数えた
修正は --exclude "*" を足すだけです。ただ、それだけ入れると今度は二段目が本当に動き出すので、「*.html 以外は全部一年 immutable」という粗い分類がそのまま本番に効いてしまいます。
そこで、ビルド成果物の中身を数えました。
| 種別 | 件数 | URL が変わるか | あるべき Cache-Control |
|---|---|---|---|
| HTML | 108 | 変わらない | no-cache(毎回再検証) |
| /_astro/ 配下 | 32 | 内容が変われば変わる | max-age=31536000, immutable |
| 画像・フォント・sitemap 等 | 121 | 変わらない | max-age=86400 程度 |
全 261 ファイルのうち、一年 immutable にして安全なのは 32 件だけでした。残りの 121 件は public/ にそのまま置いている画像やフォント、それとビルド時に生成される sitemap-index.xml や robots.txt です。これらはファイル名が固定なので、同じ URL のまま中身だけ差し替わる運用になります。ここに immutable を付けてしまうと、画像を差し替えても既に取得したブラウザは一年間古いものを見続けることになる。
*.html かどうかだけで二分する発想が、そもそも雑だったということです。
三段構成にした
分類を三つに分けて、拡張子ではなくパスで振り分ける形にしました。
# 1. ハッシュ付きアセット → 一年 immutable
aws s3 sync ./dist s3://$PROD_BUCKET/ \
--exclude "*" --include "_astro/*" \
--cache-control "public, max-age=31536000, immutable"
# 2. 非ハッシュの静的ファイル → 一日
aws s3 sync ./dist s3://$PROD_BUCKET/ \
--exclude "*" --include "*" --exclude "_astro/*" --exclude "*.html" \
--cache-control "public, max-age=86400"
# 3. HTML → 再検証させる
aws s3 sync ./dist s3://$PROD_BUCKET/ --delete \
--exclude "*" --include "*.html" \
--cache-control "no-cache"
三段目にだけ --delete を残しています。フィルタを付けた sync の --delete はフィルタに一致する対象だけを削除範囲にするので、この書き方だと HTML 以外の消し込みが走りません。ここは意図的に妥協した部分で、消えたページを確実に消したい HTML を優先しました。
この三段構成は、まだ改善の余地があると考えています。 振り分けの根拠が「_astro/ かどうか」というパスの決め打ちなので、将来 public/ 配下にハッシュ付きのファイルを置くような運用が入ると、また静かに壊れます。本来はビルド出力のマニフェストからハッシュ付きファイルの一覧を取り出して、そこを唯一の根拠にすべきです。今回はそこまで手を入れず、代わりにデプロイ後に head-object でヘッダーを抜き取って想定と一致するか確認する検証ステップを足す、という折衷にしました。
それと、no-store を no-cache に変えています。no-store は「保存するな」なので、認証情報を含むレスポンス向けの強い指示です。公開している静的な HTML に対しては過剰で、no-cache(保存はするが再利用前に必ず再検証する)で足ります。こちらなら中身が変わっていなければ 304 が返るので、毎回本体を転送せずに済む。
キャッシュパージに頼るのをやめた
もう一つ見直したのが、デプロイの最後に毎回叩いていた全パージです。
aws cloudfront create-invalidation \
--distribution-id $CF_DISTRIBUTION_ID \
--paths "/*"
これ自体は動きます。料金の面でも、ワイルドカードは何ファイル消えても一パス換算で、無料枠が月 1,000 パス。デプロイが月 20 回なら 20 パスで、超過分の $0.005 が発生することはまずありません。金額だけ見れば、やめる理由がない。
やめた理由はコストではなく、二つあります。
一つは、これが今回の不具合を隠していたこと。毎デプロイで全部消していたので、ヘッダーが壊れていても「更新はちゃんと反映される」状態が保たれてしまい、症状が「なんとなく遅い」という形でしか出てこなかった。パージはヘッダー設計の代わりにはならない、という当たり前の話です。
もう一つは、/* がハッシュ付きアセットのキャッシュまで巻き添えにすること。中身が変わっていない 32 ファイルのエッジキャッシュを毎回捨てて、次のアクセスでオリジンまで取りに行かせている。せっかく immutable を付ける設計にしても、これでは意味が薄い。
| やり方 | 反映の速さ | エッジキャッシュへの影響 | 位置づけ |
|---|---|---|---|
| 毎回 /* をパージ | 即時 | 全件破棄 | 緊急時のみに戻した |
| HTML を no-cache で再検証 | 即時 | HTML のみ | 通常運用の主軸 |
| ハッシュ付き URL に任せる | 即時(URL が変わる) | 影響なし | アセットはこれで完結 |
パージは、想定外の事態で手動で介入するための道具として置いておく。定常のデプロイフローからは外す。そういう整理にしました。
振り返って
一番こたえたのは、四月に自分でこの構成を書いて、それを社内向けの記事にもまとめていたことです。書いた内容は設計としては間違っていなかった。ただ、書いた通りに動いているかを一度も確認していなかった。
aws s3 sync の --include の挙動は、ドキュメントを読めば書いてあります。それでも引っかかったのは、コマンドが「意図が読める形」で書けてしまうからだと思っています。--include "*.html" と書いてあれば、レビューでも自分の読み返しでも「HTML だけだな」と解釈して先に進んでしまう。エラーも警告も出ないので、CI は最後まで緑です。
運用を考えると、この手の「設定した気になっているだけ」の状態を見つける仕組みが要るんですよね。今回はデプロイ後に代表的な三種類のファイルのヘッダーを取得して想定と突き合わせる、という小さなチェックを入れました。次はこれを、想定値をコード側に持たせてズレたら落ちる形にしていきたいと考えています。設定を書くだけでなく、書いた設定が本番に届いているかまでを、デプロイの一部にしたい。