Envoy
第 10 篇 把消费侧讲清楚了:SotW 与 Incremental 是 xDS
协议的两个变体,由 Envoy 拨的是
StreamAggregatedResources 还是
DeltaAggregatedResources
决定。生产侧的问题不是”该用哪种协议”——istiod
两种都支持,由客户端选——而是:同一条 ADS
连接上,istiod
什么时候认为需要重新计算全局状态(Full),什么时候只做一次廉价的定向更新(Incremental);成千上万次
K8s watch
事件如何被合并成一次推送;端点抖动下这套机制会不会把控制面自己拖垮。
本文只钉生产侧:model.PushRequest.Full 与
debounce 合并逻辑,以及它们与 Envoy
消费侧协议变体的正交关系。控制面推送顺序与 NACK 责任见下一篇。
本文是「Istio 控制面内核」系列第 10 篇(共 16 篇)。→ 系列目录
版本锚定:Istio 1.30.3(
istio/istiotag1.30.3);数据面消费侧协议对齐 Envoy v1.39.0 xDS protocol。无本机 istiod 环境,不粘贴伪造istioctl/config_dump输出;命令与日志格式引用源码原文,不编造运行结果。
一、两个容易混淆的”SotW/Delta”
Envoy 系列讲的 SotW/Delta
是协议变体:客户端与服务端之间某个 xDS
类型走全量快照还是差量增删。Istio
生产侧还有另一套看起来同名、实则正交的区分:model.PushRequest.Full
字段——它决定的是istiod 这一次要不要重算全局 push
context,与走哪条 gRPC 方法无关。
pilot/pkg/xds/delta.go(pushDeltaXds)证明了这个正交性:即便代理走的是
DeltaAggregatedResources(协议层
Delta),生成器如果没实现 GenerateDeltas(只有
Generate),istiod
依然会做一次全量生成,再用当前
WatchedResource.ResourceNames
与生成结果做差集,手工拼出
RemovedResources,模拟出 Delta 语义:
// pilot/pkg/xds/delta.go(Istio 1.30.3,节选,pushDeltaXds)
switch g := gen.(type) {
case model.XdsDeltaResourceGenerator:
res, deletedRes, logdata, usedDelta, err = g.GenerateDeltas(con.proxy, req, w)
case model.XdsResourceGenerator:
res, logdata, err = g.Generate(con.proxy, w, req)
}
...
if usedDelta {
resp.RemovedResources = deletedRes
} else if req.Full {
// similar to sotw
removed := w.ResourceNames.Copy()
for _, r := range res {
removed.Delete(r.Name)
}
resp.RemovedResources = sets.SortedList(removed)
}结论:协议变体(走哪条
RPC)是代理的选择;Full/Incremental 是 istiod
每次推送时对”要不要重算”的判断。一个走 Delta
协议的代理,仍可能持续收到”全量生成再做差集”的响应体——只是包在
DeltaDiscoveryResponse 里。反过来,SotW
连接上的 EDS 也常年走 req.Full == false
的轻量路径。四个变体(Envoy 第 10 篇)与 Full/Incremental
是两根不同的轴,叠加在一起才是生产环境的真实矩阵。
flowchart LR
subgraph transport ["Transport axis (client choice)"]
sotw["StreamAggregatedResources"]
delta["DeltaAggregatedResources"]
end
subgraph scope ["Push scope axis (istiod decision)"]
full["PushRequest.Full = true"]
inc["PushRequest.Full = false"]
end
sotw --> full
sotw --> inc
delta --> full
delta --> inc
二、Full 推送:什么触发重算
pilot/pkg/xds/ads.go 里几处显式构造
Full: true 的地方,覆盖了三类触发源:
| 触发源 | 代码位置 | 语义 |
|---|---|---|
| 代理主动请求(如首次连接、重连) | processRequest 构造
request := &model.PushRequest{Full: true, ...} |
新建 watch,需要全量视角 |
| 调试接口全量重推 | AdsPushAll(s *DiscoveryServer) |
运维手动触发,Reason: DebugTrigger |
| 单实例定向刷新(如 xds proxy 请求) | ProxyUpdate 构造
Full: true, Forced: true |
针对某个已知连接强制重算 |
AdsPushAll(req *model.PushRequest)
是分流点:
// pilot/pkg/xds/ads.go(Istio 1.30.3,节选)
func (s *DiscoveryServer) AdsPushAll(req *model.PushRequest) {
if !req.Full {
log.Infof("XDS: Incremental Pushing ConnectedEndpoints:%d Version:%s",
s.adsClientCount(), req.Push.PushVersion)
} else {
totalService := len(req.Push.GetAllServices())
log.Infof("XDS: Pushing Services:%d ConnectedEndpoints:%d Version:%s",
totalService, s.adsClientCount(), req.Push.PushVersion)
monServices.Record(float64(totalService))
}
s.StartPush(req)
}运维可读的信号在这里:XDS: Pushing Services:%d
只在 Full 分支打,日志里能看到当前
push_context 认为的 Service
总数;XDS: Incremental Pushing 分支没有 Service
计数,因为它压根没有重算 push context——这条分支通常对应纯
EDS 端点更新。
三、Debounce:把一堆 watch 事件折成一次推送
pilot/pkg/xds/discovery.go 的
debounce 函数是所有 K8s watch
事件(Service、Endpoint、VirtualService……)进入
pushChannel 后的第一道闸:
// pilot/pkg/xds/discovery.go(Istio 1.30.3,节选,debounce)
type DebounceOptions struct {
DebounceAfter time.Duration // 静默期:距上次变更多久才推
debounceMax time.Duration // 强制上限:即便一直有新事件也要推
enableEDSDebounce bool
}核心行为:每来一个事件,重置一个
DebounceAfter 计时器;只要事件持续到达且未超过
debounceMax,推送就被无限期推迟、事件被合并成一个
PushRequest(ConfigsUpdated 是个
set,天然去重)。稳定窗口打开时打一条日志:
log.Infof("Push debounce stable[%d] %d for reason %s: %v since last change, %v since last push, full=%v",
pushCounter, debouncedEvents, reasonsUpdated(req), ...)full=%v
直接把本节的判断结果打进日志——这是排障时判断”这一次是不是全量重算”最直接的入口,不需要猜。
有一个显式的例外分支:
if !opts.enableEDSDebounce && !r.Full {
// 立即触发,不等 debounce 窗口
}即:如果关闭了 EDS
debounce(enableEDSDebounce=false),纯端点更新(!r.Full)会跳过合并窗口直接触发推送。这是一个吞吐
vs 延迟的显式开关——默认打开 EDS debounce
是为了在端点抖动时不把每一次 Pod
就绪/退出都单独推一次,代价是端点收敛多了一段
DebounceAfter 的延迟。
sequenceDiagram
participant K8s as K8s watch events
participant DB as debounce()
participant Q as pushQueue
K8s->>DB: Service/Endpoint/VS changed
DB->>DB: reset DebounceAfter timer, merge ConfigsUpdated
Note over DB: quietTime >= DebounceAfter or eventDelay >= debounceMax
DB->>Q: emit merged PushRequest (full=?)
Q->>Q: fan out to AllClients via pushQueue.Enqueue
四、端点抖动:EDS 压力落在哪一层
生产上”端点抖动”(滚动发布、HPA 伸缩、探针抖动导致 Pod 频繁 Ready/NotReady)对控制面的压力分两段:
- debounce
层:
enableEDSDebounce打开时,短时间内多次端点变化被合并成一次PushRequest,ConfigsUpdated里塞进多个kind.Endpointskey,不重复推送。 - push
层:即便合并后只推一次,
AdsPushAll仍要对每一个已连接客户端调用pushConnection/pushConnectionDelta;ProxyNeedsPush会先判断这个代理是否真的关心这批变更(例如 Sidecar 作用域外的 Service 端点变化不需要推给它)——这是把”全网抖动”收窄成”关心该 Service 的代理集合”的关键过滤点,避免每次端点变化都对全网所有代理做无差别推送。
日志可见性上有个容易被忽略的盲区:pushXds(pilot/pkg/xds/xdsgen.go)里,非
Full 推送打的是 log.Debugf,Full 推送才打
log.Infof:
// pilot/pkg/xds/xdsgen.go(Istio 1.30.3,节选)
switch {
case !req.Full:
if log.DebugEnabled() {
log.Debugf("%s: %s%s for node:%s resources:%d size:%s%s", ...)
}
default:
log.Infof("%s: %s%s for node:%s resources:%d size:%v%s%s", ...)
}也就是说:默认日志级别下,生产环境大量的纯 EDS
增量推送在 istiod 日志里几乎不可见,只有打开
debug 日志才能看到逐条
PUSH INC。运维照着默认 Info
日志判断”控制面是不是在疯狂推送”,在端点抖动场景下会得到错误的平静印象——这不是
bug,是日志分级的显式设计取舍,但排障时必须知道这个盲区存在。
五、争论与开放问题
| 争论 | A | B |
|---|---|---|
| EDS debounce 默认打开 | 减少端点抖动下的推送风暴,保护控制面 CPU | 端点收敛多了一段可观的延迟,快速伸缩场景下”新 Pod 已 Ready 但还没吃到流量”的窗口变长 |
| Full 推送重算 push context 的代价 | 语义简单,一次重算全网状态一致 | Service 数量大时 GetAllServices()
与依赖图重算本身是 CPU 热点,Full 触发越频繁越明显 |
| 用 Info/Debug 分级隐藏 Incremental 日志 | 降低默认日志噪音 | 排障时低估真实推送频率,容易误判”控制面没做事” |
开放问题(回指 Envoy 第
16 篇 §5.1):Envoy 侧的开放问题是”大规模 EDS 抖动下
SotW 带宽与 Incremental 复杂度谁先成为瓶颈”;Istio
生产侧的对应问题是debounce
窗口大小、enableEDSDebounce 开关与
ProxyNeedsPush
过滤精度三者如何联合调参,才能在”端点收敛延迟”与”控制面
CPU/带宽”之间找到一个可测量、可复现的最优点——这需要按具体集群的
Service 规模、Pod churn rate
与代理数实测,本篇不给未跑过的数字。
六、参考资料
源码(A)
istio/istiotag 1.30.3:pilot/pkg/xds/ads.go(processRequest、AdsPushAll、pushConnection、ProxyUpdate)。istio/istiotag 1.30.3:pilot/pkg/xds/delta.go(pushConnectionDelta、pushDeltaXds、GenerateDeltas回退逻辑)。istio/istiotag 1.30.3:pilot/pkg/xds/xdsgen.go(pushXds、Info/Debug 日志分级)。istio/istiotag 1.30.3:pilot/pkg/xds/discovery.go(DebounceOptions、debounce、enableEDSDebounce)。
规范 / 官方文档(A)
- Envoy Proxy, xDS REST and gRPC protocol, docs v1.39.0(SotW/Incremental 协议变体定义)。
站内对照
- Envoy 第 10 篇 · ADS / SotW / Incremental
- Envoy 第 16 篇 · 选型收束(§5.1 push economics)
- 系列第 11 篇 · NACK / Warming 责任
实验台账
- 本篇无本机 istiod 压测数据;不写未实测的推送延迟或 CPU 数字。
七、小结
- 协议变体(SotW/Delta)与推送范围(Full/Incremental)是两根正交轴:走
Delta
协议不代表每次都收到真正的差量语义,
req.Full才决定 istiod 是否重算全局状态。 - debounce
是端点抖动的第一道减震器:
DebounceAfter/debounceMax/enableEDSDebounce三个参数共同决定”合并”还是”立即推”。 full=%v日志字段是排障时判断本次推送性质的最短路径,不需要靠猜。- 默认日志级别下 Incremental 推送几乎不可见——这是设计取舍,不是故障,但会让运维低估真实推送频率。
→ 系列目录 · 下一篇:NACK / Warming 责任
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Istio 控制面】Service / Endpoint / Workload → CDS/EDS:istiod 如何拼出一个 Cluster
拆解 istiod 如何把 K8s Service、EndpointSlice、WorkloadEntry 聚合进内部服务模型,再经 CdsGenerator/EdsGenerator 分别生成 Cluster 与 ClusterLoadAssignment;钉住命名契约与部分推送边界,subset 留给 DestinationRule 篇。
【Istio 控制面】控制面全景:从 CRD 到 xDS 的翻译与推送内核
定位 Istio 控制面内核相对 Envoy 消费侧、Service Mesh 税文与 Gateway API 资源模型的缺口;给出五条坐标系、16 篇地图与本系列明确不写的范围,钉住 istiod 1.30.3 为主线。
【Istio 控制面】推送图:watch 到 DiscoveryServer.pushXds 的完整链路
画出 istiod 从 K8s watch 事件到某个代理收到 DiscoveryResponse 的完整推送图:ConfigUpdate、debounce、PushQueue、pushXds 各自的边界与代码依据,钉住 VersionInfo/Nonce 的生成规则,并说明为什么控制面的推送成功不等于 Envoy 在服务。
【Istio 控制面】NACK 与 Warming 归属:CDS→EDS→LDS→RDS 的顺序责任
用 pilot/pkg/xds 的 PushOrder 与 ShouldRespond 源码钉住 istiod 推送顺序与 NACK 处理边界,说明为什么'istiod 已推送'与'Envoy 在服务'之间的责任划分不在同一层,给出排障用的责任表。