Istio控制流设计

istio提供的最核心能力是对业务流量进行透明转发和治理。其中,数据平面聚焦流量转发、管路管理和治理;控制平面扮演控制流的角色,微数据平面提供强大的管理配置能力,为数据平面的简介高效通信提供保驾护航。

从功能上看,控制流主要提供俩类能力:一类是生命周期管理,包含具体Envoy的自动注入、Envoy启动支持、Envoy运行状态监控以及envoy异常处理等,目标是利用相应的基础设施和工具,提高envoy运维的效率,保障Envoy的可用性;另一类是动态配置能力,通过相应机制将用户配置转化为Envoy需要的场景化配置。

Envoy生命周期管理

Service Mesh架构思想不是最近才产生,之前很长时间内一直没有引起足够的重视,一个很重要的原因是部署和运维的复杂度太高,如果没有相应的基础设施和工具体系支撑,很难保证Service Mesh运维的效率,也很难保障业务Service Mesh故障情况下的业务可用性。Istio在部署运维、生命周期管理方面充分利用Kubernetes基础设施的优点,有力地保障了Service Mesh的效率和可用性。

Envoy注入

Envoy注入是通过相应的手段将Envoy注入到Envoy运行环境中,Istio利用K8s的特点以优雅的方式实现envoy注入,大大简化Envoy运维和管理上的开销。

目前Envoy注入分为手工注入和自动注入俩种方式。手工注入是使用istioctl工具构建包含Sidecar镜像的模板,然后通过过kubectl apply–f xxx.yaml将包含Sidecar镜像的模板文件注入到pod中;自动注入基于K8s底层的admision Webhook机制,在Pod资源创建过程中自动触发。手工注入方式整体效率仍然偏低,而自动注入方式充分利用K8s平台提供的机制和能力实现了Sidecar透明接入,所以推荐使用自动注入的方式来完成Envoy的注入。下面是Envoy自动注入的机制和流程

Istio的sidecar-injector服务负责自动注入的具体实现,sidecar-injector服务中首先创建一个用于Mutating Admision Webhook的Webhook资源实例,同时启动HTTPServer接受K8s发过来的自动注入请求

// 创建Webhook资源
wh := &Webhook{
    server: &http.Server{
    Addr: fmt.Sprintf(":%v", p.Port),
    },
    sidecarConfig: sidecarConfig,
    sidecarTemplateVersion: sidecarTemplateVersionHash(sidecarConfig.Template),
    meshConfig: meshConfig,
    configFile: p.ConfigFile,
    valuesFile: p.ValuesFile,
    valuesConfig: valuesConfig,
    meshFile: p.MeshFile,
    watcher: watcher,
    healthCheckInterval: p.HealthCheckInterval,
    healthCheckFile: p.HealthCheckFile,
    certFile: p.CertFile,
    keyFile: p.KeyFile,
    cert: &pair,
}
// 创建HTTP Server
h := http.NewServeMux()
h.HandleFunc("/inject", wh.serveInject)
wh.server.Handler = h

sidecar-injector服务启动后并不会自动触发注入机制,需要通过Mutating AdmisionWebhook机制创建webhook注册配置,通过上述启动的sidecar-injector服务的/inject接口,监听符合条件的Pod资源。Webhook注册完成后,当监听有符合条件的Pod资源创建时,就会调用/inject接口进行自动注入的实际处理。

Envoy注入信息中包括Envoy服务启动相关的一切信息,K8s后续再启动Pod时,会根据上述的注入信息同时启动Envoy服务。

Envoy启动管理

Pilot-agent负责Envoy的启动和运行状态监控,Pilot-agent会根据Envoy的类型拼装相应的启动配置文件和启动参数,进行Envoy的启动或重启;同时通过探测和监控Envoy当前的运行状态,或者接受外界的配置变更等控制命令,对非正常状态的Envoy进行重启或调整。

Node ID

NodeID是Envoy的唯一标识,由Istio控制平面负责生成,由节点类型、IP地址、节点ID和DNS域名4部分组成以~分割

节点类型是Envoy具体运行模式,当前支持sidecar、ingress和router3种运行模式,Sidecar模式和业务微服务捆绑部署,用于Istio集群内微服务之间流量的透明代理;Ingress用于Istio集群对外的流量代理,负责将集群内不服务暴露出去,供集群外部访问;Router是独立的L4、L7层代理

IP地址、节点ID信息和DNS域名具体和当前支持的服务注册中心相关,可以通过Pilot-agent的命令行参数的对应flag指定,如果没有具体指定,Pilot-agent会根据当前连接的服务注册中心采用相应的配置方式。以Kubernetes为例,Kubernetes节点ID就是Pod ID加上当前的命名空间,DNS域名就是DNS域名后缀

Mata元数据

Envoy元数据负责Envoy自定义属性信息的维护,主要用于数据平面和控制平面之间的XDS交互。元数据信息由控制平面Pilot-agent生成,作为Boostrap启动信息的一部分,供Envoy服务启动时使用。Envoy启动后控制平面交互获取当前节点的XDS信息时,会把本节点的Envoy元数据信息传给控制平面,辅助控制哦面生成相应的XDS配置信息。

总之控制平面生成元数据信息用于Envoy启动,Envoy启动后透传元数据信息给控制面用与XDS配置的生成策略。

Envoy元数据以Key-Value对的形式存在,当前已经支持如ISTIO_PROXY_VERSION、CONFIG_NAMESPACE等超过20个元数据,具体定义在 /pilot/pkg/model/context.go。

Epoch

Epoch是Envoy进程的启动序号,从0开始编号,第一个Envoy进程对应的Epoch为0,此后Envoy每重启一次,Epoch递增1。引入Epoch是为了管理Envoy的热重启。

命令行参数

Pilot-agent根据Envoy配置模板和当前的启动信息,生成相应的Envoy启动文件,用于接下来的Envoy启动过程。

配置文件生成

