Software engineer


Envoy Gateway 中偶发的 CORS 预检 404:HTTP/2 连接复用与 Listener 主机名

在 Envoy Gateway 中,浏览器的 CORS 预检请求有时成功、有时返回 404,访问日志还可能出现下面这种看似矛盾的情况:

method: OPTIONS
protocol: HTTP/2
authority: api.example.com
requested_server_name: api-alt.example.com

这两个主机名不一致并不一定表示请求被篡改。它通常与 TLS、HTTP/2 的分层设计以及浏览器的连接复用有关。

authorityrequested_server_name 来自不同协议层

requested_server_name 是客户端在 TLS 握手阶段发送的 SNI。它用于选择证书和 HTTPS Listener,一条 TLS 连接在建立时确定一次。

authority 是 HTTP/2 请求中的 :authority 伪首部,作用类似 HTTP/1.1 的 Host。同一条 HTTP/2 连接上的每个请求都可以携带自己的 :authority

因此,一条连接可能表现为:

TLS 连接:SNI = api-alt.example.com
  ├─ HTTP/2 请求:authority = api-alt.example.com
  └─ HTTP/2 请求:authority = api.example.com

为什么浏览器会复用这条连接

HTTP/2 允许客户端在满足安全条件时,将一条 TLS 连接用于多个 origin。这种行为通常称为 HTTP/2 connection coalescing

常见条件包括:

  1. 两个域名解析到相同的入口 IP;
  2. 服务端证书对两个域名都有效,例如使用 *.example.com 通配符证书;
  3. 客户端认为同一个服务器对目标域名具有权威性。

可以通过以下命令检查 DNS 和证书:

dig +short api.example.com
dig +short api-alt.example.com

openssl s_client \
  -connect api-alt.example.com:443 \
  -servername api-alt.example.com </dev/null 2>/dev/null |
openssl x509 -noout -ext subjectAltName

若两个域名解析到同一入口,并且证书 SAN 同时覆盖它们,浏览器就可能复用 HTTP/2 连接。请求顺序、连接池和浏览器状态不同,会让问题看起来具有偶发性。

为什么这会引起预检请求 404

假设 Gateway 配置了两个精确 HTTPS Listener:

listeners:
  - name: api-https
    hostname: api.example.com
    port: 443
    protocol: HTTPS

  - name: api-alt-https
    hostname: api-alt.example.com
    port: 443
    protocol: HTTPS

浏览器先连接 api-alt.example.com 时,TLS SNI 会选择 api-alt-https。随后浏览器可能复用这条连接访问 api.example.com,此时出现:

SNI / Listener: api-alt.example.com
:authority:      api.example.com

如果 api.example.com 对应的 HTTPRoute 只挂载到 api-https,当前 Listener 的路由配置中就可能找不到匹配项。根据实现和配置差异,请求可能得到 404 Not Found421 Misdirected Request

该问题并非只影响 OPTIONS。预检请求更容易暴露它,是因为浏览器会自动发送预检,而且 CORS filter 只有在请求先匹配到目标路由后才能工作。

此外,如果 HTTPRoute 使用了方法匹配,还必须确保 OPTIONS 能匹配路由。下面的规则只接受 POST,预检请求不会命中:

matches:
  - path:
      type: PathPrefix
      value: /api
    method: POST

Access-Control-Request-Method: POST 不会让 Envoy 把 OPTIONS 当作 POST 进行路由匹配。可以去掉方法限制,或者增加相同路径的 OPTIONS 规则。

推荐配置:合并为通配符 Listener

如果这些子域名本来就属于同一个网关和安全边界,可以使用一个通配符 HTTPS Listener:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: edge-gateway
  namespace: gateway-system
spec:
  gatewayClassName: envoy-gateway
  listeners:
    - name: generic-https
      hostname: "*.example.com"
      port: 443
      protocol: HTTPS
      allowedRoutes:
        namespaces:
          from: All
      tls:
        mode: Terminate
        certificateRefs:
          - group: ""
            kind: Secret
            name: wildcard-example-tls

再让 HTTPRoute 通过精确 hostname 分流:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-route
  namespace: application
spec:
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: edge-gateway
      namespace: gateway-system
      sectionName: generic-https
  hostnames:
    - api.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api
      backendRefs:
        - name: api-service
          port: 8080

