CDN と ETag を入れたのにサーバ負荷が上がった ― 動的生成と CDN はなぜ相性が悪いのか
動的に HTML を生成するサイトの前に CDN を置き、オリジン(配信元サーバ)が ETag を返すようにした。「変わっていなければ 304 Not Modified で済むから、オリジンは楽になるはず」 ―― そう期待したのに、負荷は下がらない。場合によってはむしろ上がる。動的生成サイトでよくある話です。原因は 2 つの事実に集約されます。動的生成では ETag は「作ってからしか決まらない」こと、そして nginx のような静的配信では ETag を「ファイルを読まずに」作れること。順に見ていきます。
「304」が省くのは本体の再送だけ ― 生成コストは別
まず ETag と 304 のおさらいです。配信側はリソースに ETag(中身の版を表す指紋)を付けて返します。次回、ブラウザや CDN は手元の ETag を If-None-Match に載せて問い合わせ、配信側の ETag と一致すれば 304 Not Modified(本体は送らず「変わっていない」とだけ返す応答)で済みます。
GET /news/ HTTP/1.1
If-None-Match: "664f1a2b-1f40" <- 手元の版
HTTP/1.1 304 Not Modified <- 本体は送らない
ここで大事なのは、304 が節約するのは「本体の再送」だけで、その本体を作るコスト(アプリの処理・DB 問い合わせ・レンダリング)は一切省かれないことです。動的生成で配信されるのは、画像のような大物ではなくコンテンツ本体(HTML)です。HTML は数〜数十 KB 程度で、いまの回線ではその再送を省いて浮く時間はわずか。304 の恩恵が本当に効くのは「本体が極端に大きく、回線も遅い」という特殊な状況だけです。
動的生成では、ETag は「作ってからしか決まらない」
では、なぜ動的生成だと 304 がほとんど効かないのか。中身が変わったかどうかは、生成してみるまで分からないからです。本文から ETag(ハッシュ)を作る以上、ETag を答えられる時点で、重い処理 ―― アプリの起動・DB 問い合わせ・HTML の組み立て ―― はすべて終わっています。つまり「304 で本体だけ送らずに済ませた」としても、負荷の正体である生成は毎回フルで走っているのです。
(DB の更新時刻や版番号を ETag にしてレンダリングだけ飛ばす手もありますが、その版番号を得るのにアプリと DB は動きます。アプリにも DB にも一切触れずに検証できるのは、次に見る静的配信だけです。)
nginx の ETag は更新時刻とサイズから作る ― だから静的は安い
静的ファイルを配信する nginx は、ETag の作り方が根本的に違います。ファイルの中身を読まず、stat() で分かる「更新時刻」と「サイズ」だけから ETag を組み立てます。形はおおよそ次のとおりです。
ETag: "<更新時刻を16進>-<サイズを16進>"
例: "664f1a2b-1f40"
中身を読まないので、条件付きリクエストに対して アプリも DB も本体の読み出しも無しで 304 を返せます。検証にかかるのは実質 stat() 1 回分(ファイル情報のキャッシュが効けばさらに安い)。動的生成が「重い処理のあとにしか ETag を決められない」のに対し、静的配信は「触る前に、更新時刻とサイズだけで判定できる」 ―― この差が決定的です。
謎解き ― 動的 × CDN で「ETag を返したのに負荷が上がる」
ここまで来ると、冒頭の状況が説明できます。動的オリジンの前に CDN を置いて ETag を返すと、CDN はキャッシュの期限が切れるたびにオリジンへ条件付きで再検証しに行きます。ところがオリジンは動的なので、その問い合わせに答えるために毎回フルで再生成して ETag を確かめます。304 が返っても、省けたのは本体送信だけ。生成は走りっぱなしです。
さらに事情を重くするのが次の点です。
- エッジは拠点ごとに別キャッシュ ―― 利用者は最寄りの拠点に振られるため、拠点の数だけ「初回ミス」が起き、そのたびにオリジンで生成が走ります。
- 動的応答はキャッシュされにくい ―― Cookie が付く・
Varyが多い・Cache-Controlが弱い、といった理由でキャッシュ率が落ち、CDN が実質「素通りのもう一段」になります。 - ETag を足したことで再検証が増える ―― それまで手元のキャッシュをそのまま使えていたものが、わざわざ問い合わせに変わり、その一つひとつが生成を呼びます。
こうして「キャッシュを足したのに楽にならない、むしろ忙しくなった」が起こります。動的生成と CDN(の ETag 再検証頼み)は、相性が良くありません。
だから事前に静的化し、更新時刻を据え置いて ETag を安定させる
解決は、考え方を逆にすることです。リクエストのたびに作るのをやめ、公開時に一度だけサイト全体を静的ファイルとして生成し、その一式をエッジへ置きます。こうすれば利用者のアクセスは「出来上がったファイルを返すだけ」。配信も、先ほどの stat() ベースの検証も、アプリにも DB にも触れません。重い生成は、利用者の来訪時ではなく公開時に一度きりです。事前静的化が速くて落ちにくく狙われにくい理由そのものは なぜ速くて、落ちなくて、狙われにくいのか ― ケイズのCMSの静的配信 で詳しく扱っています。
ただし静的化にも一つ注意があります。再生成のとき、変更が一部でも全ファイルを書き直すと全ファイルの更新時刻が変わり、ETag も全部変わって全再取得になってしまう。そこで、中身が前回と同じファイルは書き換えず、更新時刻もそのまま据え置きます。
新しい内容を生成
└ 既存ファイルと中身は同じ?
├ はい → 何もしない(更新時刻そのまま → ETag 不変)
└ いいえ → 書き込む(更新時刻が変わる → ETag が変わる)
これで本当に変わったページだけ ETag が動き、変わっていないページは前の ETag のまま。期限切れ後の再検証も、stat ベースの安い 304 で静かに済みます。
大物アセットは content hash で「再検証ゼロ」に
画像・CSS・JS のように「変わったら名前ごと変わる」大物には、もう一段強い手を使います。ファイルの内容から計算した短いハッシュを URL に付け(style.css?v=1a2b3c4d のような形)、そのアセットには「1 年そのまま使ってよい・再確認も不要」を意味する Cache-Control: public, max-age=31536000, immutable を添えます。内容が変われば URL ごと変わるので、変わったファイルだけが新しい URL として取得され、変わっていないものは問い合わせすら起きません。サイト全体で 1 つの版番号を使うと 1 ファイル直しただけで全 URL が変わってしまいますが、ファイル単位のハッシュならそれを避けられます。
本当に大きいもの(アセット)はそもそも再検証させず、HTML 本体には短い寿命と「期限が切れても一旦は手元を見せて裏で更新する」指定(例: max-age=300, s-maxage=3600, stale-while-revalidate=86400)を併せ、反映の速さと待たせなさを両立させます。
ETag が効くのは「静的ファイル+更新時刻とサイズで検証できる」状況だけです。動的生成の前に CDN を置いて ETag の再検証に頼ると、304 は本体の再送を省くだけで、肝心の生成は毎回走り、負荷は下がりません。先に静的化してエッジへ置き、更新時刻を据え置いて ETag を安定させる ―― 順番を逆にすることが、配信元を楽に保つ近道です。
ケイズの CMS はこの仕組みをそのまま備えています
ここまでの「動的に作らず静的化してエッジへ」「更新時刻を据え置いて ETag を安定」「アセットはファイル単位ハッシュで再検証ゼロ」を、ひとつの CMS としてまとめて備えているのがケイズの CMS です。公開のたびにサイト全体を静的ファイルとして生成してエッジへ反映し、利用者の配信経路にはアプリも DB も管理画面も入りません。静的配信そのものの仕組みと利点は なぜ速くて、落ちなくて、狙われにくいのか ― ケイズのCMSの静的配信 にまとめています。
ケイズの CMS は、もともと更新頻度の高いサイト ―― ニュースや IR を抱えるコーポレートサイト、FAQ サイト、オウンドメディアなど ―― を対象に作られています。頻繁に更新しても配信元が重くならないことが、こうしたサイトほど効いてきます。いま読んでいるこのブログも、その一例です。
動的生成では空回りしがちな ETag が、静的化して初めて素直に効く ―― これがこの記事のかなめでした。次回は、ここで触れたキャッシュバスティング(ファイル単位の content hash で URL を変え、CDN とブラウザのキャッシュを必要なときだけ・必要な分だけ失効させる仕組み)を、もう少し踏み込んで取り上げます ―― 更新したのに古い CSS が表示される ― キャッシュバスティングを content hash で正しくやる。

