最近在做K8s Operator的开发和架构设计,踩了不少坑,也积累了一些经验,今天就来分享一下,K8s Operator的架构设计,如何做到高可用,高并发。

K8s Operator,是Kubernetes的扩展机制,用来封装、部署和管理复杂的有状态应用,是云原生生态里很重要的一个技术。但是,Operator的开发,并不简单,尤其是要做到高可用,高并发,需要考虑很多问题。今天这篇文章,就从架构设计的角度,详细聊聊K8s Operator的高可用高并发设计。

一、先说说K8s Operator的基本原理

先简单说说,K8s Operator的基本原理。

K8s Operator,本质上,是一个控制器,它通过监听Kubernetes API资源的变化,然后根据资源的期望状态,和实际状态,进行调和,让实际状态,达到期望状态。这个过程,叫做Reconcile,也就是调和循环。

Operator的核心,是Controller,也就是控制器,它通过Informer,监听Kubernetes API资源的变化,比如CRD(自定义资源)的创建、更新、删除,然后把事件,放到WorkQueue(工作队列)里,然后,Worker从队列里,取出事件,调用Reconcile方法,进行调和。

Reconcile方法,是Operator的核心逻辑,它会读取CRD的期望状态,然后检查集群里的实际状态,比如Pod、Service、ConfigMap、PVC等,然后,如果实际状态,和期望状态不一致,就进行创建、更新、删除等操作,让实际状态,达到期望状态。

这个过程,是不断循环的,也就是说,Operator会不断地检查,不断地调和,确保实际状态,一直符合期望状态。这就是K8s Operator的基本原理,也是Kubernetes声明式API的核心。

二、Operator的基本架构

再说说,Operator的基本架构。

一个标准的K8s Operator,一般包含以下几个核心组件:

1. CRD(自定义资源定义)

CRD,是Operator的入口,用户通过创建CRD实例,来声明期望状态。比如,你要部署一个MySQL集群,你就创建一个MySQLCluster的CRD实例,在里面声明,你要几个节点,什么版本,什么配置,等等。Operator监听到这个CRD的创建,就会根据你的声明,来部署和管理MySQL集群。

2. Controller(控制器)

Controller,是Operator的核心,负责监听CRD的变化,然后进行调和。Controller一般包含Informer、WorkQueue、Worker三个部分。Informer负责监听API资源的变化,把事件放到WorkQueue里;Worker从WorkQueue里取出事件,调用Reconcile方法,进行调和。

3. Reconcile(调和逻辑)

Reconcile,是Operator的核心业务逻辑,负责读取CRD的期望状态,检查实际状态,然后进行调和,让实际状态,达到期望状态。Reconcile方法,是幂等的,也就是说,不管调用多少次,结果都是一样的,这很重要,因为,Reconcile可能会被多次调用,比如,资源变化的时候,定时调和的时候,出错重试的时候,等等。

4. Client(Kubernetes客户端)

Client,是Operator和Kubernetes API交互的客户端,用来创建、更新、删除、查询Kubernetes资源,比如Pod、Service、ConfigMap、PVC等。一般用controller-runtime提供的Client,或者client-go。

5. Scheme(资源注册)

Scheme,用来注册CRD类型,和内置资源类型,让Client能够正确地序列化和反序列化这些资源。

这就是一个标准的K8s Operator的基本架构,了解了这个架构,我们才能更好地设计高可用高并发的Operator。

三、高可用设计

再说说,高可用设计,这是Operator架构设计里,很重要的一部分,因为,Operator是管理应用的,如果Operator挂了,那么,应用就没人管理了,出了问题,也没人处理,所以,Operator必须是高可用的。

1. 多副本部署 + Leader选举

最基本的高可用,就是多副本部署,一般部署2到3个副本,然后,通过Leader选举,选出一个Leader,只有Leader,才会执行Reconcile逻辑,其他的副本,处于Standby状态,不执行Reconcile,只是在那里等着,如果Leader挂了,就重新选举,选出新的Leader,继续执行Reconcile。

这样,就算其中一个副本挂了,其他的副本,也能接管,继续工作,保证Operator的高可用。而且,Leader选举,能保证,同一时间,只有一个Leader在执行Reconcile,避免多个副本,同时执行Reconcile,导致冲突和重复操作。

Leader选举,一般用Kubernetes的Lease资源,或者ConfigMap、Secret来实现,controller-runtime已经内置了Leader选举的功能,只需要开启,就可以用了,很方便。

