土法炼钢兴趣小组的算法知识备份

【Istio 控制面】推送图:watch 到 DiscoveryServer.pushXds 的完整链路

文章导航

分类入口
networkkubernetes
标签入口
#istio#xds#push-queue#debounce#pushxds#ads#nonce#version-info

目录

前两篇分别讲清楚了 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.goConfigUpdatedebouncePush)、pilot/pkg/xds/pushqueue.goPushQueue)、pilot/pkg/xds/xdsgen.gopushXds)、pilot/pkg/xds/ads.godoSendPushes);官方架构文档 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(携带 ConfigsUpdatedFullReason 等字段)塞进一个容量为 10 的 buffered channel。InboundUpdates/CommittedUpdates 这两个计数器的差值,是官方源码注释里明确给出的运维信号:差值大于零,说明有事件已经进来但还没被 debounce 处理完,这是判断”控制面是否正在追赶积压事件”最直接的指标,不需要猜。


三、PushQueue:每个代理最多一个在途请求

debounce 稳定后调用的 Push(下一节展开)最终会走到 AdsPushAll,对每个已连接的代理调用 pushQueue.EnqueuePushQueuepilot/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 篇 里「客户端可能发出多个请求,不期望每个都有独立响应」是同一种”合并优先于逐条处理”的设计取向,只是发生在生产侧而不是协议消费侧。

出队之后,doSendPushespilot/pkg/xds/ads.go)用一个容量为 features.PushThrottle 的信号量 concurrentPushLimit 控制同时进行中的推送数量——这是显式的背压机制:即使 PushQueue 里瞬间堆了几千个待推连接,实际并发生成+发送的数量也被这个信号量卡住,避免所有代理的 xDS 生成同时抢 CPU。


四、Push:Full 决定要不要重算全局状态

debounce 稳定窗口打开后调用的 Pushpilot/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:真正生成并发送的地方

pushXdspilot/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)
}

三个细节决定了这一步的行为边界:

  1. 生成器可以直接拒绝响应gen == nilres == nil 时函数直接返回,不会发送空的 DiscoveryResponse——这是「只有 Delta 场景下纯删除不需要响应」(Envoy 第 9 篇 提到的空响应语义)在生产侧的具体实现。
  2. 增量订阅在这一层做过滤:如果这次推送携带了 Delta(客户端刚订阅了新资源名),pushXds 会先把 WatchedResource.ResourceNames 收窄成 req.Delta.Subscribed,只生成新订阅的那部分——已订阅但未变化的资源不会被重新生成,这是「客户端加订阅不等于服务端要重推全部已知资源」的关键优化点。
  3. 成功发送不等于被接受xds.Send 返回 nil 只表示这次 gRPC 帧成功写出;它对应本文流程图上的 send 节点,不等于下游的 ACK(协议校验通过)或 warming(依赖就绪、流量已切)——第六节展开。

六、VersionInfoNonce:一次 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 决定要不要重算全局状态
pushXdsxds.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)

规范 / 官方文档(A)

站内对照

实验台账


十、小结

  1. 一次 push 有明确的六个阶段:事件汇入、debounce、(可能的)全局重算、按代理排队、生成并发送、代理确认——排障时先定位卡在哪一段,再决定该查 istiod 日志还是 Envoy /config_dump
  2. PushQueue 保证每个代理最多一个在途请求,中途到达的新变更会被合并而不是排队等待,配合 PushThrottle 信号量做并发限流。
  3. Full 决定要不要重算 PushContext,这是 istiod CPU 开销的主要分野,与协议层的 SotW/Delta 是正交问题(第 10 篇)。
  4. VersionInfo 按整次 push 统一生成,Nonce 把该版本号编码进去——协议兼容,但简化了跨类型对齐时间线的排障成本。
  5. xds.Send 成功只是链路的中点,不是终点;控制面推送成功、代理 ACK、warming 完成、流量真正切换是四个独立事件,混为一谈是生产事故复盘里最常见的归因错误。

系列目录 · 上一篇:代理身份与订阅 · 下一篇:Service/Endpoint → CDS/EDS

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .