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 的分层设计以及浏览器的连接复用有关。
authority 与 requested_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。
常见条件包括:
- 两个域名解析到相同的入口 IP;
- 服务端证书对两个域名都有效,例如使用
*.example.com通配符证书; - 客户端认为同一个服务器对目标域名具有权威性。
可以通过以下命令检查 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 Found 或 421 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[].name和namespace指向正确的 Gateway;- 实际 Listener hostname 确实是
*.example.com; - Route hostname 没有尾随点、不可见字符或错误后缀;
- Gateway Listener 本身处于
Accepted=True、Programmed=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_ID,requested_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,可以按以下顺序排查:
- 检查
OPTIONS + path + authority是否能匹配 HTTPRoute; - 对比 TLS SNI 与 HTTP/2
:authority; - 检查两个域名是否共用 IP 和通配符证书;
- 使用
CONNECTION_ID验证 HTTP/2 连接复用; - 在同一安全边界内,用一个通配符 Listener 配合精确 HTTPRoute hostname 分流;
- 如果出现
NoMatchingListenerHostname,核对实际对象、parent 状态、observedGeneration以及 controller/CRD 版本。
问题的核心不是简单地让 authority 等于 SNI,而是确保网关在 HTTP/2 合法复用连接时,仍能根据每个请求的 authority 找到正确路由。