↓ メインコンテンツへスキップ

Cloudflare Early Hints HTTP 103

Cloudflareの504エラーは障害ではなかった:Early Hints(HTTP 103)とは

Cloudflareに504エラーが何千件も記録されたのに、実際のユーザーに届いたものは1件もありませんでした。正体はEarly Hintsのキャッシュミスです。HTTP 103の仕組み、偽の504と本物の504の見分け方、そして問題を解決したヘッダーを解説します。

Cloudflareの504エラーは障害ではなかった:Early Hints(HTTP 103)とは

9月に、ヘルスチェックはすべて正常なのにCloudflareのダッシュボードではリクエストの22%が失敗していた件について書きました。その504のほとんどはユーザーではなくCloudflareのEarly Hints機能から来ていたことがわかり、私はEarly Hintsをオフにして次へ進みました。

11月に同じゾーンを開いてみると、1日でまた504が2,600件たまっていました。Early Hintsがまたオンになっていたのです。今回は、その504の正体を正確に知りたいと思いました。そして、もう一度機能をオフにするのが正解なのか、それともきちんと働かせるのが正解なのかも。

このゾーンはFly.ioのオリジン1台の前段にあり、そのオリジンが3つのものを配信しています。Django API、Expo製のWebアプリ、そしてHugoで作ったマーケティングサイトです。APIのことは覚えておいてください。APIには別に本物のキャパシティの問題があり、それがこの区別を重要にしています。

短い答え
#

  • この504を書いているのは訪問者ではなく、CloudflareのEarly Hintsキャッシュです。CloudflareのログではRequestSource = earlyHintsCacheになっています。
  • ここでの504はキャッシュミスを意味します。そのURLに保存されたヒントがなかったということです。ヒットは2xxとして記録されます。
  • これらはオリジンに一切届きません。OriginResponseStatusは0です。
  • Early Hintsは**Link HTTPレスポンスヘッダー**で動きます。HTML内の<link rel="preload">タグでは動きません。
  • ヘッダーを送れば、プリロードするものがあるページでは、このミスがヒットに変わり、本物の103 Early Hintsレスポンスが返るようになります。

偽のCloudflare 504と本物の504の見分け方
#

LogpushやInstant Logsを使っているなら、まず2つのフィールドを見てください。RequestSourceは、実際の訪問者のリクエストなら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は接続はできたが100秒以内(Enterprise未満のプランのデフォルト)に応答がなかったことを意味します。

504のせいでCloudflareのタイムアウトを延ばす方法を探してこの記事に来たのなら:504の背後にあるタイムアウトは、ほぼ確実にCloudflareのものではありません。nginxのproxy_read_timeout、ロードバランサーのアイドルタイムアウト、あるいはプラットフォームがマシンを起こすのにかかる時間を確認してください。役に立つ手がかりがひとつあります。100秒よりかなり短い、30秒や60秒といったきりのいい数字でリクエストが失敗するなら、それはCloudflareではなく、あなたのプロキシの上限です。(Cloudflareの100秒の上限を最大6,000秒まで延ばせるのはEnterpriseプランだけで、Cloudflare自身もそれを応急処置と位置づけています。)

同じ週、APIでは本物の504が出ていた
#

この区別が大事な理由がここにあります。その数日前、まさに同じAPIが本当にダウンしていました。APIはFly.ioのshared-cpu-1xマシンで動いていて、これはバーストクレジット方式です。ニュージーランドのピーク時間帯にバーストを使い切ると、毎秒およそ20〜30リクエストの持続上限までスロットリングされました。レスポンスタイムは崩壊し、訪問者はエッジで本物の504を受け取りました。shared-cpu-2xに移したら解決しました。

その後、504のチャートがまた埋まりました。今度はEarly Hintsのミスです。requestSourceで分けずに見ると、そのチャートは「大きいマシンにしても効果がなかった」と言っているように見えます。実際には、同じ週の同じゾーンに2種類の別々の504があり、人に届いたのはそのうち片方だけでした。request sourceで分けていない5xxチャートをもとに、キャパシティを判断しないでください。

この504の正体
#

Early Hintsが有効なゾーンでは、すべてのリクエストに内部の参照がひとつ追加されます。Cloudflareが、送るべきヒントがあるか自分のキャッシュを確認しているのです。この参照はRequestSource = earlyHintsCacheとして独立した行に記録されます。エンドユーザーのリクエストではなく、オリジンに送られることもありません。

その行のステータスは、エラーではなくキャッシュ参照の結果です。

  • 2xxはヒットです。CloudflareにそのURL用のLinkヘッダーが保存されていて、それを103としてブラウザに送ったということです。私のゾーンでは、ヒットは200と204の両方で現れます。
  • 504はミスです。何も保存されていなかったので、先に送るものがなかったということです。