需要注意的是,Leader选举的超时时间,要设置合适,不能太短,也不能太长。太短的话,网络抖动,可能会导致Leader频繁切换,影响稳定性;太长的话,Leader挂了,要等很久,才能选出新的Leader,影响可用性。一般设置为15秒到30秒,比较合适。

2. 优雅退出

Operator在退出的时候,要优雅退出,不能直接杀掉,不然,可能会导致Reconcile执行到一半,就被中断了,留下一些中间状态,导致资源不一致。

优雅退出,就是在收到退出信号的时候,先停止接收新的Reconcile请求,然后,等待正在执行的Reconcile完成,然后,再退出。controller-runtime已经内置了优雅退出的功能,会处理这些事情,但是,我们在写Reconcile逻辑的时候,也要注意,不要有太长的阻塞操作,不然,优雅退出,可能会等很久。

而且,Reconcile逻辑,要是幂等的,就算执行到一半,被中断了,下次重新执行,也能从中间状态,恢复过来,继续完成,不会留下不一致的状态。这一点,很重要,因为,不管是优雅退出,还是崩溃,Reconcile都可能会被中断,所以,幂等性,是必须的。

3. 健康检查

Operator,要配置健康检查,包括Liveness Probe(存活探针)和Readiness Probe(就绪探针),让Kubernetes能够知道,Operator的健康状态,如果Operator不健康,就会重启它,或者,不把流量,转发给它。

Liveness Probe,用来检查,Operator是不是还活着,如果Liveness Probe失败了,Kubernetes就会重启这个Pod。Readiness Probe,用来检查,Operator是不是就绪了,能不能接收请求,如果Readiness Probe失败了,Kubernetes就不会把这个Pod,加入到Service的Endpoints里,也就不会有流量,转发给它。

对于Operator来说,因为一般是多副本,Leader选举,所以,Readiness Probe,可以检查,这个副本,是不是已经完成了Leader选举,是不是已经准备好了,能够接管工作。Liveness Probe,可以检查,Operator的主循环,是不是还在正常运行,有没有死锁,有没有卡住。

健康检查,很重要,能让Kubernetes,自动发现不健康的Operator,然后重启它,保证Operator的高可用。

4. 错误处理和重试

Reconcile逻辑,可能会遇到各种错误,比如,API调用失败,网络超时,资源冲突,等等。这些错误,有些是临时的,重试一下,就能成功;有些是永久的,重试也没用。所以,我们要做好错误处理和重试。

对于临时错误,比如,网络超时,API限流,资源冲突,我们可以返回错误,让controller-runtime自动重试,controller-runtime会根据错误的类型,自动设置重试的间隔,一般是指数退避,不会一直重试,避免压垮API Server。

对于永久错误,比如,CRD配置错误,不合法,我们不需要重试,因为重试也没用,我们可以记录日志,返回nil,不重试,或者,把错误状态,写到CRD的Status里,让用户知道,配置有问题,需要修改。

而且,我们要设置重试的次数,或者,最大重试时间,避免一直重试,浪费资源。controller-runtime默认会重试,但是,我们可以根据自己的需求,调整重试的策略。

5. 状态记录和事件通知

Operator在执行Reconcile的时候,要把执行的状态,记录到CRD的Status里,比如,当前的阶段,成功还是失败,错误信息,等等。这样,用户就能通过查看CRD的Status,知道Operator的执行状态,出了问题,也能快速定位。

而且,Operator还可以通过Kubernetes Event(事件),来通知用户,重要的状态变化,比如,应用部署成功,部署失败,升级成功,等等。用户可以通过kubectl describe,查看这些事件,了解Operator的执行情况。

状态记录和事件通知,虽然不是直接的高可用,但是,能让用户,快速发现问题,定位问题,解决问题,间接提升了系统的可用性。

四、高并发设计

再说说,高并发设计,这也是Operator架构设计里,很重要的一部分。如果集群里,有很多CRD实例,或者,资源变化很频繁,那么,Reconcile的请求,就会很多,如果处理不过来,就会导致队列积压,延迟很高,影响用户体验。所以,Operator必须要能处理高并发。

1. 多Worker并发处理

最基本的高并发,就是多Worker并发处理,也就是,启动多个Worker,同时从WorkQueue里,取出事件,进行Reconcile。这样,就能同时处理多个Reconcile请求,提高吞吐量。

controller-runtime默认会启动多个Worker,一般是CPU的核数,我们也可以根据自己的需求,调整Worker的数量。如果Reconcile逻辑,比较耗时,比如,要等待Pod启动,要等待资源就绪,那么,可以多开一些Worker,提高并发度。如果Reconcile逻辑,比较快,那么,Worker不用太多,不然,反而会增加API Server的压力。

