キャッシュバスティング: その ?v= はおそらく何もしていない
本番のナビゲーションが、ある日の午後のあいだずっと、スタイルの当たっていない状態で表示されていました。HTML は正しく、サーバー上の CSS ファイルも正しく、デプロイもエラーなく完了しています。正しい場所を見るまでに、二度も誤診しました。
app.css には ?v= が一切付いていませんでした。Cloudflare は 4 時間キャッシュし、ブラウザはそれよりずっと長く保持します。そのリリースは CSS のクラス名をまとめて変更していたので、新しい HTML が、前のビルドのスタイルシートを持ったままのブラウザに届きました。マークアップ側のクラスは、どれ一つ対応する定義を持ちません。
デプロイではなくキャッシュだと分かる手がかりは、そのまま見つけにくさの原因でもありました。スーパーリロードで直り、初めてサイトを開いた人には正常に見えるのです。コールドキャッシュは正常なデプロイと区別がつかないので、きれいなプロファイルで検証した人は誰も再現できませんでした。
app.js のほうには ?v= がありました。そして、それは無いより悪いものでした。package.json のバージョンを埋め込んでいたからです。
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
この数字はリリースをまたいでも動きません。バージョンを上げる頻度より、リリースの頻度のほうがはるかに高いからです。つまり JS には、テンプレートを読む人にはキャッシュバスティングに見えて、実際には何も無効化しないトークンが付いていました。CSS と同じバグが、発火の一リリース手前で、おとりを前に立てて待っていたわけです。
ファイルをハッシュする
キャッシュバスティングのトークンの仕事は一つだけです。ファイルが変わったときに変わり、それ以外では変わらないこと。それを満たす値は、ファイル自身のバイト列から導いたものしかありません。
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
function assetVersion(relativePath) {
const buf = readFileSync(join(__dirname, 'public', relativePath));
return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}
const cssVersion = assetVersion('css/app.css');
読むのは起動時の一度きりです。ビルド成果物なので、動いているプロセスの下で中身が変わることはありません。16 進 8 文字あれば十分ですし、SHA-1 で構いません。防ぎたいのは自分のファイルの新旧が偶然衝突することであって、中身を選べる攻撃者ではないからです。
これでテンプレートが嘘をつかなくなります。
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
代わりによく使われるトークンは三種類あります。
バージョン番号(?v=1.4.2)は、人が変更を思い出したときに変わります。ファイルが変わったときとは別ものです。うちの app.js がこれでした。
タイムスタンプやビルド ID はデプロイごとに必ず変わるので、古いものを配る事故は起きません。ただし何も変わっていないときにも変わります。1 バイトも動いていなくても、デプロイのたびに全訪問者のキャッシュ済み CSS と CDN 各エッジのコピーを捨てることになり、バックエンドしか触っていないリリースでも全員に再ダウンロードを強いります。
ランダムな値(?v=<%= Math.random() %>、実際に書く人がいます)は、ページ表示のたびに新しい URL になります。これはキャッシュを無効化しているのではなく、全員に対して恒久的にキャッシュを切っているだけです。
両方を同時に満たすのは内容ハッシュだけです。古いものを配らず、変わっていないものを再ダウンロードさせません。
クエリ文字列かファイル名か
app.a1b2c3d4.css は、ハッシュをクエリ文字列ではなくファイル名に入れて同じ約束をします。参照をすべて書き換えるビルド工程が必要で、バンドラを使う構成では標準的、サーバーサイドレンダリングの構成ではあまり見かけないのはそのためです。
ファイル名派の古典的な根拠は、キャッシュキーの計算でクエリ文字列を無視するキャッシュがある、というものでした。そうなると ?v= は飾りです。これは今でもうっかり有効にできる設定で、Cloudflare のキャッシュレベルには「ignore query string」があります。オンになっていれば、サイト中の ?v= が何も言わずに機能しなくなります。頼る前に確認してください。
immutable は URL に対する約束であって、ファイルに対する約束ではない
Cache-Control: public, max-age=31536000, immutable は、ユーザーがリロードしても再検証するなとブラウザに伝えます。これが本当なのは URL が内容アドレスになっている場合だけで、そう言い切ってしまえばルールは自然に決まります。
if (/\.(css|js)$/.test(name)) {
const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
res.setHeader('Cache-Control', versioned
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600');
}
同じファイルでもトークンなしで要求されたら 1 時間です。約束をしているのはトークンなので、誰もしていない約束を根拠に 1 年キャッシュさせてはいけません。
静的ファイルのミドルウェアに上書きされていないかも確認してください。@fastify/static は既定で max-age=0 を送ります。自前の cacheControl を無効にしないままヘッダーを設定すると、その既定値からヘッダーを組み立て直し、あなたの指定を黙って上書きします。ヘッダーは残り、意図的に見え、そして間違っています。
フォントは名前で immutable にしています。サブセットは編集ではなくリネームで差し替えるので、ファイル名がすでに内容アドレスです。同梱ライブラリも同じで、alpine-3.14.8.min.js は名前にバージョンを持ち、その場で書き換える人はいません。
画像は 1 年ではなく 1 週間です。バージョンが付いておらず、ロゴやファビコンは同じ URL のまま差し替わります。1 週間なら効果はあり、間違いを直せる短さでもあります。
短命でなければならないファイル
私たちは、他人のページの中で動くバッジ用スクリプトを配信しています。相手の HTML も、相手が埋め込む URL も、こちらの管理下にありません。ここに 1 年の immutable を付ければ、1 年間手の届かないバグを抱えることになります。無効化する手段はなく、タグを貼っている全サイトに書き換えを頼むこともできません。だから 1 時間にしています。ファイルがほとんど変わらなくても、これが正解です。
バージョンを付けられない URL に置くものは、すべてこの性質を持ちます。キャッシュの寿命を決めるのは、変更の頻度ではなく、どれだけ速く直せる必要があるかです。
確認する
HTML に入っているトークンは、ディスク上のファイルと一致していますか。
curl -s https://example.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
同じ文字列になるはずで、そうでなければ HTML は自分が説明していないものを指しています。CSS を変更した二度のデプロイをまたいでトークンが動かないなら、うちの app.js と同じ状態です。
次に、そのアセット自身が何を返すか聞きます。
curl -sI 'https://example.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
古いエッジを捕まえるのは last-modified です。そのファイルを最後にデプロイした時刻より前なら、サーバーとブラウザのあいだの何かがまだ古いコピーを抱えています。そして、それが被害になるかどうかを決めるのは、HTML の中のトークンです。