Kubernetes 学习(十)Kubernetes 容器持久化存储

2022-11-23 19:36:37 浏览数 (1)

0. 前言

  • 最近在学习张磊老师的 深入剖析Kubernetes 系列课程,最近学到了 Kubernetes 容器持久化存储部分
  • 现对这一部分的相关学习和体会做一下整理,内容参考 深入剖析Kubernetes 原文,仅作为自己后续回顾方便
  • 希望详细了解的同学可以移步至原文支持一下原作者
  • 参考原文:深入剖析Kubernetes

1. PV、PVC、StorageClass 关系梳理

1.1 相关概念

  • Volume:其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起
  • 持久化 Volume:指的就是这个宿主机上的目录,具备“持久性”
    • 即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定
    • 这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容
    • 大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等
    • 而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用
    • 而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”
  • PV:表示是持久化存储数据卷对象。这个 API 对象定义了一个持久化存储在宿主机上的目录(如 NFS 的挂载目录)
    • 通常情况下,PV 对象由运维人员事先创建在 Kubernetes 集群里,比如:
代码语言:javascript复制
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。比如:
代码语言:javascript复制
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 绑定起来
代码语言:javascript复制
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 容器内的目录上
代码语言:javascript复制
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。比如:
代码语言:javascript复制
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 名字即可,如:
代码语言:javascript复制
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

0 人点赞