需要注意的是,多Worker并发处理,要保证,同一个CRD实例,不会被多个Worker,同时处理,不然,会导致冲突。controller-runtime的WorkQueue,已经保证了这一点,同一个key(也就是同一个CRD实例),在队列里,只会有一个,正在处理的key,不会再加入队列,所以,同一个CRD实例,不会被多个Worker,同时处理,我们不用担心这个问题。

2. 缓存优化,减少API调用

Reconcile逻辑,需要频繁地查询Kubernetes资源,比如,查询Pod、Service、ConfigMap等,如果每次都直接调用API Server,那么,API Server的压力,会很大,而且,延迟也会很高。所以,我们要用缓存,减少API调用。

controller-runtime已经内置了缓存,也就是Informer的本地缓存,它会把监听的资源,缓存到本地,查询的时候,直接从本地缓存查询,不用调用API Server,这样,就能大大减少API调用,降低API Server的压力,提高查询速度。

我们在写Reconcile逻辑的时候,要尽量用缓存的Client,也就是controller-runtime提供的Client,它默认会从缓存查询,而不是直接调用API Server。只有在一些特殊的场景,比如,需要实时的数据,或者,缓存还没同步,才需要直接调用API Server。

而且,我们要合理设置缓存的资源范围,只缓存我们需要的资源,不要缓存所有的资源,不然,会占用很多内存。controller-runtime可以通过Scheme,和Watch的资源,来设置缓存的范围,我们要根据自己的需求,合理设置。

3. 批量处理,减少Reconcile次数

有时候,资源变化很频繁,比如,一个Pod,频繁地更新状态,那么,就会触发很多次Reconcile,但是,很多Reconcile,其实是重复的,因为,最终的状态,是一样的。所以,我们可以批量处理,减少Reconcile次数。

controller-runtime的WorkQueue,已经做了一些批量处理,比如,同一个key,在队列里,只会有一个,如果在处理之前,又有新的事件,那么,只会更新队列里的key,不会重复加入,所以,处理的时候,只会处理一次,用最新的状态。

而且,我们还可以用限流,或者,延迟队列,把短时间内的多次变化,合并成一次Reconcile,减少Reconcile次数。比如,设置一个5秒的延迟,5秒内的多次变化,只处理一次,这样,就能大大减少Reconcile次数,降低系统负载。

不过,延迟也不能太长,不然,会导致Reconcile不及时,影响用户体验。一般设置为几秒,比较合适,既能合并重复的变化,又不会太延迟。

4. 异步处理长耗时操作

有些Reconcile逻辑,可能会有长耗时的操作,比如,等待Pod启动,等待Job完成,等待外部资源就绪,等等。如果这些操作,在Reconcile里同步等待,那么,Worker就会被占用,不能处理其他的Reconcile请求,影响并发度。

所以,对于长耗时的操作,我们要异步处理,不要在Reconcile里同步等待。比如,我们可以检查资源的状态,如果还没就绪,就返回,重新入队,过一段时间,再检查,而不是在Reconcile里,一直等待,直到就绪。

controller-runtime的Reconcile,返回结果的时候,可以指定RequeueAfter,也就是,过多久,重新入队,这样,就能实现异步的轮询,不用同步等待。比如,我们检查Pod,如果还没启动,就返回RequeueAfter=10秒,那么,10秒后,会重新Reconcile,再检查,这样,Worker就不会被占用,能处理其他的请求。

而且,对于一些特别长的操作,比如,备份,恢复,升级,我们可以用Job,或者,单独的Controller,来异步处理,不要在主Reconcile里处理,不然,会占用主Reconcile的Worker,影响其他资源的处理。

5. 限流和熔断,保护API Server

Operator在高并发的时候,会频繁地调用API Server,如果不加控制,可能会把API Server压垮,影响整个集群的稳定性。所以,我们要做好限流和熔断,保护API Server。

controller-runtime的Client,已经内置了限流,默认会限制,每秒的请求数,和并发的请求数,避免一下子,发太多请求,压垮API Server。我们也可以根据自己的需求,调整限流的参数,比如,每秒多少请求,最大并发多少,等等。

而且,我们还要做好熔断,也就是,如果API Server出现问题,比如,超时,错误率很高,那么,我们要减少请求,或者,暂停请求,等API Server恢复了,再继续。不然,会恶性循环,API Server越慢,我们重试越多,API Server越慢,最后,整个集群都挂了。

还有,我们要合理设置重试的间隔,用指数退避,不要一直高频重试,不然,会给API Server,造成很大的压力。controller-runtime默认已经用了指数退避,我们也可以根据自己的需求,调整。

