↓ 跳过正文

Cloudflare Early Hints HTTP 103

Cloudflare 504 错误其实不是故障:Early Hints 与 HTTP 103 详解

Cloudflare 上记录了几千个 504 错误,却没有一个到达真实用户:它们全是 Early Hints 的缓存未命中。本文讲清 HTTP 103 的工作方式、如何分辨假 504 和真 504,以及解决问题的那一个响应头。

Cloudflare 504 错误其实不是故障:Early Hints 与 HTTP 103 详解

9 月我写过一篇文章,讲健康检查全绿、Cloudflare 面板上却有 22% 的请求在失败。后来发现那些 504 大多来自 Cloudflare 的 Early Hints 功能,而不是我的用户,于是我关掉了 Early Hints,就此作罢。

11 月我再打开同一个 zone,一天之内又多出了 2,600 个 504。Early Hints 又被打开了。这一次我想弄清楚这些 504 到底是什么,以及正确的做法是再关一次这个功能,还是让它真正发挥作用。

这个 zone 前面挂着一个 Fly.io 源站,它同时提供三样东西:一个 Django API、一个 Expo Web 应用,以及一个用 Hugo 搭的营销网站。请记住这个 API。它自己有一段真实的容量问题,而这正是区分这两类 504 如此重要的原因。

简短的答案
#

  • 这些 504 是 Cloudflare 的 Early Hints 缓存写下的,不是你的访客。在 Cloudflare 日志里,它们的 RequestSource = earlyHintsCache。
  • 这里的 504 表示缓存未命中:Cloudflare 没有为这个 URL 存下任何提示。命中则记录为 2xx。
  • 它们根本不会到达你的源站,OriginResponseStatus 为 0。
  • Early Hints 依赖的是 Link HTTP 响应头,HTML 里的 <link rel="preload"> 标签喂不了它。
  • 把这个响应头发出去,在那些有资源可预加载的页面上,这些未命中就会变成命中,并开始返回真正的 103 Early Hints 响应。

如何分辨假的 Cloudflare 504 和真的 504
#

如果你用 Logpush 或 Instant Logs,先看两个字段。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 小时内,这个 zone 的 2,631 个 504 全部来自这些 User-Agent,没有一个来自浏览器、应用、curl 或搜索引擎爬虫。

访客真正看到的 504,来自 Cloudflare 之外,也就是你这一侧:挡在应用前面的代理或负载均衡器等不及放弃了。可能是 nginx,可能是云负载均衡器,也可能是等待休眠机器启动的平台代理。Cloudflare 自己的超时用的是别的状态码:522 表示连不上源站,524 表示连上了,但在 100 秒内(Enterprise 以下套餐的默认值)没有收到响应。

所以,如果你是因为 504 想调大 Cloudflare 的超时时间而搜到这里:504 背后的那个超时几乎从来都不是 Cloudflare 的。去检查 nginx 的 proxy_read_timeout、负载均衡器的空闲超时,或者平台唤醒机器需要多久。有个很有用的线索:如果请求总在远低于 100 秒的整数时间点失败,比如 30 秒或 60 秒,那是你自己代理的上限,不是 Cloudflare 的。(只有 Enterprise 套餐能把 Cloudflare 的 100 秒上限提高到最多 6,000 秒,而 Cloudflare 自己也把这当作权宜之计。)

同一周,API 出现了真正的 504
#

这就是区分它们的意义所在。几天前,正是这个 API 真的垮了。它跑在 Fly.io 的 shared-cpu-1x 机器上,这类机器靠突发积分(burst credit)运行:在新西兰的高峰流量下,突发积分用完后就被限流到每秒大约 20 到 30 个请求的持续上限。响应时间崩溃,访客在边缘收到了真正的 504。换成 shared-cpu-2x 之后问题解决了。

然后 504 图表又被填满了,这次是 Early Hints 的未命中。如果不按 requestSource 拆开看,这张图会告诉你:换了更大的机器也没用。实际上,同一周、同一个 zone 上有两批完全不同的 504,而只有其中一批真正落到了人身上。不要根据一张没有按 request source 拆分的 5xx 图表来做容量决策。

这些 504 到底是什么
#

在开启了 Early Hints 的 zone 上,每个请求都会附带一次内部查询:Cloudflare 查看自己的缓存里有没有可以发送的提示。这次查询会单独记成一行,RequestSource = earlyHintsCache。它不是终端用户的请求,也永远不会被发往你的源站。