Pilot-agent根据Envoy配置模板和当前的启动信息,生成相应的Envoy启动文件,用于接下来的Envoy启动过程。Envoy配置模板用于定义Envoy Bootstrap启动配置的格式,istio/tools/deb/envoy_bootstrap_v2.json就是Envoy配置模板的一个典型例子。

配置模板定义了一系列需要填充的配置字段,WriteBootstrap函数根据当前启动信息,在配置模板的基础生成相应的配置文件。

Envoy具体的启动

Envoy启动后,会记录当前Envoy的配置信息,后续启动相关操作前会先比较新配置是否和当前配置相同,如果配置相同不进行任何处理,否则会继续启动流程。

Envoy启动相关准备工作完成后,即可通过Exec命令启动Envoy进程,启动时将新Envoy进程的标准输出stdout和标准错误stderr进行重定向。启动完成后,通过监控Envoy进程的退出码,对Envoy的运行状态进行监控。

Envoy配置和运行状态监控

配置文件监控

Pilot-agent通过watch对象负责配置文件的监控,比如证书发生变化时,watch对象感知到变化的事件后,通知Pilot-agent进行处理。

配置文件变更感知和处理是通过单独启动一个Go协程的方式进行处理具体处理函数是watchCerts。wachCerts通过fsnotify的机制来进行变更,感知到配置文件的变化时,会调用watchFileEvnet进行处理。

watchFileEvents处理时有个小技巧,配置变更时没有立即进行处理,而是创建一个定时器,定时一段时间后再进行处理,这个是配置热加载厂家经常会使用的处理方式。通过延迟一段时间,保证配置变更后配置文件的完整性,防止配置变更立即处理导致各种异常问题

定时触发时会调用notifyFN函数进行处理,这里的notifyFn实际上是SendConfig,SendConfig计算证书文件的Hash值通过Updates Chan通道通知Pilot-agent进行处理。

(w *watcher) SendConfig() {
    h := sha256.New()
    generateCertHash(h, w.certs)
    w.updates <- h.Sum(nil)
}

运行状态监控

Pilot-agent对Envoy运行状态监控和控制有俩个维度。

一是,通过监控Envoy的退出码,当Envoy进程退出时,会通过cmd.Wait捕获退出码,然后写到done通道中,Run函数会将推出码返回给Pilot-agent

func (e *envoy) Run(config interface{}, epoch int, abort <-chan error) error {
    ...
    done := make(chan error, 1)
    go func() {
        done <- cmd.Wait()
    }()
    select {
    case err := <-abort:
        cmd.Process.Kill();
        return err
    case err := <-done:
        return err
    }
}

二是,当pilot-agent判断当前Envoy需要退出时,会将消息写入abort通道,Envoy进程接收到abort推出消息后,会调用cmd.Process.Kill将当前Envoy进程杀掉。

运行状态查看

为了方便外部用户直接查看Envoy的实时运行状态,Pilot-agent启动一个HTTP服务器,外界可以直接通过HTTP的URL接口查看Envoy的运行状态。

Pilot-agent的管理服务器的具体实现在pilot/cmd/pilot-agent/status下,这里会启动一个HTTP服务器,当前支持如下URL查询方式,比如通过“/app-health”可以查看Envoy的健康状态,通过“/healthz/ready”查看Envoy的就绪状态等。

重启策略

Pilot-agent会根据Envoy进程的退出状态决定具体的重启策略,Envoy进程的退出状态

大体可以分为如下3种情况。

1)退出状态码为空:说明是Envoy正常退出的,这里只需要记录日志即可。

2)退出状态码为errAbort:当Envoy进程退出状态为errAbort时,说明当前退出是由Pilot-agent主动触发,Pilot-agent在配置变更等场景时会发送退出控制指令给当前Envoy,然后启动新的Envoy进程,因此这里收到errAbort时只需要记录相应的日志即可。

3)退出状态码非空,但不是errAbort:该状态说明Envoy热重启过程中出错,这时需要通过errAbort消息通知当前所有Envoy进程进行恢复。

针对Envoy退出或重启出错的场景,Pilot-agent当前采用尽力而为的方式,如果失败会继续进行重试处理,如果连续重试10次均没有成功,说明当前节点可能出问题了,这时Pilot-agent进程会主动退出,由外界进行干预和处理,比如通过Kubernetes调度到其他节点上继续运行。

配置变更管理

Istio通过通用配置和服务模型,灵活方便地支持不同平台与环境下的配置、服务变更和管理,下面首先介绍Istio配置管理的通用机制,然后以Kubernetes为例,重点介绍Kubernetes平台下配置管理和服务管理的具体实现。

同样模型和机制

对象配置模型

Istio针对对象配置定制通用的Schema描述,具体通过下面的protoSchema结构来表示,针对每一个配置对象,Istio均会定义相应的Schema描述,IstioConfigTypes中包括Istio当前支持的核心对象。

Istio根据IstioConfigTypes想配置中心注册关心的对象列表,当对象信息发生变化时,配置中心将最新配置推送给Istio

type ProtoSchema struct {
    //对象范围,当ClusterScoped为true时表示当前对象是集群范围
    ClusterScoped bool
    //scheme对象名
    SchemaObjectName string
    //类型
    Type string
    // Plural is the type in plural.
    Plural string
    //对象所在的组
    Group string
    //对象版本
    Version string
    //对象名,可以通过该名称创建protobuf格式的对象实体
    MessageName string
    //配置验证函数
    Validate func(name, namespace string, config proto.Message) error
    //MCP集合
    Collection string
}

PotoSchema规定了每个对象类型的Schema,关键词是类型的Schema描述,通过Schema即可实现对某一种对象的描述,针对每一个具体的对象实体,Istio通过如下Config对象进行描述:

type Config struct {
    //对象元数据信息
    ConfigMeta
    //对象实际信息,以protobuf消息的方式存放
    Spec proto.Message
}

config对象包括对象元数据和对象实体俩部分,Istio针对所有对象定制了统一的元数据格式ConfigMeta具体包含如下信息。

1)Type:对象的配置类型,以Kubernetes为例,对应的是Kubernetes资源对象的类型,比如virtual service。

