CDNを置いてもHTMLはキャッシュされない|S3 + CloudFront の設計

CDNを置いてもHTMLはキャッシュされない|S3 + CloudFront の設計

CDNを置けば速くなる、と思っていたので、自分のサイトのレスポンスヘッダーを確認してみました。HTMLは1度もキャッシュされていませんでした。

$ curl -D - -o /dev/null https://wadokon.com/personal-dev-pdca/

HTTP/1.1 200 OK
Server: cloudflare
Vary: Accept-Encoding
Vary: User-Agent
cf-cache-status: DYNAMIC          ← エッジでキャッシュされていない
   (Cache-Control ヘッダーが1つも無い)

一方で画像やCSSはちゃんと効いていました。

$ curl -D - -o /dev/null https://wadokon.com/wp-content/.../main.css

Cache-Control: max-age=604800
cf-cache-status: HIT
Age: 220078                       ← 約61時間キャッシュされている
画像やCSSはCDNのエッジで止まる一方、HTMLはCache-Controlが無いためオリジンまで届いてしまうことを示した図
同じサイトでも、種類によって止まる場所が違う

この記事では、この違いがなぜ生まれるのかを整理したうえで、S3 + CloudFront で静的サイトを配信するときの設計をまとめます。CDNを置くこと自体は簡単で、難しいのは「何を、どれだけキャッシュさせるか」を決めることのほうです。

目次

キャッシュされない理由は2つあった

1. Cache-Control が無い

CDNは、オリジンが「これはキャッシュしていい」と明示しない限り、基本的にキャッシュしません。指示がないものを勝手にキャッシュすると、ログイン後のページを別人に見せてしまう可能性があるからです。

つまりヘッダーが無いのは「キャッシュしないで」と言っているのとほぼ同じです。動的なサイトではこれが正しい挙動ですが、静的サイトでこの状態だと、CDNを置いた意味が半減します。

2. Vary: User-Agent が付いている

こちらのほうが厄介です。Vary は「このヘッダーの値が違えば別のレスポンスとして扱え」という指示です。

User-Agent 文字列は、ブラウザとOSのバージョンの組み合わせで無数に存在します。これを Vary に指定すると、キャッシュがUAごとに分裂して、ヒット率が実質ゼロになります。

仮に Cache-Control を追加しても、Vary: User-Agent が残っていれば効果は出ません。キャッシュが効かないときは、Vary に何が入っているかを必ず確認します。

S3 + CloudFront での設計

ここからは、同じことを AWS で構成する場合の話です。

S3は公開しない

まず前提として、S3バケットは非公開のままにします。「静的ウェブサイトホスティング」機能を有効にしてバケットを公開する方法は、いまは選ぶ理由がありません。

  • バケットを公開するとCloudFrontを迂回して直接アクセスできてしまう
    CDNもWAFも通らない経路が残る
  • S3の静的サイトホスティングのエンドポイントはHTTPSに対応していない

OAC(Origin Access Control)を使うと、CloudFrontだけがS3を読める状態になります。バケットポリシーで、特定のCloudFrontディストリビューションからのアクセスだけを許可する形です。

パスごとにキャッシュの寿命を変える

これがいちばん重要な設計です。全部同じ設定にすると、必ずどちらかで困ります。

対象Cache-Control理由
ファイル名にハッシュが入った
JS / CSS / 画像
max-age=31536000, immutable中身が変わればファイル名も変わる。1年キャッシュして問題ない
HTMLmax-age=0, must-revalidate常に最新を配りたい。ただし後述の方法でエッジには置ける
ファイル名が固定の画像max-age=86400 程度差し替えたときに反映される必要がある

ポイントは「ファイル名にハッシュを入れる」ことです。app.a3f9c2.js のように中身から作ったハッシュを名前に含めると、内容が変われば必ず別のURLになります。古いキャッシュが残っていても、参照するURLが変わるので影響しません。

逆に ?ver=2.16.0 のようなクエリ文字列でのキャッシュ破棄は、動きますが弱いところがあります。クエリ文字列をキャッシュキーに含めない設定のCDNやプロキシがあると、更新が反映されません。ファイル名そのものを変えるほうが確実です。

物流倉庫の棚が奥まで続く写真。大半は箱で埋まり、いくつかの区画だけ空いている
置けるものと、置いてはいけないもの

