当前位置: 首页 > Web前端 > vue.js

云原生灰度更新实践

时间:2023-03-31 21:37:37 vue.js

相信在座的各位应该都听说过云原生,这是近三四年很火的一个东西。什么是云原生?现在的云原生是一个很宽泛的定义。可以简单理解为你的服务是为云而生的,或者说因为云原生现在是基于Kubernetes容器技术作为基础设施,只要你的服务运行在Kubernetes之上,就可以认为是云原生。今天要和大家分享的话题是路飞3利用云原生技术实现的灰度更新。主要介绍以下四个方面:什么是灰度更新?云原生实践现状总结与展望什么是灰度更新?为了让大家更好的理解,我通过一个简单的例子来告诉大家什么是灰度更新。假设你有一个关于酒店预订的项目,你需要提供一个网站供用户预订房间。为了保证业务的高可用,本项目开发的服务器支持分布式。因此,您在生产环境搭建了一个酒店预订web集群,一共搭建了3台服务器,通过Nginx反向代理对外提供服务。左图是传统意义上的灰度更新,即先将部分流量引导至新版本进行测试,若能全面推广,若不能,则返回上一版本。具体举个例子,服务器所在的机器有三台,IP地址分别为0.2、0.3、0.4。日常更新,选择先在0.4服务器上更新,看看有没有问题,确认没有问题后再更新0.3和0.2。右图使用容器技术,比物理机部署更灵活。它使用的概念是instance,也就是一个实例,同一台机器上可以启动多个实例。访问流量会如图所示从左到右,先经过网关。通过在网关上加一些策略,95%的流量会流向上面的原始服务,5%的流量会流向下面的灰度服务。通过观察灰度服务是否有异常,如果没有异常,可以将原服务的容器镜像版本更新到最新版本,删除下面的灰度服务。这与左图不同。不是一个一个滚动更新,而是借助弹性资源平台直接更新所有原有服务。灰度更新状态上图是目前路飞2的灰度更新状态。主要问题出在API处理上,因为之前的状态是由数据库维护的,容易出现状态不一致的问题。左图是简化的处理流程。当API请求服务请求服务的灰度更新时,第一步将生成一个具有灰度名称的应用程序。第二步在这里详细说明。首先将生成的App放入数据库,在Kubernetes中创建一个无状态的服务。这通常需要大约10分钟。在此期间,一个Go语言程序会不断扫描App表,确认服务是否已经创建。同时需要使用Kubernetes创建转发规则等,所有需求创建完成后,将原来的ok返回给调用方。这就涉及到性能问题,因为数据库中要处理的东西很多,这些都要一个一个处理,而且很多都是无用的数据。扫描到App前的10分钟,即使你去Kubernetes调用,也是在做无用的操作。另外,还有调用链长的问题。在Kubernetes中创建的很多东西都会包含在同一个API请求中,这可能导致一步完成后随时崩溃。这时候你可能需要考虑是否回滚,如果回滚,就必须删除相关的服务和数据库。当调用更多的外部组件时,更容易出现这种情况。更直观的方案是简化API流程,Kubernetes为这种方式提供了CRD。云原生实践CRD上图是摘自Kubernetes官网对CRD的描述。这个大家都应该很熟悉了。Kubernetes中最重要的概念就是资源,里面的一切都是资源或者对象。右图是一个相关的无状态服务的例子,包括容器提供的服务版本、类型、标签、镜像版本、对外端口。在Kubernetes中创建一个无状态的服务,只需要完成定义,CRD可以帮助我们定制spec中的内容。需要注意的是,自定义资源本身只能用于访问结构化数据。只有与自定义控制器(CustomController)结合使用,才能提供真正的声明式API(DeclarativeAPI)。通过使用声明式API,您可以声明或设置资源的所需状态,并将Kubernetes对象的当前状态同步到其所需状态。也就是说,控制器负责将结构化数据解释为用户期望状态的记录,并持续保持该状态。上图是声明式API的相关实践,采用水平触发的方式。举个简单的例子,电视机使用的遥控器是边沿触发的,只要按下换台,换台就会立即触发。闹钟是横向触发的,无论在闹钟响起前改变了多少次,它只会在你设置的最后一次触发。综上所述,edgetriggering更注重时效性,有变化会立即反馈。水平触发只关注最终的一致性。不管之前发生什么,它只需要保证最终的状态和我们设置的一样就可以了。上图Luffy3.0CRD是优派云使用luffy3.0做的整体架构。它建立在Kubernetes之上,与Kubernetes的服务相关交互均由apiserver完成。图中右下角是一个关系数据库,用户关系、从属关系等关系都在里面。它上面有一层redis缓存,用来提高热数据查询的效率。左图是我们自己实现的几个CRD。第二个项目是相关项目。该项目成立时,得到了CRD的支持。先写入数据库,然后在Kubernetes中创建项目的CRD对象。Kubernetesclient-goinformer机制接下来说下informer的实现逻辑。Informer是Kubernetes官方提供的一套SDK,方便你与Apiserver进行交互。它更多地依赖于水平触发的机制。上图左侧是我们的apiserver,所有数据都存储在Key-value数据库ETCD中。存储时使用如下结构:/registry/{kind}/{namespace}/{name}复制代码registry可以修改前缀防止冲突,kind为类型,namespace为命名空间或项目名称对应路飞3。之后的名称是服务名称。当通过apiserver创建、更新、删除等对象时,etcd会将这个事件反馈给apiserver。然后apiserver会将变更对象暴露给informer。informer是基于单一类型{kind}的,这意味着如果你有多种类型,你必须为每种类型创建一个对应的informer。当然,这可以通过代码生成。https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...https://zhuanlan.zhihu.com/p/...回到informer的实现逻辑,informer在运行时,会先去Kubernetes获取全量数据。比如当前informer对应的类型是无状态服务,那么它会获取所有的无状态服务。然后继续观察apiserver,一旦apiserver有新的无状态服务,就会收到相应的事件。informer收到一个新的事件后,会将时间放入先进先出队列,供controller消费。controller会将事件交给模块Processer进行特殊处理。Processer模块上有很多监听器,它们是针对特定类型设置的回调函数。那我们就来看看为什么controller中的lister和indexer是关联的。因为命名空间和目录很相似,所以这个目录下会有很多无状态的服务。如果要按照一定的规则来处理,在nativeservice上处理一定是最差的选择,而这就是lister会做的事情。它会把这部分缓存起来,做一个索引,也就是indexer。这个索引很像数据库,由一些键组成。对于CRD,需要实现的是contorller,以及informer和controller之间的交互。其他部分由代码本身生成。如果未生成代码,将使用上图。前三项与编写代码有关。其中,API类型需要我们填写CRD的定义,灰度更新的定义等,完成定义后,我们必须将定义注册到Kubernetes中,否则无法生效。然后,代码会生成以下4项,包括deepcopy深拷贝功能、client、informer和lister使用CRD。第三块是自定义controller相关的controller,包括与Apiserver打交道的Kubernetesrestclient,timecontroller或者timefunctioneventhandler和handlerfuncs等,需要写的是reconciliation函数reconciliation,因为其他官方已经封装好了对于我们来说,我们只需要定义对账函数。全部打包完成后,这些东西就需要连接在一起了。目前主流的选择有两个,OperatorSDK和Kubebuilder。OperatorSDKvsKubebuilder接下来我们看看代码是如何生成的。以OperatorSDK为例,看看代码是如何生成的。当然你也可以选择使用Kubebuilder,两者的生成方式差别不大。在上图中的“初始化项目”中,可以看到仓库的名称,其中定义了一个版本的版本号,以及类型canaryDeployment,即灰度无服务状态。然后生成相应的资源和控制器。完成后,编写刚刚提到的对账函数和API定义。全部搞定后就可以执行了,很简单。灰度更新的设计说完上面的知识,我们再来看看灰度更新。上图是一个灰度更新的简单例子,过程从左边开始,到右边结束。第一步是创建一个灰度服务,创建后可以更新。比如刚才的Nginx例子,我们创建的版本号是1.19。但是在灰度过程中,发现当前版本存在BUG,修复BUG后,确认无误后可将原服务更新至1.20版本,再删除灰度服务。如果你发现1.20版本还有bug,你也可以选择删除灰度服务,让你原来的服务接管所有流量。这就是CRD简化开发步骤的方式。灰度更新有以下四个阶段:createupdatereplacedeletecreate因为Kubernetes是横向触发的,所以它所有的创建和更新处理逻辑都是一样的,只看最终状态。这张图比较重要,大家可以仔细看看。图中右上部分是原始服务,包括Kubernetes无状态服务、服务内部域名、ApisixRoute、Apisix路由规则、ApisixUpstrean,以及Apisix上游的一些配置。原始服务下方是灰度服务,左边的控制器是前面提到的CRD控制器。原服务创建完成后,创建一个无状态服务,配置相应的http转发规则,然后去ApisixRoute服务站配置相应的路由。之后只有容器网关会自动定位到指定的服务。然后可以看到我们自定义的CRD类型的名称是CanaryDeployment,它是一个灰度无状态服务。创建此无状态服务的过程与原始服务相同。CRD的定义是如何设计的?下图是一个简单的例子:apiVersion,先不说了,我们详细看下面的部分:kind:type,上图中的type是CanaryDeployment(statelessservice)name:namenamespace:location,version测试空间下mohb-test:Versionreplicas:灰度实例数量,这个数量是可配置的weight:权重,影响灰度服务接管多少流量apisix:服务对应的hb转换规则这里定义了镜像的配置,其他命令,以及刚刚提到的开放端口。定义CRD时可能会遇到几个问题。第一个问题是,如果删除原有服务,灰度服务不会自动删除,会留下。出现这个问题是因为Kubernetes没有回收技术,需要Kubernetes的ownerReferences来解决这个问题。可以帮你把灰度服务的CRD指向原服务的无状态服务,即灰度服务的所有者是原服务的责任人。这样,当原有服务被删除时,所有者将负责删除灰度服务。删除CanaryDeployment时,只会删除其右侧的Deployment。ownerReferences的具体设置如下图所示:我们在定义CRD的时候,在红框中添加字段,会指定它是谁的owner,指向哪里。至此,创建阶段基本完成。置换再来看第二阶段——置换。我通过添加一个字段replace来控制它,默认情况下它是false,如果值为true那么控制器知道用部署的替换它。这里的问题是什么时候进行替换?也就是什么时候断流。虽然也可以直接切换,但在原有服务完全运行后再切换无疑更好。那怎么办呢?这就涉及到部分告密者的逻辑。这就需要controller能够感知到灰度服务的parentDeployment是否发生了变化。operator-sdk和Kubebuilder这部分非常好。它还可以将不是CRD事件的更改导入到协调功能中,以便控制器可以监控无状态服务。您可以查看代码了解详细信息。先注册一些watch,用来监控无状态服务,然后写一个函数让无状态服务对应CanaryDeployment,比如在文后标记无状态服务,这样当检测到事件的时候,可以看到是哪个无状态服务正在运行Replacement,并计算对应的CanaryDeployment,然后调用reconciliation函数,比较是否与期望有差距。取消让我们看看最后一个阶段——取消阶段。如果直接删除CanaryDeployment对应的对象,会发现在它的右边有一个deletionTimestamp字段,就是Kubernetes标记的删除时间戳。对于controller来说,它只是知道这个已经是删除状态了,需要调整相应的内容。这里有一个问题。删除是瞬时操作。可能不会等到controller运行起来才完成删除。因此,Kubernetes提供了一个Finalizer,它决定了最终由谁来释放。Finalizer是自定义的,对应我们自己写的controller。当Kubernetes看到Finalizer不为空时,并不会立即删除,而是处于删除状态,这样可以让控制器有时间做一些相应的处理。压力测试一套wrk完成后,验证是否正确的方法就是进行压力测试。我使用了一个更通用的压力测试工具,可以设置更多的东西。比如可以做一些逻辑处理。如上例,假设有一个服务,请求原始服务会返回“helloword”,请求灰度版本会返回“helloHongbo”。然后定义返回的包,这样在每次请求之后,都会调用一个函数判断是否等于200,如果不等于,则可能是切割过程中出现异常。如果等于200,可以看看里面有没有“鸿博”。如果是,则证明请求的是灰度版本。这样门就设置了一个文件(summary),统计了对原始服务、灰度服务、失败请求的请求次数。另外,还可以设置header:-c:多少个链接,比如20-d:下多长时间,比如3分钟-s:脚本对应的地址上图是压的结果测试,你可以简单地看一下。总结与规划接下来跟大家说说CRD介绍之后的总结。引入CRD后,基于Kubernetes事件驱动、层次触发的理念,简化了实现的复杂度。并且由于采用了OperatorSDK成熟的框架,不再需要关心底层实现,可以更专注于业务的逻辑实现。降低了开发成本,提高了开发效率。