2)Version:对象的API版本信息。

3)Name:对象资源名,也是对象的唯一标识。

4)Namespace:对象所属的命名空间,Envoy节点也会设置相应的命令空间,获取XDS配置时会选取和当前节点处于相同命名空间的对象。

5)Label:对象的标签信息,和Kubernetes的Label含义相同,用于在对象选取时根据标签进行匹配。

对象实体信息由Config对象的Spec字段来表示,这是个Protobuf格式的消息,各配置中心需要提供相应的转换机制,将自身的数据格式转换成Istio内部的通用配置模型。

除了配置对象描述外,还需要有统一的对象管理机制,Istio通过下面的ConfigStoreCache接口对Istio对象进行管理,ConfigStoreCache提供了一组抽象接口,通过这些抽象接口完成对象的增删改查、存储和变更通知。可以基于ConfigStoreCache进行扩展实现,定义自己的插件实现。

服务配置模型

服务通用模型定义了什么是Istio的服务、如何发现Istio服务以及服务变更时的通知机制,各个平台、各个服务注册中心均可以基于服务通用模型定义自己的插件扩展。Istio Service对象用于描述一个Istio服务,一个Istio服务主要包含如下信息。

1)Hostname:服务名,也是服务的唯一标识。

2)Address:服务对应负载均衡器的IPV4地址,通过负载均衡器来完成服务访问流量的分发。

3)ClusterVIPs:服务的虚拟IP地址。

4)PortList:服务的端口信息列表,服务的端口可能不止一个,同时需要强调的是服务的端口和服务实例的端口信息不一定一致。

5)Resolution:服务实例获取方式,比如通过DNS方式或者直通模式。

6)ServiceAttributes:服务属性信息,一般用于Mixer或者RBAC策略使用。

此外,Istio通过ServiceDiscovery定义了服务注册中心的抽象接口,服务注册中心通过实现这组接口进行服务发现扩展支持。与对象通用配置类似,服务注册中心通过

Controller接口提供服务变更机制,关注服务变更的模块通过注册变更通知事件就可以对服务和服务节点变更事件进行相应处理。

配置中心

配置中心负责实现Istio对象配置模型,对Istio对象配置进行管理,Istio当前内置支持文件存储K8s CRD以及MCP这几种配置中心的方式,当然也可以支持自定义配置中心。

文件存储方式不灵活,管理上非常不方便,Istio当前主要使用Kubernetes CRD的方式对配置文件进行存储。为了减少各个组件的开发工作,IstioMCP机制专门抽出一个组件Galley对配置进行统一管理,Istio配置中心后续会逐渐迁移到基于MCP的Galley上。

服务注册中心

Istio服务注册中心负责Istio集群内部服务,以及Istio集群使用的集群外服务信息的管理,服务注册中心负责管理服务发现信息,具体主要包括服务信息、节点信息、端口信息等。服务发现信息发生变更时通过统一的抽象接口进行变更通知。下面分别从Istio集群内部服务和集群外部俩方面介绍。

在Istio集群内部服务的注册和管理上,Istio原生支持Kubernetes、Consul和Memory 3种服务注册中心方式,当前只有Kubernetes达到生产环境下可用,其他成熟度偏低,不推荐生产环境下使用。

Istio通过External注册中心对Istio集群外部服务进行管理。External注册中心通过kubernetes CRD资源ServiceEntry定义了Istio集群引入外部服务的方式,其本身并不负责外部服务的具体实现。Istio使用方通过相应的机制将外部服务以ServiceEntry的形式进行存储和变更管理,External注册中心只需要关注ServiceEntry CRD资源的变更事件,有变更时触发Envoy的配置变更。

由于Istio同时存在集群内部服务注册中心和针对集群外部服务的External注册中心,为了方便对这些注册中心进行管理,Istio引入了聚合服务注册中心aggregate的分层概念,其他注册中心向aggregate注册,aggregate自身不负责任何逻辑,只负责调用自身管理的注册中心服务。Istio服务注册中心的整体架构如图6-2所示。

聚合注册中心对Istio外部服务注册中心External和Istio内部服务注册中心Kubernetes、Consul进行统一管理,初始化过程中,会根据配置创建具体的注册中心实例,同时会创建缺省的External注册中心,聚合注册中心通过统一的接口层对它们进行统一管理。

Kubernetes具体实现

配置中心

kubernetes CRD方式的初始化在makeKubeConfigController函数中实现,首先会读取配置文件,根据配置文件调用crd.NewClient创建kubernetes CRD Client,创建过程中会完成kubernetes CRD资源的注册,当前支持的kubernetes CRD资源定义在model.IstioConfigTypes中,包括VirtualService、Gateway等。

func (s *Server) makeKubeConfigController(args *PilotArgs) (model.ConfigStoreCache, error) {
    kubeCfgFile := s.getKubeCfgFile(args)
    configClient, err := controller.NewClient(kubeCfgFile, "", model.IstioConfigTypes, args.Config.ControllerOptions.DomainSuffix)
    ...
    return controller.NewController(configClient, args.Config.ControllerOptions), nil
}

Kubernetes CRD资源注册完成后将会调用crd.NewController,构建Kubernetes CRD资源对象变更事件的处理框架,处理框架的思路很简单,框架首先向Kubernetes注册关注的资源类型,资源变更时Kubernetes会回调框架,同时框架提供注册函数让关心资源变更的逻辑注册回调函数。注册信息包括关注的资源类型和变更通知回调函数,通过这种注册机制,每种资源类型都会形成一个变更通知链,资源变更触发时,框架会遍历变更资料对应的通知链,调用事先注册好的回调函数进行通知处理。具体实现时由SharedIndexInformer、queue和Kube Client配合完成,crd.NewController会调用addInformer向Kubernetes注册资源变更回调函数,同时提供RegisterEventHandler函数用于其他模型向crd.NewController注册回调函数。

服务注册中心

Kubernetes注册中心负责Kubernetes平台服务和节点信息的获取与变更处理,通过Kubernetes Informer机制关注Kubernetes核心资源的变化,当核心资源发生变化时通知Istio进行配置变更处理。

