1

上篇,我们讲到了 istio 和底下的 Envoy 之间的交互过程。本文则更上一层楼,看看上层部分,istio 和 k8s 是如何交互的。

istio 不仅支持从 k8s 中获取配置,还支持通过 MCP over XDS 从实现了 MCP 协议的服务器中获取配置,抑或从文件中直接获取数据。在实践中,没听说过有哪些项目通过文件的方式来提供 istio 配置,所以这里不谈。MCP 实际上就只是把 istio 资源装进 MCP 这个箱子里,然后通过全量 XDS 协议下发配置给 istio。Higress 应该是最大规模应用 MCP 的开源项目。不过它的 MCP server 是从 istio 改出来的,复用了 istio 的 xDS 下发通道来下发 MCP。如果要借鉴,看起来可能会比较费力。Nacos 也实现了对 MCP 的支持,如果对 Java 有了解,可以参考下。

当然本文的重点是 istio 是如何从 k8s 中获取配置,并转换到可生成 xDS 的格式。所以让我们开始溯游而上,寻根究底吧。

上篇我们讲到 istio 会根据发生变化的资源类别,给不同的 Envoy 生成不同的 xDS。其中承载着当前变更的资源的结构体就是 model.PushRequest。那这个 PushRequest 是从哪里来的呢?顺着 PushRequest 的源头,我们可以找到 ConfigUpdate 这一个方法。

条条大道通 ConfigUpdate

istio 里时常看得到这样的代码:构造 model.PushRequest 接着传给 ConfigUpdate,比如:

// Trigger a push so we can recompute status
s.XDSServer.ConfigUpdate(&model.PushRequest{
Full:   true,
Reason: model.NewReasonStats(model.GlobalUpdate),
})

ConfigUpdate 就是 model.PushRequest 的原产地了。这个方法很简单,大体上就是包装了 pushChannel 写操作。

func (s *DiscoveryServer) ConfigUpdate(req *model.PushRequest) {
...
s.pushChannel <- req
}

pushChannel 的消费者是 istio 的 debounce 机制。该防抖机制做了下变更合并,避免因频繁变更导致 CPU 过于繁忙。相邻的 PushRequest 会被合并成一个 PushRequest,过了 PILOT_DEBOUNCE_AFTER(100ms)后如果没有新的配置,就继续下发流程;否则继续等待 PILOT_DEBOUNCE_AFTER,直到 PILOT_DEBOUNCE_MAX(10s)到达。

合并后的 PushRequest 会通过 Push 方法进行处理:

func (s *DiscoveryServer) Push(req *model.PushRequest) {
if !req.Full {
req.Push = s.globalPushContext()
s.dropCacheForRequest(req)
s.AdsPushAll(req)
return
}
// Reset the status during the push.
oldPushContext := s.globalPushContext()
...
push, err := s.initPushContext(req, oldPushContext, versionLocal)
...

req.Push = push
s.AdsPushAll(req)
}

还记得在上篇提到,endpoint only 的推送的特征是 req.Full == false 吗?在 Push 方法里,endpoint only 的推送几乎就等于走上快捷通道,很快就走到了 AdsPushAll 这个方法。Istio 会在 AdsPushAll 里遍历每个 Envoy,完成上篇描述的 xDS 生成和发送操作。

其他改动需要走上更长的路径。路上最核心的是 initPushContext 这个入口。initPushContext 主要的工作在于完成生成 xDS 所需的准备,比如将 Gateway API 的资源翻译成 istio API 的资源、构建 Namespace 到 Gateway 的索引等等。

注意由于 debounce 的存在,endpoint only 的推送有可能被合入一个 req.Full == true 的推送。如果这种行为影响了业务(比如 endpoint 的变更被迟滞到 PILOT_DEBOUNCE_MAX 之后才得以处理),可以通过环境变量 PILOT_ENABLE_EDS_DEBOUNCE 改变它。

配置下发的三大通道

那么 k8s 到 ConfigUpdate 中间又经过了哪些风景呢?

大部分 k8s controller 都是通过 controller-runtime 这个库来跟 k8s API server 交互。因为 istio 的开发年代较为久远,在那时 controller-runtime 还没有足够好用,所以它基于更底层的 client-go (k8s API server 的 SDK) 自己实现了一套 controller 框架。关于 controller 框架的细节,可以看官方文档:https://github.com/istio/istio/blob/master/architecture/networking/controllers.md。不同 k8s 资源会由不同 controller 处理,也即走上不同的道路。

