在 Kubernetes 中,存储插件的开发有两种方式:FlexVolume 和 CSI。
接下来,我就先为你剖析一下Flexvolume 的原理和使用方法。
举个例子,现在我们要编写的是一个使用 NFS 实现的 FlexVolume 插件。
对于一个 FlexVolume 类型的 PV 来说,它的 YAML 文件如下所示:
可以看到,这个 PV 定义的 Volume 类型是 flexVolume。并且,我们指定了这个 Volume 的 driver 叫作 k8s/nfs。这个名字很重要,我后面马上会为你解释它的含义。
而 Volume 的 options 字段,则是一个自定义字段。也就是说,它的类型,其实是map[string]string。所以,你可以在这一部分自由地加上你想要定义的参数。
在我们这个例子里,options 字段指定了 NFS 服务器的地址(server: “10.10.0.25”),以及 NFS 共享目录的名字(share: “export”)。当然,你这里定义的所有参数,后面都会被 FlexVolume 拿到。
备注:你可以使用这个 Docker 镜像轻松地部署一个试验用的 NFS 服务器。
像这样的一个 PV 被创建后,一旦和某个 PVC 绑定起来,这个 FlexVolume 类型的 Volume 就会进入到我们前面讲解过的 Volume 处理流程。
你应该还记得,这个流程的名字叫作“两阶段处理”,即“Attach 阶段”和“Mount 阶段”。它们的主要作用,是在 Pod 所绑定的宿主机上,完成这个 Volume 目录的持久化过程,比如为虚拟机挂载磁盘(Attach),或者挂载一个 NFS 的共享目录(Mount)。
而在具体的控制循环中,这两个操作实际上调用的,正是 Kubernetes 的 pkg/volume 目录下的存储插件(Volume Plugin)。在我们这个例子里,就是 pkg/volume/flexvolume 这个目录里的代码。
当然了,这个目录其实只是 FlexVolume 插件的入口。以“Mount 阶段”为例,在 FlexVolume 目录里,它的处理过程非常简单,如下所示:
上面这个名叫 SetUpAt() 的方法,正是 FlexVolume 插件对“Mount 阶段”的实现位置。而 SetUpAt() 实际上只做了一件事,那就是封装出了一行命令(即:NewDriverCall),由 kubelet 在“Mount 阶段”去执行。
在我们这个例子中,kubelet 要通过插件在宿主机上执行的命令,如下所示:
其中,/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs 就是插件的可执行文件的路径。这个名叫 nfs 的文件,正是你要编写的插件的实现。它可以是一个二进制文件,也可以是一个脚本。总之,只要能在宿主机上被执行起来即可。
而且这个路径里的 k8s~nfs 部分,正是这个插件在 Kubernetes 里的名字。它是从 driver="k8s/nfs"字段解析出来的。
这个 driver 字段的格式是:vendor/driver。比如,一家存储插件的提供商(vendor)的名字叫作 k8s,提供的存储驱动(driver)是 nfs,那么 Kubernetes 就会使用 k8s~nfs 来作为插件名。
所以说,当你编写完了 FlexVolume 的实现之后,一定要把它的可执行文件放在每个节点的插件目录下。
而紧跟在可执行文件后面的“mount”参数,定义的就是当前的操作。在 FlexVolume 里,这些操作参数的名字是固定的,比如 init、mount、unmount、attach,以及 dettach 等等,分别对应不同的 Volume 处理操作。
而跟在 mount 参数后面的两个字段:<mount dir>和<json params>,则是 FlexVolume 必须提供给这条命令的两个执行参数。
其中第一个执行参数<mount dir>,正是 kubelet 调用 SetUpAt() 方法传递来的 dir 的值。它代表的是当前正在处理的 Volume 在宿主机上的目录。在我们的例子里,这个路径如下所示:
其中,test 正是我们前面定义的 PV 的名字;而 k8s~nfs,则是插件的名字。可以看到,插件的名字正是从你声明的 driver="k8s/nfs"字段里解析出来的。
而第二个执行参数<json params>,则是一个 JSON Map 格式的参数列表。我们在前面 PV 里定义的 options 字段的值,都会被追加在这个参数里。此外,在 SetUpAt() 方法里可以看到,这个参数列表里还包括了 Pod 的名字、Namespace 等元数据(Metadata)。
在明白了存储插件的调用方式和参数列表之后,这个插件的可执行文件的实现部分就非常容易理解了。
在这个例子中,我直接编写了一个简单的 shell 脚本来作为插件的实现,它对“Mount 阶段”的处理过程,如下所示:
可以看到,当 kubelet 在宿主机上执行“nfs mount <mount dir> <json params>”的时候,这个名叫 nfs 的脚本,就可以直接从<mount dir>参数里拿到 Volume 在宿主机上的目录,即:MNTPATH=$1。而你在 PV 的 options 字段里定义的 NFS 的服务器地址(options.server)和共享目录名字(options.share),则可以从第二个<json params>参数里解析出来。这里,我们使用了 jq 命令,来进行解析工作。
有了这三个参数之后,这个脚本最关键的一步,当然就是执行:mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} 。这样,一个 NFS 的数据卷就被挂载到了 MNTPATH,也就是 Volume 所在的宿主机目录上,一个持久化的 Volume 目录就处理完了。
需要注意的是,当这个 mount -t nfs 操作完成后,你必须把一个 JOSN 格式的字符串,比如:{“status”: “Success”},返回给调用者,也就是 kubelet。这是 kubelet 判断这次调用是否成功的唯一依据。
综上所述,在“Mount 阶段”,kubelet 的 VolumeManagerReconcile 控制循环里的一次“调谐”操作的执行流程,如下所示:
当然,在前面文章中我也提到过,像 NFS 这样的文件系统存储,并不需要在宿主机上挂载磁盘或者块设备。所以,我们也就不需要实现 attach 和 dettach 操作了。
不过,像这样的 FlexVolume 实现方式,虽然简单,但局限性却很大。
比如,跟 Kubernetes 内置的 NFS 插件类似,这个 NFS FlexVolume 插件,也不能支持 Dynamic Provisioning(即:为每个 PVC 自动创建 PV 和对应的 Volume)。除非你再为它编写一个专门的 External Provisioner。
再比如,我的插件在执行 mount 操作的时候,可能会生成一些挂载信息。这些信息,在后面执行 unmount 操作的时候会被用到。可是,在上述 FlexVolume 的实现里,你没办法把这些信息保存在一个变量里,等到 unmount 的时候直接使用。
这个原因也很容易理解:FlexVolume 每一次对插件可执行文件的调用,都是一次完全独立的操作。所以,我们只能把这些信息写在一个宿主机上的临时文件里,等到 unmount 的时候再去读取。
这也是为什么,我们需要有 Container Storage Interface(CSI)这样更完善、更编程友好的插件方式。