存储管理2:Kubernetes是如何准备持久化目录的

我们要知道的是,所谓的容器Volume,其实就是将宿主机上的一个目录,跟容器中的一个目录绑定挂载在一起。而所谓的“持久化Volume”,指的就是宿主机上的目录具备“持久性”。即,这个目录里的内容,即不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其它节点上重建,它仍然能通过挂载这个Volume,访问到这些内容。

那么,在Kubernetes中又是如何准备持久化目录的呢?

1.1、两阶段处理

首先,我们需要知道的是,当一个 Pod 被调度到一个节点上之后,kubelet 就要负责为这个 Pod 创建它的 Volume 目录。默认情况下,kubelet 为 Volume 创建的目录是如下所示的一个宿主机上的路径:

1
/var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

接下来,kubelet 要做的操作就取决于Volume 的类型了。下面我们分别讨论。

1.1.1、远程块存储

如果我们的 Volume 类型是远程块存储,比如 Google Cloud 的 Persistent Disk(GCE 提供的远程磁盘服务),那么 kubelet 就需要先调用 Goolge Cloud 的 API,将它所提供的 Persistent Disk 挂载到 Pod 所在的宿主机上。这相当于执行:

1
$ gcloud compute instances attach-disk <虚拟机名字> --disk <远程磁盘名字>

这一步挂载远程磁盘的操作,对应的正是“两阶段处理”的第一阶段。在 Kubernetes 中,我们把这个阶段称为 Attach。

Attach阶段完成后,为了能够使用这个远程磁盘,kubelet 还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点(Volume 的宿主机目录)上。这一步相当于执行:

1
2
3
4
5
6
7
8
# 通过lsblk命令获取磁盘设备ID
$ sudo lsblk

# 格式化成ext4格式
$ sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/<磁盘设备ID>

# 挂载到挂载点
$ sudo mkdir -p /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

这个将磁盘设备格式化并挂载到 Volume 宿主机目录的操作,对应的正是“两阶段处理”的第二个阶段,我们一般称为:Mount。这个阶段完成后,这个 Volume 的宿主机目录就是一个“持久化”的目录了,容器在它里面写入的内容,会保存在 Google Cloud 的远程磁盘中。

1.1.2、远程文件存储

如果我们的 Volume 类型是远程文件存储(比如 NFS)的话,kubelet 的处理过程就会更简单一些。

在这种场景下,kubelet 可以跳过“第一阶段”(Attach)的操作,这是因为一般来说,远程文件存储并没有一个“存储设备”需要挂载在宿主机上。所以,kubelet 会直接从“第二阶段”(Mount)开始,准备宿主机上的 Volume 目录。在这一步中,kubelet 需要作为 client,将远端 NFS 服务器的目录(比如:“/”目录),挂载到 Volume 的宿主机目录上,即相当于执行如下所示的命令:

1
$ mount -t nfs <NFS服务器地址>:/ /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

通过这个挂载操作,Volume 的宿主机目录就成为了一个远程 NFS 目录的挂载点,后面我们在这个目录里写入的所有文件,都会被保存在远程 NFS 服务器上。所以,我们也就完成了对这个 Volume 宿主机目录的“持久化”。

1.1.3、如何定义和区分两阶段

到了这里,我们可能会有这样的疑问:

Kubernetes 又是如何定义和区分这两个阶段的呢?

其实很简单,在具体的 Volume 插件的接口实现上,Kubernetes 分别给这两个阶段提供了两种不同的参数列表:

  • 对于“第一阶段”(Attach),Kubernetes 提供的可用参数是 nodeName,即宿主机的名字。

  • 对于“第二阶段”(Mount),Kubernetes 提供的可用参数是 dir,即 Volume 的宿主机目录。

所以,作为一个存储插件,我们只需要根据自己的需求进行选择和实现即可。

1.2、容器挂载Volume

经过了“两阶段处理”,我们就能得到一个“持久化”的 Volume 宿主机目录。所以,接下来,kubelet 只要把这个 Volume 目录通过 CRI 里的 Mounts 参数,传递给 Docker,然后就可以为 Pod 里的容器挂载这个“持久化”的 Volume 了。其实,这一步相当于执行了如下所示的命令:

1
$ docker run -v /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>:/<容器内的目标目录> 我的镜像 ...

以上,就是 Kubernetes 创建 PV 的具体原理了。对应地,在删除一个 PV 的时候,Kubernetes 也需要 Unmount 和 Dettach 两个阶段来处理。

1.3、相关控制循环

从上面的流程中,我们可以看出,PV 的处理流程似乎跟 Pod 以及容器的启动流程没有太多的耦合,只要 kubelet 在向 Docker 发起 CRI 请求之前,确保“持久化”的宿主机目录已经处理完毕即可。所以,在 Kubernetes 中,上述关于 PV 的“两阶段处理”流程,是靠独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。其中:

  • “第一阶段”的 Attach(以及 Dettach)操作,是由 Volume Controller 负责维护的,这个控制循环的名字叫作:AttachDetachController。而它的作用,就是不断地检查每一个 Pod 对应的 PV,和这个 Pod 所在宿主机之间的挂载情况。从而决定,是否需要对这个 PV 进行 Attach(或者 Dettach)操作。

    需要注意的是,作为一个 Kubernetes 内置的控制器,Volume Controller 自然是 kube-controller-manager 的一部分。所以,AttachDetachController 也一定是运行在 Master 节点上的。当然,Attach 操作只需要调用公有云或者具体存储项目的 API,并不需要在具体的宿主机上执行操作,所以这个设计没有任何问题。

  • “第二阶段”的 Mount(以及 Unmount)操作,必须发生在 Pod 对应的宿主机上,所以它必须是 kubelet 组件的一部分。这个控制循环的名字,叫作:VolumeManagerReconciler,它运行起来之后,是一个独立于 kubelet 主循环的 Goroutine。

    通过将 Volume 的处理同 kubelet 的主循环解耦,Kubernetes 就避免了这些耗时的远程挂载操作拖慢 kubelet 的主控制循环,进而导致 Pod 的创建效率大幅下降的问题。实际上,kubelet 的一个主要设计原则,就是它的主控制循环绝对不可以被 block。

1.4、小结

Kubernetes通过“两阶段”完成持久化宿主机目录。这两个阶段分别是Attach / Detach(远程挂载 / 卸载)和Mount / Unmount(本地挂载 / 卸载)。

1.5、参考

https://time.geekbang.org/column/intro/100015201


存储管理2:Kubernetes是如何准备持久化目录的
https://kuberxy.github.io/2020/10/05/存储管理2:Kubernetes是如何准备持久化目录的/
作者
Mr.x
发布于
2020年10月5日
许可协议