一、背景
1.1 个人调试kvm
我们这边基于云平台的k8s+kubevirt,给安卓手机领域的开发工程师们提供了独占式虚拟机资源。这些资源主要用于工程师的个人级开发与调试,因此有如下特点:
- 使用时间与工作时间强相关,即工程师工作时间使用该资源多,晚上与周末基本不使用;
- 写代码时对cpu、内存需求不多,整机/多组件编译时可能把分配的虚拟机资源用满,但整机/多组件编译场景次数不多,大部分是不需要这么多资源的模块编译;
- kubevirt虚拟机平台对资源做了虚拟化与资源隔离,其中cpu配置了超分,内存未超分(底层暂不支持内存超分);
- 一台物理机上一般会有四五个虚拟机,也就是对应四五个用户,这些用户同时做整机/多组件编译的概率很低。
基于这些特点和相关资源监控数据,我们发现该场景下整个k8s集群节点的资源使用率都很低,于是我们考虑用pod容器来代替虚拟机。之所以考虑使用pod容器主要有两个原因:
- pod容器支持内存超分,我们可以配置更高的内存超分比,使得一台物理机上可以分配更多的实例给到工程师使用,从而减小资源的分母,达到提升资源使用率的效果;
- pod容器的性能损耗相比于虚拟机虚拟化的损耗理论上要小,我们实际测试编译耗时数据也证明如此,pod容器在不少项目中都有10+%的编译性能提升。
1.2 kvm切pod容器的挑战
在kvm切换到pod容器过程中,我们的原则是尽可能地减少工程师们对底层资源变化的感知,当然这也包括尽可能地保持工程师们对kvm资源的使用习惯。
于是我们就遇到了一个大挑战:kubevirt的kvm支持动态挂卷,而原生pod容器在挂卷后,对应的容器会发生重建操作,这个重建操作会有如下问题:
- 容器系统盘数据丢失(后面发现云平台有支持保留系统盘功能);
- 中断用户的ssh/samba/vscode连接;
- 有一个磁盘在编译的时候再去挂载/卸载一个盘,会导致编译中的任务终止。
上述变化会严重影响工程师的使用习惯,为了解决这个问题,我们需要重新梳理下工程师kvm的卷:
- 数据卷:可读写,用户独享。一般大小为2T,作为用户的home目录盘,生命周期与实例生命周期一致;
- mirror/opt/pkg卷:只读方式挂载,多个用户可以共用一份数据。对应代码和工具等内容,其中mirror中的代码主要是用作增量下载,减少整个pipeline拉取代码的时间。这些卷在实例创建时便会挂载上去,后面会不定时更新;
- 代码卷:读写方式挂载,用户独享。代码卷中主要是一些.so等中间编译产物,用于减少编译时长。实例启动时不挂载,工程师需要时选择自己对应项目的代码卷挂载,开发完成后则会卸载对应的代码卷;
- 其它场景:例如允许实例挂一个卷后,作为jenkins slave节点跑构建任务。
再考虑到当前kvm对应的卷容量已经达到PB级别,如果pod容器底层再搭一套独立的存储,不管是从资源层面还是整套流程层面(例如代码卷的制作与维护)都是难以接受的。因此,在kvm切pod容器的挂卷层面我们归纳为两点诉求:
1. pod容器能共用kvm存储卷(共用pvc或者底层存储卷)
2. pod业务容器支持动态挂卷
二、k8s pod挂卷
2.1 pod挂卷
pod是k8s的最小调度单位,一个pod可以包含多个容器(container),给pod中的容器挂卷(本文仅讨论pvc)要考虑两个问题:挂哪个卷
以及挂载到容器的哪个目录
。pod把这两个点声明成了两个字段:volume
与volumeMount
:
apiVersion: v1
kind: Pod
metadata:test
spec:containers:- name: workervolumeMounts:- name: datamountPath: /work/data- name: code1mountPath: /work/code1- name: code2mountPath: /work/code2...volumes:- name: datapersistentVolumeClaim:claimName: pvc-data- name: code1persistentVolumeClaim:claimName: pvc-code1- name: code2persistentVolumeClaim:claimName: pvc-code2...
如上所示,定义了一个只有一个worker容器的pod,这个容器挂载了三个pvc。 当这个pod Running后,如果编辑pod再挂载一个pvc到worker容器,则会触发worker容器的重建
,业务层面也就是前面提到的中断连接,影响用户体验。
2.2 CSI
k8s把pvc相关操作(例如建卷、挂载)封装成了CSI(Container Storage Interface),一般一个类型的卷对应一个CSI,而一个完整功能的CSI一般由两部分组成:
controller
:一般以deployment/statefulSet形式部署,主要负责dynamic provisioning、快照等功能;driver
:也有地方叫作node,以daemonSet形式部署,主要负责一个必须在实例宿主机上的操作,例如attach、mount。
2.3 CSI controller
CSI controller主要负责一些在任意节点都可以完成的操作,我们以创建pvc后的dynamic provisioning流程,看看CSI controller的工作原理(假设底层存储是ceph RBD):
- 用户调用k8s API server创建pvc;
- k8s保存pvc对象;
- CSI controller监听到创建pvc事件,且发现pvc的storageClass是自己;
- CSI controller调底层ceph接口创建ceph卷;
- CSI controller根据创建的ceph卷信息创建对应的pv;
- CSI controller绑定pvc和pv。
2.4 CSI driver
假设要手动把一个ceph RBD设备挂载到某个物理机上,一般会有如下动作:
- 使用rbd或者rbd-nbd命令把RBD设备映射到物理机上,假设映射后的设备名称为/dev/nbd0;
- 如果是个新盘,会用mkfs.ext4等命令对/dev/nbd0做些格式化操作;
- 使用mount命令把设备挂载到目标目录,例如:mount /dev/nbd0 /targetPath。
CSI在给pod挂卷时,其实也是类似的操作,但是考虑到一个卷可能会被宿主机上多个容器使用,CSI还会额外封装一层给容器使用:
- 使用rbd或者rbd-nbd命令把RBD设备映射到物理机上,假设映射后的设备名称为/dev/nbd0;
- 如果是个新盘,会用mkfs.ext4等命令对/dev/nbd0做些格式化操作;
- 使用mount命令把设备挂载到目标目录,例如:mount /dev/nbd0 /stagingPath。其中stagingPath是一个带有pv名称的路径,一个stagingPath对应一个pv,也就是对应一个底层ceph卷;
再执行mount --bind /stagingPath /targetPath将stagingPath绑定到targetPath。其中targetPath是一个带有pod uid和pv的路径,后续kubelet会把targetPath作为创建容器参数重建对应需要挂卷的容器。
上述过程就是CSI driver相关功能,并且封装到了两个grpc接口中:
NodeStageVolume
:负责映射、格式化并mount到stagingPath;NodePublishVolume
:负责stagingPath与targetPath的绑定
于是我们来看看给一个运行的pod容器挂卷是什么逻辑:
- 修改pod volume与pod下容器volumeMount字段;
- k8s API server保存对pod对象的修改;
- pod宿主机上的kubelet监听到pod挂卷信息变化;
- kubelet调CSI driver的NodeStageVolume方法;
- CSI driver的NodeStageVolume方法中映射ceph设备到宿主机上,假设为/dev/nbd0;
- CSI driver的NodeStageVolume方法中检查设备如果需要格式化会先进行格式化,最后再挂载到stagingPath;
- kubelet调CSI driver的NodePublishVolume方法;
- CSI driver的NodePublishVolume方法中执行mount --bind将stagingPath和targetPath绑定;
- kubelet配置targetPath重建容器。
2.5 云平台CSI
在云平台提供的k8s集群环境中,因需要同时支持kubevirt kvm虚拟机和常规pod容器,为了更简单管理,我们把kvm宿主机和pod容器宿主机打了不同标签做了区分。同时因为kubvirt kvm挂卷和pod容器挂卷的实现逻辑不同,于是整个集群的CSI部署如下:
注:
- 底层用的是ceph RBD块存储;
- 云平台提供的pod CSI中,映射ceph卷使用的是rbd-nbd
三、pod业务容器动态挂卷
3.1 如何让业务容器动态挂卷不重建
前面说到,如果给一个pod的容器挂卷,这个容器会重建。但是,如果一个pod有两个容器,当给其中一个容器挂卷时,只有被挂卷的容器会重建,未挂卷的容器不受影响
。于是考虑,如果给业务容器的pod中再加一个辅助的sidecar容器,当业务容器需要挂卷时,可以把挂卷信息写到sidecar容器中,从而触发kubelet对CSI的调用,然后通过某种手段把这个卷动态挂到业务容器中,这样就实现了worker业务容器的动态挂卷功能。
但是,需要怎样才能将宿主机上的卷挂载到正在运行的业务容器中呢?又是什么阶段去做这个事情比较合理呢?
3.2 容器与linux namespace
要解决如何给运行中的容器动态挂卷问题,还需要进一步理解容器。容器的本质是一个配置了linux namespace资源隔离与cgroups资源限制的特殊进程
,而linux namespace资源隔离有:
Mount namespace
:隔离文件系统挂载点UTS namespace
:隔离主机名和域名IPC namespace
:隔离进程间通信资源Pid namespace
:隔离进程id空间Network namespace
:隔离网络设备、网络接口、ip地址、路由表等User namespace
:隔离用户和用户组
在这里,我们需要的显然是进入容器进程的mount namespace执行mount操作。而nsenter
就是一个很好进入进程linux namespace的工具,我们可以使用nsenter -t {业务容器Pid} -m
进入容器mount namespace再执行mount操作把宿主机上的ceph设备挂载到业务容器中。
再结合前面的CSI driver的NodeStageVolume与NodePublishVolume逻辑,可以发现在NodePublishVolume执行完mount --bind后再用nsenter进入业务容器执行mount操作是更合适的。
3.3 业务容器动态挂卷
有了上面的分析,pod业务容器动态挂卷就有如下逻辑:
- 调k8s API server接口修改pod volume与sidecar容器下的volumeMount;
- k8s保存对pod对象的修改;
- pod实例宿主机上的kubelet监听到pod挂卷信息变化;
- kubelet调CSI driver的NodeStageVolume方法;
- CSI driver的NodeStageVolume方法中映射ceph设备到宿主机上,假设为/dev/nbd0;
- CSI driver的NodeStageVolume方法中检查设备如果需要格式化会先进行格式化,最后再挂载到stagingPath;
- kubelet调CSI driver的NodePublishVolume方法;
- CSI driver的NodePublishVolume方法中执行mount --bind将stagingPath和targetPath绑定;
- CSI driver的NodePublishVolume方法中
根据targetPath中的pod uid找到对应的pod信息,并从pod.status中进一步找到业务容器的容器id
。之后通过docker inspect {业务容器id}
找到业务容器进程pid,最后再使用nsenter -t {业务容器进程pid} -m mount /dev/nbdx /targetPath
进入业务容器进程的mount namespace执行mount操作; - kubelet配置targetPath重建sidecar容器。
细心的读者可能会发现,通过上述挂卷过程后,一个卷既挂载到了worker容器中,也挂载到了sidecar容器中,这里之所以还要保留继续挂载到sidecar容器中的逻辑,是考虑到卸载卷的时候kubelet会做一些判断,如果发现sidecar容器未挂卷(挂卷时修改的是sidecar的volumeMount),不会调用CSI的NodePublishVolume方法,这样会导致worker容器中的卷不会卸载。
3.4 新方案CSI部署
上述方案解决了原始需求中的pod业务容器支持动态挂卷
问题,但没解决pod容器能共用kvm存储卷
问题。其实要解决pod容器能共用kvm存储卷问题很简单,只需要把pod CSI driver的名称和kvm CSI controller/driver的名称保持一致即可,kubelet挂卷时是根据名称来找CSI driver的,于是集群中CSI的部署变成了如下所示:
四、遇到的问题
4.1 创建pod时挂载动态卷
3.3章节说的是已经pod已经running后给业务容器挂卷场景,但是如果创建pod时就在sidecar容器的volumeMount中给业务容器配置动态挂卷信息,按照上述逻辑是走不下去的。
因为3.3章节中有很重要的一步就是CSI driver中的NodePublishVolume方法中找到业务容器的进程pid,而kubelet在创建pod时是这样的顺序:
- kubelet调CSI driver的NodeStageVolume方法;
- kubelet调CSI driver的NodePublishVolume方法;
- kubelet把宿主机上准备好的卷目录配置给容器并创建容器。
很显然在kubelet调CSI driver的NodePublishVolume方法时,此时业务容器都还没创建,因此并不能找到容器进程pid。
为了解决这种场景,在CSI driver的NodePublishVolume中找业务容器进程id时,如果找不到先给pod打个标记,再返回成功,让kubelet创建pod逻辑可以继续走下去。之后再用一个异步逻辑处理这种打了标记的pod,发现业务容器起来能找到容器进程pid后再做nsenter mount操作。于是kubelet创建pod逻辑为:
- kubelet调CSI driver的NodeStageVolume方法;
- CSI driver的NodeStageVolume方法中映射ceph设备到宿主机上,假设为/dev/nbd0;
- CSI driver的NodeStageVolume方法中检查设备如果需要格式化会先进行格式化,最后再挂载到stagingPath;
- kubelet调CSI driver的NodePublishVolume方法;
- CSI driver的NodePublishVolume方法中执行mount --bind将stagingPath和targetPath绑定;
CSI driver的NodePublishVolume方法中发现还找不到容器进程pid,先调用k8s API server接口把pod标记为延迟挂载,并记录挂卷信息
;k8s更新pod
;- kubelet创建worker容器;
- kubelet配置targetPath创建sidecar容器;
CSI driver中的异步逻辑监听到本宿主机上的pod有延时挂载标记
;CSI driver中的异步逻辑尝试去找对应业务容器的进程pid,如果能找到,说明业务容器已启动,于是执行nsenter进入业务容器进程的mount namespace进程挂卷操作
。
宿主机重启场景kubelet行为与本场景一致,不再赘述。
4.2 业务容器重启
在给业务容器做动态挂卷的时候,我们是通过nsenter进入业务容器mount namespace执行mount操作挂载的,而容器的本质是一个特殊进程,因此这些挂卷信息会保留在/proc/{pid}/mountinfo中。业务容器因OOM等原因重建后其实是新起了另外一个进程,pid发生了变化,对应的挂卷信息也会丢失。
为了解决这个问题,CSI driver需要监听pod业务容器的重起事件。好在pod.status中对应容器有restartCount或者startedAt字段可以判断业务容器是否发生重启,但是在实现上用informer去watch pod事件可能并不是一个最优解,因为CSI driver是以daemonSet形式部署的,如果集群中节点太多(我们这边单集群有几千个节点),每个节点上都做list-watch,这可能会增加API server的压力。
此时可以考虑定期调kubelet的/pods
接口查询本节点上的pod列表,通过对比前后两次查询结果来判断是否业务容器是否发生重启,于是业务容器重启场景有如下逻辑:
- kubelet发现业务容器重启;
- kubelet调k8s API server接口更新重启后的业务容器信息;
- k8s保存pod的更新,同时kubelet内存中也会通过informer拿到更新后的pod信息;
- CSI driver定期调用kubelet /pods接口查询本宿主机上pod列表信息,并根据前后两次查询的结果判断业务容器是否重启;
- CSI driver发现业务容器重启,找到新业务容器进程pid,重新执行nsenter进入业务容器做mount操作。
4.3 CSI driver重启
CSI driver在映射一个ceph卷到宿主机时使用的是rbd-nbd map命令,这个命令会在CSI driver容器中起一个用户态的进程来维护与存储卷的连接。当CSI driver重启后这些进程也就没了,但是pod容器中/proc/{pid}/mountinfo信息仍然存在,也就是此时用户仍能看到挂卷信息,但是访问卷内容时,在一些低版本内核中会出现卡死情况(此时必须重启宿主机才能恢复),高版本内核则会返回IO错误。
CSI driver的重启我们认为是避免不了的,例如版本更新、代码bug导致OOM、运维误操作等都有可能导致CSI driver的重启。面对这种场景,我们的选择是:
- 宿主机使用高版本内核,防止出现卡死而重启宿主机,同时业务层面对高版本内核做好测试;
- CSI driver启动时,把宿主机上已挂卷的pod做一个remount操作(卸载+挂载),这样可以清理掉旧容器中的残留挂卷信息,并让容器重新挂卷,代价就是业务容器会重建。相比于低版本内核的重启宿主机,以及结合业务的重要程度考虑,运维窗口期变更CSI driver让业务容器重建是可以接受的。
4.4 rbd-nbd默认最大设备数
rbd-nbd默认最大设备数是16
,在CSI中也就意味着一个宿主机最多只能挂载16个不同的pvc/pv,这个数值不满足我们的业务要求。可以通过修改nbd内核模块的参数重新加载内核模块,并通过/sys/module/nbd/parameters/nbds_max查看是否修改成功。
4.5 rbd-nbd map并发挂卷出现D进程
在高版本内核(5.14.0,有修改)中给一个pod配置多个卷,执行创建+删除+再创建,必现rbd-nbd map进程变为D进程卡死问题。内核同事帮忙分析,发现rbd-nbd进程执行NBD_SET_SOCK操作时,有IO派发超时导致卡住,怀疑是多个线程操作同一个nbd设备导致。
梳理CSI driver整体逻辑,并查看rbd-nbd源码,发现的确可能并发操作同一nbd设备。修改rbd-nbd源码不太现实,于是在CSI driver中执行rbd-nbd map操作时加了个全局锁,问题得到解决。低版本内核未出现过这个问题,但两个版本内核逻辑差异太大,待进一步分析高版本内核为什么会出现这个问题。
4.6 同一设备多个挂载点时df -h显示非预期值
在用nsenter给业务容器挂卷时,其实也是mount了两次,之所以mount两次,是考虑到一个卷同时挂载到worker容器和sidecar容器,并且有pvc的accessMode与pod volumeMount下readOnly两层访问权限配置:
nsenter -t {pid} -m -- mount /dev/nbdx /mnt/{pvName}
nsenter -t {pid} -m -- mount --bind /mnt/{pvName} {targetPath}
挂卷后再业务容器中使用df -h
命令,正常看到的是/dev/nbdx -> targetPath的映射,但是测试时发现某些卷显示的是/dev/nbdx -> /mnt/{pvName}。
经过验证发现,当targetPath路径长于/mnt/{pvName}时,显示的是/mnt/{pvName},短于/mnt/{pvName}时显示的是targetPath,也就是当一个设备有多个挂载点时,df -h只显示路径最短的那一条。
当前df的版本是8.25,在df 8.22版本中测试发现显示的都是targetPath,对比df 8.25与8.22版本源码,发现的确加了长度判断逻辑。相关代码:https://github.com/coreutils/coreutils/blob/68f73f23866d6137e9c8d88d86073b33588d7b56/src/df.c#L644
五、总结
本文主要介绍了一种pod业务容器动态挂卷的方案,文中只介绍了挂卷方面的逻辑,卸载卷的逻辑是挂卷逻辑的逆过程,本文没有详述。
文中内容都在讲pod业务容器的动态挂卷方案,其实对于用户的home数据卷(参考1.2章节内容),因其生命周期和实例生命周期一致而无需动态挂/卸载,在实际中我们是创建的时候直接挂载到业务容器中的,也就是修改的是worker容器的volumeMount,挂卷实现上与前文的动态挂卷逻辑并不冲突。
合适的才是最好的,如果业务上接受挂/卸卷后业务容器重建,也就不需要本文的内容,本文仅提供一种动态挂卷方案供大家参考。