9 月 25 日、自分のポートフォリオサイトの表示を見直していて、DevTools のコンソールで document.fonts を開きました。並んだ書体のうち、欧文用に自前で配信している一つだけが status: "error" になっておりました。
ところが、同じフォント URL を手元の curl で取ると、返ってくるのは 200 です。ファイルは確かにサーバーにあり、中身のバイト数も合っています。「サーバーは正しいのに、ブラウザだけが失敗している」——この食い違いの正体を突き止めるまでに、実は 13 日が過ぎていました。
個人開発で Web サイトを自分で運用していると、配信の確認はつい「curl で 200 が返ったから大丈夫」で済ませてしまいます。私自身がまさにそうでした。以下では、そのとき起きていたこと、確認のしかたをどう変えたか、そして二度と同じ形で踏まないために入れた設定を、実際に使っている形でお伝えします。
最初にお伝えしたいこと
素の curl が返す 200 は、ブラウザがフォントとして受け取る応答の証明になりません。
ブラウザは Web フォントを CORS で取りに行くため、要求に Origin ヘッダが付きます。私の環境では、CDN(Cloudflare)が Origin 付きの応答を素の応答とは別に保持しており、その Origin 付きの側にだけ、古い 404 が残っていました。素の curl は Origin を付けないので、正しいほうの応答しか見えなかったのです。
確認は「素の要求」「Origin 付きの要求」「キャッシュを避けた要求」の 3 通りを並べて行います。これがこの記事のいちばんの持ち帰りです。
13 日間に起きていたこと
時系列で並べると、次のようになります。日付はいずれも私の作業記録からです。
代替書体は、メトリクスを合わせた設定を入れていたこともあり、ぱっと見では区別がつきませんでした。レイアウトも崩れません。だからこそ 13 日も気づけなかったのだと思います。見た目が壊れない不具合は、壊れる不具合よりも長く残ります。
なぜ 404 が残ったのか — 二つの仕組みの重なり
原因は一つではなく、二つの仕組みが重なっていました。
一つ目は、エラー応答にまで長期キャッシュが付いていたことです。私のサーバーでは、フォントや画像などの拡張子に Cache-Control: max-age=31536000(1 年)を付けていました。この指定は拡張子で決まるため、ファイルがまだ無いときの 404 にも同じ 1 年が付きます。フォントをアップロードする前に、その URL へ一度でも要求が届くと、「404 を 1 年保持してよい」という応答が CDN に残るのです。
実はこの罠には、7 月に一度、別の書体で触れておりました。フォントのファイルを置く前に、その URL へ要求が届いていて、その 1 回の 404 が保存されていたのです。そのときはファイル名を変えて回避し、「新しいアセットは先に上げる」と手順に書きました。けれども、それだけでは足りませんでした。
二つ目が、Origin 付きの応答が別に保持されていたことです。9 月の切り替えでは、素の要求に対する古い 404 はパージで消えていました。だから curl は 200 を返します。一方でブラウザの要求には Origin が付き、CDN はその応答を素の応答とは別のものとして持っていました。ダッシュボードで URL を指定してパージしても、消えたのは素の変種だけで、Origin 付きの 404 は残り続けたのです。
仕組みの細部は Cloudflare の公式ドキュメント(CORS とキャッシュ、単一ファイルのパージ)に譲ります。ここでは、私の環境で観測できたことだけを書いています。プランや設定によって振る舞いが変わる可能性はありますので、ご自身の環境では次の確認スクリプトで実際の応答を見ていただければと思います。
確認スクリプト:3 通りの要求を並べる
新しいフォントや画像を公開するたびに流せるよう、確認を一本のスクリプトにまとめました。同じ URL を 3 通りで取り、ステータス・CDN のキャッシュ状態・実際に受け取ったバイト数を並べます。食い違えば終了コード 1 で止まります。
#!/usr/bin/env bash
# check_variants.sh — 同じ URL を「素の要求」「Origin 付き」「キャッシュを避けた要求」で取り、並べて比べる
# 使い方: ./check_variants.sh https://example.com/assets/fonts/body-latin.woff2 https://example.com
set -u
URL="${1:?フォントなどの URL}"
ORIGIN="${2:?ページのオリジン(例 https://example.com)}"
hdr() { # ヘッダ文字列 $1 から、名前 $2 の値を取り出す(大文字小文字を区別しない)
printf '%s' "$1" | tr -d '\r' | awk -v k="$2" -F': ' 'tolower($1)==k{print $2; exit}'
}
probe() { # $1=ラベル $2=要求先 URL、残り=curl に渡す追加引数
local label="$1" target="$2"; shift 2
local out
# 本文は捨て、ヘッダと実際に受け取ったバイト数だけを見る
out=$(curl -sS -o /dev/null -D - -w 'X-Bytes: %{size_download}\n' "$@" "$target") \
|| { printf '%-12s curl が失敗しました\n' "$label" >&2; echo "ERR"; return; }
local code; code=$(printf '%s' "$out" | tr -d '\r' | awk '/^HTTP\//{c=$2} END{print c}')
printf '%-12s status=%s cf-cache-status=%s bytes=%s\n cache-control=%s acao=%s\n' \
"$label" "$code" "$(hdr "$out" cf-cache-status)" "$(hdr "$out" x-bytes)" \
"$(hdr "$out" cache-control)" "$(hdr "$out" access-control-allow-origin)" >&2
echo "$code"
}
plain=$(probe "plain" "$URL")
cors=$(probe "with-Origin" "$URL" -H "Origin: ${ORIGIN}")
# クエリを付けると CDN のキャッシュキーが変わり、オリジンの現状に近い応答が返る
fresh=$(probe "cache-bust" "${URL}?cb=${RANDOM}${RANDOM}" -H "Origin: ${ORIGIN}")
if [ "$plain" != "$cors" ] || [ "$cors" != "$fresh" ]; then
echo "NG: 応答が食い違っています(plain=$plain with-Origin=$cors cache-bust=$fresh)" >&2
exit 1
fi
# そろっていても 200 でなければ失敗(全部 404、全部 curl 失敗も見逃さない)
if [ "$plain" != "200" ]; then
echo "NG: 3 通りとも status=$plain です" >&2
exit 1
fi
echo "OK: 3 通りとも status=200" >&2
書くときに気をつけた点が四つあります。
- 表示は標準エラーへ、判定値だけを標準出力へ出しています。
$(probe ...) でステータスだけを受け取りつつ、人が読む行は画面に残すためです。両方を標準出力に混ぜると、比較する文字列に表示行まで入り、判定が常に「食い違い」になります。
- ステータスは最後の
HTTP/ 行から取っています。プロキシを挟むと、curl のヘッダ出力に HTTP/1.1 200 Connection established が先に入ることがあり、最初の行だと誤ります。
- 3 通りがそろっていても、200 でなければ失敗にしています。最初の版は「食い違いがあるか」だけを見ていたため、接続できずに 3 通りとも失敗した場合まで OK と表示しました。手元で通信先を潰して流したときに気づいた抜けです。
- キャッシュ回避の要求にも
Origin を付けています。ここを素の要求にすると、「オリジンでは正しい」ことは分かっても、CORS の応答ヘッダ(access-control-allow-origin)が付くかどうかを見落とします。
手元で再現するために、Origin 付きの要求にだけ 404 を返す小さなサーバーを立てて流すと、次のように止まりました。これはスクリプトの判定を確かめるための模型で、CDN の内部を再現したものではありません。
plain status=200 cf-cache-status= bytes=100
cache-control=max-age=31536000 acao=
with-Origin status=404 cf-cache-status= bytes=2
cache-control=max-age=31536000 acao=
cache-bust status=200 cf-cache-status= bytes=100
cache-control=max-age=31536000 acao=
NG: 応答が食い違っています(plain=200 with-Origin=404 cache-bust=200)
9 月 25 日の本番で見えていたのも、この並びと同じかたちでした。素の要求と、キャッシュを避けた要求は 200。Origin 付きだけが 404 です。
ブラウザの中から確かめる
curl で見えるのは CDN の応答までです。ブラウザ自身のキャッシュに 404 が残っていることもあるため、ページを開いた状態で、DevTools のコンソールから次を実行しています。
// ページを開いた状態で DevTools のコンソールに貼る
(async () => {
await document.fonts.ready;
// 1) 読み込みに失敗した書体を一覧にする
const faces = [...document.fonts].map(f => ({ family: f.family, weight: f.weight, status: f.status }));
console.table(faces);
// 2) 同一オリジンの CSS から @font-face の URL を集める(別オリジンの CSS は読めないので飛ばす)
const urls = new Set();
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const r of rules) {
if (!(r instanceof CSSFontFaceRule)) continue;
const src = r.style.getPropertyValue('src');
for (const m of src.matchAll(/url\(["']?([^"')]+)["']?\)/g)) {
urls.add(new URL(m[1], sheet.href || location.href).href);
}
}
}
// 3) ブラウザのキャッシュを使う取得と、使わない取得を比べる
// reload はキャッシュを上書きするので、必ず force-cache を先に取る
for (const u of urls) {
const cached = await fetch(u, { cache: 'force-cache', mode: 'cors' }).then(r => r.status).catch(() => 'ERR');
const fresh = await fetch(u, { cache: 'reload', mode: 'cors' }).then(r => r.status).catch(() => 'ERR');
console.log(cached === fresh ? 'same' : 'DIFF', { url: u, forceCache: cached, reload: fresh });
}
})();
7 月の一件では、この比較で force-cache が 404、reload が 200 と分かれました。「サーバーは直っているのに、手元のブラウザだけが古い 404 を握っている」状態は、この差でしか見分けられません。
注意点が二つあります。@media の内側に書いた @font-face はこのコードでは拾いません。必要なら r.cssRules を辿ってください。また、reload で取った時点でブラウザのキャッシュは新しい応答に置き換わります。調べる前の状態は一度しか見られませんので、結果はその場で控えておくことをおすすめします。
エラー応答に長期キャッシュを乗せない
根本の対策は、404 や 500 を長く保存させないことです。私のサーバーは Apache(.htaccess が使える共用サーバー)なので、次の 1 行を入れました。
<IfModule mod_headers.c>
# 拡張子ごとに付けている長期キャッシュが、404 や 5xx にまで乗らないようにする
# expr= の条件指定は Apache 2.4.10 以降で使える
Header always set Cache-Control "no-store" "expr=%{REQUEST_STATUS} >= 400"
</IfModule>
ポイントは always です。Header set だけだと、エラー応答に対しては効かない場合があります。エラー応答はまさにここで止めたい対象なので、always の付け忘れは対策が無いのと同じになります。
入れたあとは、存在しない URL を 1 つ叩いて確かめます。
# わざと存在しないパスを取り、404 に no-store が付いているかを見る
curl -sS -o /dev/null -D - "https://example.com/assets/fonts/does-not-exist-$RANDOM.woff2" \
| tr -d '\r' | grep -iE '^(HTTP/|cache-control:)'
cache-control: no-store が返れば効いています。なお、Nginx や Cloudflare Workers で配信している場合は書き方がまったく違います。「ステータスが 400 以上なら保存させない」という考え方だけを移していただければと思います。
パージは「どの変種まで届くか」で選ぶ
9 月の件で、いちばん時間を失ったのはここでした。URL を指定してパージし、curl で 200 を確認して、直ったと判断していたのです。
選択肢を、届く範囲で並べるとこうなります。
私が 9 月 25 日に実際に使ったのは、ダッシュボードのプレフィックス指定です。フォントのディレクトリ配下だけを指定し、直後に確認スクリプトを流して 3 通りとも 200 になったことを確かめました。
API で書く場合の形は、公式のパージ API の説明に沿うと次のとおりです。こちらは私の環境では実行しておらず、形だけを示すものです。トークンは環境変数から渡し、スクリプトに直接書かないでください。
# URL 指定に、素の変種と Origin 付きの変種を両方並べる(Cloudflare API)
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" \
--data '{"files":[
{"url":"https://example.com/assets/fonts/body-latin.woff2"},
{"url":"https://example.com/assets/fonts/body-latin.woff2","headers":{"Origin":"https://example.com"}}
]}'
# プレフィックス指定(スキームは付けない)
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" \
--data '{"prefixes":["example.com/assets/fonts/"]}'
パージの成否は、API の "success": true では判断しません。返ってくるのは「受け付けた」ことだけだからです。判断はあくまで、確認スクリプトの 3 通りがそろうかどうかで行っています。
汚染された URL は、名前を変えるのがいちばん確実
ここまでの手段で直らないこともあります。ブラウザ側に 404 が残っている訪問者には、CDN のパージは届きません。その人のブラウザは、次に取りに来るまで 1 年待つかもしれないのです。
だから、一度 404 で汚れた URL は、ファイル名ごと変えるのがいちばん確実です。body-latin.woff2 を body-latin-v2.woff2 にし、CSS の @font-face と preload の参照を両方書き換えます。片方だけ変えると、preload で取った新しいファイルが使われずに、二重に取得することになります。
私は 7 月の一件ではこの方法を使い、9 月の件ではプレフィックス指定のパージで済ませました。違いは、ブラウザ側に 404 が残っていたかどうかです。7 月は手元のブラウザで force-cache が 404 を返していたので、名前を変えるしかありませんでした。
落とし穴
実際に踏んだもの、踏みかけたものを並べます。
- 素の
curl の 200 で安心してしまうこと。 フォントと、CORS で取る画像(crossorigin 付きの <img> や fetch)は、すべて Origin 付きでも確かめます。
preload の crossorigin を外してしまうこと。 同一オリジンでも、フォントの preload には crossorigin が要ります。外すと、preload した応答と本番の取得が別物として扱われ、二重に取りに行きます。
- 代替書体が上手すぎて、失敗に気づけないこと。 メトリクスを合わせた代替フェイスは、崩れを防ぐぶん失敗も隠します。公開後に一度、
document.fonts で error が無いことを見る手順を入れました。
- パージ API の
success を完了と見なしてしまうこと。 受け付けと反映は別です。
- 参照する側を先に公開してしまうこと。 新しいアセットを参照する HTML や CSS が先に出ると、ファイルが届くまでの数分で 404 が保存されえます。順番は「アセット → 確認スクリプト → 参照する側」です。
エージェントに確認を任せるとき
Antigravity のエージェントにデプロイ後の確認を任せる場合は、「フォントが読み込めているか確認して」とだけ頼むと、素の curl で 200 を見て完了と報告されがちです。指示には次の 3 点を書いておくことをおすすめします。
デプロイ後の確認:
1. ./check_variants.sh <フォントの URL> <サイトのオリジン> を、追加・変更したフォントと画像のすべてで実行する
2. 終了コードが 1 のファイルがあれば、パージせずに出力の 3 行をそのまま報告する(判断は私が行う)
3. 終了コードがすべて 0 でも、「確認した URL の一覧」を報告に含める
2 番目で「パージせずに報告」としているのは、どのパージを使うかが、ブラウザ側に 404 が残っているか、変種がいくつあるかで変わるからです。確認は機械に、範囲の判断は自分に——この分担なら、エージェントが善意で広くパージして、原因の手がかりごと消してしまうことも防げます。
線引き
「200 が返った」ではなく、「ブラウザと同じ要求で 200 が返った」を完了の条件にします。 13 日間の代替書体表示から私が引いた線は、この一つです。
まずは、いま配信しているフォントの URL を 1 つだけ選び、curl -H "Origin: <サイトのオリジン>" を付けて取ってみてください。素の要求と同じ結果が返れば、それで十分です。違う結果が返ったなら、それはまだ誰も気づいていない不具合かもしれません。