Day 09 / 共 20 天 · 第 2 周 xDS 生成

Push 与事件模型

你改了一条 VirtualService,几秒后所有相关 Envoy 就更新了——中间发生了什么?今天追这条"神经反射弧":ConfigUpdate → debounce → Push → PushQueue → 各 proxy

📍 你在控制面 istiod 之旅的位置(配置会翻译了,今天看"配置一变,怎么自动送到 Envoy")
D1 全景· D2 启动· D3 配置CRD· D4 服务注册· D5 快照· D6 xDS服务· D7 生成· D8 Builder· D9 推送· D10 连接
L01

完整链路

🤔 痛点:几千个 Envoy,配置一变全部无脑重推会怎样kubectl apply 改一条路由。若 istiod 立刻给全网 3000 个 Envoy 各自全量重算重推一遍——CPU 打满、网络风暴、Envoy 集体抖动。可你又希望"改完几秒就生效"。既要快,又要稳,中间必须有套精巧的机制。
💡 本质:一条"神经反射弧" + 三道减负闸门 这套推送机制像人的神经反射:感知(K8s 事件)→ 传导(debounce 合并)→ 中枢处理(重建 PushContext)→ 指挥(PushQueue 分发)→ 动作(生成下发 xDS)。为了不被刺激淹没,路上设三道闸门——debounce(把连按的电梯合成一趟)、PushContext 增量(只重算变的)、proxy 级裁剪(只通知相关的保安)。快与稳,全靠这三道闸门平衡。
registry/config 事件(K8s CRD/服务变化)
DiscoveryServer.ConfigUpdate(discovery.go:318)投递到 pushChannel
handleUpdates → debounce(防抖合并,100ms)
Push(构建/复用 PushContext,Day 05)
AdsPushAll → StartPush:遍历所有连接入 pushQueue
sendPushes:并发从 pushQueue 出队
pushConnection → pushXds:生成并下发 xDS(Day 07-08)
读法:这条链是控制面的"神经系统"——从"配置变了"到"Envoy 收到新配置"的完整路径。两级缓冲pushChannel(防抖前)和 pushQueue(防抖后、下发前)。
L02

事件源

// pilot/pkg/bootstrap/server.go:893 initRegistryEventHandlers
// 服务变更(:896):serviceHandler → PushRequest{Full:true, ConfigsUpdated:{ServiceEntry...}} → ConfigUpdate
// 配置(CRD)变更(:908):configHandler,EventUpdate 时若 spec 未变则跳过(needsPush)
// EDS 增量:Full:false,由 EDSUpdate 触发(不经这里,Day 07)
读法:Day 04 的服务注册表、Day 03 的 ConfigStore 检测到变化,就调 ConfigUpdate 触发推送。关键优化:配置更新时若 spec 没实质变化(只是 resourceVersion 变了)就跳过needsPush)——避免无谓推送。
L03

PushRequest

// pilot/pkg/model/push_context.go:366
type PushRequest struct {
  Full           bool                 // true=全量重算 PushContext;false=增量(一般仅 EDS)
  ConfigsUpdated sets.Set[ConfigKey]  // 变更配置集合;空=推所有;非空=只推依赖它的 proxy
  Push           *PushContext         // 本次用的快照
  Reason         ReasonStats          // 触发原因(受控枚举,防 metrics 基数爆炸)
  Forced         bool                 // 强制生成下发
}
// Merge()(:500):debounce 时合并 —— Full 取或、ConfigsUpdated 求并
读法:PushRequest 描述"一次推送请求"。ConfigsUpdated 是关键——记录"哪些配置变了",用于 proxy 级裁剪(L07)。Merge 让 debounce 时能把多个请求合并成一个(Full 求或、ConfigsUpdated 求并)。配置变更绝不能是增量(只有 EDS 会 Full=false)。
L04

debounce 防抖

// pilot/pkg/xds/discovery.go:357 debounce
// 默认:DebounceAfter=100ms(每事件后静默期)、DebounceMax=10s(累计最长等待)
// pushWorker(:378):quietTime >= DebounceAfter || eventDelay >= debounceMax 才真推
// 期间的事件用 req = req.Merge(r) 累积合并
debounce(防抖)= "等一等再一起处理" 想象你批量 kubectl apply 了 50 个 CRD——如果每个都立即触发一次全量推送,istiod 会被打爆、Envoy 收到 50 次抖动的配置。debounce 的策略:收到事件后不立即推,而是等一个"静默期"(100ms)。如果 100ms 内又来事件,合并(Merge)并重置计时;直到 100ms 内没有新事件(或累计等了 10s),才把合并后的一个 PushRequest 真正推出去。好比电梯——不是每按一次就走,而是等一会儿把同时按的人一起送。这把"50 次抖动"合并成"1 次推送",是控制面稳定性的关键。Higress 还加了 ShouldBlockPush 全局节流。
时间轴 → 事件密集到来(每来一次重置 100ms 计时) 静默 100ms 无事件 → 一次推送(合并后的 PushRequest) 4 次抖动 + 静默期 合并成 1 次
debounce 时间线:事件不断来就不断重置计时,直到静默 100ms(或累计满 10s),才把合并后的请求推一次。
📝 单步走查:批量 apply 50 个 CRD
时刻发生什么debounce 状态
t=0ms第 1 个 CRD 事件到启动 100ms 计时,req = e1
t=0~40ms第 2~50 个陆续到每来一个 Merge 并重置计时
t=140ms最后一个后静默满 100ms触发!推 1 个合并后的 PushRequest
结果50 次事件只产生 1 次全量推送
L05