オリジンがEarly Hintsの使うヘッダーを一度も送らなければ、すべての参照がミスになり、すべてのリクエストが504の行を1行ずつ残します。私が何も変える前の3時間で、このゾーンは173件のEarly Hints参照を記録し、173件すべてがミスでした。機能がオンになってから、一度もヒントを送っていなかったのです。

9月の記事についての訂正もひとつ。私はこれを「Flyのオリジンから応答を得られない」プローブだと説明していました。それは正確ではありません。この参照はそもそもオリジンに行きません。504はタイムアウトではなく、キャッシュミスを記録したものです。

HTTP 103はエラーなのか?
#

違います。1xx台のステータスコードは情報レスポンス、つまりサーバーが本当のレスポンスの前に送れる中間レスポンスです。RFC 8297で定義された103 Early HintsはLinkヘッダーを運び、サーバーがまだページを組み立てている間に、ブラウザが重要なCSS、JavaScript、フォントの取得を始めたり、別のオリジンへの接続を開いたりできるようにします。最終的な200は同じリクエストの中で後から届きます。

対応状況はまちまちですが、安全です。Chromium系ブラウザ(Chrome 103以降、Edge)と最新のFirefoxは、preloadとpreconnectの両方のヒントを処理します。Safariは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ヘッダーに写せばいいのです。

import re

PRELOAD = re.compile(rb"""<link\b[^>]*?\brel=["']?preload["']?[^>]*?>""", re.I)


def attr(tag: bytes, name: bytes) -> bytes | None:
    # A real attribute: preceded by whitespace, followed by =, space, / or >.
    m = re.search(
        rb"(?<=\s)" + re.escape(name)
        + rb"""(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?(?=[\s/>])""",
        tag,
        re.I,
    )
    if not m:
        return None
    return next((g for g in m.groups() if g is not None), b"")


def link_header(html: bytes, limit: int = 12) -> str | None:
    parts, seen = [], set()
    for tag in PRELOAD.findall(html):
        href = attr(tag, b"href")
        if not href or href in seen:
            continue
        seen.add(href)
        seg = f"<{href.decode()}>; rel=preload"
        if as_ := attr(tag, b"as"):
            seg += f"; as={as_.decode()}"
        if as_ == b"font" or attr(tag, b"crossorigin") is not None:
            seg += "; crossorigin"
        parts.append(seg)
        if len(parts) == limit:
            break
    return ", ".join(parts) or None

そして、DjangoのHTMLレスポンスに付けます。

response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
    response["Link"] = link

私がはまった細部が2つあり、どちらも押さえておく価値があります。

  • 属性パーサーの境界を固定する。 asを素朴に検索すると、href="/assets/..."の中の「as」が先に一致してしまい、フォントのプリロードからas=fontが黙って消えます。属性名が空白の後で始まり、=、空白、/、>で終わるよう強制してください。HugoのようなSSGはhref=/fonts/x.woff2 as=font crossoriginのように引用符なしで出力するので、引用符ありとなしの両方に対応する必要もあります。これは単体テストが捕まえてくれました。
  • フォントには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つあります。

  1. 効果のあるページにヘッダーを送り、APIのパスがミスを記録し続けるのは受け入れる。機能は動き、チャートは静かになりますが、完全には静かになりません。
  2. earlyHintsCacheを5xxのアラートとダッシュボードから除外する。 ほかに何を決めるにせよ、これはやってください。内部のキャッシュ参照で誰かが呼び出されるべきではありません。
  3. Early Hintsをオフにする(ダッシュボードのSpeed → Optimization、またはAPIのゾーン設定early_hints)。今後もLinkヘッダーを送らないならです。HTMLがとても小さいシングルページアプリでは得るものが少ないかもしれず、オフにすればすべての参照が止まり、ノイズも一緒に消えます。

私はオンのままにしました。Webページは先回りできるようになり、APIのミスはアラートが関わるすべての場所から除外しました。9月に機能をオフにする代わりにやるべきだったのは、まさにこれでした。

チェックリスト
#

  1. Cloudflareの5xxの急増を障害と呼ぶ前に、requestSource(またはUser-Agent)で分けて見る。
  2. earlyHintsCacheの504は、OriginResponseStatus = 0のキャッシュミス。無害で、オリジンには届かない。
  3. 本物の504はあなたのプロキシかロードバランサーから来る。Cloudflare自身のタイムアウトは522(接続不可)と524(100秒応答なし)。
  4. Early HintsにはLinkレスポンスヘッダーが必要。HTML内のプリロードタグはカウントされない。
  5. レンダリングに必須のCSS、スクリプト、フォントだけをヒントにし、フォントにはcrossoriginを残す。
  6. curl -sv --http2で確認する。200の前に103が見えるはず。
  7. earlyHintsCacheはアラートから外しておく。
  8. 今後もヘッダーを送らないなら、ログのノイズで代償を払うより、Early Hintsをオフにする。

コメント

コメントは確認後に表示されます。