HTMLもエッジには置ける

「HTMLは常に最新を配りたいからキャッシュできない」と思いがちですが、ブラウザキャッシュとエッジキャッシュを分けて指定できます。

Cache-Control: public, max-age=0, s-maxage=600
  • max-age=0
    ブラウザは毎回確認しにくる
  • s-maxage=600
    CDNのエッジは10分間そのまま配る

これで、更新は最大10分で反映されつつ、その間のアクセスはオリジンに届きません。10分でも、アクセスが集中したときのオリジンの負荷はまったく違います。

すぐに反映したい場合は、デプロイ時にキャッシュ削除(invalidation)を実行します。ただしinvalidation は無料枠を超えると課金対象なので、/* を毎回実行する運用にはしないほうがいいです。ハッシュ付きのファイル名にしておけば、invalidation が必要なのはHTMLだけになります。

圧縮を有効にする

CloudFront には自動圧縮の設定があります。有効にすると、対応しているクライアントには gzip / Brotli で配信されます。テキスト系のファイルは数分の1になるので、入れない理由がありません。

ただし条件があり、ファイルサイズの下限・上限と、対象のContent-Typeが決まっています。S3にアップロードするときにContent-Typeが正しく設定されていないと、圧縮対象から外れます。aws s3 sync は拡張子から推測しますが、拡張子のないファイルや独自の拡張子では明示が必要です。

SPAなら404の扱いを決める

クライアント側でルーティングするSPAでは、/about のようなURLに直接アクセスされたとき、S3にそのファイルが無いので404になります。

CloudFront のカスタムエラーレスポンスで、404 を /index.html に、ステータス200で差し替えるのが定番の対処です。ただしこの設定をすると、本当に存在しないページも200を返すようになります。検索エンジンから見ると「すべてのURLが存在する」ことになるので、アプリ側で適切に404を表現する必要があります。

デプロイの順番

地味ですが事故になりやすいところです。アップロードの順番を間違えると、一瞬だけ壊れた状態が見えます。

# 1. ハッシュ付きのアセットを先に上げる(長期キャッシュ)
aws s3 sync ./dist/assets s3://my-bucket/assets \
  --cache-control "public, max-age=31536000, immutable"

# 2. HTMLを後から上げる(短期キャッシュ)
aws s3 sync ./dist s3://my-bucket \
  --exclude "assets/*" \
  --cache-control "public, max-age=0, s-maxage=600"

# 3. HTMLだけキャッシュを消す
aws cloudfront create-invalidation \
  --distribution-id XXXX --paths "/" "/index.html"

HTMLを先に上げてしまうと、新しいHTMLがまだ存在しないアセットを参照する瞬間が生まれます。逆にアセットを先に上げておけば、古いHTMLは古いアセットを、新しいHTMLは新しいアセットを参照するので、どのタイミングでも整合します。

この理由からも、古いアセットはすぐ消さないほうが安全です。ページを開いたまま滞在しているユーザーは、古いHTMLのまま操作を続けています。

効いているかを確認する

設定したら、必ず実際のヘッダーを見ます。冒頭でやったのと同じことです。

curl -D - -o /dev/null https://example.com/index.html
  • x-cache: Hit from cloudfront になっているか(CloudFrontの場合)
  • Age が増えているか
    0のままなら毎回オリジンに行っている
  • Cache-Control が意図した値か
  • Vary に余計なものが入っていないか

2回叩いて2回とも Miss なら、どこかの設定が効いていません。設定した気になって終わらせないことが、この作業でいちばん大事な部分だと思います。

まとめ

  • CDNは置いただけでは効かない
    実測したらHTMLは1度もキャッシュされていなかった
  • Cache-Control が無い=キャッシュしない
    明示しない限りCDNは通す
  • Vary: User-Agent はヒット率をゼロにする
    効かないときはここを見る
  • S3は公開せず、OACでCloudFrontからだけ読ませる
  • ファイル名にハッシュを入れる
    クエリ文字列より確実で、invalidationも要らなくなる
  • HTMLは s-maxage でエッジにだけ置く
    10分でも負荷はまったく違う
  • アセットを先、HTMLを後にデプロイする
    逆だと一瞬壊れる
  • 設定したら curl で確認する
    2回叩いて2回ともMissなら効いていない

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次