HTTP / Cloudflare Workers
HTTP の条件付きリクエスト — ETag と Last-Modified
ブラウザに何をどれだけ覚えさせるかは、秒数を決める問題ではなく「いつ変わるか」を言葉にする問題である。個人用のランニング記録アプリを題材に、条件付きリクエストを組み込むまでの判断と、その過程で踏んだ 3 つの落とし穴を辿る。
01三択のどれを選ぶか
ブラウザに何かをキャッシュさせるとき、取れる方針は実質 3 つしかない。「させない」「秒数を決めて任せる」「毎回確かめさせる」である。速いのは 2 番目で、正しいのは 1 番目と 3 番目である。
この文書は 3 番目、つまり 条件付きリクエスト の設計を扱う。題材は個人用のランニング記録アプリ marathon-log(Cloudflare Workers + D1)で、そこで実際に踏んだ 3 つの落とし穴 — Cloudflare が HTML の ETag を落とすこと、Last-Modified を何から作るか、Workers のグローバルスコープで Date.now() が 0 を返すこと — まで含めて辿る。
02Cache-Control の指示を読み分ける
名前が紛らわしいので、まず RFC 9111 の定義を確認しておく。とくに no-cache は「キャッシュするな」ではない。
- no-store(5.2.2.5)
- 「キャッシュはリクエスト・レスポンスのいかなる部分も保存してはならない」。保存そのものを禁じる。
- no-cache(5.2.2.4)
- 「検証のために転送し、成功の応答を受け取るまでは、そのレスポンスを他のリクエストに使ってはならない」。保存はしてよい。使う前に確認せよという指示である。
- private(5.2.2.7)
- 「共有キャッシュはこのレスポンスを保存してはならない」。1 人のためのレスポンスだという宣言で、ブラウザのキャッシュには残る。
- max-age(5.2.2.1)
- 指定した秒数のあいだは新鮮とみなしてよい。この間、ブラウザはサーバーに問い合わせない。
ここで注意がいるのが「何も付けない」場合である。何も付けなければキャッシュされない、ということにはならない。RFC 9111 4.2.2 は、明示的な有効期限がないときキャッシュはヒューリスティックに期限を決めてよいと定めている。禁じているのはその逆 — 「保存されたレスポンスに明示的な有効期限があるとき、キャッシュはヒューリスティックを使ってはならない」 — だけである。
ヘッダを付けないのは「キャッシュしない」ではなく「ブラウザに任せる」である。判断が実装ごとに変わるうえ、開発中にデプロイしたのに古い画面が出続ける、という形で表面化する。意図がどちらであれ、明示したほうがよい。
03条件付きリクエストの往復
no-cache を選ぶと、ブラウザは毎回サーバーに問い合わせる。このままでは no-store と変わらない。差が出るのは、サーバーが 検証子 を一緒に返しているときである。
検証子は 2 種類ある。ETag は内容を表す不透明な文字列で、ふつうは本文のハッシュを使う。Last-Modified は最終更新時刻である。ブラウザは次の要求でそれぞれ If-None-Match と If-Modified-Since に載せて送り返す。サーバーは一致すれば 304 Not Modified を本文なしで返す。
両方を送ってきたときの扱いは決まっている。RFC 9110 13.2.2 は If-None-Match を先に評価し、それがあるときは If-Modified-Since を見ないと定めている。片方が一致し片方が外れたときに 304 を返してしまわないよう、実装でもこの順を守る。
本文のハッシュを ETag にする方式では、304 を返すためにも本文を組み立てなければならない。省けるのは転送と再描画だけで、サーバー側は軽くならない。ただし Last-Modified のほうは本文を見ずに決まる。この差が後で効いてくる(9 章)。
04max-age を選ばなかった理由
marathon-log の画面は、そう頻繁には変わらない。内容が変わる条件は 4 つしかない。
| 変わる条件 | 頻度 | 起点 |
|---|---|---|
| ワークアウトが取り込まれた | 走った日に数回 | iPhone のショートカットからの POST |
| 計画をいじった(目標変更・再生成・組み替え) | まれ | 画面からの POST |
| 日付が変わった | 1 日 1 回 | 時計 |
| デプロイした | 開発中だけ | wrangler |
これだけ見れば max-age がよく効きそうに思える。実際 1 日の大半は同じ内容である。それでも選ばなかったのは、このアプリのいちばん多い使われ方が「走り終えてショートカットを実行し、その直後に開く」だからである。ここで古い画面が出ると、アプリの用をなさない。日付をまたいだ直後も同じで、今日・未実施・未来の区別と残り日数が一斉に変わる。
つまり 更新の頻度が低いことと、古い内容を見せてよいことは別の話である。更新が低頻度でも、更新の直後に見られるものは古くしてはいけない。秒数で区切る方式は、まさにその瞬間を外す。
結論として Cache-Control: private, no-cache を選んだ。private を付けているのは、認証の内側にある個人のデータなので共有キャッシュに残したくないためである。
05workers.dev では HTML の ETag が落ちる
ETag を返すコードを書いてデプロイし、ヘッダを確認したところ、JSON には付いているのに HTML には付いていなかった。
$ curl -sI https://<name>.workers.dev/api/v1/health
content-type: application/json; charset=utf-8
cache-control: private, no-cache
etag: W/"41aa6b2e763e…" ← 残っている
$ curl -sI https://<name>.workers.dev/races
content-type: text/html; charset=utf-8
cache-control: private, no-cache
← etag がない
Worker のコードは両者で同じ経路を通る。ローカルの wrangler dev で同じ URL を叩くと HTML にも ETag が付いていたので、落としているのは Cloudflare のエッジだと分かる。
理由は Cloudflare の仕様で説明が付く。ETag のドキュメントは、HTML を書き換える機能が有効だと ETag が取り除かれると述べており、弱い ETag を使う場合は Email Obfuscation と Automatic HTTPS Rewrites を無効にする必要があると明記している。Rocket Loader や自動 Minify も同じ扱いである。workers.dev のサブドメインは Cloudflare が管理するゾーンなので、これらの設定を利用者が触ることはできない。
幸い Last-Modified は HTML でも残る。そこで検証子を 2 つ出し、JSON は ETag で、HTML は Last-Modified で判定することにした。自前ドメインに移して該当機能を切れば、HTML でも ETag が効くようになる。
06Last-Modified を何から作るか
ETag は本文のハッシュでよいので迷う余地がない。難しいのは Last-Modified である。日時を出さなければならないので、内容のハッシュから作ることができない。「内容が変わったときに必ず新しくなる、現在時刻以下の日時」を別途用意する必要がある。
4 章で挙げた 4 つの変化のうち、日付とデプロイは時刻そのものから作れる。残るデータの更新をどう捕まえるかだが、ここは 1 か所で済む。データを変える操作はすべて GET 以外のリクエストである。ルーターの出口で「GET 以外が成功したら現在時刻を記録する」と書けば、取り込みも目標変更も組み替えも一網打尽になる。
export async function route(req, env, app) {
const res = await handle(req, env, app);
if (req.method !== "GET" && res.status < 400) {
// 書き込みが通った。次からの Last-Modified がこれで新しくなる
await app.settings.set("data_rev", new Date().toISOString());
return res;
}
return withValidators(req, res, () => app.settings.get("data_rev"), env.BUILD_STAMP);
}
あとは 3 つの時刻の最大を採る。
秒に丸めるのを忘れないこと。Last-Modified の書式は秒までしか表せないので、ミリ秒を持ったまま比較すると、返した値をそのまま送り返されても「それより後に更新された」と判定してしまい、永久に 304 が返らない。
デプロイ時刻は wrangler deploy --var BUILD_STAMP:$(date -u +%Y-%m-%dT%H:%M:%SZ) で渡している。isolate の起動時刻でも代用できそうに見えるが、これは colo ごとにばらつくため、リクエストのたびに Last-Modified が揺れて 304 が安定しない。実測でも 3 回連続で 200 が返った。
07Workers の時計はグローバルスコープで止まっている
その isolate の起動時刻を使おうとして、最初はこう書いた。
const ISOLATE_START = Date.now(); // モジュールのトップレベル
ところがデプロイしても Last-Modified が更新されず、値は常に「今日の 0 時」になった。ISOLATE_START が最大値に一度も勝っていない。つまり 0 が入っている。
Workers のドキュメントに答えがある。「Date.now() は最後の I/O の時刻を返す。コードの実行中には進まない」。同じ理由で performance.now() も精度が落とされている。タイミング攻撃を防ぐための設計である。
グローバルスコープが評価されるのは isolate の起動時で、その時点ではまだ I/O が 1 度も起きていない。したがって「最後の I/O の時刻」は存在せず、0 — Unix エポック — が返る。
このふるまいは黙って 0 を返すだけで、エラーにも警告にもならない。「デプロイ時刻」「起動時刻」「バージョン」をグローバルスコープの時計から作ろうとすると、テストでは気付かないまま本番で機能しない。こういう値はビルド時に外から渡すのが確実である。
08実装と、結局どこが速くなるのか
できあがった検証子の付与はこうなる。ルーターの出口に 1 か所だけ置く。
const CACHEABLE = /^(text\/html|application\/json)/;
export async function withValidators(req, res, dataRev, buildStamp) {
if (req.method !== "GET" || res.status !== 200) return res;
// アイコンのように自前の cache-control を持つものは触らない
if (!CACHEABLE.test(res.headers.get("content-type") ?? "") || res.headers.has("cache-control")) return res;
const body = await res.clone().arrayBuffer();
const digest = await crypto.subtle.digest("SHA-1", body);
const etag = `W/"${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")}"`;
const rev = Date.parse((await dataRev()) ?? "");
const built = Date.parse(buildStamp ?? "");
// ミリ秒は Last-Modified で表せないので秒に丸める
const lastMs = Math.floor(Math.max(built || 0, dayStartMs(Date.now()), rev || 0) / 1000) * 1000;
const headers = new Headers(res.headers);
headers.set("etag", etag);
headers.set("last-modified", new Date(lastMs).toUTCString());
headers.set("cache-control", "private, no-cache");
// RFC 9110 13.2.2: If-None-Match があるときは If-Modified-Since を見ない
const inm = req.headers.get("if-none-match");
const ims = Date.parse(req.headers.get("if-modified-since") ?? "");
const fresh = inm !== null ? inm === etag : isFinite(ims) && ims >= lastMs;
return fresh ? new Response(null, { status: 304, headers }) : new Response(body, { status: 200, headers });
}
実測は次のとおりである。ダッシュボードの JSON は 128 KB あるので、変化していないときの往復がそのまま 0 になる。
| 要求 | 応答 | 本文 |
|---|---|---|
| HTML、条件なし | 200 | 33 KB |
| HTML、If-Modified-Since 付き | 304 | 0 |
| HTML、計画を組み替えた直後に同じ条件 | 200 | 33 KB |
| JSON、条件なし | 200 | 128 KB |
| JSON、If-None-Match 付き | 304 | 0 |
ここまでで速くなったのは本文の転送と、ブラウザ側の再解析・再描画である。サーバー側は軽くなっていない。304 を返す場合でも D1 の読み取りと HTML の組み立ては行われ、data_rev を読むぶんクエリはむしろ 1 本増えている。次章でここを詰める。
09本文を作る前に打ち切る
転送量ではなくデータベースの使用量を減らしたい、という動機なら話が変わる。D1 の課金は 読んだ行数 で数える。ドキュメントは「行の読み取りは、それぞれの行の大きさに関係なく、クエリが何行を読んだ(走査した)かを測る」「1 KB の行も 100 KB の行も 1 行として数える」と述べている。列を絞っても、返す行を減らさなければ数は変わらない。
marathon-log のダッシュボードを 1 回開くと、こうなる。
| クエリ | 読んだ行 |
|---|---|
| plan_days(レースの計画 1 件ぶん) | 70 |
| workouts(直近 200 件と期間指定の 2 本) | 11 |
| races / settings ×2 | 3 |
| 合計 | 84 |
ここで 3 章の差が効く。ETag は本文のハッシュなので本文がないと決まらないが、Last-Modified は本文を見ずに決まる。6 章で作った 3 つの時刻はどれもページの中身と無関係に取れる。つまり If-Modified-Since だけを持つ条件付きリクエストは、ハンドラを呼ぶ前に判定して打ち切れる。
export async function route(req, env, app) {
if (req.method !== "GET") { /* 書き込み。data_rev を進める */ }
// 本文を作る前に打ち切れるものはここで返す。D1 の読み取りが 1 行で済む
if (canEarlyOut(req) && authOk(req, env)) {
const lastMs = await lastModifiedMs(dataRev, env.BUILD_STAMP);
if (Date.parse(req.headers.get("if-modified-since") ?? "") >= lastMs) return notModified(lastMs);
}
return withValidators(req, await handle(req, env, app), dataRev, env.BUILD_STAMP);
}
読む行は data_rev の 1 行だけになる。84 分の 1 である。応答も 0.19 秒から 0.06〜0.10 秒に落ちた。
打ち切る前に認証を通すこと。セッションが切れているのに 304 を返すと、ログイン画面へ送るべき場面で古いページがそのまま表示される。幸いこのアプリの認証はトークンのハッシュ比較だけで、データベースを引かない。
対象も絞る。If-None-Match が付いているときは本文まで作る(RFC 9110 13.2.2 でそちらが優先でもある)。自前の Cache-Control で配っているアイコンやマニフェストも除く。
もっとも、この規模で D1 の無料枠を心配する必要はない。1 日 500 万行に対して 1 画面 84 行なので、1 日 6 万回開ける。それでも入れる価値があるのは、行数が効いてくるのは利用者や計画が増えたときであり、そのときには「打ち切れる形になっているかどうか」を後から変えるほうが高くつくからである。
ヘッダを付けないという最初の状態と比べると、変わったのは「ブラウザに委ねるのをやめて、こちらで決めた」という 1 点である。キャッシュの設計とは結局それに尽きる。