Kubernetes注册中心当前关注Pod、Node、Service、Endpoint 4种核心资源,当Service和Endpoint资源变更时,直接触发服务和节点信息的变更;当Pod资源变更时,比如标签变化可能导致根据标签进行资源匹配发生变化,应该触发XDS配置变更。

控制平面和数据平面的XDS交互

Istio的控制平面和数据平面通过XDS协议进行配置资源的请求与下发,通信层面对XDS的支持,由V1版本的HTTP+Json方式和V2版本的gRPC+protobuf方式,由于V1版本存在资源消耗过多、不支持双向通信等问题,社区后续不再继续支持V1版本(当前存在是为了兼容之前的存量用户,新用户不建议使用V1版本,后续会逐渐废弃掉),所以接下来基于V2版本对控制平面和数据平面的XDS交互展开讨论。

Istio控制平面服务会专门启动一个gRPC Server,用于Envoy之间的XDS数据交互,Envoy启动时会通过调用这个接口和Pilot-discovery建立连接。建立连接后,Pilot-discovery会将该Envoy节点相关的全量配置信息通过ADS协议返回给Envoy,同时Pilot-discovery会管理与Envoy交互的每个连接。如果没有任何配置变更,则Pilot-discovery和Envoy之间不会有任何的通信交互;Pilot-discovery会监控配置变更事件,如果配置发生变更,则Pilot-discovery会轮训和Envoy的每个连接,将变更后的最新配置返回给Envoy。

下面展开讨论

控制平面的gRPC Server启动

控制平面Pilot服务启动时,启动专门的gRPC Server,用于管理和数据平面之间的XDS消息交互。数据平面的新连接到来时,会通过gRPC接口处理函数StreamAggregated-Resource进行新链接处理。

为了对配置中心和服务注册中心的变更进行感知与处理,gRPC Server启动后就注册相应的回调函数,用于处理变更后的XDS,注册逻辑如下

//注册服务变更通知函数
serviceHandler := func(*model.Service, model.Event) { out.clearCache() }
if err := ctl.AppendServiceHandler(serviceHandler); err != nil {
    return nil
}
//注册服务节点变更通知函数
instanceHandler := func(*model.ServiceInstance, model.Event) { out.clearCache() }
if err := ctl.AppendInstanceHandler(instanceHandler); err != nil {
    return nil
}
if configCache != nil {
    //注册配置变更通知函数
    configHandler := func(model.Config, model.Event) { out.clearCache() }
    for _, descriptor := range model.IstioConfigTypes {
        configCache.RegisterEventHandler(descriptor.Type, configHandler)
    }
}

Envoy的XDS请求

对于V2版本gRPC协议来说,ADS的触发由GrpcMuxImpl负责管理。GrpcMuxImpl的start函数会调用establishNewStream创建gRPC连接,并发起XDS请求流程。通过GrpcMuxImpl::onReceiveMessage进行XDS响应消息的解析和处理,onReceiveMessage主要通过watch->callbacks_.onConfigUpdate触发各个具体XDS资源的配置更新,各个XDS资源初始化时会通过SubscriptionFactory::subscriptionFromConfigSource注册对ADS配置变更的关注。

Istio XDS配置下发

先看新连接场景下的XDS配置下发逻辑,StreamAggregatedResources函数负责新连接的处理,定义在pilot/pkg/proxy/envoy/v2/ads.go文件中。StreamAggregatedResources函数首先调用newXdsConnection创建连接管理结构,然后启动一个新的协程单独负责新连接消息的解析,同时主流程进入等待逻辑,等待新的消息(主要是新请求消息和配置更新消息)。这个新的协程不是长驻协程,处理完请求后就会立即退出。receiveThread函数负责新协程的消息处理,主要是判断消息是否有问题,如果没有问题,将读到的请求消息放到reqChannel通道里面。

StreamAggregatedResources函数的for循环是无限循环流程,这里会监控两个Channel通道的消息,一个是reqChannel的新连接消息,一个是pushChannel的配置变更消息。

func (s *DiscoveryServer) StreamAggregatedResources(stream ads.AggregatedDiscoveryService_StreamAggregatedResourcesServer) error {
    // 新连接到来,初始化XDS响应消息推送对应的上下文
    err := s.globalPushContext().InitContext(s.Env)
    ...
    // 创建XDS连接并保存,用于后续配置变更时的XDS响应消息下发
    con := newXdsConnection(peerAddr, stream)
    // XDS请求消息接收,接收后存放到reqChannel中
    reqChannel := make(chan *xdsapi.DiscoveryRequest, 1)
    go receiveThread(con, reqChannel, &receiveError)
    for {
        select {
            case discReq, ok := <-reqChannel:
            err = s.initConnectionNode(discReq, con)
            ...
            switch discReq.TypeUrl {
            case ClusterType:
                ...
                err := s.pushCds(con, s.globalPushContext(), versionInfo())
            case ListenerType:
                ...
                err := s.pushLds(con, s.globalPushContext(), versionInfo())
            case RouteType:
                ...
                err := s.pushRoute(con, s.globalPushContext(), versionInfo())
            case EndpointType:
                ...
                err := s.pushEds(s.globalPushContext(), con, versionInfo(), nil)
            }
            if !con.added {
                con.added = true
                s.addCon(con.ConID, con)
                defer s.removeCon(con.ConID, con)
            }
            case pushEv := <-con.pushChannel:
            //配置变更时的XDS消息下发
            err := s.pushConnection(con, pushEv)
            ...
        }
    }
}

来看reqChannel消息的处理,reqChannel收到新数据时,表明新连接对应的数据已经读取完成,这里会从reqChannel中取出XDS请求消息,然后根据不同类型的XDS请求,调用相应的XDS下发逻辑。比如,TypeUrl为ClusterType时,调用pushCds进行集群信息的推送,TypeUrl为ClusterType或ListenerType时,调用pushLds完成监听器信息的推送。

