StatefulSet有状态应用中使用到了PV与PVC

PV与PVC

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 所希望使用的持久化存储的属性。比如大小、可读写权限。PVC通常由开发人员创建,或者以PVC模板的方式称为StatefulSet的一部分,由StatefulSet控制器负责创建带编号的PVC。

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi

用户创建的PVC要想使用,就必须和某个符合条件的PV绑定起来,绑定需要两个条件:

  • 两者的spec字段,比如PV的大小必须满足PVC的需求。
  • PV和PVC的storageClassName字段必须一样

成功绑定之后,用户就能够像使用Volume一样声明PVC,然后挂载到容器上。如下:

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

PV和PVC的设计使得开发人员无需关注底层存储的实现。

PersistentVolumeController

如果Pod创建的时候,定义的PVC没有PV与它绑定,那么Pod的启动就会报错。运维发现这个错误会立即创建PV,k8s也会立即将没有绑定的PVC进行绑定。这个机制就是Volume Controller

其实就是用来管理持久化存储的控制器,Volume Controller维护者多个控制循环,其中一个循环就是PersistentVolumeController。它会不断查看当前每一个PVC是否处于绑定状态,没有绑定,那么就会尝试遍历所有可用的PV来与它进行绑定。这样k8s保证了每个PVC都能很快绑定到合适的PV上去。

所谓将一个 PV 与 PVC 进行“绑定”,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上

PV如何成为容器中的持久化存储

容器概念中也介绍过Volume,其实就是将宿主机上的目录与容器上的一个目录绑定挂载了一起。而持久化就是说这个Volume:

  • 不与宿主机绑定。就算容器删除,这个目录也不会被清理掉。就算容器在其它节点重启,也仍然能够挂载到这个Volume,访问到里面的数据。

所以大多数情况下,持久化Volume的实现都依赖于一个远程存储服务,比如远程文件存储,远程块存储等等。

k8s就是利用这些存储服务,来为用户提供一个持久化的宿主机目录让容器挂载,这样容器往这个目录中写数据都会保存在远程存储中。

这个持久化宿主机目录的过程,可以称为“两阶段处理”。

持久化宿主机目录

当一个Pod被调度到一个节点上后,kubelet就要负责为这个Pod创建它的Volume目录,默认路径如下:

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

kubelet会根据Volume的类型来进行操作。如果你的 Volume 类型是远程块存储,比如 Google Cloud 的 Persistent Disk(GCE 提供的远程磁盘服务),那么 kubelet 就需要先调用 Goolge Cloud 的 API,将它所提供的 Persistent Disk 挂载到 Pod 所在的宿主机上

这是两阶段中的第一阶段,在k8s中称为Attach

Attach完成后,kubelet还要进行第二个操作:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上。这个挂载点就是前面创建的对应的目录。相当于执行:

# 通过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类型是远程文件存储的话,kubelet能够直接从第二步开始处理,因为远程文件存储并没有一个“存储设备”需要挂载到宿主机上。直接将其远程服务器的目录挂载到对应的目录即可:

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

经过这两步处理之后,就得到了一个持久化的宿主机目录,kubelet把这个Volume目录通过CRI里的Mounts参数传递给Docker,就可以为Pod里的容器挂载这个“持久化”的Volume了。相当于如下命令:

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

删除PV的时候需要经过umount和dettach两阶段反向操作。

PV的两阶段处理与Pod和容器的处理没有太多的耦合,只要在kubelet调用CRI之前,确保持久化的宿主机目录已经处理完毕即可。在 Kubernetes 中,上述关于 PV 的“两阶段处理”流程,是靠独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的

处理PV的两个控制循环

第一阶段的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的主控制循环解耦,避免了这些远程操作拖慢kubelet的主控制循环。kubelet 的一个主要设计原则,就是它的主控制循环绝对不可以被 block

StorageClass

在实际生产中每个PVC都要有对应PV,这个PV是需要由运维人员来完成的,但是在大规模生产环境中,有成千上万的PVC,都要让运维人员来创建是非常麻烦的,更何况还不断有新的PVC被提交。

所以,Kubernetes 为我们提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning

Dynamic Provisioning的核心就是创建一个名为StorageClass的API对象,这个API对象的作用就是创建PV模板

StorageClass对象会定义如何两部分内容:

  • PV的属性,比如存储类型、Volume的大小。
  • 创建这种PV需要用到的插件,比如Ceph等等。

有了这些信息,k8s能够根据用户提交的PVC,然后找到一个对应的StorageClass,k8s就会调用StorageClass声明的存储插件,创建出需要的PV

举个例子,假如我们的 Volume 的类型是 GCE 的 Persistent Disk 的话,运维人员就需要定义一个如下所示的 StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd

这里定义了一个名为block-service的storageClass。provisioner是存储插件的名字,而parameters就是PV的参数。

然后使用YAML文件创建StorageClass就行了

$ kubectl create -f sc.yaml

然后在PVC里指定要使用的StorageClass就行了

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claim1
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: block-service
  resources:
    requests:
      storage: 30Gi

那么创建这个PVC

$ kubectl create -f pvc.yaml

就会绑定一个k8s自动创建的PV:

$ kubectl describe pvc claim1
Name:           claim1
Namespace:      default
StorageClass:   block-service
Status:         Bound
Volume:         pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:         <none>
Capacity:       30Gi
Access Modes:   RWO
No Events.

查看这个PV的信息就会发现这个PV也有StorageClass 字段,并且值与PVC的相同。这是因为,Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来

$ kubectl describe pv pvc-e5578707-c626-11e6-baf6-08002729a32b
Name:            pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:          <none>
StorageClass:    block-service
Status:          Bound
Claim:           default/claim1
Reclaim Policy:  Delete
Access Modes:    RWO
Capacity:        30Gi
...
No events.

有了Dynamic Provisioning机制,运维人员只需要创建数量优先的StorageClass对象就行了。这样开发人员提交PVC包含StorageClass字段就能够自动创建出PV了。

StorageClass 并不是专门为了 Dynamic Provisioning 而设计的。就是可以利用这个字段来掌控PV与PVC的绑定关系,就算没有对应的StorageClass也可。

集群已经开启了名叫 DefaultStorageClass 的 Admission Plugin,它就会为 PVC 和 PV 自动添加一个默认的 StorageClass;否则,PVC 的 storageClassName 的值就是“”,这也意味着它只能够跟 storageClassName 也是“”的 PV 进行绑定。


tags: 容器持久化存储