Envoy 第 10、11 篇 从消费侧钉死了两件事:NACK 只拒绝这一次协议更新,旧配置继续跑;ACK 之后还要过 warming,Route 本身不 warming,全靠控制面按 CDS→EDS→LDS→RDS 顺序推送来避免黑洞。这些结论都是”Envoy 会怎么做”。生产联调时真正的问题是:istiod 在 NACK 之后做了什么(还是什么都没做);istiod 是否真的按这个顺序推;以及”控制面已推送”这句话在 istiod 内部对应的是哪一行代码、覆盖到状态机的哪一步。
本文只回答控制面这一侧,把责任边界钉在 istiod 代码上,为第 15 篇的排障清单提供一个可复用的心智模型。
本文是「Istio 控制面内核」系列第 11 篇(共 16 篇)。→ 系列目录
版本锚定:Istio 1.30.3;消费侧协议语义对齐 Envoy v1.39.0。无本机环境,不粘贴伪造
istioctl/config_dump输出。
一、istiod 如何”看见”NACK
pkg/xds/server.go 的
ShouldRespond 是每条
DiscoveryRequest 进来后第一个判定函数,NACK
分支在最前面:
// pkg/xds/server.go(Istio 1.30.3,节选,ShouldRespond)
if request.ErrorDetail != nil {
errCode := codes.Code(request.ErrorDetail.Code)
log.Warnf("ADS:%s: ACK ERROR %s %s:%s", stype, id, errCode.String(), request.ErrorDetail.GetMessage())
IncrementXDSRejects(request.TypeUrl, w.GetID(), errCode.String())
w.UpdateWatchedResource(request.TypeUrl, func(wr *WatchedResource) *WatchedResource {
wr.LastError = request.ErrorDetail.GetMessage()
return wr
})
return false, emptyResourceDelta
}三件事,逐条对应 Envoy 侧协议承诺:
- 打日志(
ADS:%s: ACK ERROR)并增加一个 Prometheus 计数器pilot_total_xds_rejects(按type、node、err_code打标签)。这是控制面唯一主动留下的可观测信号。 - 把错误信息记到
WatchedResource.LastError——这是每个连接内存里的状态,不落盘、不跨 istiod 重启存活。 - 返回
false:istiod 不会因为这次 NACK 而做任何补救推送。函数签名叫ShouldRespond,NACK 分支的返回值就是”不需要响应”——控制面这一轮什么都不做。
责任边界在这里就分清楚了:istiod 对 NACK
的全部责任是”记录 +
计数”,不包含”重试”或”回滚”。旧配置继续服务,不是因为 istiod
主动保护了什么,而是因为 istiod 从未发起任何撤回动作——这与
Envoy 侧”NACK
不回滚已生效配置”是同一个事实的两个观察角度:没有任何一方在主动做恢复,双方都只是”不动”。要让这个连接吃到修复后的配置,必须等下一次真正的
PushRequest(配置变更触发新的一轮 debounce →
push,见第
10 篇),NACK 本身不会触发重推。
sequenceDiagram
participant Op as Operator
participant Pilot as istiod
participant Envoy as Envoy
Op->>Pilot: apply broken config (e.g. bad DestinationRule)
Pilot->>Envoy: DiscoveryResponse (new version)
Envoy-->>Pilot: NACK error_detail
Pilot->>Pilot: log.Warnf + IncrementXDSRejects + LastError
Note over Pilot,Envoy: No retry. Envoy keeps old config. Pilot does nothing further.
Op->>Pilot: fix config (real change)
Pilot->>Envoy: new PushRequest, new version
Envoy-->>Pilot: ACK
二、istiod 是否真的按 CDS→EDS→LDS→RDS 推
pilot/pkg/xds/ads.go
里有一个显式常量,回答了这个问题:
// pilot/pkg/xds/ads.go(Istio 1.30.3,节选)
// PushOrder defines the order that updates will be pushed in. Any types not listed here will be pushed in random
// order after the types listed here
var PushOrder = []string{
v3.ClusterType,
v3.EndpointType,
v3.ListenerType,
v3.RouteType,
v3.SecretType,
v3.AddressType,
v3.WorkloadType,
v3.WorkloadAuthorizationType,
}watchedResourcesByOrder
用这个常量把某一个连接当前订阅的类型排序,pushConnection
/ pushConnectionDelta 再按这个顺序逐个调用
pushXds / pushDeltaXds:
// pilot/pkg/xds/ads.go(Istio 1.30.3,节选,pushConnection)
wrl := con.watchedResourcesByOrder()
for _, w := range wrl {
if err := s.pushXds(con, w, pushRequest); err != nil {
return err
}
}这正是 CDS→EDS→LDS→RDS 顺序的源码落点,并且比 Envoy 协议文档写的四段多了三段:Secret(SDS)→ Address(Ambient waypoint/ztunnel 用的工作负载地址)→ Workload/WorkloadAuthorization。多客户端场景下(sidecar、waypoint、ztunnel)需要这些额外类型,见第 13 篇。
要澄清一个容易误解的点:这个顺序是同一条 gRPC
流上依次发送的顺序,不是”等 CDS 被 ACK 之后才发
EDS”的同步等待——pushXds
是连续调用,中间没有等待响应。它保证的是 Envoy
侧收包的到达顺序(ADS
单流保序),而不是”上一类型确认生效”。这与 Envoy 第 10
篇的结论完全对应:ADS
降低多服务端抢序的难度,但顺序保证仅限于”同一控制面按什么顺序发”,不覆盖”对端处理到哪一步”。
三、四层责任表:从 istiod 视角重画
Envoy 第 11 篇把”配置生效”拆成协议 / 依赖 / 快照 / 请求四层,全部站在 Envoy 内部状态机的角度。把镜头切到 istiod,会发现控制面只对第一层有观测能力:
| 层 | 谁负责 | istiod 能看到什么 | istiod 看不到什么 |
|---|---|---|---|
| 协议(ACK/NACK) | istiod 生成,Envoy 判定 | ShouldRespond 里的
ErrorDetail;pilot_total_xds_rejects
计数 |
无法区分”客户端还没处理”与”客户端已拒绝但还没发 NACK” |
| 依赖(warming) | Envoy 独占 | 无原生信号;只能通过代理下一次行为间接推断 | Cluster/Listener 是否 warming 完成、是否卡在等 EDS/SDS |
| 快照(Worker 切换) | Envoy 独占,进程内部 | 无 | 具体哪个 Worker 线程用的是哪版快照 |
| 请求(长连接绑定旧路由) | Envoy + 客户端应用 | 无 | 某个具体请求绑的是新路由还是旧路由 |
这张表是本篇最重要的结论:istiod
的职责边界在”发出去、且这次协议交互本身是否被接受”为止。“istiod
已推送”这句话最多只能担保到协议层——它甚至不担保
Envoy 已经处理完这次响应,因为 ACK
本身就只是”隔离校验通过并意图应用”(Envoy 第 10
篇)。warming、快照切换、请求绑定这三层,istiod
完全没有原生的可观测通道,只能靠数据面自己暴露的
/config_dump、/clusters、/stats
反推——这也是为什么第 15 篇的排障清单必须同时看
istioctl(问 istiod 端的推送/ACK 状态)和 Envoy
admin(问数据面端的实际生效状态),单看一边都不完整。
flowchart TD
subgraph pilot ["istiod visibility"]
ack["Protocol: ACK / NACK<br/>ShouldRespond + pilot_total_xds_rejects"]
end
subgraph envoy ["Envoy-only visibility"]
warm["Dependency: Cluster/Listener warming"]
snap["Snapshot: Worker TLS config swap"]
req["Request: in-flight route binding"]
end
ack -.->|"opaque boundary"| warm
warm --> snap
snap --> req
四、Make-before-break:istiod 侧的落地方式
Envoy 协议文档给出的顺序建议(CDS → EDS → LDS → RDS →(再删陈旧 CDS/EDS))在 istiod 里不是靠”手动排队等确认”实现的,而是靠两个机制的组合:
PushOrder常量保证同一连接上,新增/更新类的资源总是先发 Cluster/Endpoint,后发 Listener/Route——新 Cluster 大概率先于引用它的 Route 到达 Envoy 侧的 warming 队列。- 删除类操作没有对称的”先后”保证:
PushOrder只管发送顺序,不管”哪些资源该在这一批里被删除”。如果一次配置变更让 RDS 指向的 Cluster 在同一批推送里被删除(例如误删 DestinationRule 导致的 subset cluster 消失),PushOrder里 Cluster 排在 Route 前面并不能阻止”新 Route 引用的 Cluster 恰好没在这批里”这种时序错位——这仍然是 Envoy 第 11 篇说的”控制面自己排序责任”,PushOrder只解决了”同批内先发哪个类型”,没有解决”跨批次的资源生命周期编排”。
因此,make-before-break 在 Istio
里更准确的表述是:istiod
用固定的类型发送顺序降低了单批推送内的短暂黑洞概率,但跨资源生命周期的一致性(新
Cluster 必须先于引用它的 Route 存在)仍然依赖 istiod
自身的配置翻译逻辑是否正确构造了这一批
ConfigsUpdated,而不是靠 PushOrder
本身兜底。
五、争论与开放问题
| 争论 | A | B |
|---|---|---|
| NACK 后 istiod 完全被动 | 简单、无重试风暴风险 | 排障者容易忽略”配置一直坏着”,需要额外告警在
pilot_total_xds_rejects 上 |
PushOrder 用固定顺序而非等待 ACK
再发下一类型 |
吞吐更高,不被慢客户端拖住整条推送流水线 | 无法保证”上一类型已被处理”,一致性责任转嫁给配置翻译层 |
| istiod 不感知 warming | 职责边界清晰,控制面不需要理解数据面内部状态机 | 排障时控制面侧和数据面侧的”已完成”定义不一致,容易产生误判发布门禁 |
开放问题(承接第
10 篇与 Envoy
第 15 篇):如果要建立一个”配置已真正生效”的 SLI,需要把
pilot_total_xds_rejects(控制面侧)与数据面
warming/ACK 信号做关联,但两者目前分别暴露在 istiod 和 Envoy
各自的指标体系里,没有统一的 trace id
或关联键把同一次配置变更的控制面推送与数据面生效状态串起来——这是否应该由控制面主动生成、数据面透传,还是留给外部可观测性平台拼接,目前没有权威结论。
六、参考资料
源码(A)
istio/istiotag 1.30.3:pkg/xds/server.go(ShouldRespond、NACK 分支、IncrementXDSRejects)。istio/istiotag 1.30.3:pkg/xds/monitoring.go(pilot_total_xds_rejects指标定义)。istio/istiotag 1.30.3:pilot/pkg/xds/ads.go(PushOrder、watchedResourcesByOrder、pushConnection)。
规范 / 官方文档(A)
- Envoy Proxy, xDS REST and gRPC protocol, docs v1.39.0(ACK/NACK 语义、make-before-break 顺序建议)。
站内对照
- Envoy 第 10 篇 · ADS / SotW / Incremental
- Envoy 第 11 篇 · Warming / SDS
- 系列第 10 篇 · 生产侧 SotW / Delta
- 系列第 15 篇 · 生产排障
实验台账
- 本篇无本机环境实测;
pilot_total_xds_rejects的具体数值需在有集群时按第 15 篇清单核对,不在此编造。
七、小结
- istiod 对 NACK 的响应只有”记录 +
计数”:
LastError写进内存,pilot_total_xds_rejects加一,不重试、不回滚。 PushOrder是 make-before-break 建议在 istiod 里的真实源码落点,但它只管同批推送内的类型发送顺序,不管跨批次的资源生命周期编排。- istiod 的可观测边界止步于协议层;warming、快照切换、请求绑定三层完全是 Envoy 内部状态,控制面没有原生信号。
- 排障必须两端对齐:
pilot_total_xds_rejects之类的控制面信号回答”有没有被拒绝”,Envoy admin 回答”有没有真的在服务”——两个问题答案可以不一致,且经常不一致。
→ 系列目录 · 上一篇:生产侧 SotW / Delta · 下一篇:多集群东西向发现扇出
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Istio 控制面】控制面全景:从 CRD 到 xDS 的翻译与推送内核
定位 Istio 控制面内核相对 Envoy 消费侧、Service Mesh 税文与 Gateway API 资源模型的缺口;给出五条坐标系、16 篇地图与本系列明确不写的范围,钉住 istiod 1.30.3 为主线。
【Istio 控制面】Service / Endpoint / Workload → CDS/EDS:istiod 如何拼出一个 Cluster
拆解 istiod 如何把 K8s Service、EndpointSlice、WorkloadEntry 聚合进内部服务模型,再经 CdsGenerator/EdsGenerator 分别生成 Cluster 与 ClusterLoadAssignment;钉住命名契约与部分推送边界,subset 留给 DestinationRule 篇。
【Istio 控制面】生产侧 SotW 与 Delta:debounce、全量推与端点抖动
拆解 istiod 生产侧的 Full/Incremental 推送决策与 debounce 合并逻辑,说明它与 Envoy 消费侧 SotW/Delta 协议变体是两个正交问题;用 pilot/pkg/xds 源码钉住端点抖动下的日志可见性盲区。
Istio / 网格控制面内核:从 CRD 到 xDS
补齐 Envoy 数据面 xDS 消费侧与 Mesh 选型叙事之间的控制面内核:istiod 推送图、CRD/HTTPRoute 翻译、Ambient 多客户端、NACK/warming 责任与排障对齐。