这里的“应用变更”,是指应用程序的升级更新,也即应用的日常的版本变更。

跟传统的物理机/虚拟机下的应用变更相比,K8s下的应用变更具有如下特点:

  • 变更粒度不同:物理机/虚拟机下部署的粒度一般是机器,一台机器部署一个应用实例;K8s下,POD才是部署和升级的基本单位,一个POD代表一个应用实例;

  • 部署产物不同:物理机/虚拟机下的部署制品是CI构建好的二进制包(一般会存在FTP服务器),K8s下则是制作好的镜像(一般会存放在镜像仓库里);

  • 自动程度不同:K8s下的部署变更的自动化程度会更高,从POD的删除、重新调度、拉镜像、容器启动等,都是交由K8s会自动完成;

接下来将会聊聊业内有哪些应用变更方式(k8s原生的变更、POD原地升级、容器热升级、容器内发布)及其优缺点、适用场景等。

注:本文并不是去比较各种发布模式(如灰度发布、蓝绿发布等) ,这方面在网上已有很多资料讲的很详尽,不再赘述


变更发布的基本要求

生产环境的应用变更,一般会有如下基本要求:

  • 灰度原则:能够做到分批逐步变更,如先变更少量实例,待验证通过后,再对剩余的实例分批变更。

  • 业务无损:在发布期间对业务造成的影响尽可能小,一般是指发布期间,业务流量不受影响。

  • 发布效率:控制发布的总时长,这里的时长主要是消耗在流量剔除和恢复、镜像拉取、容器启动、变为ready状态这几个环节 。

为了降低变更风险,保证变更的稳定性,每个企业对于各自生产环境的变更发布,也会有自身的一些要求,这方面业内比较知名的是阿里提出的“变更三板斧“:

  • 可灰度:任何变更,都必须是可以灰度的,即控制变更的生效范围。先做小范围内变更,验证通过之后才扩大范围;

  • 可观测:在灰度过程中,必须能做到可监控,能了解到变更之后对系统的应用。如果没有可监控,则可灰度也就没有意义了;

  • 可回滚:当通过监控发现变更后会引发问题时,还需要有方法可以回滚,将版本快速回退到变更前的原版本;


K8s原生的滚动更新

K8s是通过工作负载来管理POD的创建、运行、销毁等,K8s原生的工作负载(Deployment、DaemonSet、StatefulSet等)都支持滚动更新,即通过启动一些新的POD,等这些新的POD启动完成后,再杀掉一些老的POD,循环此过程,直至完成更新

虽然K8s原生的这种滚动更新策略可以在大部分情况下,实现零宕机时间和无生产中断的升级,但也存在一些没有解决的问题,主要包括以下几点:

  • 支持的发布策略有限:K8s 原生仅支持停机发布、滚动发布两类变更策略。如果应用有蓝绿发布、金丝雀发布(即灰度发布)、A/B 测试等需求,需要进行二次开发或使用第三方工具。

  • 缺乏可控升级的机制:K8s缺乏发布之后的验证控制,即发布完成后,readness probe成功了,K8s就会认为服务发布成功,自动继续滚动更新。K8s原生的滚动更新是全自动的,中途无法做到发布暂停,以灰度验证,而很多企业在实际生产环境的应用变更上,往往是有这方面的要求的,例如要求有手工确认的过程,确认后再进行下一批次的变更 或者 至少前面的灰度阶段可以支持手工确认。

  • 无法指定实例变更 :即无法让用户指定任意实例进行变更,例如无法按节点灰度,或按照指定条件选择指定的实例进行灰度等。

  • 不支持容器热重启:K8s原生的应用变更都是基于标准的镜像发布,即需要销毁老POD、重新调度创建新POD、再去仓库拉取新版本镜像、启动容器等,由于整个POD要销毁重建,不支持POD不重建,更不支持POD内的容器不重建。

  • 无法分批逐步扩缩容:同前面第2点,有些应用在扩容或缩容时同样需要分批逐步执行,需要可控的、能暂停进行灰度验证的扩缩容,k8s 还不支持。

可以看到,在如何保证应用在企业生产环境的变更升级过程中,更加安全、可控、自动化上,K8s还有待进一步优化和提升,以满足更多面向生产场景的发布需求。

目前K8s对于使用了K8s Service的应用能够做到自动摘除流量和恢复流量,但实际生产环境中使用K8s service作为服务发现的应用占比还是比较低(很低)的,因此大部分应用如果要保证在发布期间流量无损,一般还需要二次开发,例如在发布系统或自建的云平台中,去调用服务发现平台来摘除和恢复流量(在发布前摘除流量,在发布后恢复流量),即先将业务切流,再升级版本(这可能是目前采用最多的一种流量无损的变更升级方式)。当然,也可以在应用编排里,设置preStop的事件处理(如主动去切流),这样POD在停止前会调用该preStop事件对应的处理(可以是一个脚本或一个tcp/http接口),尽量降低POD停止(即销毁删除)时对业务流量的影响。