在V2版本的XDS协议实现中,为了保证多个XDS数据下发的顺序,LDS、RDS、CDS和EDS等所有XDS交互均在一个gRPC连接上完成,因此StreamAggregatedResources接收到第一个请求时会调用s.addCon将连接保存起来,供后续配置变更时使用。

下面再看一下配置更新的时机,之前NewDiscoveryServer函数已经分别注册了针对Kubernetes CRD资源和注册中心的变更事件通知回调函数,当这两者有变更时,会调用注册的回调函数,这几类资源变更的回调处理都是调用out.clearCache函数,当配置中心和服务注册中心发生变更时,通过之前注册的回调函数clearCache进行变更处理,clearCache的实现很简单,就是将变更消息写到updateChannel通道里。

func (s *DiscoveryServer) clearCache() {
    s.ConfigUpdate(true)
}
func (s *DiscoveryServer) ConfigUpdate(full bool) {
    s.updateChannel <- &updateReq{full: full}
}

handleUpdates函数负责变更事件的具体处理,Istio收到变更事件并没有立即进行处理,而是创建一个定时器事件,通过定时器事件延迟一段时间。这样做的初衷:一方面可以减少配置变更下发的频率,进而减少控制平面和Envoy交互的通信开销;另一方面,延迟对配置变更消息的处理,可以保证配置变更下发时变更的完整性,如果变更时立即处理,可能会遇到变更不完整的情况,导致一些不可控的结果。配置变更时的延迟生效思路,在很多开源系统都被采用,也算是一个通用的架构思路。

func (s *DiscoveryServer) handleUpdates(stopCh <-chan struct{}) {
    fullPush := false
    for {
        select {
            case r := <-s.updateChannel:
                lastConfigUpdateTime = time.Now()
            if debouncedEvents == 0 {
                timeChan = time.After(DebounceAfter)
                startDebounce = lastConfigUpdateTime
            }
            debouncedEvents++
            if r.full {
                fullPush = true
            }
            case now := <-timeChan:
                timeChan = nil
                eventDelay := now.Sub(startDebounce)
                quietTime := now.Sub(lastConfigUpdateTime)
            // 判断当前是否触发push下发
            if eventDelay >= DebounceMax || quietTime >= DebounceAfter {
                pushCounter++
                go s.doPush(fullPush)
                fullPush = false
                debouncedEvents = 0
                continue
            }
            timeChan = time.After(DebounceAfter - quietTime)
            case <-stopCh:
            return
        }
    }
}

按时上述配置变更延迟触发策略,当满足触发逻辑时就会创建一个新的协程,调用AdsPushAll进行XDS配置下发的实际处理。AdsPushAll中针对EDS增量更新的场景,调用edsIncremental进行EDS增量处理,否则通过startPush进行全量更新XDS下发,startPush函数会遍历当前的Envoy连接,将配置更新消息写到每个连接对应的pushChannel通道。

pushChannel通道消息的处理由StreamAggregatedResources完成。该函数通过无限循环关注eqChannel和pushChannel两个通道的消息。收到pushChannel消息时,直接调用pushAll函数完成配置下发,这里会依次调用pushCds、pushEds、pushLds、pushRoute分别进行集群信息、服务发现信息、监听器信息和路由信息的下发,从机制上解决了由于V1版本各XDS资源下发顺序有问题导致的配置变更瞬间请求处理出错问题。

Envoy的XDS消息接收

对于v2版本gRPC协议来说,ADS的触发由GrpcMuxImpl负责管理,GrpcMuxImpl的start函数中调用establishNewStream创建gRPC连接,并发起XDS请求流程。通过GrpcMuxImpl::onReceiveMessage进行XDS响应消息的解析和处理,onReceiveMessage中重点是通过watch->callbacks_.onConfigUpdate触发各个具体XDS资源的配置更新,各个XDS资源初始化时会通过SubscriptionFactory::subscriptionFromConfigSource注册对ADS配置变更的关注。

XDS配置生成

可见性

在istio1.1版本之前,在服务发现和流量路由方面,每个Envoy的行为是完全相同的,都需要接受整个Istio集群的服务和路由信息,同时要感知整个Istio集群的XDS信息变更,比如Istio集群有2000个微服务,Envoy节点A关联的服务节点的出入口流量,设计的服务只有俩个,面对这种场景,Istio1.1版本之前的XDS交互有俩个突出的问题。

  1. 不必要的配置技术局下发:每个XDS配置变更时,对整个Istio集群的XDS配置进行全量下发,Envoy节点A关注的服务信息只有俩个,但每次下发2000个服务的XDS配置信息。海量数据下发,不仅会影响Pilot和Envoy之间网络传输的性能,同时XDS配置的编解码与存储也会消耗Envoy节点大量的计算资源。
  2. 不必要的配置下发频率:虽然Envoy节点A关注的服务信息只有俩个,但当2000个服务的信息以及路由变化时,均需要向所有Envoy节点下发全量XDS配置信息,即使这些服务的信息和变更Envoy节点A根本不需要关注,这样就会产生大量不必要的XDS配置变更交互。
    为了解决这个问题,Istio1.1版本引入了Envoy可见性的概念,具体通过Sidecar资源和ExportTo属性来定义,通过Sidecar资源可以自定义设置一类Sidecar的行为,用于对数据平面代理进行精细化和场景化配置,通过ExportTo可以指定服务或者路由资源的作用域。

此外,Istio还可以通过工作负载定义相应的标签,对关注的资源进行选取和过滤。

ExportTo属性

ExportTo属性用于指定核心资源的作用域,Istio作用域以命名空间为边界,当前VirtualService、DestinationRule和ServiceEntry均支持ExportTo属性,以VirtualService为例,ExportTo的定义如下:

message VirtualService {
    ...
    //由当前对象导出的namespace列表,用于限制Istio核心资源的可见性和作用域
    //原则上,export_to可以设置为任意的namespace列表,但当前仅支持设置为.或*
    //.表示当前对象是私有对象,作用域限制在当前namespace
    //*表示当前对象是公有对象,可以作用于所有的namespace
    //如果export_to没有设置任何namespace,则使用全局的缺省可见性语义
    //可以设置全局缺省的可见性语义为.或*
    repeated string export_to = 6;
}

