跳到内容
观澜涉川 · 札记EN

在 Istio Gateway 中保留客户端传入的 X-Request-ID

· Cloud Engineering · 约 13 分钟阅读

客户端发送了 X-Request-ID,服务端收到的却是另一个值。为什么 Istio Gateway 要改写它?

本文由 DeepSeek V4.1 Flash 1017 完成搜索和分析。

原因并不复杂:Istio Gateway 改写 X-Request-ID 不是 bug,而是 Envoy 在信任边界上的默认行为。preserve_external_request_id 能解决“整值替换”,但解决不了“UUID 第 13 个十六进制位被改写”。如果要求字节级不变,还需要调整 request_id_extension;如果只是要业务链路关联,更稳妥的做法是单独使用一个 correlation header,而不是和 Envoy 的 X-Request-ID 语义硬碰硬。

现象:是谁改了 X-Request-ID

Istio 的 ingress gateway 本质上是一个 Envoy。X-Request-ID 由 Envoy 的 HTTP Connection Manager(HCM)管理,Envoy 文档对它的定义是:

The x-request-id header is used by Envoy to uniquely identify a request as well as perform stable access logging and tracing. Envoy will generate an x-request-id header for all external origin requests (the header is sanitized). It will also generate an x-request-id header for internal requests that do not already have one.

—— Envoy HTTP header manipulation: x-request-id

HTTP header 名称大小写不敏感(RFC 9110 §5.1),X-Request-ID、X-Request-Id 和 x-request-id 是同一个字段;HTTP/2 在构造消息时要求字段名转为小写(RFC 9113 §8.2)。本文正文统一写作 X-Request-ID,Envoy 文档和配置示例中则常见小写 x-request-id。

这段话里有两个关键词:external origin request 和 sanitized。也就是说,对于来自外部客户端的请求,Envoy 默认不信任客户端带来的 X-Request-ID,会重新生成一个;对于 mesh 内部的请求,如果已经有值,则保留。具体行为可以归纳为四种:

请求情况Envoy 默认行为
没有 X-Request-ID生成新的 UUID
有 X-Request-ID,非 edge 请求保留原值
有 X-Request-ID,edge 请求,preserve_external_request_id=false替换为新 UUID
有 X-Request-ID,edge 请求,preserve_external_request_id=true保留原值

这张表只描述 preserve_external_request_id 本身;如果 pack_trace_reason 保持默认的 true,36 字符的 UUID 还可能被改写第 13 个十六进制位,见后文。

这里的 edge 指 Envoy 术语中的 edge request:外部客户端进入受信任网络的第一跳代理,通常是 ingress gateway;它不是“边缘计算”的 edge,也不特指某个 CDN 节点。相对于这个 Envoy,后续 mesh 内部的请求都算内部请求。

这里的“edge 请求”由 use_remote_address 和地址判断共同决定。Istio 的 gateway HCM 使用 use_remote_address: true,所以从公网或集群外来的请求会被当作 edge 请求,于是就会出现“客户端明明传了 ID,服务端却收到另一个”的现象。

为什么 Istio 1.25 之后更容易遇到

在 Envoy 1.33 之前,RFC 1918 私网地址默认被视为 internal。很多请求经过云负载均衡器或另一层代理后,源地址是 10.x、192.168.x 这类私网地址,Envoy 会把它当成内部请求,于是 X-Request-ID 被保留。看起来“一直都没问题”。

Envoy 1.33 改了这个默认值:

http: RFC1918 addresses are no longer considered to be internal addresses by default. This addresses a security issue for Envoy’s in multi-tenant mesh environments. Please explicitly set internal_address_config to retain the prior behavior.

—— Envoy 1.33 release notes

Istio 1.25 开始带 Envoy 1.33,因此升级后,同样的私网来源会被重新判定为 edge 请求,X-Request-ID 就开始被替换了。Istio 的 issue #57938 里记录了这个升级前后的差异,维护者的解释也是这个原因。

所以,如果是在升级 Istio 之后才发现这个问题,很可能不是 Istio 单独改了逻辑,而是 Envoy 上游的安全默认值变了。

Envoy 为什么要这样做

从“客户端认为 ID 应该端到端不变”的角度看,这个行为确实反直觉;但从网关的信任边界看,它是合理的:

  1. 外部 ID 不可信。客户端可以伪造、复用、构造超长值,甚至用 ID 影响采样。网关作为 edge,负责签发一个可信的 ID。
  2. 需要稳定的采样和日志关联。Envoy 的默认 UUID 扩展会把 trace sampling 决策编码进 UUID 的第 13 个十六进制位(即 UUID 第三组的第一个字符)。如果直接信任外部 ID,外部就可以携带一个“已采样”的标记进来。
  3. 信任边界重启是行业惯例。W3C Trace Context 规范明确允许 front gate 重启 trace,用于消除 DoS 攻击面;Envoy 对 X-Request-ID 的处理是同一类思路。