POD原地升级

所谓原地升级模式,就是在应用变更过程中避免将整个 Pod 对象删除、新建,而是基于原有的 POD对象,只对该POD内的某一个或多个容器的镜像版本进行更新

K8s原生的工作负载并不直接提供原地升级能力,但其实kublet组件隐藏地支持这个能力。对于某个正常运行的POD,可以通过patch的方式更新pod spec中的镜像,kublet感知到这变化后,会自动杀掉POD内旧版本镜像的容器,再拉起一个新版本镜像的容器。在整个过程中,POD并不需要销毁重建,依旧保持运行。

K8s 的更新策略是删除旧 Pod,新建一个 POD,然后调度等一系列流程,才能运行起来,而且 POD原先的绑定的资源(本地磁盘、IP 等)都会变化。而原地升级受影响的只是容器(容器会重建,但POD不会重建),但本地磁盘不变、IP 不变,能大大地减少变更带来的性能损失以及服务不可用,进一步降低变更带来的影响,

具体来说,原地升级能够带来如下收益:

  • 节省了调度的耗时,POD 的位置、资源都不发生变化;

  • 节省了分配网络的耗时,POD 还使用原有的 IP;

  • 节省了分配、挂载远程盘的耗时,POD 还使用原有的 PV(且都是已经在 Node 上挂载好的);

  • 节省了大部分拉取镜像的耗时,因为 Node 上已经存在了应用的旧镜像,当拉取新版本镜像时只需要下载很少的几层 layer。

一般需要基于现有的K8s机制去自研新的工作负载来支持原地升级,不过,目前已开源的K8s平台或组件都已提供了支持原地升级的工作负载,例如阿里云的OpenKruise中的CloneSet/Advanced StatefulSet、蚂蚁的CafeDeployment、腾讯TKE的TApp/StatefulSetPlus 等。


容器热升级

跟上面的原地升级(POD不重建,但POD内的容器会重建)相比,容器热升级则更进一步,POD内的容器也不需要重建,容器能自动完成镜像热更新,容器内的业务进程也可以实现热重启,最大限度减少变更发布对业务产生的影响。

以DaemonSet为例,目前K8s标准的DaemonSet 滚动升级过程,是通过先删除旧Pod、再创建新 Pod 的方式来做的。在绝大部分场景下这样的方式都是可以满足的,然而如果这个 daemon Pod 的作用还需要对外提供服务,那么滚动的时候可能对应 Node 上的服务就不可用了,也即事实上是流量有损的。

另外,不仅仅是DaemonSet,对于即使像Deployment这种用于部署无状态应用的工作负载,虽然可以采用”先对业务切流,再升级版本“(即先切走应用的流量,等待一段时间没有请求之后,再升级应用版本,然后再恢复流量)这种形式来变更升级,但这种方式下,POD需要销毁、重新调度、重建,带来较大开销的同时,也可能影响业务的稳定性。并且有的企业因业务场景的需要,也会对部分非常敏感、及其重要的场景的应用,要求尽量热重启,则此时,原地升级是无法满足要求的。

但目前K8s原生的工作负载是无法做到热升级,往往需要自己实现,常见的一种思路是,修改docker和kubelet的源码支持镜像热更新,当把spec.updateStrategy.type配置为HostPatchUpdate时,就会通过更新POD中的容器镜像版本并添加annotation的方式,联动kubelet和docker完成容器镜像热更新的功能。在整个过程中,POD及POD内的容器均无需重建,此后,用户可以通过向容器中的进程发送信号(例如调用容器内业务进程的重启脚本)的方式,完成业务进程的reload,保证服务的不中断。

这种方式的前提是,该应用本身在物理机/虚拟机环境下就支持热重启(目前很多rpc框架已支持应用的热重启),因为在镜像热更新完成后,还需要复用该应用的热重启能力。

由于上面这种方式涉及到修改kubelet和docker源码,可能会造成依赖的K8s平台与特定版本的k8s和docker绑定耦合,对后续的K8s社区版本的同步升级会引入复杂度和风险,所以现在也有越来越多的企业通过sidecar的方式来实现容器热升级,例如蚂蚁开源的MOSN,通过自己实现的SidecarSet 提供了对业务无感的热升级方式。它开发了长连接迁移方案,把这条链接迁移到 New process 上,整个过程对 Client 透明,不需要重新建链接,达到请求无损的平滑升级。

