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時間キャッシュされている

この記事では、この違いがなぜ生まれるのかを整理したうえで、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年キャッシュして問題ない |
| HTML | max-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なら効いていない
次に読む記事
