更新したのに古い CSS が表示される ― キャッシュバスティングを content hash で正しくやる
CDN と ETag で配信元の負荷を抑える話では、動的生成の前に CDN を置いて ETag を返してもかえって負荷が上がること、そして静的化して nginx の更新時刻ベースの ETag を安定させると配信元が楽になることを扱いました。その続きとして、ここではアセット(CSS・JS・画像・フォント)側の話をします。よくある事故から始めます。CSS を直して公開したのに、利用者の画面は古いままでレイアウトが崩れる ―― ブラウザや CDN のキャッシュが「効きすぎ」て、新しい CSS を取りに来てくれないのです。逆に毎回読み直させれば最新になりますが、今度は遅くて重い。この板挟みを解くのがキャッシュバスティングです。
「古いまま」と「毎回読み直し」の板挟み
アセットのキャッシュには、ふたつの極端があります。
- 長くキャッシュさせる ―― 速いし配信元も楽。けれど更新しても古いものが残り、CSS や JS の差し替えが利用者に届かない(画面崩れ・古い挙動)。
- キャッシュさせない/短くする ―― 常に最新。けれど毎回ダウンロードが走り、表示は遅く配信元も忙しい。
欲しいのは「変わっていない間はずっとキャッシュ、変わった瞬間だけ取り直す」という良いとこ取りです。それを実現する素直な方法が、中身が変わったら URL を変えること。キャッシュは URL 単位で効くので、URL が変われば「別物」として確実に取りに来てくれます。
中身から URL を決める ― content hash で ?v= を付ける
やり方はシンプルです。各ファイルの中身(バイト列)から短いハッシュを計算し、それを URL の末尾に付けます。
<link href="https://keis-software.com/assets/css/style.css?v=1a2b3c4d">
<script src="https://keis-software.com/assets/js/app.js?v=9f8e7d6c">
この v= の値は中身が同じなら必ず同じ・変われば必ず変わる(中身がそのまま住所になる=content-addressed)。だからファイルを更新するとハッシュ=URL が自動的に変わり、ブラウザも CDN も「新しい URL だから取りに行く」。逆に、何も変えていなければ URL は前のまま ―― キャッシュにそのまま当たります。バージョン番号を手で振る必要も、リリースのたびに番号を上げ忘れる事故もありません。
「全体で 1 つの版番号」は過剰に失効する
ここで素朴にやりがちなのが、サイト全体で 1 つの版番号(例: リリースごとの通し番号)を全アセットに付ける方法です。これだと CSS を 1 つ直しただけで全アセットの URL が一斉に変わり、変えていない画像も JS も含めて全部が取り直しになります。CDN もブラウザも、せっかくのキャッシュをまとめて捨てることになります(CDN と ETag の話で触れた「全ファイルの更新時刻が動くと ETag も全部変わる」と同じ構図です)。
正しくはファイル単位の content hash です。各ファイルの中身だけからハッシュを作るので、blog.css を直しても style.css の URL は変わりません。本当に変わったファイルだけが新しい URL になり、それ以外は静かにキャッシュされ続けます。なお何も付けない(無印)と、今度は逆に古いものが残り続けて陳腐化します。3 つを並べると違いがはっきりします。
だから「1 年 immutable」と安全に組める
URL が中身に対応していると、キャッシュ期間を思い切り長くできます。アセットには Cache-Control: public, max-age=31536000, immutable(1 年・再確認不要)を付けます。immutable は「有効期間中は再検証すらしなくてよい」という意味で、リロードしても条件付きリクエスト(ETag による 304 の問い合わせ)すら飛びません。
普通なら「1 年もキャッシュして大丈夫?更新が届かないのでは」と不安になりますが、content hash URL ならその心配が要りません。中身が変われば URL ごと変わるので、古い URL のキャッシュがいくら残っていても、新しい中身は新しい URL として取りに行くからです。「変わらない限り絶対に取りに来ない/変わったら確実に取りに来る」を同時に成り立たせられます。
何に付けて、何に付けないか
キャッシュバスティングは、付けてよい URL にだけ付けるのが肝心です。対象はサイト内(ルート相対)の静的アセット ―― CSS・JS・画像(PNG / JPEG / GIF / SVG / WebP / ICO)・フォント(WOFF / WOFF2 / TTF / EOT)など。一方で次はそのまま触りません。
- 外部 URL(
https://や//始まり)・data:URI ―― 自サイトのファイルではないので中身のハッシュを持てません。 - HTML 自身(
.html) ―― これはETag と短い寿命で扱う側で、URL を固定したいページ本体です。 - 実体が見つからない URL ―― ハッシュの計算しようがないので、推測で付けず原文のままにします。
こうして「中身からハッシュを作れるものだけ」に限定することで、外部リソースや動的なページを壊さずに、アセットだけを安全にバージョン管理できます。
「では ?v= が付かない PDF はどうなるのか」。PDF は置き方で 2 通りに分かれ、どちらも別の仕組みで「変えた分だけ確実に届ける」を成り立たせています。
- 記事に挿入するメディア(画像・PDF)―― 保存時にファイル名へ一意の識別子が付きます(
a1b2c3d4-report.pdfのような形)。差し替えれば新しいファイル名=新しい URL になるので、ここも「中身が変われば URL が変わる」。だから 1 年 immutable で安全に配信できます(ファイル名でのバスティング)。 - サイトにファイル管理機能で直接置いたファイル ―― こちらは元のファイル名のままで、URL も固定です。だから immutable は付けず、HTML と同じ短い寿命+更新時刻ベースの ETag で配信します。中身を差し替えれば更新時刻が変わって ETag も変わり、数分のうちに新しいものへ切り替わります(同じ URL のまま中身だけ入れ替えられる)。
つまり ?v=・一意ファイル名・更新時刻ベースの ETag と、入口は違っても狙いはどれも同じ「中身が変わったら、確実に新しいものを届ける」です。URL ごと変えられるものは長期 immutable に、固定 URL で差し替えたいものは ETag に振り分けています。
HTML は ETag、アセットは URL ―― 役割分担で配信を軽くする
HTML 本体の ETag とアセットのキャッシュバスティングで、配信を軽くする 2 つの層がそろいます。
- HTML 本体 ―― URL は固定したまま、更新時刻ベースの ETag で「変わったか」を安く確かめ、変わっていなければ 304 で済ませる(短い寿命+裏で更新)。
- アセット ―― 中身が変わったら URL ごと変える。変わらない限り 1 年 immutable で再検証ゼロ、変わったものだけ新 URL で取り直す。
nano-cms は、この ?v= の書き換えと immutable 付与を公開(静的生成)時に自動で行います。生成した HTML の中のアセット参照を走査し、各ファイルの中身から計算したハッシュで ?v= を書き換え、アセットには 1 年 immutable を付けてエッジへ配ります。担当者は番号を振る必要も、キャッシュを手で消して回る必要もありません。そもそも公開すると動くプログラムではなくファイルができ上がる仕組み自体は、なぜ速くて、落ちなくて、狙われにくいのか ― nano-cmsの静的配信で説明しています。
キャッシュバスティングの肝は「中身が変わったら URL を変える」。全体で 1 つの版番号にすると過剰に失効し、付けないと陳腐化する。ファイル単位の content hash なら、変えた分だけ新 URL になり、それ以外は 1 年 immutable で再検証ゼロ。HTML 側の ETag と合わせて、更新は速く・配信元は軽く保てます。
このサイトのスタイルや画像も、この方法で配られています。普段は気づかないうちにキャッシュが効き、更新したときだけ静かに新しいものへ入れ替わる ―― 「変えた分だけ、確実に届ける」を、手間なく成り立たせる仕組みです。こうして配るファイルをどこで・何回圧縮するかは、事前圧縮が効く構成と、CDN に任せる構成で扱っています。