在k8s中,存储插件的开发有两种方式:FlexVolume和CSI。

FlexVolume的原理

如果现在要编写一个使用NFS实现的FlexVolume插件,那么一个FlexVolume类型的PV的YAML文件如下:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-flex-nfs
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteMany
  flexVolume:
    driver: "k8s/nfs"
    fsType: "nfs"
    options:
      server: "10.10.0.25" # 改成你自己的NFS服务器地址
      share: "export"

这个PV的类型是flexVolume,指定的driver是k8s/nfs。option字段是一个自定义字段。

这样一个PV被创建后一旦和某个PVC绑定起来,就会进入到PV,PVC,StorageClass中讲述的两阶段处理当中。 其实Attach和Mount这两个操作调用的就是k8s/pkg/volume目录下的存储插件(Volume Plugin),当然在这里就是/pkg/volume/flexvolume这个目录中的代码。

// SetUpAt creates new directory.
func (f *flexVolumeMounter) SetUpAt(dir string, fsGroup *int64) error {
  ...
  call := f.plugin.NewDriverCall(mountCmd)
  
  // Interface parameters
  call.Append(dir)
  
  extraOptions := make(map[string]string)
  
  // pod metadata
  extraOptions[optionKeyPodName] = f.podName
  extraOptions[optionKeyPodNamespace] = f.podNamespace
  
  ...
  
  call.AppendSpec(f.spec, f.plugin.host, extraOptions)
  
  _, err = call.Run()
  
  ...
  
  return nil
}

这个SetUpAt方法,是FlexVolume插件对Mount节点的实现位置,实际上就是封装了一条命令,即NewDriverCall。让kubelet在Mount阶段执行。

kubelet要通过插件在宿主机上执行的命令:

/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>

前面的/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs就是插件的可执行文件的路径,nfs是编写的插件的实现,可以是二进制文件也可以是脚本,只要能够在机器上运行。k8s-nfs是插件在k8s中的名字,是从driver=k8s/nfs中解析出来的。

driver字段的格式是:vendor/driver。比如存储插件的提供商的名字为k8s,提供的存储驱动为nfs,那么插件名就是k8s~nfs

编写完了FlexVolume的实现之后,一定要把可执行文件放在每个节点的插件目录下。紧跟在可执行文件后面的“mount”参数,定义的就是当前的操作。在FlexVolume中,这些操作参数的名字是固定的,比如init、mount、unmount、attach以及dettach等等。再后面就是操作所需的参数。

比如第一个参数就是SetAtUp函数的第一个参数,第二个参数是前面PV里定义的options字段的值,这是一个JSON Map格式的参数列表,options字段的值就追加到这里。

一个简单的shell脚本的插件实现如下:

domount() {
 MNTPATH=$1
 
 NFS_SERVER=$(echo $2 | jq -r '.server')
 SHARE=$(echo $2 | jq -r '.share')
 
 ...
 
 mkdir -p ${MNTPATH} &> /dev/null
 
 mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} &> /dev/null
 if [ $? -ne 0 ]; then
  err "{ \"status\": \"Failure\", \"message\": \"Failed to mount ${NFS_SERVER}:${SHARE} at ${MNTPATH}\"}"
  exit 1
 fi
 log '{"status": "Success"}'
 exit 0
}

当kubelet执行nfs mount <mount dir> <json params>的时候,这个nfs脚本就可以从参数里难道volume在宿主机上的目录,再拿到NFS服务器地址和共享目录名字,然后挂载NFS的数据卷。需要注意的是需要把执行的结果以JSON格式的形式返回给调用者,即kubelet,kubelet据此来判断调用是否成功

FlexVolume实现方式虽然简单,但是局限性很大。不能支持Dynamic Provisioning(即,为每个PVC自动创建PV和对应的Volume),除非再为他编写一个专门的External Provisioner。不能将mount的挂载信息保留用于unmount,所以FlexVolume的每一次对插件可执行文件的调用,都是一次完全独立的操作

因此有了第二种插件方式Container Storage interface(CSI)这样更完善、更编程友好的插件方式。

CSI插件体系的设计原理

k8s通过存储插件管理容器持久化的原理图如下:

存储插件实际上担任的角色,仅仅是Volume管理中的Attach阶段和Mount阶段的具体执行者。像Dynamic Provisioning这样的功能,就不是存储插件的责任,而是k8s本身存储管理功能的一部分。CSI插件体系的设计思想,就是把这个Provision阶段,以及Kubenetes里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件。这些组件会通过Watch API监听k8s里存储相关的事件变化,比如PVC的创建,来执行具体的存储管理动作。这些管理动作就是通过调用CSI插件来完成的。

这套存储插件体系多了三个独立的外部组件,即Driver Registrar、External Provisioner和External Attacher。不过这三个外部组件仍然由k8s社区来开发和维护。

  • Driver Registrar组件,负责将插件注册到Kubelet里面。具体实现上,请求CSI插件的Identity服务来获取插件信息。
  • External Provisioner组件,负责Provision阶段。监听APIServer里的PVC对象,当一个PVC被创建,它就会调用CSI Controller的CreateVolume方法创建对应的PV。
  • External Attacher组件,负责“Attach阶段”。监听APIServer里VolumeAttachment对象的变化,而这个对象是k8s确认一个Volume可以进入Attach阶段的重要标志。一旦出现了这个对象,External Attacher调用CSI Controller服务的ControllerPublish方法,完成它所对应的Volume的Attach阶段。