6. 资源限制,防止OOM和CPU占用过高

Operator在高并发的时候,可能会占用很多内存和CPU,如果不加限制,可能会把节点的资源用完,影响其他应用。所以,我们要设置资源限制,也就是,在Deployment里,设置resources.requests和resources.limits,限制Operator的CPU和内存使用。

内存方面,因为有缓存,缓存的资源越多,占用的内存越多,所以,我们要合理设置缓存的范围,不要缓存太多资源,而且,要设置内存的limit,防止OOM。一般来说,一个中等规模的Operator,内存设置为512Mi到1Gi,就差不多了,具体要看缓存的资源数量。

CPU方面,高并发的时候,CPU占用会比较高,我们要设置CPU的limit,防止占用太多CPU,影响其他应用。一般来说,设置为1到2个CPU,就差不多了,具体要看Reconcile的复杂度,和并发度。

而且,我们要监控Operator的资源使用,比如,用Prometheus,监控CPU、内存、队列长度、Reconcile延迟、错误率等指标,这样,就能及时发现问题,调整参数,优化性能。

五、性能优化的一些最佳实践

最后,分享一些,性能优化的最佳实践,都是我在实际开发中,总结出来的,希望能帮到大家。

1. Reconcile逻辑要尽量简单,快速

Reconcile逻辑,要尽量简单,快速,不要有太复杂的逻辑,不要有长耗时的操作,这样,Worker就能快速处理完一个请求,然后处理下一个,提高并发度。如果Reconcile逻辑很复杂,很耗时,那么,Worker就会被占用很久,并发度就上不去。

如果有复杂的逻辑,可以拆分,放到多个Controller,或者,用异步的方式处理,不要都放在主Reconcile里。

2. 尽量用声明式,不要用命令式

Operator的设计,要尽量用声明式,也就是,用户声明期望状态,Operator负责调和,达到期望状态。不要用命令式,也就是,用户发一个命令,Operator执行一个操作,这样,不符合Kubernetes的设计哲学,也不好做高可用高并发。

声明式的好处是,Reconcile是幂等的,不管调用多少次,结果都是一样的,而且,就算Operator重启,也能从当前状态,继续调和,不会有问题。命令式的话,就很难保证幂等性,也不好做高可用。

3. 合理使用Finalizer

Finalizer,是Kubernetes的一个机制,用来在资源删除的时候,做一些清理工作。Operator在管理有状态应用的时候,经常会用到Finalizer,比如,在删除CRD的时候,先删除关联的资源,做一些清理工作,然后,再删除CRD。

使用Finalizer的时候,要注意,不要在Finalizer里,做太耗时的操作,不然,删除会很慢。而且,要处理好错误,如果清理失败,要重试,或者,记录错误,让用户知道,不要一直卡住,导致CRD删不掉。

4. 做好版本兼容和升级

Operator在迭代的时候,要做好版本兼容和升级,比如,CRD的版本升级,要做转换,不要让旧版本的CRD,不能用。而且,Operator的升级,要平滑,不要因为升级,导致正在运行的应用,出问题。

一般来说,CRD的版本,用v1alpha1、v1beta1、v1这样的方式,逐步升级,并且,做好版本之间的转换。Operator的升级,可以用滚动更新,并且,做好数据迁移和状态恢复。

5. 做好测试和CI/CD

Operator的开发,要做好测试,包括单元测试,集成测试,E2E测试,确保逻辑正确,稳定性好。而且,要做好CI/CD,自动化测试,自动化构建,自动化部署,提高开发效率,保证质量。

controller-runtime提供了envtest,可以用来做集成测试,启动一个本地的Kubernetes API Server,测试Operator的逻辑,很方便。我们要尽量写测试,覆盖核心逻辑,保证质量。

六、写在最后

K8s Operator的架构设计,要做到高可用,高并发,需要考虑很多问题,从多副本部署,Leader选举,到多Worker并发,缓存优化,限流熔断,等等。这些,都需要我们在实际开发中,不断地摸索,不断地优化。

不过,好在,现在的Operator开发框架,比如controller-runtime,Operator SDK,已经帮我们做了很多事情,内置了Leader选举,缓存,WorkQueue,限流,优雅退出等功能,我们只需要关注核心的Reconcile逻辑,就能开发出高可用高并发的Operator。

希望今天的分享,能给正在做Operator开发的朋友,一些参考和帮助,也欢迎大家,在评论区,分享你们的经验和问题,一起交流,一起进步。

愿我们都能开发出稳定,高效,高可用高并发的Operator,为云原生生态,贡献自己的力量。