在 Istio Gateway 中保留客户端传入的 X-Request-ID
客户端发送了 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-idheader is used by Envoy to uniquely identify a request as well as perform stable access logging and tracing. Envoy will generate anx-request-idheader for all external origin requests (the header is sanitized). It will also generate anx-request-idheader for internal requests that do not already have one.
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_configto retain the prior behavior.
Istio 1.25 开始带 Envoy 1.33,因此升级后,同样的私网来源会被重新判定为 edge 请求,X-Request-ID 就开始被替换了。Istio 的 issue #57938 里记录了这个升级前后的差异,维护者的解释也是这个原因。
所以,如果是在升级 Istio 之后才发现这个问题,很可能不是 Istio 单独改了逻辑,而是 Envoy 上游的安全默认值变了。
Envoy 为什么要这样做
从“客户端认为 ID 应该端到端不变”的角度看,这个行为确实反直觉;但从网关的信任边界看,它是合理的:
- 外部 ID 不可信。客户端可以伪造、复用、构造超长值,甚至用 ID 影响采样。网关作为 edge,负责签发一个可信的 ID。
- 需要稳定的采样和日志关联。Envoy 的默认 UUID 扩展会把 trace sampling 决策编码进 UUID 的第 13 个十六进制位(即 UUID 第三组的第一个字符)。如果直接信任外部 ID,外部就可以携带一个“已采样”的标记进来。
- 信任边界重启是行业惯例。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/v1alpha3kind: EnvoyFiltermetadata: 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 里有没有生效:
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_reasonfield can be used to disable this behavior at the expense of also disabling stable trace reason propagation and associated features.
具体表现取决于 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 请求;在信任边界可能被重置 | 事实标准,不是 RFC | edge/gateway 或客户端 | 访问日志、单次请求排障 |
X-Correlation-ID | 标识一笔业务事务,可能跨多个请求和服务 | 事实标准,不是 RFC | 第一次交互处 | 跨服务日志关联、客服排障 |
traceparent / tracestate | 分布式 trace 的上下文 | W3C 标准 | 链路起点或各跳 | 分布式追踪 |
b3 / x-b3-* | Zipkin 的 trace 上下文 | 事实标准 | 链路起点或各跳 | Zipkin/Jaeger 生态 |
x-amzn-trace-id | AWS X-Ray/ALB 的 trace 上下文 | AWS 专有 | ALB 或上游 | AWS 内追踪 |
x-cloud-trace-context | GCP 旧版 trace 上下文 | GCP 专有,旧版 | GCP 负载均衡/服务 | GCP 追踪,现已建议用 traceparent |
x-ms-request-id / x-ms-client-request-id | Azure 的服务端请求 ID / 客户端请求 ID | Azure 专有 | 服务端 / 客户端 | 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。
参考
- Envoy HTTP Connection Manager proto
- Envoy HTTP header manipulation: x-request-id
- Envoy UUID request ID extension
- Envoy tracing overview
- Envoy 1.33 release notes
- Envoy
conn_manager_utility.cc - Envoy
uuid/config.cc - Istio EnvoyFilter reference
- Istio resource labels
- Istio issue #10875: X-Request-Id gets overwritten by Istio
- Istio issue #57938: x-request-id not preserved after upgrade
- Istio issue #53402: internal Envoy header sanitization
- ingress-nginx
nginx.tmpl - Kong Correlation ID plugin
- AWS ALB request tracing
- Azure
x-ms-client-request-id - Microsoft Diagnostics Correlation
- Microsoft Engineering Playbook: Correlation IDs
- Azure API design best practices
- Google Cloud Trace context
- W3C Trace Context
- RFC 6648: Deprecating the “X-” Prefix
- RFC 9110: HTTP Semantics
- RFC 9113: HTTP/2
- The Idempotency-Key HTTP Header Field