图中最右侧部分就是需要编写代码实现的CSI插件。一个CSI插件只有一个二进制文件,但它会以gRPC的方式对外提供三个服务,分别叫做CSI Identity、CSI Controller和CSI Node。

在实际使用CSI插件的时候,会将这三个External Components作为sidecar容器和CSI插件放置在同一个Pod中。关于CSI插件中的三个服务

  • CSI Identity服务,负责对外暴露这个插件本身的信息。
service Identity {
  // return the version and name of the plugin
  rpc GetPluginInfo(GetPluginInfoRequest)
    returns (GetPluginInfoResponse) {}
  // reports whether the plugin has the ability of serving the Controller interface
  rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
    returns (GetPluginCapabilitiesResponse) {}
  // called by the CO just to check whether the plugin is running or not
  rpc Probe (ProbeRequest)
    returns (ProbeResponse) {}
}
  • CSI Controller服务定义的是对CSI Volume的管理接口。比如创建和删除CSI Volume、对CSI Volume进行Attach/Dettach,对CSI Volume进行Snapshot等。
service Controller {
  // provisions a volume
  rpc CreateVolume (CreateVolumeRequest)
    returns (CreateVolumeResponse) {}
    
  // deletes a previously provisioned volume
  rpc DeleteVolume (DeleteVolumeRequest)
    returns (DeleteVolumeResponse) {}
    
  // make a volume available on some required node
  rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
    returns (ControllerPublishVolumeResponse) {}
    
  // make a volume un-available on some required node
  rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
    returns (ControllerUnpublishVolumeResponse) {}
    
  ...
  
  // make a snapshot
  rpc CreateSnapshot (CreateSnapshotRequest)
    returns (CreateSnapshotResponse) {}
    
  // Delete a given snapshot
  rpc DeleteSnapshot (DeleteSnapshotRequest)
    returns (DeleteSnapshotResponse) {}
    
  ...
}

CSI Controller 服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于 Kubernetes 里 Volume Controller 的逻辑,也就是属于 Master 节点的一部分。CSI Controller服务的实际调用者,是External Provisioner和External Attacher,这两个外部组件,分别通过监听PVC和VolumeAttachement对象来跟k8s进行协作。

  • CSI Node服务,负责CSI Volume需要在宿主机上执行的操作。
service Node {
  // temporarily mount the volume to a staging path
  rpc NodeStageVolume (NodeStageVolumeRequest)
    returns (NodeStageVolumeResponse) {}
    
  // unmount the volume from staging path
  rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
    returns (NodeUnstageVolumeResponse) {}
    
  // mount the volume from staging to target path
  rpc NodePublishVolume (NodePublishVolumeRequest)
    returns (NodePublishVolumeResponse) {}
    
  // unmount the volume from staging path
  rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
    returns (NodeUnpublishVolumeResponse) {}
    
  // stats for the volume
  rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
    returns (NodeGetVolumeStatsResponse) {}
    
  ...
  
  // Similar to NodeGetId
  rpc NodeGetInfo (NodeGetInfoRequest)
    returns (NodeGetInfoResponse) {}
}

CSI 的设计思想,把插件的职责从“两阶段处理”,扩展成了 Provision、Attach 和 Mount 三个阶段。其中,Provision 等价于“创建磁盘”,Attach 等价于“挂载磁盘到虚拟机”,Mount 等价于“将该磁盘格式化后,挂载在 Volume 的宿主机目录上”

小结

总结一下CSI插件的工作流程。

==register过程==:首先CSI插件编写好之后需要进行注册,csi插件应该作为daemonSet部署到每一个节点上。然后插件容器会挂载所在节点上的hostPath文件加,然后把插件的可执行文件放在其中,并启动rpc服务。 External Component Driver Register利用kubelet plugin watcher特性来监听指定的文件夹路径来检测自动检测到这个存储插件,然后通过调用identity rpc服务,获得driver的信息,并完成注册

==provision过程==:External Provisoner组件会监听APIServer中PVC资源的创建,并且PVC所指定的storageClass的provisioner是我们上面启动的插件。那么External Provisioner就会调用插件的controller.CreateVolume接口创建出相应的PV。

==attach过程==:有Pod要使用这个PV,那么就会创建VolumeAttachment对象,External Attacher监听这个对象的变化,一旦出现新对象,就会调用controller.ControllerPublish服务,将相应的磁盘挂载到使用这个PVC/PV的节点上去

==mount过程:这个工作需要节点的kubelet来做,kubelet的VolumeManagerReconciler控制循环监听到需要执行Mount操作的时候,通过调用CSI Node服务,完成volume的Mount阶段==。具体的任务就是调用CRI启动带有volume参数的容器,把上阶段那准备好的磁盘mount到container指定的目录。