XDS配置变更时,会根据全局Mesh配置,设置VirtualService当前的缺省可见性,VirtualService资源初始化会根据ExportTo属性,将VirtualService资源划分为公有VirtualService资源和私有VirtualService资源。

当向具体的Envoy节点下发XDS配置时,会根据Envoy节点的命名空间,选取相应的VirtualService资源,具体查找的逻辑如下:

func (ps *PushContext) VirtualServices(proxy *Proxy, gateways map[string]bool) []Config {
    configs := make([]Config, 0)
    //首先查找私有VirtualService
    if proxy == nil {
        for _, virtualSvcs := range ps.privateVirtualServicesByNamespace {
        configs = append(configs, virtualSvcs...)
    }
    } else {
        configs = append(configs, ps.privateVirtualServicesByNamespace[proxy.ConfigNamespace]...)
    }
        //然后查找公有VirtualService
        configs = append(configs, ps.publicVirtualServices...)
        ...
}

Sidecar Scope

Sidecar资源定义了一类Sidecar节点的行为,基于Sidecar资源,可以实现Sidecar节点监听行为的精确过滤,减少Sidecar节点对不必要信息的关注。

message Sidecar {
    WorkloadSelector workload_selector = 1;
    repeated IstioIngressListener ingress = 2;
    repeated IstioEgressListener egress = 3;
}

其中workloadSelector指定了具体的负载规则,通过Workloadselector可以将Envoy部署到某些特定节点上,IstioIngressListener和IstioEgressListener分别用于对Inbound监听器与OutBound监听器进行定制,比如设置监听端口号,设置流量拦截策略等。

配置生成机制

XDS相应消息生成是根据当前Envoy节点的配置,将当前节点相关的服务配置和流量管理配置转换成XDS消息的过程,ConfigGenerator是Istio的XDS相应消息生成器,负责生成和数据平台Envoy进行交互的各种XDS相应消息,当前包括 监听器配置LDS、HTTP路由配置RDS、集群配置CDS(EDS相应相对比较简单,当前没有在ConfigGenerator范围内),ConfigGenerator除了基本的核心实现外,还提供相应的扩展性机制。

通用扩展性机制

为了方便实现特性扩展,ConfigGenerator不仅支持基本信息的组织和拼装,还支持多个维度的插件扩展。Istio Pilot初始化时会注册当前使用的插件,XDS信息组织时会调用注册的插件回调函数进行扩展,比如对应上述的扩展机制,LDS会调用当前注册插件的OnOutBoundListerner和OnInboundListener接口,分别执行Outbound和Inbound方向的监听器信息扩展。Istio当前支持authz、authn、health和mixer这几种扩展插件。

此外,Istio可以通过EnvoyFilter对象,对Envoy监听器的过滤链进行定制和扩展,实时控制Envoy的运行行为。具体实现由insertUserFilters负责完成,先通过getUserFiltersForWorkload获取当前Envoy节点对应的EnvoyFilter信息,然后按照EnvoyFilter中指定的具体位置,将自定义Filter对象放到指定位置。

监听器的动态调整机制

为了灵活地对Istio服务运行状态进行观测和控制,Istio引入了在线动态调整机制,通过实时对Istio数据平面代理的监听器进行修改和调整,进行代理行为的个性化定制,可以实现诸如自定义方式查看流量状态、动态跟踪和调试以及自定义流量整形的和路由调整等功能。

Istio的在线动态调整机制具体通过EnvoyFilter对象实现,

message EnvoyFilter {
    //工作负载
    map<string, string> workload_labels = 1;
    //自定义过滤器列表
    repeated Filter filters = 2;
}

其中工作负载workload_labels用于指定该EnvoyFilter对象的作用范围,EnvoyFilter对象的突出特点是个性化定制,配置下发时,通过下面的getUserFiltersForWorkload函数获取和当前节点匹配的EnvoyFilter,获取成功时才会进行后续的EnvoyFilter处理。

func getUserFiltersForWorkload(in *plugin.InputParams) *networking.EnvoyFilter {
    env := in.Env
    //获取和当前Envoy节点对应WorkloadLabel匹配的EnvoyFilter
    f := env.EnvoyFilter(in.Node.WorkloadLabels)
    if f != nil {
        return f.Spec.(*networking.EnvoyFilter)
    }
    return nil
}

下面Filter对象用于定义自定义过滤器的具体行为,除了通过工作负载进行整个EnvoyFilter对象的匹配条件判断之外,每一个Filter还可以通过ListenerMatch指定精细粒度的匹配行为,当前已经支持通过IP地址或者端口号进行匹配过滤(比如只在特定地址上生效)和利用监听类型进行过滤(比如只在Gateway模式下生效)等多种过滤条件。为了精细控制自定义过滤器的行为,Filter对象可以通过InsertPosition精确控制每个自定义过滤器在整个过滤器链的具体位置,比如在整个过滤链开头:整个过滤链结尾,甚至某个特定过滤器的前后等,通过InsertPosition,可以实现对流量和转发行为的精确观测与控制。

message Filter {
    //自定义过滤器匹配条件
    ListenerMatch listener_match = 1;
    //插入位置
    InsertPosition insert_position = 2;
    //过滤器类型,当前主要有HTTP过滤器和NETWORK过滤器两种类型
    FilterType filter_type = 3;
    //过滤器名称
    string filter_name = 4;
    //过滤器配置
    google.protobuf.Struct filter_config = 5;
}

EnvoyFilter是Istio一个非常大胆的尝试和创新,通过EnvoyFilter机制,可以把所有的流量展示到用户面前,结合lua脚本等动态语言,可以实现可见可感,实时在线调整和生效。

基于EnvoyFilter的在线调整机制,会影响整个Istio网络的行为,稍有不慎,就会导致整个Istio网络不可用,因此一般情况下最好不要使用,特殊场景下使用时需要异常小心,配合相应的灰度机制,通过灰度观察确保没有问题之后再放量生效。

