前两篇分别讲清楚了 istiod
的进程边界与代理身份,本篇把「一次配置变更如何变成某个代理收到的一份
DiscoveryResponse」画成一张完整的图。这张图的价值不在于罗列函数名,而在于回答一个排障时必须先问的问题:当运维说”控制面已经推送了”,这句话具体对应图上哪一个节点?
这句话可能只是”K8s watch 事件已经进了
pushChannel“,也可能是”xds.Send
已经把字节写到 gRPC 流上”——两者中间隔着
debounce、PushContext
重算、PushQueue
排队、生成器执行整整四道工序,任何一道卡住都会让”已推送”这个说法失去意义。
本文是「Istio / 网格控制面内核」系列第 4 篇(共 16 篇)。→ 系列目录
上接 第 3 篇 · 代理身份与订阅;第 5 篇 · Service/Endpoint → CDS/EDS 与 第 10 篇 · 生产侧 SotW/Delta 分别深挖翻译层与 Full/Incremental 判定,本篇只画完整链路。
版本锚定:Istio 1.30.3。源码:
pilot/pkg/xds/discovery.go(ConfigUpdate、debounce、Push)、pilot/pkg/xds/pushqueue.go(PushQueue)、pilot/pkg/xds/xdsgen.go(pushXds)、pilot/pkg/xds/ads.go(doSendPushes);官方架构文档architecture/networking/pilot.md。数据面消费侧的 ACK/NACK/warming 语义外链 Envoy v1.39.0 系列,不在本篇重复。
一、一次 push 的完整链路
flowchart TD
watch["K8s watch event<br/>Service/VirtualService/..."] --> cu["ConfigUpdate()"]
cu --> pc["pushChannel"]
pc --> db["debounce()<br/>merge PushRequests"]
db -->|"quiet or maxDelay reached"| push["Push(req)"]
push -->|"req.Full"| ipc["initPushContext()<br/>NextVersion + InitContext"]
push -->|"!req.Full"| skip["reuse existing PushContext"]
ipc --> apa["AdsPushAll(req)"]
skip --> apa
apa --> enq["pushQueue.Enqueue<br/>per connected proxy"]
enq --> deq["doSendPushes: Dequeue()"]
deq --> pushconn["Connection.Push(event)"]
pushconn --> pushxds["pushXds(con, watched, req)"]
pushxds --> gen["Generator.Generate / GenerateDeltas"]
gen --> send["xds.Send DiscoveryResponse"]
send --> ack["Envoy ACK/NACK<br/>see Envoy 10"]
ack --> warm["Envoy warming<br/>see Envoy 11"]
图上每一段都在源码里有明确的函数边界,也对应本系列不同篇目的深挖对象:
| 阶段 | 函数/结构 | 本文钉住的问题 | 深挖篇目 |
|---|---|---|---|
| 事件汇入 | ConfigUpdate |
谁往 pushChannel 里写 |
本篇 §2 |
| 去抖合并 | debounce |
多少事件会被合成一次 PushRequest |
第 10 篇 |
| 决定重算范围 | Push / initPushContext |
Full 什么时候触发全局重算 |
本篇 §4、第 10 篇 |
| 按代理排队 | PushQueue |
同一代理的多次待推送请求如何合并 | 本篇 §3 |
| 生成并发送 | pushXds |
谁生成资源、VersionInfo/Nonce
怎么来 |
本篇 §5–6 |
| 代理确认 | ACK/NACK | 协议校验通过≠已应用 | Envoy 第 10 篇 |
| 真正生效 | warming | 依赖就绪才切流量 | Envoy 第 11 篇 |
二、事件汇入:ConfigUpdate
只做一件事
任何一个 K8s
控制器(Service、EndpointSlice、VirtualService
等的 informer)检测到变化后,最终都会调用同一个入口:
// pilot/pkg/xds/discovery.go(Istio 1.30.3,节选)
func (s *DiscoveryServer) ConfigUpdate(req *model.PushRequest) {
...
inboundConfigUpdates.Increment()
s.InboundUpdates.Inc()
s.pushChannel <- req
}ConfigUpdate
不做任何合并、不判断要不要推、不接触
PushContext——它只是把一个
*model.PushRequest(携带
ConfigsUpdated、Full、Reason
等字段)塞进一个容量为 10 的 buffered
channel。InboundUpdates/CommittedUpdates
这两个计数器的差值,是官方源码注释里明确给出的运维信号:差值大于零,说明有事件已经进来但还没被
debounce
处理完,这是判断”控制面是否正在追赶积压事件”最直接的指标,不需要猜。
三、PushQueue:每个代理最多一个在途请求
debounce 稳定后调用的
Push(下一节展开)最终会走到
AdsPushAll,对每个已连接的代理调用
pushQueue.Enqueue。PushQueue(pilot/pkg/xds/pushqueue.go,1.30.3)的核心不变量写在注释里:同一个连接如果已经在队列里(pending)或正在处理中(processing),新的
PushRequest
会被合并(CopyMerge),而不是排成两条记录:
// pilot/pkg/xds/pushqueue.go(Istio 1.30.3,节选)
func (p *PushQueue) Enqueue(con *Connection, pushRequest *model.PushRequest) {
...
if request, f := p.processing[con]; f {
p.processing[con] = request.CopyMerge(pushRequest)
return
}
if request, f := p.pending[con]; f {
p.pending[con] = request.CopyMerge(pushRequest)
return
}
p.pending[con] = pushRequest
p.queue = append(p.queue, con)
p.cond.Signal()
}MarkDone
里还有一层容易被忽略的语义:如果一个连接正在被推送的过程中又有新的变更到达(被合并进
processing[con]),MarkDone
会把这份合并后的请求重新放回 pending
队列——也就是说,一个连接永远不会因为”上一次推送还没做完”而丢失中间产生的新变更,代价是它可能需要再排一次队,拿到的是”这次推送开始后又发生的变更”的合并结果,而不是每一条变更各自触发一次独立推送。这与
Envoy
第 10 篇
里「客户端可能发出多个请求,不期望每个都有独立响应」是同一种”合并优先于逐条处理”的设计取向,只是发生在生产侧而不是协议消费侧。
出队之后,doSendPushes(pilot/pkg/xds/ads.go)用一个容量为
features.PushThrottle 的信号量
concurrentPushLimit
控制同时进行中的推送数量——这是显式的背压机制:即使
PushQueue
里瞬间堆了几千个待推连接,实际并发生成+发送的数量也被这个信号量卡住,避免所有代理的
xDS 生成同时抢 CPU。
四、Push:Full
决定要不要重算全局状态
debounce 稳定窗口打开后调用的
Push(pilot/pkg/xds/discovery.go)是整张图上唯一区分「重量级」和「轻量级」路径的分叉点:
// pilot/pkg/xds/discovery.go(Istio 1.30.3,节选)
func (s *DiscoveryServer) Push(req *model.PushRequest) {
if !req.Full {
req.Push = s.globalPushContext()
s.dropCacheForRequest(req)
s.AdsPushAll(req)
return
}
oldPushContext := s.globalPushContext()
...
versionLocal := s.NextVersion()
push := s.initPushContext(req, oldPushContext, versionLocal)
req.Push = push
s.AdsPushAll(req)
}非 Full 分支直接复用已有的全局
PushContext,只清对应的缓存条目——这条路径是纯
EDS 端点更新的常规去处(详见 第
10 篇)。Full 分支才会调用
NextVersion() 生成一个新的推送版本号,并触发
initPushContext 重新计算整份
PushContext——官方架构文档明确指出这一步是
istiod 资源开销的主要来源之一,也是「即使只改了一个
WasmPlugin,也会尽量复用旧
PushContext
的其余部分而不是从零重算」这类优化存在的原因。两条分支最终都汇入同一个
AdsPushAll,图上看不出分叉的痕迹,但排障时”这次是不是
Full”直接决定了 CPU 开销量级是否可比。
五、pushXds:真正生成并发送的地方
pushXds(pilot/pkg/xds/xdsgen.go)是链路的终点,也是唯一真正调用生成器、拼出
DiscoveryResponse 并发送的函数:
// pilot/pkg/xds/xdsgen.go(Istio 1.30.3,节选)
func (s *DiscoveryServer) pushXds(con *Connection, w *model.WatchedResource, req *model.PushRequest) error {
gen := s.findGenerator(w.TypeUrl, con)
if gen == nil { return nil }
if !req.Delta.IsEmpty() && !con.proxy.IsProxylessGrpc() {
w = &model.WatchedResource{TypeUrl: w.TypeUrl, ResourceNames: req.Delta.Subscribed}
}
res, logdata, err := gen.Generate(con.proxy, w, req)
if err != nil || res == nil { return err }
resp := &discovery.DiscoveryResponse{
TypeUrl: w.TypeUrl,
VersionInfo: req.Push.PushVersion,
Nonce: nonce(req.Push.PushVersion),
Resources: xds.ResourcesToAny(res),
}
return xds.Send(con, resp)
}三个细节决定了这一步的行为边界:
- 生成器可以直接拒绝响应:
gen == nil或res == nil时函数直接返回,不会发送空的DiscoveryResponse——这是「只有 Delta 场景下纯删除不需要响应」(Envoy 第 9 篇 提到的空响应语义)在生产侧的具体实现。 - 增量订阅在这一层做过滤:如果这次推送携带了
Delta(客户端刚订阅了新资源名),pushXds会先把WatchedResource.ResourceNames收窄成req.Delta.Subscribed,只生成新订阅的那部分——已订阅但未变化的资源不会被重新生成,这是「客户端加订阅不等于服务端要重推全部已知资源」的关键优化点。 - 成功发送不等于被接受:
xds.Send返回nil只表示这次 gRPC 帧成功写出;它对应本文流程图上的 send 节点,不等于下游的 ACK(协议校验通过)或 warming(依赖就绪、流量已切)——第六节展开。
六、VersionInfo
与 Nonce:一次 push 只有一个版本号
Envoy 侧的协议文档把 version_info
描述成「按资源类型独立」的时钟(Envoy
第 10 篇);Istio 生产侧的实现更简单粗暴:同一次
Push 调用产生的所有类型、发给所有代理的
DiscoveryResponse,共享同一个
req.Push.PushVersion 字符串,来自
NextVersion():
// pilot/pkg/xds/discovery.go(Istio 1.30.3,节选)
func (s *DiscoveryServer) NextVersion() string {
return time.Now().Format(time.RFC3339) + "/" + strconv.FormatUint(s.pushVersion.Inc(), 10)
}这不违反协议——协议只要求 version_info
对客户端而言是一个不透明字符串,客户端只需要在下一次请求里回填「自己已接受的版本」,不要求服务端按类型维护独立版本空间。Istio
选择「一次 push
一个版本号,所有类型共用」,换来的是日志和调试的简单性:看到某个代理的某个类型
ACK 了版本
2026-08-11T12:00:00Z/431,就知道它对应的是哪一轮
Push 调用产生的哪一份全局
PushContext,不需要分类型对齐时间线。
Nonce 的生成规则是
nonce(req.Push.PushVersion)——把当前 push
版本号当前缀,拼上一个随机 UUID:
// pilot/pkg/xds/ads.go(Istio 1.30.3,节选)
func nonce(noncePrefix string) string {
return noncePrefix + uuid.New().String()
}这与 Envoy 协议要求的「nonce 按流、按类型单独跟踪,用于给 ACK/NACK 配对」完全兼容——Istio 只是在生成 nonce 字符串时顺带把 push 版本编码进去,方便排障时从一条 nonce 反查它属于哪一轮推送,协议本身并不关心 nonce 的内部结构。
七、控制面推送成功,不等于 Envoy 在服务
把本文的图和 Envoy 第 11 篇 的图拼在一起,才是一次配置变更的完整生命周期:
| 阶段 | 谁负责 | 「成功」的含义 |
|---|---|---|
ConfigUpdate 入队 |
istiod | 事件被观测到,尚未处理 |
debounce 稳定、Push 触发 |
istiod | 决定要不要重算全局状态 |
pushXds → xds.Send 返回
nil |
istiod | gRPC 帧已写出,不代表对方已处理 |
| 客户端 ACK | Envoy | 协议隔离校验通过、意图应用,不代表已生效 |
| Cluster/Listener warming 完成 | Envoy | 依赖就绪,原子切换进活跃配置集 |
| 新请求命中新配置 | Envoy | 流量真正切换 |
PushQueue.MarkDone 的调用时机只取决于”这次
pushConnection 调用有没有返回”,与 Envoy 是否
ACK、是否完成 warming 完全无关——istiod
的推送统计(Pending()、并发数)只反映自己这一侧的队列状态,不是端到端的生效状态。这正是第
1 篇「一致性/warming 责任轴」的落地案例:只看 istiod
推送指标判断”配置已生效”,会系统性忽略 Envoy 侧 ACK 与
warming——第
11 篇 会把这条边界延伸到「NACK 该算谁的责任」。
八、争论与开放问题
谱系:PushQueue
的”按代理合并、FIFO 出队、信号量限流”设计,与 2018 年 Pilot
从分类型多流切到单流 ADS(istio/istio#4670,第
1 篇已引用)解决的是同一类问题的生产侧延伸——ADS
让”一个连接内部的多类型资源”可排序,PushQueue
让”同一连接的多次待推送请求”可合并,两者共同的目标都是避免对同一份状态做重复或错序的工作。
开放问题(官方文档明确承认的边界):architecture/networking/pilot.md(1.30.3)原文写道「Istio
currently supports both SotW and Delta protocol. However,
the delta implementation is not yet optimized well, so it
performs mostly the same as SotW.」——也就是说,即便代理走
Delta 协议连接,生产侧目前也大概率仍在做接近全量生成的工作,第
10 篇提到的”协议变体与推送范围是两根正交轴”在 1.30.3
这个时间点上,后者的性能收益还没有充分兑现。这不是本站的推测,而是控制面自己在架构文档里承认的现状,后续版本是否会补齐
Delta 路径的生成优化,需要跟踪官方发布说明,本文不做预测。
九、参考资料
源码(A)
istio/istiotag 1.30.3:pilot/pkg/xds/discovery.go(ConfigUpdate、debounce、Push、NextVersion)。istio/istiotag 1.30.3:pilot/pkg/xds/pushqueue.go(PushQueue.Enqueue/Dequeue/MarkDone)。istio/istiotag 1.30.3:pilot/pkg/xds/xdsgen.go(pushXds、ResourceSize)。istio/istiotag 1.30.3:pilot/pkg/xds/ads.go(doSendPushes、nonce、concurrentPushLimit)。istio/istiotag 1.30.3:architecture/networking/pilot.md(Config Serving 一节:Requests/Pushes/Optimizations、SotW/Delta 现状说明)。
规范 / 官方文档(A)
- Envoy Proxy, xDS REST and gRPC protocol, docs
v1.39.0(
version_info/nonce协议语义,供本篇第六节对照)。
站内对照
- 第 3 篇 · 代理身份与订阅
- 第 5 篇 · Service/Endpoint → CDS/EDS
- 第 10 篇 · 生产侧 SotW/Delta
- Envoy 第 10 篇 · ADS/SotW/Delta · Envoy 第 11 篇 · Warming/SDS
实验台账
- 本篇不粘贴 istiod
日志或推送延迟实测数字;链路与代码行为均以源码为准,无本机环境时不编造
full=%v之类的日志片段作为”实跑输出”。
十、小结
- 一次 push
有明确的六个阶段:事件汇入、debounce、(可能的)全局重算、按代理排队、生成并发送、代理确认——排障时先定位卡在哪一段,再决定该查
istiod 日志还是 Envoy
/config_dump。 PushQueue保证每个代理最多一个在途请求,中途到达的新变更会被合并而不是排队等待,配合PushThrottle信号量做并发限流。Full决定要不要重算PushContext,这是 istiod CPU 开销的主要分野,与协议层的 SotW/Delta 是正交问题(第 10 篇)。VersionInfo按整次 push 统一生成,Nonce把该版本号编码进去——协议兼容,但简化了跨类型对齐时间线的排障成本。xds.Send成功只是链路的中点,不是终点;控制面推送成功、代理 ACK、warming 完成、流量真正切换是四个独立事件,混为一谈是生产事故复盘里最常见的归因错误。
→ 系列目录 · 上一篇:代理身份与订阅 · 下一篇:Service/Endpoint → CDS/EDS
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Istio 控制面】生产侧 SotW 与 Delta:debounce、全量推与端点抖动
拆解 istiod 生产侧的 Full/Incremental 推送决策与 debounce 合并逻辑,说明它与 Envoy 消费侧 SotW/Delta 协议变体是两个正交问题;用 pilot/pkg/xds 源码钉住端点抖动下的日志可见性盲区。
【Istio 控制面】控制面全景:从 CRD 到 xDS 的翻译与推送内核
定位 Istio 控制面内核相对 Envoy 消费侧、Service Mesh 税文与 Gateway API 资源模型的缺口;给出五条坐标系、16 篇地图与本系列明确不写的范围,钉住 istiod 1.30.3 为主线。
【Istio 控制面】代理身份与订阅:sidecar、ztunnel、waypoint、Gateway 谁在订阅什么
拆解 istiod 如何从节点 ID 与 mTLS/JWT 得到一个 model.Proxy 身份,以及 NodeType 如何决定该连接会订阅哪一套 xDS 资源类型:sidecar/Router 走标准 LDS/RDS/CDS/EDS,ztunnel 走自定义 Address/Authorization,waypoint 是 Envoy 但推送触发条件不同。
【Istio 控制面】Service / Endpoint / Workload → CDS/EDS:istiod 如何拼出一个 Cluster
拆解 istiod 如何把 K8s Service、EndpointSlice、WorkloadEntry 聚合进内部服务模型,再经 CdsGenerator/EdsGenerator 分别生成 Cluster 与 ClusterLoadAssignment;钉住命名契约与部分推送边界,subset 留给 DestinationRule 篇。