那一行的状态码是缓存查询的结果,而不是错误:

  • 2xx 是命中。Cloudflare 存有这个 URL 的 Link 头,并把它作为 103 发给了浏览器。在我的 zone 上,命中同时以 200 和 204 出现。
  • 504 是未命中。没有存下任何东西,所以没有什么可以提前发送。

如果源站从不发送 Early Hints 所依赖的那个响应头,每次查询都会未命中,每个请求都会写下一行 504。在我做任何改动之前的三个小时里,这个 zone 记录了 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。浏览器只在 HTTP/2 和 HTTP/3 上处理 103。不认识它的浏览器只会像往常一样等 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

有两个细节坑过我,值得保留:

  • 给属性解析器加上边界。 天真地搜索 as,会先匹配到 href="/assets/..." 里的 “as”,于是字体预加载悄无声息地丢掉了 as=font。要求属性名以空白开头、以 =、空格、/ 或 > 结尾。还要同时处理带引号和不带引号的属性,因为 Hugo 这类静态站点生成器会输出 href=/fonts/x.woff2 as=font crossorigin 这种不带引号的写法。这个问题是单元测试帮我抓到的。
  • 字体要保留 crossorigin。 没有它的字体预加载会被当成另一个请求,字体会被下载两次,页面不但没变快,反而变慢了。

你不需要自己写 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 头。针对它报告过的两个问题,正好就是上面那两个坑:它的 modulepreload 提示曾经不带 crossorigin 发出,导致浏览器可能丢弃它们并把脚本下载两次;在大页面上,这个响应头还曾超过 Apache 约 8 KB 的头部上限。请在你使用的版本上把这两点都确认一下。

  • 其他任何环境:用一个 Link 头以逗号分隔,或者发多个 Link 头,两种方式都行。

大家最容易过度的,是提示哪些资源。只提示渲染关键的 CSS、入口脚本和首屏用到的一两个字体,不要把每个 chunk 都塞进去。清单太长,浏览器就会先去取暂时用不到的东西,反而耽误了真正需要的。我把上限设为 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,我最初的三次里有两次拿到了。改动之后的第一次访问只是把响应头存下来,而当最终响应的头部已经就绪时,Cloudflare 会跳过 103。

然后回到上面的分析查询,用 requestSource: "earlyHintsCache" 过滤,按状态码和路径分组。在你修好的页面上,命中应该会逐渐取代未命中。

改变了什么,还剩下什么
#

响应头上线几分钟后,这个 zone 第一次记录到了 Early Hints 命中,curl 也在网页上看到了真正的 103 响应。一个响应头同时解决了两个问题:功能开始生效,一部分"504"也随之消失。

剩下的问题是,JSON API 没有什么可预加载的。API 端点会继续未命中,继续写下 504 行;在以 API 为主的 zone 上,这些仍然占大多数。你有三个合理的选择:

  1. 在有收益的页面上发送响应头,接受 API 路径继续记录未命中。功能生效,图表会安静下来,但不会完全安静。
  2. 把 earlyHintsCache 从 5xx 告警和仪表盘中排除。 无论你做什么决定,这一步都要做。内部的缓存查询永远不应该把人叫醒。
  3. 关闭 Early Hints(在控制台的 Speed → Optimization,或者通过 API 修改 zone 的 early_hints 设置),前提是你今后都不会发送 Link 头。HTML 很小的单页应用可能本来就收益不大,关掉之后所有查询都会停止,噪音也会一起消失。

我选择保留它。现在网页能抢先一步,API 的未命中也已经从所有会触发告警的地方过滤掉了。这正是 9 月时我本该做的事,而不是把功能关掉。

检查清单
#

  1. 在把 Cloudflare 的 5xx 飙升称为故障之前,先按 requestSource(或 User-Agent)拆开来看。
  2. earlyHintsCache 的 504 是 OriginResponseStatus = 0 的缓存未命中。它们无害,也不会到达你的源站。
  3. 真正的 504 来自你的代理或负载均衡器。Cloudflare 自己的超时是 522(无法连接)和 524(100 秒无响应)。
  4. Early Hints 需要 Link 响应头。HTML 里的 preload 标签不算数。
  5. 只提示渲染关键的 CSS、脚本和字体,并且字体要保留 crossorigin。
  6. 用 curl -sv --http2 验证:你应该在 200 之前看到 103。
  7. 把 earlyHintsCache 排除在告警之外。
  8. 如果你今后都不会发送这个响应头,就关掉 Early Hints,别用日志噪音为它买单。

评论

评论经审核后显示。