XDS配置生成实现

Enoy按照使用场景可以分为三种。

  1. Sidecar模式:和应用服务部署在容器中,对进出应用服务的流量进行拦截。
  2. Router模式:作为独立的代理服务,对应用的L4、L7层流量进行代理
  3. Ingress模式:作为集群入口的Ingress代理,对集群的入口流量进行拦截和代理

其中Router模式与Ingress模式均属于应用服务不在一起部署的纯代理场景,在XDS配置方面没有任何区别,因此从XDS相应消息生成的角度看,可以归为一类,称为GetWay模式。

对于Sidecar模式来说,Envoy负责服务出入方向流量的透明拦截,并且出入方向的流量在监听管理、路由管理等方面有很大的区别,因此Sidecar的XDS配置均按照出入方向分别进行组织管理。入流量一般成为Inbound,出流量一般称为Outbound。

由于Router模式和Ingress模式均属于单独的代理模式,在XDS配置管理方面没有大的差异,可以统一为Getway模式。因此在XDS管理配置上可以分为SidecarInbound模式、Sidecar Outbound模式和Gateway模式

这几个模式下的XDS配置生成的方式也都比较类似,首先获取当前模式下Sidecar对应的服务列表,然后基于服务列表,拼装相应的XDS信息。

InboundSidecar

Inbound方向代表的是发往本节点的流量,同时Inbound只会将请求转发到和当前Sidecar部署在同一个部署单元上的服务节点上,因此Inbound方向的集群信息和路由信息都比较确定,只需要获取到当前Sidecar节点对应的服务信息即可。

对于HTTP来说,这里会拼装HTTP对应的路由信息,具体包含clusterName集群信息和VirtualHost信息。由于Inbound方向使用单一的集群、单一的VirtualHost,并且集群固定只有一个节点信息(本机节点),因此Inbound方向的路由查找和流量转发比较确定。Inbound方向的VirtualHost称为“Inbound|http|instance.Endpoint.ServicePort.Port”。

对于TCP来说,Inbound方向的路由信息当前未按照具体的协议类型进行区分,直接通过TCP Proxy的方式进行路由,这样只能进行全局的统计和管控,无法对Inbound方向的流量进行协议相关的链路治理和管控。

OutBoundSidecar

Outbound方向代表的是从当前节点发往本节点外的流量,因此首先要获取当前节点服务对应的上游,对于有作用域相关设置的Sidecar来说,需要根据作用域设置获取服务列表,对于没有作用域相关设置的sidecar来说,返回的是注册中心所有的服务列表因此当集群和服务规模比较大时,为了减少大量无效的XDS交互需要设置相应的作用域信息。

获取到服务列表之后,需要根据服务当前支持的协议信息进行XDS响应消息的拼装,对于HTTP协议来说,一般通过80端口号对外提供服务,因此多个HTTP Outbound服务可以共用一个监听器“0.0.0.0:80”,使用统一的HTTPConnManager对不同HTTP Outbound服务进行管理,使用80端口号作为Rds配置的RouteConfigName,Rds对应的具体路由配置由RDS协议负责解析。

由于具体的应用协议不同,TCP协议并不像HTTP协议那样有知名端口号,因此无法直接使用端口号作为监听表示,对于这类协议,一般会采用虚拟IP地址作为服务的监听标识。

Getway

对于Geteway模式来说,Gateway对应的服务的信息和路由信息通过Istio核心资源Gateway指定。Gateway节点配置生成时,首先根据当前Gateway节点的工作负载标签,获取匹配的Gateway核心资源列表,然后将这些核心资源列表进行合并处理,产出一个逻辑的Getway定义如下:

type MergedGateway struct {
    //物理端口到虚拟Server的映射
    Servers map[uint32][]*networking.Server
    //Server到Gateway名称的映射
    GatewayNameForServer map[*networking.Server]string
    //路由名到Server的映射
    ServersByRouteName map[string][]*networking.Server
    //Server到路由名的映射
    RouteNamesByServer map[*networking.Server]string
}

上述逻辑Geteway包括Gateway节点XDS配置生成需要的元数据信息,根据这些信息就可以拼装成相应的XDS相应消息。

XDS配置Envoy处理

XDS配置变更的判断

Envoy接收到控制平面发过来的XDS新配置后,首先需要对比新配置和当前配置,判断是否有变更,如果没有任何变更,则不需要进行任何处理。

针对配置变更判断,Envoy对当前配置和新配置分别计算Hash值,Hash值的计算方法如下

static std::size_t hash(const Protobuf::Message& message) {
    ProtobufTypes::String text;
    {
        Protobuf::io::StringOutputStream string_stream(&text);
        Protobuf::io::CodedOutputStream coded_stream(&string_stream);
        coded_stream.SetSerializationDeterministic(true);
        message.SerializeToCodedStream(&coded_stream);
    }
    return HashUtil::xxHash64(text);
}

将protobuf消息序列化成字符串,然后生成相应的Hash值即可,基于Hash值的判断简单高效,可以避免大量无畏的消息解析和处理。如果Hash值相同,不进行任何处理;Hash值不同,才需要进行XDS新配置的解析和加载。

CDS

针对配置频繁变更的场景,为了减少频繁变更对Envoy CPU计算的消耗,Envoy针对这种场景采用变更延迟处理的方式,CDS变更延迟处理特性的定义如下:

message Cluster {
    ...
    message CommonLbConfig {
        //变更合并窗口,如果设置,窗口内的本集群的健康检查、权重、元数据等配置变更消息均合并在一起
        //等到update_merge_window过期时间到达时,统一处理
        //如果没有设置update_merge_window,则采用默认值1000ms
        //如果想关闭这个特性,需要将update_merge_window设置为0
        google.protobuf.Duration update_merge_window = 4;
    }
    CommonLbConfig common_lb_config = 27;
}

