next/font/google は woff2 ファイルを fonts.gstatic.com から ビルド時に ダウンロードする。Google がファイルのハッシュをローテートしたとき、deploy が 404 で落ち始めたが、ローカルでは再現できなかった。同じ fetch が数秒前に自分のマシンではすでに成功していたからだ。修正方法: フォントをリポジトリに vendoring して next/font/local を使う。そうすればバイト列は自分が所有する入力になる。
症状
deploy が 2 回失敗した。アプリ側は何も変わっていない — 同じ commit、同じ lockfile、同じ Node。
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'最後の行は 21 回出ていた。404 は fonts.gstatic.com から返ってきたものだ。
自分が想定していたこと
deploy 用のコンテナで何かを壊したのだと思った。依存解決のミス、壊れた Docker レイヤー、リクエストを食っている proxy。なにしろ同じコマンドを自分のマシンで — next build、Next.js 16.2.10 — 実行すると問題なくビルドできた。2 回とも。グリーンだった。
そこが罠だ。「ローカルではビルドが通る」がコンテナ側が壊れている証拠のように思えた。実際には何の証拠でもなかった。
なぜそれが間違いだったのか
next/font/google はランタイムの CDN リンクではない。ビルド時のダウンローダーだ。パッケージに同梱されている Next 自身のドキュメント node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13 に、そのまま書いてある:
Google Fonts もすべて手軽に使える。CSS とフォントファイルはビルド時にダウンロードされ、他の静的アセットと一緒にセルフホストされる。ブラウザから Google へリクエストが送られることはない。
ブラウザからはリクエストが送られない。リクエストを送るのは あなたのビルド だ。その 1 本 1 本が、自分のコードとはまったく関係のない理由でビルドを失敗させるチャンスになる。
これが実際の仕組みで、node_modules/next/dist/compiled/@next/font/dist/google/ に同梱されているローダーがやっていることだ:
get-google-fonts-url.jsがオプションからcss2?family=...の URL を組み立てる。fetch-css-from-google-fonts.jsがその CSS をfetch-resource.js経由で GET する。fetch-resource.jsは Chrome 104 の user agent をハードコードしていて、Google が ttf ではなく woff2 を返すようにしている。find-font-files-in-css.jsがsrc: url(...)のエントリを抽出する — 完全にハッシュ化され、バージョン付けされたfonts.gstatic.comのパスだ。fetch-font-file.jsがそれぞれをダウンロードし、ビルドが.next/static/mediaに吐き出す。
hermetic 性が死ぬのはステップ 3 だ。これらの URL は自分でコントロールできる安定した入力ではない。ビルドが走ったその瞬間に Google が CSS レスポンスに入れていたもの、それだけだ。
そして fetch-resource.js が 200 以外に対して持っているポリシーはたったひとつ:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}これが retry(fn, 3) でラップされている。実体は async-retry なので、最初の 1 回に加えて 3 回 — 4 回とも 404 を返す URL に対して 4 回リクエストすることになる。
ローテーションの窓
失敗したリクエストは .../inter/v20/UcCB3Fwr...woff2 に対するものだった。同じファミリーの css2 エンドポイントを自分で叩いてみると、Google が返してきたのは .../inter/v20/UcCO3Fwr...woff2 だった。
UcCB と UcCO。1 文字違い。実行中に起きたハッシュのローテーションだ: 手元の CSS は、もう存在しないファイルパスを指していた。
ではなぜ自分のマシンではビルドが通ったのか
最初の仮説は古いキャッシュだった。自分の .next はローテーションより前のものだから、ビルドはディスクにある正しい答えを再生していて、コンテナのほうは生きたエンドポイントを叩いている、と。分かりやすく、きれいで、そして — この構成では — 間違いだった。
ローダーのキャッシュは loader.js の先頭で作られる 2 つのインメモリ Map (cssCache、fontCache) だ。クライアントとサーバーのコンパイラが同じ URL を 2 回 fetch しないようにするためだけに存在し、プロセスと一緒に消える。Turbopack のディスクキャッシュならプロセスをまたいで残るが、experimental.turbopackFileSystemCacheForBuild は opt-in で Next 16 ではデフォルト off であり (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204)、このプロジェクトでは設定していない。ここで .next/cache に実際に入っていたのは: .tsbuildinfo、小さな info ファイル 2 つ、そして fetch-cache のエントリがちょうど 1 件 — 191,476 バイト、content-type: application/octet-stream、wasm のマジックバイト、url フィールドは data:application/octet-stream;base64,…。data URI だ。フォントでもなければ、そもそもネットワーク呼び出しですらない。ディスク上に再生できる Google のレスポンスなど存在しなかった。
つまり両方のマシンが本当に Google に問い合わせていた。問い合わせた秒が違い、それぞれ最大 4 回まで試し、ホスト側の試行がたまたま正常なレスポンスに当たっただけだ。
.next をリネームしてリビルドもした — そしてホスト側でも失敗が再現し、それが証拠のように思えた。違った: 悪い窓がまだ開いていただけで、同じクリーンビルドが 1 時間後には通った。それでも削除ではなくリネームにしておくこと、コストはゼロだ。ただし 1 回再現したくらいでメカニズムを買ってはいけない。キャッシュのせいにする前に、そのキャッシュが存在するかを確認すること。
もっと厄介なのは: ひとつの不具合に 2 つの重大度
ホスト側では 404 が出力されても next build の終了コードは 0 のままだった。コンテナの中では同じ 404 が Turbopack のモジュール解決エラーになり、ビルドは死んだ。
exit 0 のケースは retry.js が喋っているだけで、許容された失敗ではない。リトライのたびに、再試行の前にエラーをログへ出している:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}つまりフォントの 404 で埋まったログは「3 回目で復帰した」かもしれないし「4 回目で死んだ」かもしれない。どちらでも行はまったく同じに見える。怖そうなテキストではなく終了コードを読むこと。
production ビルドでローダーが やらない のは、静かに degrade することだ — catch が環境で分岐していて、フォールバックの face をもらえるのは dev だけだ:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}この分岐こそローカル作業における本当の罠だ。next dev では同じ死んだ URL に対して override メトリクス付きの local(...) システムフォントが返ってくる。だから deploy を殺している不具合が、自分のマシンでは「ほんの少しずれたタイポグラフィ」としてしか現れない。
Next.js 16 は next build のデフォルトバンドラーを Turbopack にした (node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116)。そして Turbopack の経路では、投げられたエラーが解決不能な @vercel/turbopack-next/internal/font/google/font モジュールに化ける — 自分のログでは 21 個だった。
診断の手順
これはそのまま持って行っていい。当てずっぽうがなくなった:
- エラーに出ている woff2 の URL をそのまま
curl -Iする。404 → ファイルが消えているのであって、ネットワークの問題ではない。 - 同じファミリーの
css2?family=...エンドポイントを、モダンな Chrome の user agent でcurlする。200 が返りsrc: url(...)の中のハッシュが 違って いたら → 障害ではなくローテーションが確定。 mv .next .next-badしてローカルでリビルドする。ここで失敗すれば生きた再現だ — ただしキャッシュのおかげだと信じる前に、そもそもキャッシュがあったのかを確認すること。Turbopack のビルドキャッシュは opt-in なので、古い.nextはたいてい無実だ。- そのクリーンビルドを ホスト 側でも、時間を空けて 2 回走らせる。みんなが飛ばすのがこれで、「ファイルが消えた」と「ファイルが 10 分間消えていた」を切り分けられるのもこれだ。
1 時間後、クリーンビルドは 404 ゼロで通った。それがこのバグの形だ: 状態ではなく窓 — だからこそ、どのマシンであれグリーンなビルド 1 回は答えにならない。
修正と、その正直なコスト
woff2 ファイルをリポジトリに vendoring して next/font/local に切り替える。そうすればフォントのバイト列は自分が所有し、git に入った入力になり、ビルドはタイポグラフィのためにネットワーク呼び出しを 1 回もしなくなる。
コストは本物だし、ごまかすつもりもない:
- 更新は自分の責任になる。 Google がファミリーを再リリースしても、もうタダでアップグレードされない。それこそが狙いなのだが — 今後は自分の仕事だ。
- サブセット化は自分でやる必要がある — そしてたぶん、思っているより多くを配信している。
subsets: ["latin"]はダウンロード対象を絞らない。この値はcss2の URL には届かない。loader.jsがsubsetsToPreloadとしてfindFontFilesInCssに渡すだけなので、決めているのはどのファイルに preload ヒントが付くかだけだ。Google のレスポンスに入っている@font-faceはすべて fetch されて出力される。Launch は 8 ファミリー全部にsubsets: ["latin"]を宣言していて、それでも 35 個の woff2 を出力し、うちファイル名に.pの preload マーカーが付いているのは 9 個だけだ。だから vendoring は文字セットを本当に削るチャンスでもある (fonttools のpyftsubset) — が、.next/static/mediaから woff2 をそのままコピーしただけなら、重さについては何も変わっていない。 - リポジトリの重さ。 自分にとってスケールが悪いのはここだ。販売している 5 つのテンプレート全体で、ローダーは 51 の Google ファミリーを宣言している:
folio-template/src/app/layout.tsxが 12、lens-template/src/app/fonts.tsが 6、levain-template/src/app/fonts.tsが 8、launch-template/src/lib/fonts.tsが 8、booking-template/src/lib/fonts.tsが 17。ウェイト、スタイル、サブセットが組み合わさると一気に膨らむ: launch テンプレートのクリーンビルドは.next/static/mediaに 35 個の woff2 を出力し、booking は 92 個だ (ls .next/static/media | grep -c woff2)。
だから正直に言えばこうだ: 実際にレンダリングするフォントだけを vendoring して、残りは消す。17 ファミリーを提供するテーマスイッチャーはビルド時の HTTP 依存を 17 本抱えているということで、そのどれか 1 本が火曜日に deploy を 404 で落としうる。
ここで失敗したビルドは Launch のものだ — ライブデモは https://launch.violettadev.com。
次に捕まえるために
- ローカルがグリーンなのはシグナルではない。 同じ commit で自分のマシンが通り CI が落ちたとき、既定の説明は「ネットワークを 2 つの異なる瞬間にサンプリングした」であって、「どちらかのマシンが壊れている」ではない。原因を探しに行く前に、失敗したビルドをもう一度走らせること。
- 終了コードが 0 でも、ビルドログをフォントの 404 で grep すること。 それはリトライが今回は勝ったという意味だ。同じ fetch、同じ脆さで、赤い deploy まであと 1 回。
- ビルド時の fetch はすべて依存だと考えること。 フォント、wasm のバイナリ、リモートのスキーマ、ビルド時の CMS 取得。自分のビルドがどのネットワーク呼び出しをするのか書き出しておくこと。ほとんどの人は自分のプロジェクトについてこの質問に答えられないし、404 の山でビルドに教えられるまでは自分もそうだった。