<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>HTTP 103 · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/http-103/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Wed, 04 Nov 2026 13:36:11 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/http-103/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflareの504エラーは障害ではなかった：Early Hints（HTTP 103）とは</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>Webパフォーマンス</category>
      <category>Preload</category>
      <category>オブザーバビリティ</category>
      <category>デバッグ</category>
      <description>Cloudflareに504エラーが何千件も記録されたのに、実際のユーザーに届いたものは1件もありませんでした。正体はEarly Hintsのキャッシュミスです。HTTP 103の仕組み、偽の504と本物の504の見分け方、そして問題を解決したヘッダーを解説します。</description>
      <content:encoded><![CDATA[<p>9月に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ヘルスチェックはすべて正常なのにCloudflareのダッシュボードではリクエストの22%が失敗していた件</a>について書きました。その504のほとんどはユーザーではなくCloudflareのEarly Hints機能から来ていたことがわかり、私はEarly Hintsをオフにして次へ進みました。</p>
<p>11月に同じゾーンを開いてみると、1日でまた504が2,600件たまっていました。Early Hintsがまたオンになっていたのです。今回は、その504の正体を正確に知りたいと思いました。そして、もう一度機能をオフにするのが正解なのか、それともきちんと働かせるのが正解なのかも。</p>
<p>このゾーンはFly.ioのオリジン1台の前段にあり、そのオリジンが3つのものを配信しています。Django API、Expo製のWebアプリ、そしてHugoで作ったマーケティングサイトです。APIのことは覚えておいてください。APIには別に本物のキャパシティの問題があり、それがこの区別を重要にしています。</p>

<h2 class="relative group">短い答え
    <div id="短い答え" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9f%ad%e3%81%84%e7%ad%94%e3%81%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li>この504を書いているのは訪問者ではなく、<strong>CloudflareのEarly Hintsキャッシュ</strong>です。Cloudflareのログでは<code>RequestSource = earlyHintsCache</code>になっています。</li>
<li>ここでの<strong>504はキャッシュミス</strong>を意味します。そのURLに保存されたヒントがなかったということです。ヒットは2xxとして記録されます。</li>
<li>これらは<strong>オリジンに一切届きません</strong>。<code>OriginResponseStatus</code>は<code>0</code>です。</li>
<li>Early Hintsは**<code>Link</code> HTTPレスポンスヘッダー**で動きます。HTML内の<code>&lt;link rel=&quot;preload&quot;&gt;</code>タグでは動きません。</li>
<li>ヘッダーを送れば、プリロードするものがあるページでは、このミスがヒットに変わり、本物の<code>103 Early Hints</code>レスポンスが返るようになります。</li>
</ul>

<h2 class="relative group">偽のCloudflare 504と本物の504の見分け方
    <div id="偽のcloudflare-504と本物の504の見分け方" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%81%bd%e3%81%aecloudflare-504%e3%81%a8%e6%9c%ac%e7%89%a9%e3%81%ae504%e3%81%ae%e8%a6%8b%e5%88%86%e3%81%91%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>LogpushやInstant Logsを使っているなら、まず2つのフィールドを見てください。<code>RequestSource</code>は、実際の訪問者のリクエストなら<code>eyeball</code>、Early Hintsの参照なら<code>earlyHintsCache</code>です。<code>OriginResponseStatus</code>が<code>0</code>なら、オリジンには接続すらしていません。</p>
<p>GraphQL Analytics APIでは、同じ区別が<code>requestSource</code>ディメンションとして取れます。5xxの急増を信じる前に、私は今このクエリを実行するようにしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>User-Agentでまとめても同じ話になります。9月、Early Hintsの参照は<code>bastion early hints</code>と<code>nginx-ssl early hints</code>として記録されていました。11月は<code>fl2 early hints</code>と<code>nginx-ssl early hints</code>でした（FL2はCloudflareのプロキシの新しい世代です）。24時間で、このゾーンの504の2,631件すべてがこれらのUser-Agentから来ていました。ブラウザ、アプリ、curl、検索クローラーからのものは1件もありませんでした。</p>
<p>訪問者が実際に見た本物の504は、Cloudflareの外側、つまりあなたの側から来ています。アプリケーションの前段にあるプロキシやロードバランサーが待ちきれずに諦めたのです。nginxかもしれないし、クラウドのロードバランサーかもしれないし、スリープ中のマシンが起きるのを待つプラットフォームのプロキシかもしれません。Cloudflare自身のタイムアウトは別のコードを使います。<strong>522</strong>はオリジンに接続できなかったこと、<strong>524</strong>は接続はできたが100秒以内（Enterprise未満のプランのデフォルト）に応答がなかったことを意味します。</p>
<p>504のせいでCloudflareのタイムアウトを延ばす方法を探してこの記事に来たのなら：504の背後にあるタイムアウトは、ほぼ確実にCloudflareのものではありません。nginxの<code>proxy_read_timeout</code>、ロードバランサーのアイドルタイムアウト、あるいはプラットフォームがマシンを起こすのにかかる時間を確認してください。役に立つ手がかりがひとつあります。100秒よりかなり短い、30秒や60秒といったきりのいい数字でリクエストが失敗するなら、それはCloudflareではなく、あなたのプロキシの上限です。（Cloudflareの100秒の上限を最大6,000秒まで延ばせるのはEnterpriseプランだけで、Cloudflare自身もそれを応急処置と位置づけています。）</p>

<h2 class="relative group">同じ週、APIでは本物の504が出ていた
    <div id="同じ週apiでは本物の504が出ていた" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%90%8c%e3%81%98%e9%80%b1api%e3%81%a7%e3%81%af%e6%9c%ac%e7%89%a9%e3%81%ae504%e3%81%8c%e5%87%ba%e3%81%a6%e3%81%84%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>この区別が大事な理由がここにあります。その数日前、まさに同じAPIが本当にダウンしていました。APIはFly.ioの<code>shared-cpu-1x</code>マシンで動いていて、これはバーストクレジット方式です。ニュージーランドのピーク時間帯にバーストを使い切ると、毎秒およそ20〜30リクエストの持続上限までスロットリングされました。レスポンスタイムは崩壊し、訪問者はエッジで本物の504を受け取りました。<code>shared-cpu-2x</code>に移したら解決しました。</p>
<p>その後、504のチャートがまた埋まりました。今度はEarly Hintsのミスです。<code>requestSource</code>で分けずに見ると、そのチャートは「大きいマシンにしても効果がなかった」と言っているように見えます。実際には、同じ週の同じゾーンに2種類の別々の504があり、人に届いたのはそのうち片方だけでした。request sourceで分けていない5xxチャートをもとに、キャパシティを判断しないでください。</p>

<h2 class="relative group">この504の正体
    <div id="この504の正体" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%93%e3%81%ae504%e3%81%ae%e6%ad%a3%e4%bd%93" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Early Hintsが有効なゾーンでは、すべてのリクエストに内部の参照がひとつ追加されます。Cloudflareが、送るべきヒントがあるか自分のキャッシュを確認しているのです。この参照は<code>RequestSource = earlyHintsCache</code>として独立した行に記録されます。エンドユーザーのリクエストではなく、オリジンに送られることもありません。</p>
<p>その行のステータスは、エラーではなくキャッシュ参照の結果です。</p>
<ul>
<li><strong>2xx</strong>はヒットです。CloudflareにそのURL用の<code>Link</code>ヘッダーが保存されていて、それを<code>103</code>としてブラウザに送ったということです。私のゾーンでは、ヒットは<code>200</code>と<code>204</code>の両方で現れます。</li>
<li><strong>504</strong>はミスです。何も保存されていなかったので、先に送るものがなかったということです。</li>
</ul>
<p>オリジンがEarly Hintsの使うヘッダーを一度も送らなければ、すべての参照がミスになり、すべてのリクエストが504の行を1行ずつ残します。私が何も変える前の3時間で、このゾーンは173件のEarly Hints参照を記録し、173件すべてがミスでした。機能がオンになってから、一度もヒントを送っていなかったのです。</p>
<p>9月の記事についての訂正もひとつ。私はこれを「Flyのオリジンから応答を得られない」プローブだと説明していました。それは正確ではありません。この参照はそもそもオリジンに行きません。504はタイムアウトではなく、キャッシュミスを記録したものです。</p>

<h2 class="relative group">HTTP 103はエラーなのか？
    <div id="http-103はエラーなのか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#http-103%e3%81%af%e3%82%a8%e3%83%a9%e3%83%bc%e3%81%aa%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>違います。1xx台のステータスコードは情報レスポンス、つまりサーバーが本当のレスポンスの前に送れる中間レスポンスです。RFC 8297で定義された<code>103 Early Hints</code>は<code>Link</code>ヘッダーを運び、サーバーがまだページを組み立てている間に、ブラウザが重要なCSS、JavaScript、フォントの取得を始めたり、別のオリジンへの接続を開いたりできるようにします。最終的な<code>200</code>は同じリクエストの中で後から届きます。</p>
<p>対応状況はまちまちですが、安全です。Chromium系ブラウザ（Chrome 103以降、Edge）と最新のFirefoxは、<code>preload</code>と<code>preconnect</code>の両方のヒントを処理します。Safariは<code>preconnect</code>だけを使います。ブラウザが<code>103</code>を処理するのはHTTP/2とHTTP/3の場合だけです。理解しないブラウザはいつも通り<code>200</code>を待つだけなので、<code>103</code>はほとんどの訪問者にとっては先回りになり、それ以外の訪問者には何の影響もありません。</p>

<h2 class="relative group">私の設定が空だった理由：Linkヘッダーと<code>&lt;link rel=preload&gt;</code>の違い
    <div id="私の設定が空だった理由linkヘッダーとlink-relpreloadの違い" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%a7%81%e3%81%ae%e8%a8%ad%e5%ae%9a%e3%81%8c%e7%a9%ba%e3%81%a0%e3%81%a3%e3%81%9f%e7%90%86%e7%94%b1link%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%81%a8link-relpreload%e3%81%ae%e9%81%95%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここに一番時間を使いました。すでに動いていて当然に見えたからです。</p>
<p>Early Hintsがキャッシュするのはただひとつ、<code>rel=preload</code>か<code>rel=preconnect</code>の関係を持つ**<code>Link</code> HTTPレスポンスヘッダー**です。Cloudflareは通常のレスポンス（HTMLまたは拡張子なしURLの<code>200</code>、<code>301</code>、<code>302</code>）からこのヘッダーを読み取って保存し、次の訪問者には完全なレスポンスの準備ができる前に<code>103</code>として再送します。</p>
<p>私のページにはすでにプリロードがありました。ただし、HTMLの<code>&lt;head&gt;</code>内のタグとしてです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>これらのタグにも意味はあります。ブラウザはこれを見て動きます。ただし、ブラウザがこのタグを目にするのはHTMLを受け取ってパースを始めた後で、それこそEarly Hintsが先回りしようとしている瞬間です。Expoのエクスポートも Hugo も、このタグは自動で書いてくれますが、ヘッダーはどちらも設定しません。機能はオンで、プリロードも宣言されていたのに、両者は一度も出会っていなかったのです。</p>

<h2 class="relative group">解決策：すでに持っているデータでヘッダーを送る
    <div id="解決策すでに持っているデータでヘッダーを送る" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a7%a3%e6%b1%ba%e7%ad%96%e3%81%99%e3%81%a7%e3%81%ab%e6%8c%81%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e3%83%87%e3%83%bc%e3%82%bf%e3%81%a7%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%82%92%e9%80%81%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>いちばんきれいな解決策は、バンドルのハッシュが変わるたびに古くなる重要アセットのリストを手で管理することではありません。各ページは、何をプリロードしたいかをすでにタグで宣言しています。だから、ページを配信するときにそれを<code>Link</code>ヘッダーに写せばいいのです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PRELOAD</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">rb</span><span class="s2">&#34;&#34;&#34;&lt;link\b[^&gt;]*?\brel=[&#34;&#39;]?preload[&#34;&#39;]?[^&gt;]*?&gt;&#34;&#34;&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">name</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A real attribute: preceded by whitespace, followed by =, space, / or &gt;.</span>
</span></span><span class="line"><span class="cl">    <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="sa">rb</span><span class="s2">&#34;(?&lt;=\s)&#34;</span> <span class="o">+</span> <span class="n">re</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">+</span> <span class="sa">rb</span><span class="s2">&#34;&#34;&#34;(?:\s*=\s*(?:&#34;([^&#34;]*)&#34;|&#39;([^&#39;]*)&#39;|([^\s&gt;]+)))?(?=[\s/&gt;])&#34;&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">tag</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">next</span><span class="p">((</span><span class="n">g</span> <span class="k">for</span> <span class="n">g</span> <span class="ow">in</span> <span class="n">m</span><span class="o">.</span><span class="n">groups</span><span class="p">()</span> <span class="k">if</span> <span class="n">g</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">),</span> <span class="sa">b</span><span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parts</span><span class="p">,</span> <span class="n">seen</span> <span class="o">=</span> <span class="p">[],</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">tag</span> <span class="ow">in</span> <span class="n">PRELOAD</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">html</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">href</span> <span class="o">=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;href&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">href</span> <span class="ow">or</span> <span class="n">href</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">seg</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">href</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">:=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">as_</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;crossorigin&#34;</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">seg</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">==</span> <span class="n">limit</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>そして、DjangoのHTMLレスポンスに付けます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>私がはまった細部が2つあり、どちらも押さえておく価値があります。</p>
<ul>
<li><strong>属性パーサーの境界を固定する。</strong> <code>as</code>を素朴に検索すると、<code>href=&quot;/assets/...&quot;</code>の中の「as」が先に一致してしまい、フォントのプリロードから<code>as=font</code>が黙って消えます。属性名が空白の後で始まり、<code>=</code>、空白、<code>/</code>、<code>&gt;</code>で終わるよう強制してください。HugoのようなSSGは<code>href=https://jared.lynskey.co.nz/fonts/x.woff2 as=font crossorigin</code>のように引用符なしで出力するので、引用符ありとなしの両方に対応する必要もあります。これは単体テストが捕まえてくれました。</li>
<li><strong>フォントには<code>crossorigin</code>を残す。</strong> これがないフォントのプリロードは別のリクエストとして扱われ、フォントが2回ダウンロードされて、ページは速くなるどころか遅くなります。</li>
</ul>
<p><code>103</code>を自分で書く必要はありません。ASGIやGranian、uvicornに中間レスポンスのコードを足すこともありません。Cloudflareがあなたのヘッダーから<code>103</code>を作ります。あなたの仕事はヘッダーを送ることだけです。</p>

<h2 class="relative group">ほかのスタックでは
    <div id="ほかのスタックでは" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%bb%e3%81%8b%e3%81%ae%e3%82%b9%e3%82%bf%e3%83%83%e3%82%af%e3%81%a7%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ルールはどこでも同じです。HTMLレスポンスに<code>Link</code>ヘッダーを付け、残りはCloudflareに任せます。</p>
<ul>
<li>
<p><strong>nginx</strong>では、HTMLを配信するlocationに：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p><strong>Laravel</strong>とViteの組み合わせには、このためのミドルウェア<code>AddLinkHeadersForPreloadedAssets</code>がすでにあり、Viteのプリロードを<code>Link</code>ヘッダーに変換してくれます。これに対して報告されている2つの問題は、上で書いた落とし穴と同じです。<code>modulepreload</code>のヒントが<code>crossorigin</code>なしで送られ、ブラウザがそれを捨ててスクリプトを2回ダウンロードしうること、そして大きなページではヘッダーがApacheの約8KBのヘッダー上限を超えたことです。お使いのバージョンで両方を確認してください。</p>
</li>
<li>
<p><strong>それ以外</strong>：<code>Link</code>ヘッダー1つにカンマ区切りで書くか、<code>Link</code>ヘッダーを複数送ります。どちらでも動きます。</p>
</li>
</ul>
<p>みんながやりすぎるのは、何をヒントにするかです。レンダリングに必須のCSS、エントリースクリプト、ファーストビューで使うフォント1〜2個だけをヒントにして、すべてのチャンクを入れないでください。リストが長いと、ブラウザは今必要なものより先に、まだ必要ないものを取りに行ってしまいます。私は12個を上限にしていますが、それもExpoのエクスポートがプリロードのリストをすでに絞ってくれているからです。</p>
<p>Cloudflareの後ろにいない場合や、CDNがEarly Hintsに対応していない場合でも、<code>Link</code>ヘッダーは少しだけ役に立ちます。ボディをパースする前、レスポンスヘッダーが届いた時点でブラウザがそれらの取得を始められるからです。ただし、サーバーかCDNが送らない限り<code>103</code>は返りません。</p>

<h2 class="relative group">動いているか確認する方法
    <div id="動いているか確認する方法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8b%95%e3%81%84%e3%81%a6%e3%81%84%e3%82%8b%e3%81%8b%e7%a2%ba%e8%aa%8d%e3%81%99%e3%82%8b%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>curl</code>なら<code>103</code>を直接見られます。HTTP/2でリクエストし、ステータス行と<code>Link</code>ヘッダーだけを残します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>先に<code>103</code>が届き、その後に<code>200</code>が来ます。Cloudflareはヒント内の属性の順番を並べ替えますが、問題ありません。すべてのリクエストが<code>103</code>を受け取るとは思わないでください。私の場合、最初の3回のうち2回でした。変更後の最初のアクセスはヘッダーを保存するだけで、最終レスポンスのヘッダーがすでに用意できている場合、Cloudflareは<code>103</code>を省略します。</p>
<p>そのうえで、上の分析クエリに戻り、<code>requestSource: &quot;earlyHintsCache&quot;</code>で絞り込んで、ステータスとパスでまとめてください。修正したページでは、ミスに代わってヒットが出てくるはずです。</p>

<h2 class="relative group">何が変わり、何が残ったか
    <div id="何が変わり何が残ったか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%95%e3%81%8c%e5%a4%89%e3%82%8f%e3%82%8a%e4%bd%95%e3%81%8c%e6%ae%8b%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ヘッダーを本番に出して数分後、このゾーンは初めてEarly Hintsのヒットを記録し、<code>curl</code>ではWebページに本物の<code>103</code>レスポンスが見えました。ヘッダーひとつで2つの問題が同時に片づきました。機能が動き始め、「504」の一部が消えたのです。</p>
<p>残った問題は、JSON APIにはプリロードするものがないことです。APIのエンドポイントはミスを出し続け、<code>504</code>の行を残し続けます。APIの比重が大きいゾーンでは、これが依然として大部分です。妥当な選択肢は3つあります。</p>
<ol>
<li><strong>効果のあるページにヘッダーを送り</strong>、APIのパスがミスを記録し続けるのは受け入れる。機能は動き、チャートは静かになりますが、完全には静かになりません。</li>
<li><strong><code>earlyHintsCache</code>を5xxのアラートとダッシュボードから除外する。</strong> ほかに何を決めるにせよ、これはやってください。内部のキャッシュ参照で誰かが呼び出されるべきではありません。</li>
<li><strong>Early Hintsをオフにする</strong>（ダッシュボードのSpeed → Optimization、またはAPIのゾーン設定<code>early_hints</code>）。今後も<code>Link</code>ヘッダーを送らないならです。HTMLがとても小さいシングルページアプリでは得るものが少ないかもしれず、オフにすればすべての参照が止まり、ノイズも一緒に消えます。</li>
</ol>
<p>私はオンのままにしました。Webページは先回りできるようになり、APIのミスはアラートが関わるすべての場所から除外しました。9月に機能をオフにする代わりにやるべきだったのは、まさにこれでした。</p>

<h2 class="relative group">チェックリスト
    <div id="チェックリスト" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%81%e3%82%a7%e3%83%83%e3%82%af%e3%83%aa%e3%82%b9%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ol>
<li>Cloudflareの5xxの急増を障害と呼ぶ前に、<code>requestSource</code>（またはUser-Agent）で分けて見る。</li>
<li><code>earlyHintsCache</code>の504は、<code>OriginResponseStatus = 0</code>のキャッシュミス。無害で、オリジンには届かない。</li>
<li>本物の504はあなたのプロキシかロードバランサーから来る。Cloudflare自身のタイムアウトは522（接続不可）と524（100秒応答なし）。</li>
<li>Early Hintsには<code>Link</code><strong>レスポンスヘッダー</strong>が必要。HTML内のプリロードタグはカウントされない。</li>
<li>レンダリングに必須のCSS、スクリプト、フォントだけをヒントにし、フォントには<code>crossorigin</code>を残す。</li>
<li><code>curl -sv --http2</code>で確認する。<code>200</code>の前に<code>103</code>が見えるはず。</li>
<li><code>earlyHintsCache</code>はアラートから外しておく。</li>
<li>今後もヘッダーを送らないなら、ログのノイズで代償を払うより、Early Hintsをオフにする。</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
