
CDN キャッシュ設計の基本 - Cache-Control / s-maxage・TTL・除外設計と検証のポイント
Pull 型 CDN(さくらのウェブアクセラレータ / Cloudflare / Fastly / CloudFront / Akamai 等)は、オリジンサーバーが返す HTTP レスポンスヘッダーを手がかりに、コンテンツをエッジで保持する仕組みです。CDN 側の設定を変えていないのにキャッシュが効かなくなったとき、原因の多くは CDN ではなくオリジン側の Cache-Control などのヘッダーにあります。
CMS のリプレイスやテーマ改修のあとにキャッシュヒット率が大きく下がる──こうした事象は珍しくありません。本記事では、その背景を一般的な事例として整理しつつ、
Cache-Control: max-ageだけでは CDN がキャッシュしてくれないケースがあることs-maxageの追加、HTML / 静的アセットの TTL 設計、除外設計といった基本方針- 共用ホスティングで起きうる ETag まわりの注意点
- HEAD では検証できないなど 計測の落とし穴
- 自動パージするか短 TTL で運用するかの 方針の選び方
を、CDN・CMS に依存しない形で解説します。
何が起きていたか
リプレイス前後の差分はこうでした。
| 項目 | 移行前 | 移行後 |
|---|---|---|
| CMS | Drupal 8 | WordPress |
| CDN | (同じ Pull 型 CDN) | (同じ Pull 型 CDN) |
| キャッシュヒット率 | 約 80% | 1% 未満 |
| 設定変更 | なし | なし |
CDN 側は何も触っていません。にもかかわらずヒット率が壊滅。
原因を切り分けていくと、オリジンが返すレスポンスヘッダーが変わっていたことが分かりました。
| ヘッダー | 移行前(Drupal) | 移行後(WordPress) |
|---|---|---|
Cache-Control | public, max-age=3600 | なし |
Expires | Thu, 19 Nov 2026 ... | なし |
Pragma | no-cache | no-cache(あったりなかったり) |
Drupal は 匿名ユーザー向け応答に Cache-Control: public, max-age=N を標準で付与します(コアの FinishResponseSubscriber が system.performance: cache.page.max_age 設定を見ている)。WordPress には同等のデフォルト挙動がなく、移行と同時にこのヘッダーが消失。
そして多くの Pull 型 CDN は、オリジン応答にキャッシュ可能のシグナル(Cache-Control: s-maxage / max-age / Expires のいずれか)が無いとキャッシュしないか、後述する CDN ごとの判定ロジックの違いでキャッシュ判定が壊れます。これがヒット率 1% の真因でした。
「CDN の管理画面で誰かが設定を OFF にした」のではなく、オリジン側の挙動が変わったことが真因──これ、調査が遅れがちなパターンなので最初に書いておきます。
Cache-Control の基本おさらい
最初に押さえておきたい挙動を表で整理します。
| ディレクティブ | 効く相手 | 役割 |
|---|---|---|
max-age=N | ブラウザ + 共有キャッシュ(CDN 等) | リソースを N 秒間 fresh とみなす |
s-maxage=N | 共有キャッシュ(CDN / プロキシ)だけ | CDN 限定の TTL。max-age をオーバーライド |
public | 共有キャッシュ | 「これは共有キャッシュしてよい」の明示 |
private | 共有キャッシュ | 「これは CDN/プロキシでキャッシュするな」 |
no-cache | 全キャッシュ | 「使う前に必ずオリジンへ再検証しろ」 |
no-store | 全キャッシュ | 「保存も再利用もするな」 |
must-revalidate | キャッシュ全般 | TTL 切れ後の stale 配信を禁止 |
stale-while-revalidate=N | キャッシュ | TTL 切れ後 N 秒は stale で返しつつ裏で更新 |
理屈上は max-age は CDN にも効くのですが、現実には CDN ごとの実装差で挙動が大きく変わります。
CDN ごとのキャッシュ判定の違い
ここが 一番ハマるポイント。代表的な Pull 型 CDN の判定挙動をざっくり並べると、
| CDN | デフォルトで何を見るか | max-age だけでキャッシュするか | 推奨 |
|---|---|---|---|
| さくらのウェブアクセラレータ | Cache-Control: s-maxage または Expires | しない(max-age 単体は無視扱い) | 必ず s-maxage を付ける |
| Fastly | Surrogate-Control > Cache-Control: s-maxage > max-age | する(VCL 未設定の場合) | Surrogate-Control か s-maxage 推奨 |
| CloudFront | Cache-Control / Expires | する | s-maxage を付けてブラウザと分離するのが定石 |
| Cloudflare | デフォルト「拡張子マッチ + Cache-Control」併用、Cache Rules で上書き可 | 拡張子マッチで HTML はデフォルトキャッシュしない | HTML を CDN キャッシュさせたいなら明示的に Cache Everything + s-maxage |
| Akamai | プロパティ設定 + Edge-Control / Cache-Control | プロパティ次第 | Edge-Control か s-maxage 明示 |
| Vercel / Netlify | Cache-Control: s-maxage 必須(HTML を edge cache させる場合) | しない(HTML は基本 dynamic 扱い) | s-maxage + stale-while-revalidate |
要するに、「max-age だけで全 CDN がキャッシュする」と思っていると痛い目を見る。HTML を CDN キャッシュさせたい場面で max-age だけを付けて「ヒット率が 0%」みたいなパターンの大半は、これが原因です。
対策の鉄板は Cache-Control: public, max-age=B, s-maxage=C の二段書き。ブラウザは max-age=B を見て、CDN は s-maxage=C を見ます。s-maxage は仕様上 CDN 限定なので、ブラウザは影響を受けません。
TTL 設計の基本(HTML と静的アセットを分ける)
「とりあえず全部 s-maxage=3600 でいい」ではなく、HTML と静的アセットで分けます。
| 種別 | ブラウザ max-age | CDN s-maxage | 設定例 |
|---|---|---|---|
| HTML(匿名 GET) | 短め(5〜10分) | 中(30分〜1時間) | public, max-age=600, s-maxage=3600 |
| 静的アセット(画像 / CSS / JS / フォント) | 長め(30日〜1年) | 同じ(30日〜1年) | public, max-age=2592000, s-maxage=2592000 |
設計の根拠を補足しておくと、
HTML の TTL を「短ブラウザ・長 CDN」にする理由
- ブラウザ側を CDN より短くすることで、「CDN は更新済みなのにユーザーのブラウザだけ古い」状態を最小化できる
- 同一ユーザーの連続回遊(数分内)はブラウザキャッシュで即応、それを超えると CDN から取り直し
- CDN 側を長めにするとオリジン負荷が一気に下がる
静的アセットを「長期 + 同値」にする理由
- 画像・フォントは基本不変、CSS/JS は URL にバージョンクエリ(
?v=2)や hash 付きファイル名を入れてキャッシュバスティングするのが前提 - ブラウザと CDN で TTL を変える意味が薄く、両方とも 30日〜1年の固定値で OK
- それでも事故ったとき手で剥がしやすいよう、いきなり 1年(
immutable)にせず 30日くらいから始めるのも実用解
数字の決め方
- 投稿更新後の 許容反映遅延 = CDN TTL。1時間遅れても OK な運用なら
s-maxage=3600 - WordPress 等の nonce / CSRF トークンの有効期限よりは十分短く(WordPress nonce は デフォルト 12時間、AJAX フォーム失敗を防ぐため HTML TTL は 1時間以下に)
- 旧サイトでヒット率 80% を維持できていた TTL があるなら、まずそれを踏襲
キャッシュ除外対象(事故防止)
ここをサボると「他人のカートが見える」「管理画面が共有キャッシュされる」など 致命的な事故になります。最低でも下記は キャッシュさせない。
| 対象 | 理由 |
|---|---|
| ログイン中ユーザー | 個別表示・編集 UI 混入リスク |
管理画面(/wp-admin、/admin、CMS 管理 URL) | ユーザー固有・編集情報 |
| API / AJAX / REST / GraphQL / XML-RPC | 動的応答、ユーザー固有性 |
| GET / HEAD 以外(POST / PUT / DELETE 等) | 副作用を伴うリクエスト |
| 404 / 検索結果 / フィード / トラックバック / robots | 動的応答・SEO 影響 |
| プレビュー / カスタマイザー | 公開前コンテンツ漏洩 |
?preview / ?nocache クエリ | 緊急バイパス用に空けておく |
| モバイル / PC 分岐があるページ | 同 URL で異なる HTML を返すため CDN とは相性が悪い |
特に最後の「同 URL で異なる HTML を返すページ」が見落とされがちです。よくある例は、
wp_is_mobile()のような サーバー側 UA 分岐でテンプレートを切り替えている- 言語切替が Cookie や Accept-Languageで行われ、URL 上に言語コードがない
- 価格表示が ログイン状態(会員 / 非会員)で違う
このいずれかが当てはまるページは、CDN キャッシュからは 除外するか、Vary ヘッダーで分岐軸を明示するのが安全です。Vary: User-Agent は CDN によってはキャッシュ効率が壊滅するので、可能なら クライアントサイド(JS / CSS)でレイアウト切替に寄せるのが本筋。
WordPress の例
WordPress なら、テーマ or mu-plugin で次のようなフィルタを掛けます(あくまで参考実装。要件に合わせて調整)。
add_filter('wp_headers', function ($headers) {
if (is_user_logged_in()) return $headers;
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) return $headers;
if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', ['GET', 'HEAD'], true)) return $headers;
if (is_404() || is_search() || is_feed() || is_trackback() || is_robots()) return $headers;
if (is_preview() || is_customize_preview()) return $headers;
if (isset($_GET['preview']) || isset($_GET['nocache'])) return $headers;
$headers['Cache-Control'] = 'public, max-age=600, s-maxage=3600';
return $headers;
});WordPress コアの nocache_headers() でも管理画面 / AJAX 等は自動的に no-cache 化されますが、テーマ側でも明示除外して二重保証するのが事故防止のコツ。
Apache(.htaccess)で静的アセットを設定する例
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$">
Header set Cache-Control "public, max-age=2592000, s-maxage=2592000"
</FilesMatch>
</IfModule>mod_expires でも可。Nginx なら expires ディレクティブ + add_header Cache-Control "public, s-maxage=..."。
ETag の落とし穴
これも実案件で踏みやすい副次トラブルです。
ある共用ホスティング環境で、静的アセットの ETag が こんな壊れ方をしていました。
etag: "645c8-6525e71ee5d48;642bd39d9e329
↑ 閉じクォート無し + 余計な ; サフィックス調査の結果、
- 通常ディレクトリのファイル → 正常
- シンボリックリンク経由のファイルだけ ETag が壊れる
- サフィックスは別ファイルの mtime と一致
- ホスティング側の Apache が、シンボリックリンク経由のリクエストで追加 mtime 情報を ETag に連結し、かつ閉じクォートを付け忘れていると推測される
利用者側で根本修正できないやつ。こうなると 条件付き GET(If-None-Match)が誤判定し、本来 304 を返すべき場面で 200 を返したり、CDN が ETag を正規化できずキャッシュ判定が荒れたりします。
対処は ETag 出力を諦めて Last-Modified に統一するのが手堅い。
<IfModule mod_headers.c>
Header unset ETag
</IfModule>
FileETag Noneこれで ETag が出なくなり、再検証は Last-Modified / If-Modified-Since の組み合わせで行われます。CDN ヒット率の話と直接の主犯ではなくても、共用ホスティングで FileETag None は予防策として入れておく価値が高いです。
なお ETag 不要論は古典的論点で、「Last-Modified だけで十分」「マルチサーバー構成なら inode ベースの ETag は不一致になるので消した方が良い」など状況依存。自前サーバー1台で運用していて壊れた ETag に困っていないなら、わざわざ消す必要はありません。
キャッシュの検証方法
設定を入れたら、本番で必ず HIT/MISS を計測します。
主要なレスポンスヘッダー
| ヘッダー | 意味 |
|---|---|
x-cache: HIT / MISS | キャッシュヒットしたか(CDN によりヘッダー名異なる) |
age: 1234 | キャッシュ内でリソースが何秒前から保持されているか |
x-cache-status | NGINX 系のヒット種別(HIT / MISS / STALE / BYPASS 等) |
via | プロキシチェーン。さくら ATS なら cCHp(hit)/ cCMp(miss)トークン |
cf-cache-status | Cloudflare のヒット種別 |
x-served-by + x-cache | Fastly のエッジ POP とヒット種別 |
x-amz-cf-pop | CloudFront のエッジ POP |
よくあるハマりどころ: curl -I(HEAD)はキャッシュされない
多くの CDN は HEAD リクエストをキャッシュしない仕様です。curl -I で「いつまで経っても MISS なんだけど」と悩んだら、HEAD でなく GET で確認してください。
# NG: 常に MISS になる
curl -I https://example.com/
# OK: GET で確認、ヘッダーだけ表示
curl -sD - -o /dev/null https://example.com/GET でも、初回は MISS、2回目以降に HIT になります。10〜15回連打して HIT 比率を見るのが現実的な検証方法。
for i in {1..15}; do
curl -sD - -o /dev/null https://example.com/ | grep -i '^x-cache:'
done除外設定の検証も忘れずに
「キャッシュしたいページが HIT になる」だけでなく、「キャッシュしてはいけないページが MISS のまま」も同じくらい大事。?nocache=1 / 404 / 検索 / プレビューを GET 連打して MISS が続くことを確認します。
キャッシュパージ vs 短 TTL
設定が入った後の 運用方針は大きく2択。
方針 A: 自動パージ(投稿更新時にパージ API を叩く)
- メリット: 即時反映。CDN TTL を長め(24時間〜)に設定して常時高ヒット率
- デメリット: パージ API の呼び出し失敗時の検知・再送設計が必要、関連 URL の洗い出しが大変(個別記事だけでなくトップ / カテゴリ / アーカイブ等)
CDN ごとにパージ API が用意されています。
| CDN | パージ API |
|---|---|
| Cloudflare | Purge Cache API |
| Fastly | Purge API |
| CloudFront | CreateInvalidation |
| さくら | Web Accelerator API |
WordPress なら CDN 各社の公式プラグイン(wp-sacloud-webaccel / Cloudflare 公式プラグイン等)で save_post フックから自動パージできます。
方針 B: 短 TTL のみ(パージしない)
- メリット: 実装ゼロ・障害ゼロ。仕組みが単純
- デメリット: 投稿更新から最大 TTL 分の反映遅延(1時間設定なら最大1時間)
「即時性より運用コストの低さ」を取るなら方針 B。多くのコーポレートサイト・ブログは方針 B で十分に運用できます。即時反映が必要な特定記事だけ 管理画面から手動パージするハイブリッド運用も実用的。
緊急バイパス機構を仕込んでおく
どの方針でも、緊急時に特定 URL だけキャッシュを外せる仕組みは入れておくと安心。
?nocache=1を付けたらCache-Controlを付与しない(オリジンに直行)Cookie: bypass=1で同等の挙動- CDN 管理画面から個別 URL の即時パージ
想定効果と運用後の数値
「設定変更で何がどう変わるか」の目安。実案件での修正前→修正後の比較。
| 対象 | 修正前ヒット率 | 修正後ヒット率 |
|---|---|---|
| サイト HTML(トップページ) | 10/10 HIT | 15/15 HIT |
| CSS / JS / 画像(静的アセット) | 0/10 HIT | 14-15/15 HIT |
HTML はもともとオリジン側が短期キャッシュを返していたので HIT していました。静的アセットは s-maxage を付けていなかったので CDN がキャッシュ判定を諦めていたのが真因。
max-age=2592000 だけだと「ブラウザ向けの指示」と解釈されて CDN がキャッシュしない CDN(具体的にはさくらのウェブアクセラレータ)があり、s-maxage=2592000 を追加した瞬間にヒット率が跳ね上がりました。
CDN ヒット率が上がるとオリジン負荷が劇的に下がります。共用ホスティングや小さい VPS でオリジンを動かしている場合は、これだけでサイト全体のレスポンスが体感で速くなることも多いです。
チェックリスト(実務まとめ)
最後に、明日から実プロジェクトでチェックするための一覧。
- HTML 応答に
Cache-Control: public, max-age=600, s-maxage=3600相当が付いているか - 静的アセット応答に
Cache-Control: public, max-age=2592000, s-maxage=2592000相当が付いているか -
s-maxageを付けているか(max-ageだけになっていないか) - ログイン中 / 管理画面 / AJAX / POST / 404 / 検索 / プレビューを除外しているか
- モバイル / PC で同 URL が違う HTML を返すページが無いか(あるなら除外 or
Vary明示 or JS 切替へ寄せる) - 共用ホスティングなら
FileETag Noneの検討 -
curl -sD - -o /dev/nullで GET ヘッダーを確認し、HIT 率を実測 -
?nocache=1のようなバイパス機構を仕込んだか - 自動パージするかしないかの運用方針を決め、ドキュメント化したか
- CMS 更新 / リプレイス前後で 必ずヒット率を比較する
CDN の話は「設定したら終わり」になりがちですが、オリジン側の挙動が変わると一発で壊れるのが怖いところ。CMS の major version up やテーマの大改修のあとは、念のため x-cache を見ておく癖をつけておくと良いです。
まとめ
- CDN ヒット率が突然落ちる主犯は 「オリジンが Cache-Control を返さなくなった」ことが多い(CMS リプレイスで頻発)
- 多くの CDN は
max-age単体ではキャッシュ判定が荒れる。s-maxageを必ず付ける(特にさくら / Vercel / Cloudflare HTML / Akamai) - HTML は「短ブラウザ・長 CDN」、静的アセットは「長期 + 同値」が基本設計
- 除外対象(ログイン / 管理画面 / 動的 / モバイル分岐ページ)の網羅は 事故防止上の必須項目
- 共用ホスティングでは 壊れた ETagに注意。困ったら
FileETag Noneで Last-Modified に統一 - 検証は GET で
x-cacheヘッダーを連打計測。HEAD はキャッシュされない CDN が多い - 運用は 自動パージ or 短 TTL の二択。即時性 vs 実装コストのトレードオフ
「CDN を契約してるからキャッシュは効いてるはず」── これが一番危険。curl -sD - -o /dev/null https://your-site/ を10回叩いて x-cache: HIT の比率を見るところから、定期的に確認する運用にしておくのがおすすめです。