注:更多关于MOSN容器热升级可详见https://mosn.io/docs/products/structure/smooth-upgrade/

但对于存量系统而言,尤其是业务很稳定、存在较多历史遗留问题的情况下,采用SideCar方式可能会对现有的系统有一定(甚至较大)的冲击,比如可能要求应用要进行较大改造,才能满足SideCar的引入要求,有时其收益比可能会相对较小。

如果是DaemonSet的热升级,也可以考虑阿里云开源的OpenKruise套件中的Advanced DaemonSet,它本身支持热升级。


容器内发布

容器内发布,本质上并不是直接基于镜像的发布,依旧是通过覆盖二进制文件的方式完成版本变更(这里的二进制文件可以是直接来自CI构建好的部署制品,也可以来自拉取镜像后的镜像文件),某种程度上,其实有点接近于 物理机/虚拟机 下的发布方式,因此,这是容器化环境下的一种并不云原生、非标准化的一种发布方式。

在容器化环境下,之所以会有这种“奇怪”的发布方式,是因为它也有一定的适用场景,它一般适用于因历史遗留问题,为了最大程度兼容现状,部分应用需要尽量平滑迁移上云的场景。

例如:

  • 复用现有能力:某些企业受限于自身的技术能力或资源有限,或者因为历史原因,相关微服务的技术组件或基础设施建设不太完善,尤其是服务发现组件不够成熟(比如服务发现流量摘除比较慢或不稳定),在物理机/虚拟机环境下,他们是通过应用的热重启(目前很多rpc框架已支持应用的热重启)来保证变更发布期间对业务流量的无损的,因此也希望上云后能继续复用起现有的热重启的能力,也希望现有的一些运营能力(如发布)也能尽量复用,而不想再投入资源去单独建设。

  • 富容器场景:因为历史原因,一些长尾业务,要完全按照上云的标准改造,改动成本会很大,但又想享受上云带来的红利,所以会先尝试采用富容器的方式,即一个POD内有一个业务容器,但该业务容器内有多个不同业务的进程,即把POD当做一台虚拟机来用。这种富容器模式下,如果要采用标准的镜像发布,则会带来很多问题,例如可能会因镜像体积过大、更新启动缓慢(漂移性能也堪忧),且一个业务要更新,则会连累同个容器内的其他业务也要跟着更新,扩大了变更风险。

容器内发布方式下,相对可能可以对现有体系冲击较小,例如原有的应用改造很小或几乎不用改造、可以最大程度复用原有的发布运营能力,应用侧的热重启能力也能继续复用 等,但同时也会引入版本发布时,版本不一致的风险,需要一些额外的手段来保证和消除因发布时POD重启带来的版本不一致。

容器内发布目前有两种实现方式:

方式一:类似于物理机/虚拟机下的变更发布,发布系统直接将CI构建好的二进制包,上传到目标机器,然后登录到POD内,覆盖容器下指定目录下的文件,再调用POD内业务容器里的业务重启脚本,以重启容器内的业务进程,完成变更。

方式二:POD内除了一个业务容器外,还会有一个SideCar容器。跟方式一相比,发布系统仅仅是触发,具体的执行都是交给了该SideCar容器,例如上面提到的拉取CI构建好的二进制包(这里则是直接去拉取镜像,不再是二进制包)、重启容器内的业务进程等都是SideCar容器负责。

两种方式的比较:

方式一下,也会基于最新的二进制包版本制作对应新版本的业务镜像,但制作好的业务镜像并不会直接用于变更发布(因为POD不会重启,不会去重新拉最新的镜像),而是只有在POD重启、扩容时才会用到,因此在变更的同时,如果POD刚好重启了,则此时该POD还是旧版本,需要检查出来,并再发布一次。

方式二下,POD内的SideCar容器会去拉取镜像,最后把镜像文件去覆盖业务容器的镜像,相对来说,方式二的版本一致性会更好一些,更有保证,即使覆盖时发生了POD重启,则拉取到的也是新版本的镜像,不会出现方式一那种版本不一致的情况。


最后的话

本文主要聊聊了k8s原生的变更、原地升级、容器热升级、容器内发布这4种变更方式,其实无论哪一种方式,都有其适用场景,并没有优劣之分,该采用哪一种变更方式,都是需要你结合实际场景需要来做出选择。

但无论哪一种,都要考虑好,在变更升级过程中,如何更加安全、可控、自动化,尤其是发布期间要尽量减少对业务的影响,让业务无感知

生产无小事,变更需谨慎

Logo

K8S/Kubernetes社区为您提供最前沿的新闻资讯和知识内容

更多推荐