0. 前言
- 最近在学习张磊老师的 深入剖析Kubernetes 系列课程,最近学到了 Kubernetes 容器持久化存储部分
- 现对这一部分的相关学习和体会做一下整理,内容参考 深入剖析Kubernetes 原文,仅作为自己后续回顾方便
- 希望详细了解的同学可以移步至原文支持一下原作者
- 参考原文:深入剖析Kubernetes
1. PV、PVC、StorageClass 关系梳理
1.1 相关概念
- Volume:其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起
- 持久化 Volume:指的就是这个宿主机上的目录,具备“持久性”
- 即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定
- 这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容
- 大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等
- 而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用
- 而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”
- PV:表示是持久化存储数据卷对象。这个 API 对象定义了一个持久化存储在宿主机上的目录(如 NFS 的挂载目录)
- 通常情况下,PV 对象由运维人员事先创建在 Kubernetes 集群里,比如:
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
nfs:
server: 10.244.1.4
path: "/"
- PVC:表示 Pod 所希望使用的持久化存储的属性(如:Volume 存储的大小、可读写权限等等)
- PVC 对象通常由开发人员创建,或者以 PVC 模板的方式成为 StatefulSet 的一部分,然后由 StatefulSet 控制器负责创建带编号的 PVC。比如:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs
spec:
accessModes:
- ReadWriteMany
storageClassName: manual
resources:
requests:
storage: 1Gi
- StorageClass:其实就是创建 PV 的模板。具体地说,StorageClass 对象会定义如下两个部分内容:
- 第一,PV 的属性。比如,存储类型、Volume 的大小等等
- 第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等
- Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-ssd
1.2 绑定条件
- PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 通过两个条件进行绑定:
- 首先是 PV 和 PVC 的 spec 字段,比如 PV 的存储(storage)大小,必须满足 PVC 的要求
- 其次是 PV 和 PVC 的 storageClassName 字段必须一样
- 在成功地将 PVC 和 PV 进行绑定之后,Pod 就能够像使用 hostPath 等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用这个 PVC 了,如:
- Pod 可以在 volumes 字段里声明自己要使用的 PVC 名字
- 接下来,等这个 Pod 创建之后,kubelet 就会把这个 PVC 所对应的 PV,挂载在这个 Pod 容器内的目录上
apiVersion: v1
kind: Pod
metadata:
labels:
role: web-frontend
spec:
containers:
- name: web
image: nginx
ports:
- name: web
containerPort: 80
volumeMounts:
- name: nfs
mountPath: "/usr/share/nginx/html"
volumes:
- name: nfs
persistentVolumeClaim:
claimName: nfs
1.3 绑定关系
- 从面相对象的角度思考,PVC 可以理解为持久化存储的“接口”
- 它提供了对某种持久化存储的描述,但不提供具体的实现
- 而这个持久化存储的实现部分则由 PV 负责完成
- 如果创建 Pod 的时候,系统里并没有合适的 PV 跟它定义的 PVC 绑定,Pod 的启动就会报错
- 在 Kubernetes 中,实际上存在着一个专门处理持久化存储的控制器,叫作 Volume Controller
- 这个 Volume Controller 维护着多个控制循环,其中有一个循环,扮演的就是撮合 PV 和 PVC 的“红娘”的角色:PersistentVolumeController
- PersistentVolumeController 会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态
- 如果不是,那它就会遍历所有可用的 PV,并尝试将其与这个未绑定的 PVC 进行绑定
- 这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态
- 而所谓将一个 PV 与 PVC 进行绑定,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上
- 接下来 Kubernetes 只要获取到这个 PVC 对象,就一定能够找到它所绑定的 PV
1.4 持久化
- 所谓容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起
- 而所谓的“持久化 Volume”,指的就是这个宿主机上的目录,具备“持久性”:
- 这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定
- 这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容
- 前面使用的 hostPath 和 emptyDir 类型的 Volume 并不具备这个特征:
- 它们既有可能被 kubelet 清理掉,也不能被“迁移”到其他节点上
- 所以,大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如 NFS、GlusterFS)、远程块存储(比如公有云提供的远程磁盘)等等
1.4.1 两阶段处理
- 而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用
- 而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”
- 这个准备“持久化”宿主机目录的过程,称为“两阶段处理”:
- 当一个 Pod 调度到一个节点上之后,kubelet 就要负责为这个 Pod 创建它的 Volume 目录
- 默认情况下,kubelet 为 Volume 创建的目录是如下所示的一个宿主机上的路径:/var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >
1.4.1.1 Attach
- 如果 Volume 类型是远程块存储,那么 kubelet 就需要先调用相应的 API,将它所提供的 Persistent Disk 注册到 Pod 所在的宿主机上
- 这一步为虚拟机注册远程磁盘的操作,对应的正是“两阶段处理”的第一阶段
- 在 Kubernetes 中,我们把这个阶段称为 Attach
- Kubernetes 提供的可用参数是 nodeName,即宿主机的名字
1.4.1.2 Mount
- Attach 阶段完成后,为了能够使用这个远程磁盘,kubelet 还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上
- 这个挂载点,正是在前面反复提到的 Volume 的宿主机目录
- 所以,这一步相当于执行:将磁盘设备格式化并挂载到 Volume 宿主机目录的操作,对应的正是“两阶段处理”的第二个阶段:Mount
- Kubernetes 提供的可用参数是 dir,即 Volume 的宿主机目录
- Mount 阶段完成后,这个 Volume 的宿主机目录就是一个“持久化”的目录了,容器在它里面写入的内容,会保存在远程磁盘中
- 而如果你的 Volume 类型是远程文件存储(比如 NFS)的话,kubelet 的处理过程就会更简单一些
- 因为在这种情况下,kubelet 可以跳过 Attach 阶段,因为一般来说,远程文件存储并没有一个“存储设备”需要注册在宿主机上
- 所以,kubelet 会直接从 Mount 阶段开始准备宿主机上的 Volume 目录
- 在这一步,kubelet 需要作为 client,将远端 NFS 服务器的目录(比如:“/”目录),挂载到 Volume 的宿主机目录上
- 即相当于执行如下所示的命令:mount -t nfs <NFS 服务器地址 >:/ /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >
- 通过这个挂载操作,Volume 的宿主机目录就成为了一个远程 NFS 目录的挂载点
- 后面你在这个目录里写入的所有文件,都会被保存在远程 NFS 服务器上。所以,我们也就完成了对这个 Volume 宿主机目录的“持久化”
1.4.2 后续工作
- 经过两阶段处理,就得到了一个“持久化”的 Volume 宿主机目录
- 接下来,kubelet 只要把这个 Volume 目录通过 CRI 里的 Mounts 参数,传递给 Docker,然后就可以为 Pod 里的容器挂载这个“持久化”的 Volume 了
- 其实,这一步相当于执行了如下所示的命令:docker run -v /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >:/< 容器内的目标目录 > 我的镜像 ...
- 在 Kubernetes 中,上述关于 PV 的“两阶段处理”流程,是靠独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的:
- Attach(以及 Dettach)操作,是由 Volume Controller 负责维护的:AttachDetachController(不断地检查每一个 Pod 对应的 PV,和这个 Pod 所在宿主机之间挂载情况。从而决定,是否需要对这个 PV 进行操作)
- 作为一个 Kubernetes 内置的控制器,Volume Controller 是 kube-controller-manager 的一部分
- 所以,AttachDetachController 也一定是运行在 Master 节点上的
- Mount(以及 Unmount)操作,必须发生在 Pod 对应的宿主机上,是 kubelet 组件的一部分,叫作 VolumeManagerReconciler,是一个独立于 kubelet 主循环的 Goroutine
- 通过这样将 Volume 的处理同 kubelet 的主循环解耦,Kubernetes 就避免了这些耗时的远程挂载操作拖慢 kubelet 的主控制循环,进而导致 Pod 的创建效率大幅下降的问题
1.5 StorageClass
- 一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV
- 更麻烦的是,随着新的 PVC 不断被提交,运维人员就不得不继续添加新的、能满足条件的 PV,否则新的 Pod 就会因为 PVC 绑定不到 PV 而失败
- 在实际操作中,这几乎没办法靠人工做到
- 所以,Kubernetes 提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning
- 相比之下,前面人工管理 PV 的方式就叫作 Static Provisioning
- Dynamic Provisioning 机制工作的核心,在于一个名叫 StorageClass 的 API 对象
- 而 StorageClass 对象的作用,其实就是创建 PV 的模板
- 具体地说,StorageClass 对象会定义如下两个部分内容:
- PV 的属性。比如存储类型、Volume 的大小等等
- 创建这种 PV 需要用到的存储插件。比如 Ceph 等等
- 有了这样两个信息之后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的 StorageClass
- 然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出需要的 PV。比如:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-ssd
- 在这个 YAML 文件里,我们定义了一个名叫 block-service 的 StorageClass
- provisioner 字段的值是:kubernetes.io/gce-pd,这正是 Kubernetes 内置的 GCE PD 存储插件的名字
- parameters 字段,就是 PV 的参数。比如:上面例子里的 type=pd-ssd,指的是这个 PV 的类型是“SSD 格式的 GCE 远程磁盘”
- 作为应用开发者,我们只需要在 PVC 里指定要使用的 StorageClass 名字即可,如:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: claim1
spec:
accessModes:
- ReadWriteOnce
storageClassName: block-service
resources:
requests:
storage: 30Gi
- 在 PVC 里添加了一个叫作 storageClassName 的字段,用于指定该 PVC 所要使用的 StorageClass 的名字是:block-service
- 通过 kubectl create 创建上述 PVC 对象之后,Kubernetes 就会调用 Google Cloud 的 API,创建出一块 SSD 格式的 Persistent Disk。然后,再使用这个 Persistent Disk 的信息,自动创建出一个对应的 PV 对象
- 这个自动创建出来的 PV 的 StorageClass 字段的值,也是 block-service
- 这是因为,Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来
- 有了 Dynamic Provisioning 机制,运维人员只需要在 Kubernetes 集群里创建出数量有限的 StorageClass 对象就可以了
- 当开发人员提交了包含 StorageClass 字段的 PVC 之后,Kubernetes 就会根据这个 StorageClass 创建出对应的 PV
1.6 小结
- PVC 描述的是 Pod 想要使用的持久化存储的属性,比如存储的大小、读写权限等
- PV 描述的,则是一个具体的 Volume 的属性,比如 Volume 的类型、挂载目录、远程存储服务器地址等
- 而 StorageClass 的作用,则是充当 PV 的模板。并且,只有同属于一个 StorageClass 的 PV 和 PVC,才可以绑定在一起
- 当然,StorageClass 的另一个重要作用,是指定 PV 的 Provisioner(存储插件)
- 如果你的存储插件支持 Dynamic Provisioning 的话,Kubernetes 就可以自动为你创建 PV 了
2. CSI 插件体系的设计原理
- 存储插件实际担任的角色,仅仅是 Volume 管理中的 Attach 阶段和 Mount 阶段的具体执行者
- 而像 Dynamic Provisioning 这样的功能,不是存储插件的责任,而是 Kubernetes 本身存储管理功能的一部分,如图:
- CSI 插件体系的设计思想,就是把这个 Provision 阶段,以及 Kubernetes 里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件
- 这些组件会通过 Watch API 监听 Kubernetes 里与存储相关的事件变化,比如 PVC 的创建,来执行具体的存储管理动作
- 而这些管理动作,比如 Attach 阶段和 Mount 阶段的具体操作,实际上就是通过调用 CSI 插件来完成的。设计思路如图:
- 这套存储插件体系多了三个独立的外部组件(External Components),即:Driver Registrar、External Provisioner 和 External Attacher
- 对应的正是从 Kubernetes 项目里面剥离出来的那部分存储管理功能
- 需要注意的是,External Components 虽然是外部组件,但依然由 Kubernetes 社区来开发和维护
- 而右侧的部分,就是需要编写代码来实现的 CSI 插件
- 一个 CSI 插件只有一个二进制文件,但它会以 gRPC 的方式对外提供三个服务(gRPC Service),分别叫作:CSI Identity、CSI Controller 和 CSI Node
2.1 External Components
2.1.1 Driver Registrar
- Driver Registrar 组件,负责将插件注册到 kubelet 里面(这可以类比为将可执行文件放在插件目录下)
- 而在具体实现上,Driver Registrar 需要请求 CSI 插件的 Identity 服务来获取插件信息
2.1.2 External Provisioner
- External Provisioner 组件,负责的正是 Provision 阶段
- 在具体实现上,External Provisioner 监听了 APIServer 里的 PVC 对象
- 当一个 PVC 被创建时,它就会调用 CSI Controller 的 CreateVolume 方法,为你创建对应 PV
- 此外,如果你使用的存储是公有云提供的磁盘(或者块设备)的话,这一步就需要调用公有云(或者块设备服务)的 API 来创建这个 PV 所描述的磁盘(或者块设备)
- 不过,由于 CSI 插件是独立于 Kubernetes 之外的,所以在 CSI 的 API 里不会直接使用 Kubernetes 定义的 PV 类型,而是会自己定义一个单独的 Volume 类型
2.1.3 External Attacher
- External Attacher 组件,负责的正是 Attach 阶段
- 在具体实现上,它监听了 APIServer 里 VolumeAttachment 对象的变化
- VolumeAttachment 对象是 Kubernetes 确认一个 Volume 可以进入 Attach 阶段的重要标志
- 一旦出现了 VolumeAttachment 对象,External Attacher 就会调用 CSI Controller 服务的 ControllerPublish 方法,完成它所对应的 Volume 的 Attach 阶段
- 而 Volume 的 Mount 阶段,并不属于 External Components 的职责
- 当 kubelet 的 VolumeManagerReconciler 控制循环检查到它需要执行 Mount 操作的时候,会通过 pkg/volume/csi 包,直接调用 CSI Node 服务完成 Volume 的 Mount 阶段
- 在实际使用 CSI 插件的时候,我们会将这三个 External Components 作为 sidecar 容器和 CSI 插件放置在同一个 Pod 中。由于 External Components 对 CSI 插件的调用非常频繁,所以这种 sidecar 的部署方式非常高效
2.2 CSI 插件服务
2.2.1 CSI IdentityCSI 插件的 CSI Identity 服务,负责对外暴露这个插件本身的信息
2.2.2 CSI Controller
- CSI Controller 服务,定义的则是对 CSI Volume 的管理接口,比如:创建和删除 CSI Volume、对 CSI Volume 进行 Attach/Dettach,以及对 CSI Volume 进行 Snapshot 等
- CSI Controller 服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于 Kubernetes 里 Volume Controller 的逻辑,也就是属于 Master 节点的一部分
- CSI Controller 服务的实际调用者,并不是 Kubernetes(即:通过 pkg/volume/csi 发起 CSI 请求),而是 External Provisioner 和 External Attacher
- 这两个 External Components,分别通过监听 PVC 和 VolumeAttachement 对象,来跟 Kubernetes 进行协作
2.2.3 CSI Node
- 而 CSI Volume 需要在宿主机上执行的操作,都定义在了 CSI Node 服务里面
2.3 小节
- CSI 的设计思想,把插件的职责从两阶段处理,扩展成了 Provision、Attach 和 Mount 三个阶段
- 其中,Privision 等价于“创建远程磁盘块”,Attach 等价于“注册磁盘到虚拟机”,Mount 等价于“将该磁盘格式化后,挂载在 Volume 的宿主机目录上”
- 当 AttachDetachController 需要进行 Attach 操作时,它实际上会执行到 pkg/volume/csi 目录中,创建一个 VolumeAttachment 对象,从而触发 External Attacher 调用 CSI Controller 服务的 ControllerPublishVolume 方法
- 当 VolumeManagerReconciler 需要进行 Mount 操作时,它实际上也会执行到 pkg/volume/csi 目录中,直接向 CSI Node 服务发起调用 NodePublishVolume 方法的请求。
- 以上,就是 CSI 插件最基本的工作原理了
3. CSI 插件部署
3.1 常用原则
- 第一,通过 DaemonSet 在每个节点上都启动一个 CSI 插件,来为 kubelet 提供 CSI Node 服务
- 这是因为,CSI Node 服务需要被 kubelet 直接调用,所以它要和 kubelet“一对一”地部署起来
- 此外,在上述 DaemonSet 的定义里面,除了 CSI 插件,我们还以 sidecar 的方式运行着 driver-registrar 这个外部组件
- 它的作用,是向 kubelet 注册这个 CSI 插件
- 这个注册过程使用的插件信息,则通过访问同一个 Pod 里的 CSI 插件容器的 Identity 服务获取到
- 需要注意的是,由于 CSI 插件运行在一个容器里,那么 CSI Node 服务在 Mount 阶段执行的挂载操作,实际上是发生在这个容器的 Mount Namespace 里的
- 可是,我们真正希望执行挂载操作的对象,都是宿主机 /var/lib/kubelet 目录下的文件和目录
- 所以,在定义 DaemonSet Pod 的时候,我们需要把宿主机的 /var/lib/kubelet 以 Volume 的方式挂载进 CSI 插件容器的同名目录下
- 然后设置这个 Volume 的 mountPropagation=Bidirectional,即开启双向挂载传播,从而将容器在这个目录下进行的挂载操作“传播”给宿主机,反之亦然
- 第二,通过 StatefulSet 在任意一个节点上再启动一个 CSI 插件,为 External Components 提供 CSI Controller 服务
- 所以,作为 CSI Controller 服务的调用者,External Provisioner 和 External Attacher 这两个外部组件,就需要以 sidecar 的方式和这次部署的 CSI 插件定义在同一个 Pod 里
- 而像我们上面这样将 StatefulSet 的 replicas 设置为 1 的话,StatefulSet 就会确保 Pod 被删除重建的时候,永远有且只有一个 CSI 插件的 Pod 运行在集群中
- 这对 CSI 插件的正确性来说,至关重要
3.2 小结
- 当用户创建了一个 PVC 之后,部署的 StatefulSet 里的 External Provisioner 容器,就会监听到这个 PVC 的诞生
- 然后调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 CreateVolume 方法,为你创建出对应的 PV
- 这时候,运行在 Kubernetes Master 节点上的 Volume Controller,就会通过 PersistentVolumeController 控制循环,发现这对新创建出来的 PV 和 PVC,并且看到它们声明的是同一个 StorageClass
- 所以,它会把这一对 PV 和 PVC 绑定起来,使 PVC 进入 Bound 状态
- 然后,用户创建了一个声明使用上述 PVC 的 Pod,并且这个 Pod 被调度器调度到了宿主机 A 上
- 这时候,Volume Controller 的 AttachDetachController 控制循环就会发现,上述 PVC 对应的 Volume,需要被 Attach 到宿主机 A 上
- 所以,AttachDetachController 会创建一个 VolumeAttachment 对象,这个对象携带了宿主机 A 和待处理的 Volume 的名字
- 这样,StatefulSet 里的 External Attacher 容器,就会监听到这个 VolumeAttachment 对象的诞生
- 于是,它就会使用这个对象里的宿主机和 Volume 名字,调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 ControllerPublishVolume 方法,完成 Attach 阶段
- 上述过程完成后,运行在宿主机 A 上的 kubelet,就会通过 VolumeManagerReconciler 控制循环,发现当前宿主机上有一个 Volume 对应的存储设备(比如磁盘)已经被 Attach 到了某个设备目录下
- 于是 kubelet 就会调用同一台宿主机上的 CSI 插件的 CSI Node 服务的 NodeStageVolume 和 NodePublishVolume 方法,完成这个 Volume 的 Mount 阶段
- 至此,一个完整的持久化 Volume 的创建和挂载流程就结束了
4. 总结
- 通过学习,基本了解了 Kubernetes 持久化存储的基本原理和流程
- 当前内容还是以张磊老师的原文为主,后续还需要继续思考和提炼
- 本文所有涉及的知识点汇总至图 Kubernetes 容器持久化存储 中,刚兴趣的同学可以点击查看
5. 参考文献
- 深入剖析 Kubernetes