Envoy 的模型是:edge 负责签发,mesh 内部负责传播。如果诉求是“客户端是可信的,希望端到端保持”,这是另一种同样合理的模型,但需要显式打开开关。

方案:用 EnvoyFilter 保留 X-Request-ID

解决方式并不复杂:给 gateway 的 HCM 打一个 MERGE patch,把 preserve_external_request_id 设为 true:

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: gateway-preserve-request-id
namespace: "<gateway-namespace>"
spec:
workloadSelector:
labels:
gateway.networking.k8s.io/gateway-name: "<gateway-name>"
configPatches:
- applyTo: NETWORK_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: MERGE
value:
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
preserve_external_request_id: true

这段配置有几个关键点:

  • context: GATEWAY 限定只改 gateway,不影响 sidecar。
  • applyTo: NETWORK_FILTER + filter.name: envoy.filters.network.http_connection_manager 精确匹配到 HCM。
  • MERGE 只加字段,不替换整个 HCM,风险最小。
  • workloadSelector 用的 gateway.networking.k8s.io/gateway-name 是 Istio Gateway controller 自动加到 Deployment、Service、Pod 等资源上的 well-known label,用来选自动创建的 Gateway 没问题;如果是手工部署的 gateway,需要自己确保 pod 上有这个 label。Istio 1.24 起移除了旧的 istio.io/gateway-name,新版本统一用这个 label。

有一个容易忽略的细节:EnvoyFilter 是 namespace 级的。workloadSelector 只会匹配同一 namespace 的 pod;如果 Gateway 在 istio-system,EnvoyFilter 也要放在 istio-system,或者放到 Istio 的 root namespace 并接受它作用于所有匹配 workload。

如果 Istio 版本在 1.22 及以上,也可以用 targetRefs 替代 workloadSelector,直接指向 Gateway API 的 Gateway,避免依赖生成的 label:

spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: "<gateway-name>"

targetRefs 和 workloadSelector 只能二选一。

验证时,先看 gateway 的 config dump 里有没有生效:

Terminal window
istioctl proxy-config listener <gateway-pod> -n <namespace> -o json \
| jq '.. | objects | select(has("preserveExternalRequestId")) | {preserveExternalRequestId, requestIdExtension}'

没有任何输出,说明 patch 没匹配到目标 HCM。然后用几种不同格式的 ID 各打一次(UUID4、UUIDv7 形状、32 位十六进制),对比服务端看到的原始值;只测 UUID4 很容易漏掉第 13 个十六进制位的问题。

关键坑:打开 preserve 后,第 13 个十六进制位仍可能变化

这是最容易漏掉的一点。preserve_external_request_id: true 确实阻止了 Envoy 把整个值换掉,但 Envoy 的 UUID 请求 ID 扩展还有一个 pack_trace_reason,默认是 true。当请求是 edge、选择保留外部 ID、且 pack_trace_reason 为 true 时,对于 36 字符的 UUID,Envoy 会把第 13 个十六进制位写成 4,用来清除外部 ID 里的 trace sampling 标记。这一步和是否启用 tracing 无关,只要 pack_trace_reason 为 true 就会执行。

Envoy 的 tracing 文档也明确提醒了这一点:

The default implementation will modify the request ID UUID4 to pack the final trace reason into the UUID. … However, trace reason packing may break externally generated request IDs that must be maintained. The pack_trace_reason field can be used to disable this behavior at the expense of also disabling stable trace reason propagation and associated features.

—— Envoy tracing overview

具体表现取决于 ID 的格式:

客户端发送的 ID只加 preserve_external_request_id再加 pack_trace_reason: false
标准 UUID4,例如 11111111-1111-4111-8111-111111111111不变(该位置本来就是 4)不变
UUIDv7 形状,例如 11111111-1111-7111-8111-111111111111第 13 个十六进制位从 7 变成 4不变
被 Envoy 标记过采样的 UUID,例如 11111111-1111-9111-8111-111111111111第 13 个十六进制位被清回 4不变
32 位十六进制、ULID 等非 36 字符 ID不变(长度不是 36,Envoy 不会改写)不变
任意 36 字符字符串第 13 个十六进制位被写成 4不变

这解释了一个常见现象:用 UUID4 测试时看起来“完全保留”,换成 UUIDv7 或某些客户端生成的 ID 后,第 13 个十六进制位就变了。如果验证时只用了 UUID4,可能刚好没有暴露这个问题。

