Cloudpods容器化经验分享
背景介绍
Cloudpods是一个开源的多云混合云管理平台。Cloudpods首先是一个私有云云平台,具备将计算节点使用开源QEMU/KVM虚拟化技术虚拟出虚拟机,实现私有云的功能。其次,Cloudpods能够纳管其他的云平台,包括主流私有云和公有云,实现云管的功能。Cloudpods的目标是帮助用户基于本地基础设置以及已有云基础设置,构建一个统一融合的云上之云,达到降低复杂度,提高管理效率的效果。Cloudpods从3.0开始全面拥抱Kubernetes,基于Kubernetes部署运行云平台的服务组件,采用Kubernetes Operator,基于Kubernetes集群自动化部署服务,实现了云平台的服务的容器化分布式部署。本文总结了Cloudpods在过去3年云平台底层容器化改造的经验。
目前,将Kubernetes作为IAAS平台的底层服务管理平台是一个趋势,例如OpenStack的Kolla项目,VMware的Tanzu,以及基于Kubernetes的虚拟化方案KubeVirt。Cloudpods顺应此趋势,早在2019年下半年开始基于Kubernetes构建Cloudpods的服务组件基础设施。理论上,Cloudpods站在了巨人的肩膀上。有了Kubernetes的加持,我们基于Operator管理CRD(Custom Resource Definition)机制做到了更优雅的服务自动化部署,符合IaC(Infrastructure as code)实践的服务升级和回滚,服务 的自动高可用部署等等。但在实际效果上,我们基于Kubernetes,获得了一些便利,但也遇到了不少未曾预料到的问题。本文介绍自从2019年3.0容器化改造以来,因为引入Kubernetes遇到的问题,我们的一些解决方案,以及将来的规划。
容器化带来了哪些好处
1. 方便对分布于多个节点上的服务的管理
管理员可以在控制节点统一地查看运行在各个节点的服务状态,查看日志,启停和发布回滚服务,甚至exec进入服务容器排查问题。同时我们引入Loki收集所有容器的日志,可以统一地查看各个服务的日志。对分布式集群的运维和排障都变得相对简单。采用Kubernetes之后,直接登录各个节点排障的机会大大降低了。
2. 集群配置变更变得方便和可控
整个集群的状态可以保存为 一个OnecloudCluster yaml文件。可以方便地变更集群的配置,包括集群的版本,实现版本的升级和回退,以及集群服务的开启和关闭,镜像版本等关键参数的变更等。更进一步地,可以通过git进行配置yaml的版本控制,做到变更的历史记录审计,并且可以随时恢复到任意指定的配置。
3. 易于适配不同的CPU架构和操作系统
Kubernetes作为一层中间层,从一定程度上屏蔽了底层的差异。采用Kubernetes后,对CPU和操作系统的适配大概分为三部分工作:
1)Kubernetes对CPU和操作系统的适配; 2)不同CPU架构下服务容器镜像的构建; 3)Kubernetes之外的组件的适配,例如平台依赖的rpm包等。
基于Kubernetes自身强大的生态,1)基本都有现成的解决方案,只需要做相应的集成工作。2)只需通过docker buildx工具生成异构CPU架构的镜像。因此,整个适配工作复杂度大大降低了。
4. 部署的便利性增加
引入Kubernetes之后,整个部署流程分为几个阶段:
1)Kubernetes的部署,这个步骤通过基于kubeadm改造的ocadm实现。 2)Cloudpods服务容器的部署,这个步骤通过ocadm在容器内部署operator,通过operator实现相应configmaps,deployments和daemonsets等资源的创建 ,进而自动创建服务集群。 3) Kubernetes之外依赖组件的安装部署。这个步骤通过ocboot集成ansible实现。
每个阶段都是基于成熟的开源方案扩展实现,可靠性高。同时,各个组件分工明确,模块化清晰,易于维护和扩展。
5. 可以复用Kubernetes本身自带的强大功能
如coredns可以自定义域名,甚至可以做泛域名解析。Ingress自带反向代理的功能。service+deployment提供的多副本冗余机制。daemonset提供的在新添加节点自动拉起服务的能力。对服务的资源限制(CPU,内存,进程号等)。这些都使得云平台服务功能特性的实现变得更加容易。
容器化遇到了哪些问题,如何解决
下面总结一些遇到的问题。这些问题是我们在采用Kubernetes管理和运行云平台组件中陆续发现的。有些已经彻底解决,但很大一部分还只是部分解决,彻底解决的方案还在持续摸索中。
1. 容器内运行系统级服务
Cloudpods在计算节点运行的服务都是系统级的服务,如计算节点的核心服务hostagent,需具备几个特权:
1)需启动系统的daemon服务进程,如qemu虚拟机进程,vswitchd等系统进程,这些进程由hostagent启动,但需独立于hostagent运行; 2)需访问计算节点的任意目录文件。
在容器化之前,这些服务由systemd管理,以root身份运行。这些特权都自然具备。
容器化后,服务需运行在容器内。虽然可以通过配置给与容器系统级的root权限,但是一些特权操作在容器内依然无法执行。
首先,容器内无法启动系统级daemon服务进程。如果通过容器内的程序启动进程,则该进程只能运行在容器内的PID空间(pid namespace),只能跟随容器的生命周期启停。为了解决这个问题,我们将系统服务的二进制程序安装在计算节点的底层操作系统,并且开发了一个命令执行代理executor-server。该代理安装在底层操作系统,并作为一个系统服务运行。容器内的hostagent通过该代理执行系统级命令,例如启动这些daemon服务,设置内核参数等,从而获得了执行系统级命令的特权。
其次,每个容器具有自己独立的文件系统命名空间(mount namespace)。为了允许容器内服务访问计算节点底层系统的特定路径文件,需要将该路径显式地挂载到容器的文件系统命名空间。例如,虚拟机的配置文件和本地磁盘文件都存储在/opt/cloud/workspace目录下。容器内的hostagent在虚拟机准备和配置阶段需要能够访问这个目录的文件,同时,启动虚拟机后,在底层操作系统运行的虚拟机qemu进程也需要能够访问对应的文件。并且,由于上述命令执行代理的机制,为了 简化和保持向后兼容的目的,需要确保尽量以一致的路径在容器内和容器外访问这些文件。为此,我们将一些特定的系统目录以同样的路径挂载到hostagent的容器内,例如系统设备文件路径/dev,云平台的配置文件路径/etc/yunion,虚拟机系统文件路径/opt/cloud/workspace等。然而,这个机制还无法解决容器内服务访问底层系统任意路径的问题。例如,用户可以将底层系统的任意目录设置为虚拟机磁盘的存储目录,但是该目录其实并未通过容器的spec挂载到hostagent容器内,从而导致hostagent在容器内无法访问该目录。为了解决这个问题,我们对hostagent进行了改造。当hostagent检测到用户添加了新的本地目录作为虚拟磁盘文件的存储路径,会自动地执行底层系统命令,将该路径挂载到底层操作系统的/opt/cloud/workspace目录下。因该目录已经挂载到hostagent容器内,这样hostagent就可以在容器内访问这个目录下的文件。
总之,相比将一个普通应用程序容器化,将系统级的服务程序从systemd托管变为在Kubernetes容器中运行,不是仅仅简单地打一个容器镜像,其实还需要做一系列比较复杂和繁杂的改造工作。
2. 日志持久化
容器化之前,服务日志会记录到journald中,并被持久化到/var/log/messages。按照CentOS的默认策略,保留最近一段时间的日志。遇到问题的时候,可以到对应服务器查找到对应的日志,排查错误原因。但是,不方便的地方是需要登录到服务运行的节点查看日志。在一个事故涉及的服务分布在多个节点的时候,就需要同时登录多个节点进行日志排查。
容器化之后,可以方便地在一个地方,通过kubectl log命令查看指定容器的日志,不需要登录到服务运行的节点。
然而,如果没有做特殊设置,Kubernetes里的容器的日志都是不持久保存的,并且只保留当前正在运行的容器的最近一段时间的日志。而容器往往非常动态,很容易删除。这就导致遇到问题需要排查已经被删除的容器时候,容易遇到找不到对应的日志的问题。这就使得追溯问题变得比较困难。
我们的解决方案是从3.7开始,会默认在Kubernetes集群里部署Loki套件来收集容器的日志,日志最后存在平台自带的minio的S3 Bucket里面。这样做能够持久化容器的日志。解决上述问题。但是,保存Loki日志有一定的系统负载,并且需要较大容量的存储空间。在集群容量紧张的情况下成为平台的额外负担,可能造成平台的不稳定。
3. 节点Eviction机制
Kubernetes有驱逐机制(Evict)。当节点的资源余量不足时,例如磁盘剩余空间低于阈值或剩余内存低于阈值(默认根分区磁盘空间低于85%,空闲内存低于500M)等,会触发Kubernetes的节点驱逐机制,将该节点设置为不可调度,上面的所有容器都设置为Evict状态,停止运行。
该机制对于无状态应用可以动态地规避有问题的节点,是一个好的特性。然而,在云平台的场景中,甚至对于普遍的有状态服务场景中,Eviction机制导致节点可用性变得非常动态,进而降低了整体的稳定性。例如,由于用户上传一个大的镜像,导致控制节点根分区利用率超过Eviction的阈值85%,云平台的所有控制服务就会被立即驱除,导致云平台控制平面完全不可用。用户在虚拟机磁盘写入大量数据导致宿主机磁盘空间利用率超过阈值,也会引起计算节点上所有服务被驱逐,进而导致这台计算节点上所有的虚拟机失联,无法控制。可以看到,虽然触发Eviction机制的问题存在造成服务问题的潜在可能,但是这些问题对服务的影响是延后的,逐步生效的。Eviction机制则使得这些潜在风险对服务的影响提前了,并立即发生,起到了放大的作用。
为了避免Eviction机制生效,云平台在计算节点的hostagent启动的时候,会自动检测该节点的Eviction阈值,并设置为计算节点的资源申请上限。云平台在调度主机的时候,会考虑到Eviction的阈值,避免资源分配触发Eviction。这个机制能从一定程度规避Eviction的出现,但云平台只能管理由云平台分配的资源,还是有可能不在云平台管理范围内的存储和内存的分配导致Eviction的情况。因此需要计算节点一定程度的内存和存储的over-provisioning。
目前,Eviction的存在也有一定的积极作用,那就是让节点资源的不足以云平台罢工的方式提出警示。由于云平台的冗余设计,云平台的暂时罢工并不会影响虚拟机的运行,因此影响程度还比较可控。无论如何,以云平台可用性的牺牲来实现资源不足的警示,代价还是有点大。这样的警示可以其他更柔和的方式来实现。随着云平台自身管理资源容量能力的完善,Eviction机制应该可以去除。
4. 容器内进程泄露
Cloudpods服务主要为golang开发的应用程序 ,容器镜像采用alpine基础镜像最小化构建,仅包含服务的二进制和alpine基础镜像,服务进程作为容器的启动进程(1号进程)运行。初期,我们的服务程序没有为作为1号进程做专门的优化,因此不具备systemd/init等正常操作系统1号进程具备的进程管理能力,例如处理孤儿进程,回收zombie进程等。然而,一些服务存在fork子进程的场景,例如kubeserver调用服务的时候会fork ssh执行远程命令,cloudmon则会执行采集监控数据的子进程。当这些子进程遇到异常退出时,由于我们的服务进程不具备主动回收子进程的功能,导致系统里积压了了大量退出异常未回收的子进程,导致进程泄露。这些子进程占用操作系统进程号,当达到系统最大进程数时,会出现系统CPU和内存非常空闲,但是无法进一步fork新的进程的情况,导致系统服务异常。
为了避免容器内进程泄露问题,我们在Cloudpods服务框架里加入了回收子进程的逻辑,并且添加到每个服务进程中,这样在子进程异常退出后,我们的服务进程会回收子进程资源,从而避免了这个问题。同时我们也配置了kubelet的 最大进程数的限制参数,限制一个pod里面最多能有1024个进程,作为辅助手段避免容器内的进程泄露。
5. 高可用不一定高可用
我们基于Kubernetes实现了控制节点的3节点高可用,基本思路是使用3个节点部署高可用的Kubernetes的控制服务,包括etcd, apiserver, scheduler, controller等。Kubernetes服务通过VIP访问。采用keepalived实现VIP在三个控制节点上的自动漂移。在此 高可用Kubernetes集群之上,部署云平台控制服务,实现云平台控制平面的高可用。预期效果是将3个控制节点中的任意节点宕机后,主要服务不受影响,如果有影响,需能够在短时间内自动恢复。
然而,初期测试发现采用默认参数部署的 Kubernetes 高可用自动恢复的时间高达15分钟,不符合预期。经过调研发现,可以通过给各个组件设置相关的参数来减少恢复时间https://github.com/yunionio/ocadm/pull/39/files。经过参数调整,可以让Kubernetes集群高可用切换时间缩短到1分钟以内。
6. 服务的启动顺序
Kubernetes无法指定pod启动的顺序,同时也要求部署在Kubernetes里的服务不要对其他服务的启动先后顺序有依赖。云平台服务在采用Kubernetes部署管理之前是采用systemd管理,systemd可以明确定义服务之间的启动顺序。这导致服务之间有比较明显的先后次序依赖。比如,负责认证的keystone服务就要求最先启动,其他所有服务都依赖keystone服务提供初始化服务账号的认证。容器化改造后,由于这个依赖,导致在keystone容器启动之前的服务无法正常运行。定位到该问题后,我们将服务因为未成功认证服务账号的错误升级为致命错误。这样,该服务程序遇到依赖keystone服务未启动导致的问题就立即异常退出。进而,通过Kubernetes自动重启拉起服务进程。通过这样的改造消除了其他服务对keystone的启动顺序依赖。然而,我们无法找到有效手段识别出所有依赖启动顺序而出现的错误,因此这样的服务启动无顺序改造还在持续。
7. 证书失效问题
Kubernetes集群节点之间的相互认证和通信依赖PKI秘钥体系。如果节点的PKI证书过期,则该节点kubelet无法正常和ApiServer通信,进而导致节点状态被设置为NotReady,进而出现前述的容器驱逐导致节点不可用的严重问题。刚开始,我们部署的Kubernetes集群还是采用kubeadm默认的1年有效期的证书,当时还未顾及到证书到期的问题。到2020年底,开始陆续出现多个集群莫名服务不可用的情况,才注意到证书过期的问题。针对这个问题,我们刚开始采用cronjob安装自动更新证书脚本的方案,并且在客户巡检中,专门检查证书过期问题,以提前发现问题。后来到了2021年3.8版本,采用了更糙快猛的方法,直接修改了kubeadm的证书签发代码,一次性签发99年证书,从而彻底解决了Kubernetes的证书过期问题。
8. iptables修改
Kubernetes部署后,kubelet、kube-proxy以及我们采用的calico等都依赖iptables,会接管节点的iptables规则,在kubelet启动之后,对iptables规则的修改会被重置,并且会刷新iptables规则。如何持久化对iptables规则的修改成为问题。目前,针对节点的防火墙规则可以采用calico的网络策略来实现,可参考文章https://www.cloudpods.org/zh/blog/2021/09/25/calico-customized-node-firewall/。但如何持久化配置更复杂的iptables规则,还没找到有效办法。
未来的规划
1. 升级Kubernetes版本
目前,云平台底座kubernetes的版本是1.15.12,该版本已经不再被Kubernetes官方支持。目前版本存在的比较明显的问题是和较新的采用cgroup v2的操作系统不兼容,导致无法设置容器pod的资源limit。后续考虑升级底层Kubernetes到更新版本,以期获得更新的功能特性支持。
2. 采用K3S等更轻量Kubernetes版本
目前云平台依赖底层Kubernetes的功能特性不多,同时Kubernetes本身也要消耗一定的节点资源,后面也计划考虑采用k3s等更轻量的Kubernetes版本,进一步降低Kubernetes的使用成本。
3. 移除计算节点对iptables的依赖
计算节点网络主要依赖openvswitch实现虚拟机的通信,iptables主要是给kubelet,kube-proxy和cailico-node等Kubernetes服务组件使用,而计算节点上的服务组件主要是用来管理QEMU/KVM虚拟机的host-agent等服务,这些服务本身具备基于ovs的网络管理能,不依赖Kubernetes的网络,完全可以只依赖host网络即可正常工作。因此,其实可以去掉计算节点的kubeproxy,calico等组件,去除对iptables的修改,这样简化组件依赖,进一步提高系统的可靠性。
4. 完全禁用Eviction机制
Eviction机制在虚拟化云平台或有状态服务场景中,会起到故障放大的作用。在充分掌控对节点资源耗尽预警的前提下,应考虑彻底禁用Eviction机制。
5. 多数据中心架构的支持
目前云平台所有节点都运行在一个Kubernetes集群内。而云平台本身是可以支持多数据中心部署的。但是跨数据中心部署单个kubernetes集群不是最佳实践。比较理想的架构是单个kubernetes集群部署在一个数据中心内。因此,应该允许云平台跨多个Kubernetes集群部署。例如每个数据中心一个Kubernetes集群,其中一个集群部署完整的云平台,其他Kubernetes集群以从可用区的角色加入主集群。每个Kubernetes集群之上只运行管理一个数据中心所需的云平台组件。进而构成一个多数据中心的云平台架构。