这样,无论连接最初使用哪个子域名作为 SNI,都会进入相同的 Listener,随后根据每个请求的 :authority 选择 HTTPRoute。

NoMatchingListenerHostname 怎么处理

迁移到通配符 Listener 后,HTTPRoute 可能显示:

Accepted=False
Reason=NoMatchingListenerHostname

按照 Gateway API 的 hostname intersection 规则:

Listener: *.example.com
Route:    api-alt.example.com

两者应当相交。因此,如果配置表面上如此但仍然报错,不应直接认定通配符无法匹配,而应检查集群中实际生效的对象。

首先查看 Gateway 的真实 Listener 列表:

kubectl get gateway edge-gateway \
  -n gateway-system \
  -o jsonpath='{range .spec.listeners[*]}{.name}{"\t"}{.hostname}{"\t"}{.port}{"\t"}{.protocol}{"\n"}{end}'

确认以下项目:

  • parentRefs[].sectionName 与 Listener 名称完全一致;
  • parentRefs[].namenamespace 指向正确的 Gateway;
  • 实际 Listener hostname 确实是 *.example.com
  • Route hostname 没有尾随点、不可见字符或错误后缀;
  • Gateway Listener 本身处于 Accepted=TrueProgrammed=True
  • Envoy Gateway controller 与 Gateway API CRD 版本兼容;
  • 没有多个 controller 同时更新同一个 HTTPRoute 的状态。

查看 Listener 状态:

kubectl get gateway edge-gateway -n gateway-system -o json |
jq '.status.listeners[] | {name, attachedRoutes, conditions}'

查看 Route 的每个 parent 状态,避免只看到另一条旧 parentRef 的报错:

kubectl get httproute api-route -n application -o json |
jq '.status.parents[] | {parentRef, controllerName, conditions}'

修改配置后还可以对比 metadata.generation 和 condition 中的 observedGeneration。如果后者较小,说明看到的是尚未更新的旧状态。

如何最终确认 HTTP/2 连接复用

建议在 Envoy access log 中增加以下字段:

%CONNECTION_ID%
%STREAM_ID%
%REQUESTED_SERVER_NAME%
%REQ(:AUTHORITY)%
%ROUTE_NAME%
%RESPONSE_CODE%
%RESPONSE_CODE_DETAILS%

如果多条日志具有相同的 CONNECTION_IDrequested_server_name 始终保持为首次建连的域名,而 authority 在多个域名间变化,即可确认发生了 HTTP/2 connection coalescing。

还应重点观察:

  • route_not_found:请求没有匹配 HTTPRoute;
  • 存在 upstream 且由后端返回 404:请求已被转发,后端可能没有 OPTIONS handler;
  • 返回 2xx 并包含 Access-Control-Allow-*:预检已由 CORS filter 正常处理。

模拟 SNI 与 authority 不一致

可以使用测试地址 203.0.113.10 模拟两个协议层使用不同主机名:

curl -vk --http2 \
  --resolve api-alt.example.com:443:203.0.113.10 \
  'https://api-alt.example.com/api/resource' \
  -X OPTIONS \
  -H 'Host: api.example.com' \
  -H 'Origin: https://frontend.example.net' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

该命令使用 api-alt.example.com 建立 TLS 连接,同时让 HTTP 请求携带 api.example.com authority。测试时应将示例 IP 和域名替换为测试环境值。

总结

遇到 Envoy Gateway 中偶发的 CORS 预检 404,可以按以下顺序排查:

  1. 检查 OPTIONS + path + authority 是否能匹配 HTTPRoute;
  2. 对比 TLS SNI 与 HTTP/2 :authority
  3. 检查两个域名是否共用 IP 和通配符证书;
  4. 使用 CONNECTION_ID 验证 HTTP/2 连接复用;
  5. 在同一安全边界内,用一个通配符 Listener 配合精确 HTTPRoute hostname 分流;
  6. 如果出现 NoMatchingListenerHostname,核对实际对象、parent 状态、observedGeneration 以及 controller/CRD 版本。

问题的核心不是简单地让 authority 等于 SNI,而是确保网关在 HTTP/2 合法复用连接时,仍能根据每个请求的 authority 找到正确路由。

参考资料