两级缓冲

// Push(discovery.go:277):
//   Full → initPushContext(重算/复用 PushContext,Day 05)→ AdsPushAll
//   非 Full → 直接 AdsPushAll(不重建 PushContext)
// AdsPushAll → StartPush(ads.go:584):for 每个连接 pushQueue.Enqueue(p, req)
读法:debounce 后进入 Push:全量则重建/复用 PushContext(Day 05 的增量复用优化),然后 AdsPushAll 把请求投给每个连接pushQueue两级缓冲pushChannel(防抖前,吸收突发)→ debounce → pushQueue(防抖后,控制并发下发)。
L06

PushQueue 合并

// pilot/pkg/xds/pushqueue.go:23 PushQueue
//   pending map[*Connection]*PushRequest   // 排队中,重复入队会 merge
//   processing map[*Connection]*PushRequest // 已出队未完成
// Enqueue(:51):已在 pending/processing → CopyMerge(合并,不重复排队)
// sendPushes → doSendPushes(:491):semaphore 限并发 + Dequeue
PushQueue 保证"每个 proxy 至多一个待推请求" 如果推送队列里对同一个 Envoy 堆了 10 个待推请求,纯属浪费(只需推最新的)。PushQueue 的巧思:每个 proxy 在队列里至多一个请求——重复入队时不新增,而是 Merge 进已有的那个。还有并发控制(concurrentPushLimit 信号量):同时最多推 N 个 proxy(按核数),防止一次性给几千个 Envoy 推送打爆 istiod。这是"合并 + 限流"双重保护——大规模网格下 istiod 稳定的关键。
L07

ProxyNeedsPush

// pilot/pkg/xds/proxy_dependencies.go:114 DefaultProxyNeedsPush
if req.Forced { return req, true }
req = filterRelevantUpdates(proxy, req)  // 按 Sidecar 依赖过滤(Day 05)
return req, len(req.ConfigsUpdated) > 0  // 过滤后无关联则跳过该代理
三级优化的完整拼图pilot.md:166-181):
debounce(本课 L04)——把突发事件合并成少数几次推送
PushContext 增量复用(Day 05 updateContext)——即便全量推送也只重算变化的索引
proxy 级裁剪(本课,ProxyNeedsPush)——只推给依赖变更配置的 proxy,其余跳过
层层收窄:从"哪些事件"→"重算哪些"→"推给谁"。这套优化让 istiod 能管理数千 Envoy 而不被推送风暴压垮。
💬 对话体 👶 小白:改一条 payment 服务的路由,为什么不是所有 Envoy 都要更新?
👨‍🏫 老师:因为大部分 Envoy 根本不调用 payment(它们的 SidecarScope 不依赖 payment 的配置)。ProxyNeedsPushfilterRelevantUpdates 一过滤,发现这次变更和某个 proxy 无关(ConfigsUpdated 过滤后为空),就直接跳过它,连生成都省了。只有"会调 payment"的那批 Envoy 才真收到推送。
👶 小白:那 EDS 端点变化也走这套吗?
👨‍🏫 老师:不,端点高频变化走 Full=false 的独立快路径(Day 07),不重建 PushContext,直接增量推 EDS。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • Push 完整链路的七步?两级缓冲是哪两级?
  • PushRequest 的 Full/ConfigsUpdated 各是什么?Merge 干嘛?
  • debounce 防抖解决什么问题?100ms/10s 各是什么?
  • PushQueue 怎么保证"每 proxy 至多一个请求"?
  • 三级优化分别是什么?

✋ 动手

cd /Users/bitmart/work/codes/github/higress-group/istio
grep -n 'func.*ConfigUpdate\|func debounce\|func (s \*DiscoveryServer) Push' pilot/pkg/xds/discovery.go
grep -n 'type PushQueue struct\|func.*Enqueue' pilot/pkg/xds/pushqueue.go
grep -n 'DefaultProxyNeedsPush' pilot/pkg/xds/proxy_dependencies.go
明天预告 · Day 10(第2周收官)代理连接生命周期——把第 2 周串起来:一个 Envoy 从连上 istiod、拿初始配置、到收增量更新、到断开的完整故事。
← Day 08 Builder Day 10 · 代理连接生命周期 →