如果要求字节级一致,把 request_id_extension 一起补上(仍是 HCM typed_config 的子字段):

preserve_external_request_id: true
request_id_extension:
typed_config:
"@type": type.googleapis.com/envoy.extensions.request_id.uuid.v3.UuidRequestIdConfig
pack_trace_reason: false
use_request_id_for_trace_sampling: false

注意,UuidRequestIdConfig 必须嵌在 HCM 的 request_id_extension 字段里,不能把 UuidRequestIdConfig 单独作为一个 NETWORK_FILTER patch;它不是网络过滤器,类型对不上,不会得到预期的效果。

这两个开关的代价要讲清楚:

  • pack_trace_reason: false 能保证 ID 字节级一致,但 Envoy 文档说,依赖 X-Request-ID 的稳定 trace/access log 采样会失效,只剩随机采样。
  • use_request_id_for_trace_sampling: false 让 Envoy 不再用 X-Request-ID 做采样决策,适合链路上存在非 Envoy 代理、或者不希望客户端 ID 影响采样的场景。

如果不需要字节级一致,只保留 preserve_external_request_id 也完全可以接受:第 13 个十六进制位的清理本质上是 Envoy 在拒绝信任外部采样标记,是一个安全特性,不是 bug。

这样做的副作用

副作用说明建议
客户端可伪造任何外部客户端都能指定 ID。如果下游拿它做鉴权、审计、限流或幂等,风险很高只从可信上游保留;校验格式和长度;不要用于安全决策
影响采样use_request_id_for_trace_sampling 默认 true,客户端可以用 ID 影响是否采样;复用同一个 ID 还会让日志和采样关联失真不需要时设为 false,或保留 Envoy 默认的 trace reason 清理
第 13 个十六进制位被改写pack_trace_reason 默认 true,36 字符 ID 的固定位置会被写成 4要字节级一致就关掉,并接受稳定采样失效
日志注入/噪音客户端提供的值通常会进入访问日志;超长值会污染日志限制字符集和长度;使用结构化日志并转义
EnvoyFilter 维护成本依赖 Envoy 内部字段和 type URL,Istio 升级后可能失效升级后复查 config dump;保持 patch 最小
作用域是 HCM选中 gateway 的所有 HTTP listener/filter chain 都会生效,不能按 host 区分如果多个 Gateway 共享一个 deployment,确认影响范围
响应不回显默认不会把生成的 ID 放到响应头需要时加 always_set_request_id_in_response: true
用内部地址配置绕过把 LB 网段设为 internal 也能“保留”,但会改变 x-envoy-* 头的信任逻辑不要这么做;用 preserve_external_request_id 更精准

特别要提醒的是幂等。Istio issue #57938 中记录了一个案例:网关改写 X-Request-ID 后,幂等逻辑失效,产生了重复交易。但 X-Request-ID 本身不是为幂等设计的:客户端提供的值不保证唯一,也不保证可信,可以重放同一个值。如果业务需要重试去重,应该用独立的 Idempotency-Key,而不是复用请求 ID。

另外,不建议用“把负载均衡器网段配置成 internal”来解决这个问题。那会同时改变 Envoy 对 x-envoy-* 头的信任判断,扩大攻击面;Istio 的 #53402 讨论的就是这个信任收紧。这里只需要保留一个 header,不应该顺手把信任边界也改掉。

X-Request-ID、X-Correlation-ID 和其他跟踪字段

这几个 header 经常被混用,但语义并不一样:

字段语义是否标准谁生成典型用途
X-Request-ID标识一次 HTTP 请求;在信任边界可能被重置事实标准,不是 RFCedge/gateway 或客户端访问日志、单次请求排障
X-Correlation-ID标识一笔业务事务,可能跨多个请求和服务事实标准,不是 RFC第一次交互处跨服务日志关联、客服排障
traceparent / tracestate分布式 trace 的上下文W3C 标准链路起点或各跳分布式追踪
b3 / x-b3-*Zipkin 的 trace 上下文事实标准链路起点或各跳Zipkin/Jaeger 生态
x-amzn-trace-idAWS X-Ray/ALB 的 trace 上下文AWS 专有ALB 或上游AWS 内追踪
x-cloud-trace-contextGCP 旧版 trace 上下文GCP 专有,旧版GCP 负载均衡/服务GCP 追踪,现已建议用 traceparent
x-ms-request-id / x-ms-client-request-idAzure 的服务端请求 ID / 客户端请求 IDAzure 专有服务端 / 客户端Azure 排障、重试去重
Idempotency-Key标识一次可重试的操作IETF 草案(已过期)客户端重试幂等