CDS集群配置更新onClusterInit时,首先通过scheduleUpdate判断当前配置是否需要延迟处理,如果延迟处理,则当前不进行任何处理;否则,通过postThreadLocalClusterUpdate进行配置更新处理。

只有纯粹的更新操作才会进行延迟和合并处理,对于有新增或者删除节点的配置更新消息来说,需要立即处理。

void ClusterManagerImpl::onClusterInit(Cluster& cluster) {
    ...
    bool scheduled = false;
    const auto merge_timeout = PROTOBUF_GET_MS_OR_DEFAULT(cluster.info()->lbConfig(), update_merge_window, 1000);
    const bool is_mergeable = !hosts_added.size() && !hosts_removed.size();
    if (merge_timeout > 0) {
        scheduled = scheduleUpdate(cluster, priority, is_mergeable, merge_timeout);
    }
    //如果配置更新没有被延迟处理,则立即通过postThreadLocalClusterUpdate进行投递处理,否则不进行任何处理
    if (!scheduled) {
        postThreadLocalClusterUpdate(cluster, priority, hosts_added, hosts_removed);
    }
    ...
}

scheduleUpdate负责具体的延迟判断,确认需要延迟处理时,根据merge_timeout创建延迟定时器;如果不需要延迟处理,则删除之前创建的延迟定期器。

bool ClusterManagerImpl::scheduleUpdate(const Cluster& cluster, uint32_t priority, bool mergeable, const uint64_t timeout) {
    ...
    const uint64_t delta_ms = std::chrono::duration_cast<std::chrono::milliseconds>(delta).count();
    const bool out_of_merge_window = delta_ms > timeout;
    if (out_of_merge_window || !mergeable) {
        updates->disableTimer();
        updates->last_updated_ = time_source_.monotonicTime();
        return false;
    }
    if (updates->timer_ == nullptr) {
            updates->timer_ = dispatcher_.createTimer([this, &cluster, priority, &updates]() -> void {
            applyUpdates(cluster, priority, *updates);
        });
    }
    if (!updates->timer_enabled_) {
        updates->enableTimer(timeout);
    }
    return true;
}

集群和节点的配置处理

整体结构

CDS和EDS负责集群和集群节点的信息的管理,集群管理器Cluster-ManagerImpl是Envoy集群管理的入口,负责Envoy集群相关配置操作的管理,由集群管理器factory ProdClusterManagerFactory具体创建。

集群Cluster英语对Envoy的集群进行管理,主要包括集群节点信息、均衡负载、OutlierDetection和CircuitBreaker等,Envoy集群节点信息发现当前支持静态方式和动态方式,根据节点发现方式,Envoy集群当前支持如下几种类型:

enum DiscoveryType {
    STATIC = 0; //静态配置
    STRICT_DNS = 1; //通过DNS获取节点配置
    LOGICAL_DNS = 2; //和静态DNS相比,不会对upstream节点的连接池进行回收处理
    EDS = 3; //节点配置由控制平面服务注册中心管理,通过XDS协议获取
    ORIGINAL_DST = 4; //使用被透明拦截前的DST地址作为upstream节点
}

集群节点信息的维护是集群管理的重中之重,Envoy的集群节点信息整体上分为优先级Locality俩个层次,一个集群的所有节点按照优先级不同划分为多个优先级集合(负载均衡时默认只会选取最高优先级集合里的节点)Envoy的所有计划发展机制都建立在感知地理位置的负载均衡算法上,因此针对每个优先级集合,又按照地理位置不同,划分为多个Locality节点集合。

集群变更的触发和通知机制

Envoy接收到CDS、EDS分别下发的集群和节点配置变更后,主线程讲根据最新配置构建出一颗心的集群配置树,然后通过TLS机制,通知所有的工作线程进行线程本地的配置更新。

对于集群和节点成员、节点状态信息的变化,当前主要有负载均衡、监控检查和节点熔断这几个子系统关注;Envoy的其他组件如果需要感知集群和节点相关的变更,可以通过如下系列接口注册相应的回调函数。

//集群成员变更回调
addMemberUpdateCb
//集群优先级变更回调
addPriorityUpdateCb
//集群健康检查状态变更回调
addHostCheckCompleteCb
//集群熔断状态变更回调
addChangedStateCb
//工作线程本地集群变更回调
addThreadLocalClusterUpdateCallbacks

路由配置

RouteConfiguration用于对Envoy的路由进行管理,通过对RouteConfiguration、Virtualost和Route多级路由结构,构建起一个完整的路由查找树,Envoy接收到请求消息后,通过该路由查找树,查找对嘴合适的路由节点

监听配置处理

监听器Listener用于管理Envoy网络监听相关的配置,Envoy可以同时配置一到多个监听器,监听器通过Listener对象定义

message Listener {
    string name = 1;
    core.Address address = 2;
    repeated listener.FilterChain filter_chains = 3;
    google.protobuf.BoolValue use_original_dst = 4 [deprecated = true];
    google.protobuf.UInt32Value per_connection_buffer_limit_bytes = 5;
    core.Metadata metadata = 6;
    DrainType drain_type = 8;
    repeated listener.ListenerFilter listener_filters = 9;
    google.protobuf.Duration listener_filters_timeout = 15;
    google.protobuf.BoolValue transparent = 10;
    google.protobuf.BoolValue freebind = 11;
    repeated core.SocketOption socket_options = 13;
    google.protobuf.UInt32Value tcp_fast_open_queue_length = 12;
}

监听器配置中两个重要的信息是listener_filters和filter_chains,前者是监听器对应的过滤链,用于对监听行为进行扩展定制。比如original_dst filter用于获取被Iptables透明拦截后的请求原目的地址;filter_chains用于创建监听后的连接以及后续的请求处理,filter_chains会指定对应的传输层以及协议处理,同时每个filter_chain都会有相应的匹配条件,比如按照目的地址匹配、按照源地址匹配等,监听器接收到新的连接后,会判断各个filter_chain的匹配条件,选取合适的过滤链处理。

Last modification:December 21, 2023
如果觉得我的文章对你有用,请随意赞赏