在了解k8s的CSI plugin编写前,我们需要先了解下有关K8S的持久化存储机制。
理解k8s持久化存储
在k8s中,持久化存储采用PV和PVC进行绑定的的方式进行管理。
PV(PersistentVolume):存储卷对象映射,一般由管理员手动创建或通过存储插件(External Provisioner)创建。示例:
1 | apiVersion: v1 |
PVC(PersistentVolumeClaim):存储卷声明,一般由开发人员定义,对于支持Dynamic Provisioning的存储类型,通过对PVC的声明(可以在pod中完成),可以让PersistentVolumeController找到一块合适的PV与PVC进行bound操作。示例:
1 | apiVersion: v1 |
这种绑定操作可以是静态的(Static Provisioning),也可以是动态的(Dynamic Provisioning)
首先说静态,通过静态方式进行时,由管理员创建PV,通过PersistentVolumeController,k8s可以完成PV和PVC的绑定,PersistentVolumeController(pkg/controller/volume/persistentvolume/pv_controller.go)存在一个控制循环,不断遍历所有可用状态的PV,尝试与PVC进行绑定(Bound)操作,绑定成功后,则为声明该PVC的Pod提供存储服务。
PV和PVC绑定调度流程
当PVC被声明出来时(单独声明 or statefulSet),会被cache.Controller watch到,并开始执行syncClaim函数:
1 | func (ctrl *PersistentVolumeController) syncClaim(claim *v1.PersistentVolumeClaim) error { |
通过pv.kubernetes.io/bind-completed annotation来判断pvc是否已经完成bound操作,如果该PVC未进行bound操作,则调用syncUnboundClaim进行bound操作。
在进行syncUnboundClaim前,首先会确认PVC是否定义了延迟绑定策略:
1 | // IsDelayBindingMode checks if claim is in delay binding mode. |
延迟绑定主要用在Local PersistentVolume的情况下,当采用本地卷作为持久化卷时,如果PVC和PV即时绑定,则可能在pod启动的节点上找不到PV,mount过程会失败,而延迟绑定则将PVC和PV的绑定延后到Pod 调度器中,从而使Volume卷可以被正常挂载到Pod上。
之后执行PV查找过程,首先从pvIndex中按照AccessModes找到所有符合的PV:
1 | allPossibleModes := pvIndex.allPossibleMatchingAccessModes(claim.Spec.AccessModes) |
例如PVC请求的PV的AccessMode是ReadWriteOnce,则包含ReadWriteOnce的PV都会被检索出。
之后通过调用FindMatchingVolume方法找到最合适的PV。
这里的逻辑是通过遍历符合AccessMode的所有PV,首先判定PV是否已经被其他PVC预绑定(pre-bound)或已经被绑定:
1 | // ... |
当开启了 延迟绑定后,PV将会被直接跳过,交给Pod调度器进行调度:
1 | if node == nil && delayBinding { |
最后会检查PV的状态是否处于 Available 、PVC中定义的labelSelector是否符合要求以及StorageClass是否符合(默认都为空,则为符合),不符合则跳过:
1 | if volume.Status.Phase != v1.VolumeAvailable { |
以上都完毕后,从所有的符合条件的PV中找到符合PVC requestSize且最小的一个PV:
1 | if smallestVolume == nil || smallestVolumeQty.Cmp(volumeQty) > 0 { |
以上是PV和PVC的调度绑定流程。
Dynamic Provisioning
这个过程在PersistentVolumeController中完成,而当Pod在实际使用Volume前,需要通过Attach以及Mount流程后,才能真正进行使用。
而实际的应用场景则是,在环境中可能没有提前创建好可供“bound”的PV,这时候Dynamic Provisioning就派上用场了。
使用Dynamic Provisioning方式很简单,通过定义StorageClass就可以完成。
以Rook-Ceph的RBD服务为例,可以创建如下格式的StorageClass,以提供块存储服务:
1 | apiVersion: storage.k8s.io/v1 |
通过在PVC中声明storageClassName字段,就可以进行动态使用了:
1 | apiVersion: v1 |
在PVController watch到动态PVC被声明后,首先会寻找该PVC对应的plugin和storageClass:
1 | plugin, storageClass, err := ctrl.findProvisionablePlugin(claim) |
这个过程会通过PersistentVolumeController的findProvisionablePlugin方法来进行寻找in-tree plugin,而find过程的关键在于通过PVC声明的storageClassName寻找对应的in-tree Plugin:
1 | // Find a plugin for the class |
在1.14之后,PVController会先判断是否属于in-tree plugin到CSI的迁移(migration)场景,如果属于,则会将in-tree的plugin迁移到CSI,关于migration的产生背景,可以看下这篇介绍:https://kubernetes.io/blog/2019/12/09/kubernetes-1-17-feature-csi-migration-beta/
简单来说,为了支持Plugin机制的广泛使用,K8S社区越来越倾向于减少in-tree的代码,而通过Plugin的机制来进行扩展,原先in-tree的Plugin也被通过migration的机制,逐渐往CSI上迁,从中也能看出K8S社区对扩展性的考量,未来K8S极有可能成为Plugin的“媒介”系统(目前还未采用Plugin机制的,仅有kube-scheduler,而随着K8S社区的不断演进,kube-scheduler的默认调度器也会和CSI、CNI一样,支持自定义调度插件)。
继续往下分析,PVController会通过scheduleOperation来传入PV的Operation方法作为闭包,scheduleOperation的作用主要是通过grm(goroutinemap)的读写锁来判定,是否有Operation已经在运行中,运行中的作业会被预先加入goroutinemap中,用以判断。
1 | // goroutinemap |
Attach & Mount
在实际挂载时,通过ADController调用CSI的Attach操作,并在kubelet中调用Mount操作,完成存储卷和Pod的挂载过程。
在ADController中,首先会构建出PV对应的VolumeSpec,
1 | // NewSpecFromPersistentVolume creates an Spec from an v1.PersistentVolume |
之后根据VolumeSpec寻找到plugin, 通过调用operation_executor,完成Attach操作。
1 | func (oe *operationExecutor) AttachVolume( |
而Mount操作则在kubelet中进行,在kubelet中会生成VolumeManager对象。
关于VolumeManager的处理逻辑会在kubelet的详细介绍文章中介绍。
编写CSI
在理解了K8S处理持久化卷的机制后,我们就可以来尝试编写CSI了。
首先CSI不是in-tree模式的存储插件,一般通过daemonSet的方式部署在节点上。
CSI插件体系的设计思想,就是把 Provision 阶段,以及 Kubernetes 里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件。
CSI设计思想示意图:
可以看出,CSI可以大体分为两部分(上图External Components和Custum Components部分),其中左半部分是k8S所提供的控制面服务,而右侧则是CSI开发者需要关注的部分。
而再往左侧,K8S原生的控制面服务,则是对CSI组件的请求调用,我们暂且忽略。
先看下左半部分External Components。
External Components同样也是被K8S社区所维护的项目,存放与K8S的CSI SIG中。
Driver Registrar
概述
Driver Registerar 组件通过请求CSI插件的Identity服务,来获取插件信息,将插件注册到kubelet中。在当前的K8S版本中(CSI spec 0.3后),Driver Registrar已不再维护,取而代之的是cluster-driver-registrar和node-driver-registrar。而在K8S 1.13版本以后,cluster-driver-registrar也进入deprecated,在1.16版本以后被正式弃用。而node-driver-registrar是目前仍在维护的driver registar。而cluster-deriver-registar需要通过创建 CSIDriver Object 的方式来实现。
node-driver-registrar的本质是sidecar容器,一般与CSI的daemonSet容器部署在一起。
部署yaml example:
1 | containers: |