Endpoints 下发

Endpoints 下发是网络产品控制面的核心功能。因为 endpoint 数目和变更频率都要比其他资源至少多上一个数量级。如果 endpoint 处理的效率能够提升 10%,其他资源上再怎么节约都省不了那么多。所以让我们优先看看 Endpoints 下发的路径。

在 istio 1.21 之后,Endpoints 由 endpointslice controller 处理:

func (esc *endpointSliceController) onEventInternal(_, ep *v1.EndpointSlice, event model.Event) {
...
// Update internal endpoint cache no matter what kind of service, even headless service.
// As for gateways, the cluster discovery type is `EDS` for headless service.
namespacedName := getServiceNamespacedName(ep)
...
hostnames := esc.c.hostNamesForNamespacedName(namespacedName)
// Trigger EDS push for all hostnames.
esc.pushEDS(hostnames, namespacedName.Namespace)

pushEDS 是一个 for 循环,从 cache 中拿出之前预处理好的 istioEndpoint 对象们,调用:

esc.c.opts.XDSUpdater.EDSUpdate(shard, string(hostname), namespace, endpoints)

终于走到最终的 EDS 的应许之地,让我全文列出,以作纪念:

func (s *DiscoveryServer) EDSUpdate(shard model.ShardKey, serviceName string, namespace string,
istioEndpoints []*model.IstioEndpoint,
) {
inboundEDSUpdates.Increment()
// Update the endpoint shards
pushType := s.Env.EndpointIndex.UpdateServiceEndpoints(shard, serviceName, namespace, istioEndpoints)
if pushType == model.IncrementalPush || pushType == model.FullPush {
// Trigger a push
s.ConfigUpdate(&model.PushRequest{
Full:           pushType == model.FullPush,
ConfigsUpdated: sets.New(model.ConfigKey{Kind: kind.ServiceEntry, Name: serviceName, Namespace: namespace}),
Reason:         model.NewReasonStats(model.EndpointUpdate),
})
}
}

经过九九八十一难,我们来到了 ConfigUpdate 的前面。在 istio 内部,k8s 的 Endpoint 变化和其他类似的服务地址变化一样,都是转换成 ServiceEntry 这种资源做处理。可以看到 EDSUpdate 判断了是否需要 Push,以及 Push 是否为 Full Push,然后完成 Push 的操作。除了 EDSUpdate 之外,在前面也有一些情况下会触发 Full Push,一一列出显然超过了本文的主题。感兴趣的读者可自行阅读。

需要指出的是,Push 是否为 Full Push 只影响到非 EDS 资源的生成。各位读者阅读 pilot/pkg/xds/eds.go 即可发现,是否全量生成 EDS 资源与 Push 是否为 Full Push 无关,只和当前改变的资源里是否有影响 EDS 的非 ServiceEntry 的资源有关。这里面的逻辑比较弯弯绕绕。

通用配置下发

和 Endpoints 下发相比,通用配置的下发路径可以说是简洁明了。

func (s *Server) initRegistryEventHandlers() {
    ...
if s.configController != nil {
configHandler := func(prev config.Config, curr config.Config, event model.Event) {
log.Debugf("Handle event %s for configuration %s", event, curr.Key())
// For update events, trigger push only if spec has changed.
if event == model.EventUpdate && !needsPush(prev, curr) {
log.Debugf("skipping push for %s as spec has not changed", prev.Key())
return
}
pushReq := &model.PushRequest{
Full:           true,
ConfigsUpdated: sets.New(model.ConfigKey{Kind: kind.MustFromGVK(curr.GroupVersionKind), Name: curr.Name, Namespace: curr.Namespace}),
Reason:         model.NewReasonStats(model.ConfigUpdate),
}
s.XDSServer.ConfigUpdate(pushReq)
}

大部分配置的下发都会走这条路径。从 k8s 同步过来后简单判断下是否需要 push(是否有某些特殊的 annotation、spec 是否有改动等等),如果需要就 push。

needsPush 里有一条规则,凡是非 istio 的资源的变更一律推送。这导致了一个问题:https://github.com/istio/istio/issues/50998。Gateway API 里的资源都使用 Status 来标记自己的状态,而 Status 变更也会触发 configHandler 调用。由于凡是 Gateway API 的资源都会推送,所以每次 Gateway API 资源变更,都会触发至少两次 full:一次是资源本身导致的、另一次是变更之后资源的 status 改变导致的。Istio 之所以这么做,是为了 full pu sh 过程中 initPushContext 里面的 Gateway API 翻译成 istio API 的操作。假如某个 Gateway API 资源的 status 被更改了,希望能借助翻译操作来保证 Gateway API 资源的 status 和真实情况一致。另外 istio 在部署完 k8s Gateway 后,会更新这个 Gateway 的 status。这时候也需要触发 full push 来给新的 Gateway 推送配置。不过老实说这算是为了一碟醋包了一盘饺子,其实可以实现得更加精细,而不是靠 full push 来大力出奇迹。

ServiceEntry 下发

和其他资源不同,ServiceEntry 下发路径就像骡,混合了前面两种路径。

func (s *Controller) serviceEntryHandler(old, curr config.Config, event model.Event) {
    log.Debugf("Handle event %s for service entry %s/%s", event, curr.Namespace, curr.Name)
    ...
fullPush := len(configsUpdated) > 0
// if not full push needed, at least one service unchanged
if !fullPush {
s.edsUpdate(serviceInstances)
return
}

...
pushReq := &model.PushRequest{
Full:           true,
ConfigsUpdated: configsUpdated,
Reason:         model.NewReasonStats(model.ServiceUpdate),
}
s.XdsUpdater.ConfigUpdate(pushReq)

上面的代码可以简单地总结成一句话:如果变更的部分只涉及到 IP 类型的 endpoints,就只触发 endpoint only 的推送。

  • 新增/删除 ServiceEntry:涉及 hosts 的改动,触发 full push
  • 修改 ServiceEntry 里的 hosts:触发 full push
  • endpoints 字段里只有 IP:理论上触发 endpoint only 的推送

为什么说是理论上呢,因为 istio 有个 bug:https://github.com/istio/istio/issues/52248。在 servicesDiff 里判断 Service 变更时,istio 会比较 Service 的 Addresses 字段。如果一个 Service(这里的 Service 由 ServiceEntry 派生出来)没有 Addresses,那么 istio 会通过 hash 算法生成一个地址。结果 istio 比较时,它会将全新的 Service 的空的 Addresses 字段和生成的地址做比较。可想而知,如果一个 ServiceEntry 没有指定 Addresses,代码里永远走不到触发 endpoint only 的推送的路径。这个问题修起来还不容易,因为目前 istio 还依赖这一行为,在发生 hash 碰撞导致 Service 地址漂移时通知数据面更新。一种修复方式是,把地址生成从现在的读操作时懒执行,改成每次同步时执行。这样就能在同步时识别出是否需要 full push 来推送地址漂移的变化。像是节点地址这种配置型数据,一般都是读多写少,所以从读操作时懒执行改成同步时执行,对性能影响不大。而且这么改之后,绝大部分 endpoints 的变化都只需要 endpoint only 的推送,毕竟 hash 碰撞的概率很低。

总结

Istio 配置下发可以看作 model.PushRequest 的漫步过程:

  1. Istio 中的 ConfigUpdate 方法是 model.PushRequest 的来源,它会将请求发送到 pushChannel。
  2. pushChannel 的消费者是防抖机制,它会合并相邻的 PushRequest,避免频繁变更导致 CPU 过于繁忙。
  3. 合并后的 PushRequest 会进入 Push 方法处理,其中 endpoint only 推送会快速进入 AdsPushAll 生成并发送 xDS;其他变更需要调用 initPushContext 准备 xDS 生成所需内容。

Istio 对 Kubernetes 资源的处理主要分成三条路径:

  1. Endpoints 由 endpointSliceController 处理,最终调用 EDSUpdate 触发 ConfigUpdate。
  2. 大部分配置由 configHandler 处理,简单判断后直接触发 ConfigUpdate 的 full push。
  3. ServiceEntry 的处理介于两者之间,根据变更类型选择触发 endpoint only 推送或 full push。

spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.


引用和评论

0 条评论