业界的做法大致可以分成几类:

1. Edge 签发,内部传播(Envoy/Istio 的模型)

网关负责给外部请求分配 ID,内部服务只负责传播。好处是 ID 可信、唯一、可稳定采样;代价是客户端无法自己指定端到端 ID。这是 Envoy 的默认模型,也是很多 API gateway 的默认模型。

2. 有则保留,无则生成(ingress-nginx、Kong 的模型)

ingress-nginx 的模板里有一个很典型的 map:

map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}

客户端带了 X-Request-ID 就原样传给上游,没带才用 nginx 的 $request_id 生成。Kong 的 correlation-id 插件也是类似策略:如果请求里已经有同名 header,就尊重它,不覆盖。

这类做法对客户端友好,但前提是网关信任客户端,或者至少对值做了校验。

3. 分层 ID(AWS ALB、Azure 的模型)

AWS ALB 的处理很值得借鉴:如果请求里已经有 X-Amzn-Trace-Id 的 Root 字段,ALB 保留 Root,再插入一个 Self 字段;没有才生成新的 Root。这样既保留了调用方的 trace 起点,又加上了当前负载均衡器的信息。

Azure 的部分协议也区分 x-ms-client-request-id(客户端提供)和 x-ms-request-id(服务端生成),并可以通过 x-ms-return-client-request-id 决定是否回显客户端 ID。与其覆盖,不如把“客户端 ID”和“服务端 ID”分开。

4. 标准追踪上下文(W3C traceparent)

真正的分布式追踪不应该依赖 X-Request-ID。W3C Trace Context 定义了 traceparent 和 tracestate,把 trace ID、parent span ID、采样标记标准化;Google Cloud 也明确建议优先使用 traceparent,x-cloud-trace-context 只作为兼容保留。

W3C 规范还明确允许 front gate “restart trace”,用于消除 DoS 攻击面。这再次说明:在信任边界重启追踪上下文是正当设计,不是实现错误。

5. 业务 correlation ID

Microsoft 的工程手册对 Correlation ID 的定义是:在第一次交互时分配,传播到所有下游组件,所有组件在日志里使用同一个 ID。它建议优先使用 OpenTelemetry 的 TraceId;如果不用 OTel,就用一个自定义 header(常见的就是 X-Correlation-ID)。

这类 ID 的语义是“业务事务”,不是“单次 HTTP 请求”,所以通常应该跨网关保留,而不是每跳重置。但它同样不是标准,值需要校验,也不能用于安全决策。

6. 命名规范

RFC 6648 从 2012 年起就不建议再给自定义 header 加 X- 前缀了。现实中 X-Request-ID、X-Correlation-ID 已经大量存在,不可能一夜改掉;但新设计的系统应该优先用标准名(如 traceparent),或者至少把 header 的语义、生成方、是否透传写进接口契约,而不是靠猜。

实践建议

把“请求 ID”“关联 ID”“trace ID”“幂等键”分开,是最稳妥的做法:

场景建议
公网客户端,需要排障让 gateway 生成 X-Request-ID,必要时打开 always_set_request_id_in_response,通过响应头返回给客户端;客户端记录这个 ID
可信上游(CDN、另一层网关)已有 ID用 preserve_external_request_id 保留,并在网关或应用入口校验字符集和长度
需要跨服务业务关联单独使用 X-Correlation-ID,在第一次交互处生成,网关只做校验和透传
分布式追踪使用 traceparent/tracestate,不要把 X-Request-ID 当 trace ID
重试幂等使用 Idempotency-Key,服务端做去重存储,不要复用 X-Request-ID
多厂商/多语言统一到 W3C trace context + 一个自定义 correlation header,并写进契约

如果要在网关或应用入口校验,可以先用一个保守的白名单,例如 ^[A-Za-z0-9._:-]{1,128}$,并拒绝多个同名 header;具体规则取决于 ID 的生成方式。

回到开头的方案:方向正确,可以用。如果只关心“服务端能拿到客户端传的那个 ID”,并且客户端 ID 是标准 UUID4(第三组首位为 4)或非 36 字符,现有配置就够了;如果要求字节级一致,补上 request_id_extension;如果目标是长期可维护的端到端关联,建议把 X-Request-ID 留给网关/Envoy,用一个独立的 X-Correlation-ID 承载业务语义。

一句话总结:不要和 Envoy 的 X-Request-ID 语义硬碰硬。要么显式接管它(preserve_external_request_id,必要时关闭 trace packing),要么换一个不承载 Envoy 内部语义的 header。

参考