Cloudflareのダッシュボードは、私のリクエストの4分の1が504だと言っていました。そのうちユーザーに届いたものは1件もありません。正体はCloudflareのEarly Hints機能のキャッシュミスで、ClientRequestSource = earlyHintsCache、OriginResponseStatus = 0の504として記録されていたのです。この記事では、これを本物の504と見分ける方法と、ミスをヒットに変えるたったひとつのヘッダーを説明します。
最初に出会ったのは9月でした。ヘルスチェックはすべて正常なのにCloudflareのダッシュボードではリクエストの22%が失敗していたときで、私はEarly Hintsをオフにして次へ進みました。11月に同じゾーンを見ると、Early Hintsがまたオンになっていて、1日で504が2,600件たまっていました。今回は、その正体を正確に知りたかったし、もう一度オフにするのが正解なのか、きちんと働かせるのが正解なのかも知りたかったのです。このゾーンはFly.ioのオリジン1台の前段にあり、そのオリジンがDjango API、Expo製のWebアプリ、Hugoで作ったマーケティングサイトを配信しています。
短い答え#
- この504を書いているのは訪問者ではなく、CloudflareのEarly Hintsキャッシュです。Cloudflareのログでは
ClientRequestSource = earlyHintsCacheになっています。 - ここでの504はキャッシュミスを意味します。そのURLに保存されたヒントがなかったということです。ヒットは2xxとして記録されます。
- これらはオリジンに一切届きません。
OriginResponseStatusは0です。 - Early Hintsは
LinkHTTPレスポンスヘッダーで動きます。HTML内の<link rel="preload">タグでは動きません。 - ヘッダーを送れば、プリロードするものがあるページでは、このミスがヒットに変わり、本物の
103 Early Hintsレスポンスが返るようになります。
先に知っておくと役立つことが、あと3つあります。ヒントにするのはレンダリングに必須のCSS、スクリプト、フォントだけにして、フォントにはcrossoriginを残してください。確認はcurl -sv --http2で、200の前に103が見えるはずです。そして、ほかに何を決めるにせよearlyHintsCacheはアラートから外しておき、今後もヘッダーを送らないなら、ログのノイズで代償を払うよりEarly Hintsをオフにしてください。
偽のCloudflare 504と本物の504の見分け方#
LogpushやInstant Logsを使っているなら、まず2つのフィールドを見てください。ClientRequestSourceは、実際の訪問者のリクエストならeyeball、Early Hintsの参照ならearlyHintsCacheです。OriginResponseStatusが0なら、オリジンには接続すらしていません。
GraphQL Analytics APIでは、同じ区別がrequestSourceディメンションとして取れます。5xxの急増を信じる前に、私は今このクエリを実行するようにしています。
query ($zone: String!, $since: Time!) {
viewer {
zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(
limit: 20
filter: { datetime_geq: $since, edgeResponseStatus: 504 }
orderBy: [count_DESC]
) {
count
dimensions { requestSource userAgent clientRequestPath }
}
}
}
}User-Agentでまとめても同じ話になります。9月、Early Hintsの参照はbastion early hintsとnginx-ssl early hintsとして記録されていました。11月はfl2 early hintsとnginx-ssl early hintsでした(FL2はCloudflareのプロキシの新しい世代です)。24時間で、このゾーンの504の2,631件すべてがこれらのUser-Agentから来ていました。ブラウザ、アプリ、curl、検索クローラーからのものは1件もありませんでした。
訪問者が実際に見た本物の504は、ほぼ必ずCloudflareの外側、つまりあなたの側から来ています。アプリケーションの前段にあるプロキシやロードバランサーが待ちきれずに諦めたのです。nginxかもしれないし、クラウドのロードバランサーかもしれないし、スリープ中のマシンが起きるのを待つプラットフォームのプロキシかもしれません。Cloudflare自身のタイムアウトは別のコードを使います。522はオリジンに接続できなかったこと、524は接続はできたが125秒以内(Enterprise未満のプランのデフォルト)に応答がなかったことを意味します。
504のせいでCloudflareのタイムアウトを延ばす方法を探してこの記事に来たのなら:504の背後にあるタイムアウトは、ほぼ確実にCloudflareのものではありません。nginxのproxy_read_timeout、ロードバランサーのアイドルタイムアウト、あるいはプラットフォームがマシンを起こすのにかかる時間を確認してください。役に立つ手がかりがひとつあります。125秒よりかなり短い、30秒や60秒といったきりのいい数字でリクエストが失敗するなら、それはCloudflareではなく、あなたのプロキシの上限です。(Cloudflareの125秒の上限を最大6,000秒まで延ばせるのはEnterpriseプランだけで、Cloudflare自身もそれを応急処置と位置づけています。)
この504の正体#
Early Hintsが有効なゾーンでは、HTMLページか拡張子のないURLへのリクエストごとに(APIの多いゾーンでは、API呼び出しのほとんどがこれに当たります)、内部の参照がひとつ追加されます。Cloudflareが、送るべきヒントがあるか自分のキャッシュを確認しているのです。この参照はClientRequestSource = earlyHintsCacheとして独立した行に記録されます。エンドユーザーのリクエストではなく、オリジンに送られることもありません。
その行のステータスは、エラーではなくキャッシュ参照の結果です。
- 2xxはヒットです。CloudflareにそのURL用の
Linkヘッダーが保存されていて、それを103としてブラウザに送ったということです。私のゾーンでは、ヒットは200と204の両方で現れます。 - 504はミスです。何も保存されていなかったので、先に送るものがなかったということです。
オリジンがEarly Hintsの使うヘッダーを一度も送らなければ、すべての参照がミスになり、すべてのリクエストが504の行を1行ずつ残します。私が何も変える前の3時間で、このゾーンは173件のEarly Hints参照を記録し、173件すべてがミスでした。機能がオンになってから、一度もヒントを送っていなかったのです。
9月の記事についての訂正もひとつ。私はこれを「Flyのオリジンから応答を得られない」プローブだと説明していました。それは正確ではありません。この参照はそもそもオリジンに行きません。504はタイムアウトではなく、キャッシュミスを記録したものです。
CloudflareはこのことをEarly Hintsのドキュメントで簡潔に説明しています。ClientRequestSource: earlyHintsCache として記録されたEarly Hintsの参照は「expected and benign」(想定内で無害)であり、200はキャッシュヒット、504はミスです。Log Explorerでは ClientRequestSource != "earlyHintsCache" で除外できます。Cache Analyticsはもともとこれらを除外していますが、私を怖がらせたHTTPトラフィックのグラフは除外していません。
偽の504のせいでサーバーを増強してしまった#
この区別が大事な理由がここにあります。この記事に取りかかる前日の11月3日、Cloudflareには約13,300件の訪問者リクエストに対して3,292件の504が記録されていました。見かけ上のエラー率は25%です。APIはFly.ioのshared-cpu-1xマシンで動いていて、これはバーストクレジット方式です。保証されるCPUの取り分は小さく、速く動くにはクレジットを消費します。8月の負荷テストでは、持続的な負荷がかかると毎秒およそ20〜30リクエストまでスロットリングされることが分かっていました。「ピーク時にリクエストの4分の1がタイムアウトしている」のは、まさにそのスロットリングに見えました。そこでAPIをshared-cpu-2xに移しました。
ところが、その504はすべてEarly Hintsのキャッシュミスでした。requestSourceで分け、オリジン自身のログも見直すと、こうなっていました。
- 10月28日から11月4日までのCloudflareの5xxは、1日1件ほどの525を除いてすべて
earlyHintsCacheのルックアップでした(調べた525はセキュリティスキャナーからのものでした)。 - 10月24日から11月4日までのオリジンのアクセスログには5xxが1件もなく、10秒を超えたリクエストは1件だけでした。
- Flyのメトリクスでは、最も多いときでも毎秒7リクエスト程度で、マシンが使ったCPUは1コアの5分の1以下でした。スロットリングの上限には一度も近づいていません。
大きいマシンは何も直していませんでした。何も壊れていなかったからです。APIはshared-cpu-1xに戻しました。
request sourceで分けていない5xxチャートをもとに、キャパシティを判断しないでください。オリジン自身のログとも突き合わせてください。Cloudflareはリクエストの4分の1が失敗したと言っているのにサーバーのログにエラーがないなら、サーバーに手を入れる前に、Cloudflareが何を数えているのかを確かめましょう。
2026年11月5日更新:このセクションの最初の版では、その週にAPIがshared-cpu-1xで本当にダウンしたと書いていました。実際にはダウンしていませんでした。上の数字は、確認したときに分かったことです。
HTTP 103はエラーなのか?#
違います。1xx台のステータスコードは情報レスポンス、つまりサーバーが本当のレスポンスの前に送れる中間レスポンスです。RFC 8297で定義された103 Early HintsはLinkヘッダーを運び、サーバーがまだページを組み立てている間に、ブラウザが重要なCSS、JavaScript、フォントの取得を始めたり、別のオリジンへの接続を開いたりできるようにします。最終的な200は同じリクエストの中で後から届きます。
対応状況はまちまちですが、安全です。Chromium系ブラウザ(Chrome 103以降、Edge)と最新のFirefoxは、preloadとpreconnectの両方のヒントを処理します。Safari(17以降)はpreconnectだけを使います。ブラウザが103を処理するのはHTTP/2とHTTP/3の場合だけです。理解しないブラウザはいつも通り200を待つだけなので、103はほとんどの訪問者にとっては先回りになり、それ以外の訪問者には何の影響もありません。
私の設定が空だった理由:Linkヘッダーと<link rel=preload>の違い#
ここに一番時間を使いました。すでに動いていて当然に見えたからです。
Early Hintsがキャッシュするのはただひとつ、rel=preloadかrel=preconnectの関係を持つ**Link HTTPレスポンスヘッダー**です。Cloudflareは通常のレスポンス(HTMLまたは拡張子なしURLの200、301、302)からこのヘッダーを読み取って保存し、次の訪問者には完全なレスポンスの準備ができる前に103として再送します。
私のページにはすでにプリロードがありました。ただし、HTMLの<head>内のタグとしてです。
<link rel="preload" href="/_expo/static/css/global-7d77bd.css" as="style">
<link rel="preload" href="/_expo/static/js/web/entry-fd7fa4.js" as="script">
<link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin>これらのタグにも意味はあります。ブラウザはこれを見て動きます。ただし、ブラウザがこのタグを目にするのはHTMLを受け取ってパースを始めた後で、それこそEarly Hintsが先回りしようとしている瞬間です。Expoのエクスポートも、私が使っているHugoのテーマも、このタグは自動で書いてくれますが、ヘッダーはどちらも設定しません。機能はオンで、プリロードも宣言されていたのに、両者は一度も出会っていなかったのです。
解決策:すでに持っているデータでヘッダーを送る#
いちばんきれいな解決策は、バンドルのハッシュが変わるたびに古くなる重要アセットのリストを手で管理することではありません。各ページは、何をプリロードしたいかをすでにタグで宣言しています。だから、ページを配信するときにそれをLinkヘッダーに写せばいいのです。標準ライブラリのhtml.parserは引用符ありの属性もなしの属性も(Hugoのminifierは引用符を外します)扱ってくれるので、正規表現で間違える余地はありません。
from html.parser import HTMLParser
class PreloadLinks(HTMLParser):
def __init__(self):
super().__init__()
self.hints = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag != "link" or "preload" not in (a.get("rel") or "").split() or not a.get("href"):
return
hint = f"<{a['href']}>; rel=preload"
if a.get("as"):
hint += f"; as={a['as']}"
if a.get("as") == "font" or "crossorigin" in a:
hint += "; crossorigin"
self.hints.append(hint)
def link_header(html: str, limit: int = 12) -> str | None:
parser = PreloadLinks()
parser.feed(html)
return ", ".join(list(dict.fromkeys(parser.hints))[:limit]) or Noneそして、DjangoのHTMLレスポンスに付けます。
response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
response["Link"] = linkひとつだけ、私がはまった細部を。フォントにはcrossoriginを残してください。これがないフォントのプリロードは別のリクエストとして扱われ、フォントが2回ダウンロードされて、ページは速くなるどころか遅くなります。
103を自分で書く必要はありません。ASGIやGranian、uvicornに中間レスポンスのコードを足すこともありません。Cloudflareがあなたのヘッダーから103を作ります。あなたの仕事はヘッダーを送ることだけです。
ほかのスタックでは#
ルールはどこでも同じです。HTMLレスポンスにLinkヘッダーを付け、残りはCloudflareに任せます。
nginxでは、HTMLを配信するlocationに:
location = / { add_header Link "</css/app.css>; rel=preload; as=style" always; add_header Link "</fonts/inter.woff2>; rel=preload; as=font; crossorigin" always; }LaravelとViteの組み合わせには、このためのミドルウェア
AddLinkHeadersForPreloadedAssetsがすでにあり、ViteのプリロードをLinkヘッダーに変換してくれます。これに対して報告されている2つの問題は、上で書いた落とし穴と同じです。modulepreloadのヒントがcrossoriginなしで送られ、ブラウザがそれを捨ててスクリプトを2回ダウンロードしうること、そして大きなページではヘッダーがApacheの約8KBのヘッダー上限を超えたことです。お使いのバージョンで両方を確認してください。それ以外:
Linkヘッダー1つにカンマ区切りで書くか、Linkヘッダーを複数送ります。どちらでも動きます。
みんながやりすぎるのは、何をヒントにするかです。レンダリングに必須のCSS、エントリースクリプト、ファーストビューで使うフォント1〜2個だけをヒントにして、すべてのチャンクを入れないでください。リストが長いと、ブラウザは今必要なものより先に、まだ必要ないものを取りに行ってしまいます。私は12個を上限にしていますが、それもExpoのエクスポートがプリロードのリストをすでに絞ってくれているからです。
Cloudflareの後ろにいない場合や、CDNがEarly Hintsに対応していない場合でも、Linkヘッダーは少しだけ役に立ちます。ボディをパースする前、レスポンスヘッダーが届いた時点でブラウザがそれらの取得を始められるからです。ただし、サーバーかCDNが送らない限り103は返りません。
動いているか確認する方法#
curlなら103を直接見られます。HTTP/2でリクエストし、ステータス行とLinkヘッダーだけを残します。
$ curl -sv --http2 -o /dev/null https://example.com/register 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </_expo/static/css/reset-f5d396.css>; as=style; rel=preload, …
< HTTP/2 200
< link: </_expo/static/css/reset-f5d396.css>; rel=preload; as=style, …先に103が届き、その後に200が来ます。Cloudflareはヒント内の属性の順番を並べ替えますが、問題ありません。すべてのリクエストが103を受け取るとは思わないでください。私の場合、最初の3回のうち2回でした。変更後の最初のアクセスはヘッダーを保存するだけで、最終レスポンスのヘッダーがすでに用意できている場合、Cloudflareは103を省略します。
そのうえで、上の分析クエリに戻り、requestSource: "earlyHintsCache"で絞り込んで、ステータスとパスでまとめてください。修正したページでは、ミスに代わってヒットが出てくるはずです。
何が変わり、何が残ったか#
ヘッダーを本番に出して数分後、このゾーンは初めてEarly Hintsのヒットを記録し、curlではWebページに本物の103レスポンスが見えました。ヘッダーひとつで2つの問題が同時に片づきました。機能が動き始め、「504」の一部が消えたのです。
残った問題は、JSON APIにはプリロードするものがないことです。APIのエンドポイントはミスを出し続け、504の行を残し続けます。APIの比重が大きいゾーンでは、これが依然として大部分です。妥当な選択肢は3つあります。効果のあるページにヘッダーを送り、APIのパスがミスを記録し続けるのは受け入れる。機能は動き、チャートは静かになりますが、完全には静かになりません。ほかに何を決めるにせよ、earlyHintsCacheを5xxのアラートとダッシュボードから除外する。内部のキャッシュ参照で誰かが呼び出されるべきではありません。あるいは、今後もLinkヘッダーを送らないなら、Early Hintsをオフにする(ダッシュボードのSpeed → Settings → Content Optimization、またはAPIのゾーン設定early_hints)。HTMLがとても小さいシングルページアプリでは得るものが少ないかもしれず、オフにすればすべての参照が止まり、ノイズも一緒に消えます。
私はオンのままにしました。Webページは先回りできるようになり、APIのミスはアラートが関わるすべての場所から除外しました。9月に機能をオフにする代わりにやるべきだったのは、まさにこれでした。
