Funky's NoteBook

Kubernetes Storage

字数统计: 3,173阅读时长: 13 min
2019/11/24 Share

Kubernetes 持久化存储

Kubernetes 项目中涉及存储最多的2个概念即是 Persistent Volume(PV)和 Persistent Volume Claim(PVC),这两个概念形成的kubernetes的持久化存储体系。

PV 与 PVC

  • Persistent Volume(PV)即持久化存储卷,他是实现创建好的一个已经挂载在宿主机上的目录,示例定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
nfs:
server: 10.244.1.4
path: "/"
  • Persistent Volume Claim(PVC)即持久化存储卷请求,就是Pod所希望使用的持久化存储的属性、权限等,示例定义如下:
1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs
spec:
accessModes:
- ReadWriteMany
storageClassName: manual
resources:
requests:
storage: 1Gi

这种PV、PVC设计体现了面向对象思想,PVC是持久化存储的接口,只提供存储的描述,不负责具体实现,实现部分由PV完成。

用户创建的 PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 进行绑定。这里要检查的条件,包括两部分:

  • PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就必须满足 PVC 的要求。
  • PV 和 PVC 的 storageClassName 字段必须匹配

如果你的集群在准备创建一个声明了PVC的容器之前没有创建任何PV,那么这个Pod将创建失败,因为集群内部没有合适的PV和这个PVC绑定。因此PVC必须和某一个PV绑定了,那么这个声明PVC的容器才能被成功创建。

PV 和 PVC 里都声明了 storageClassName=manual。而集群里,实际上并没有一个名叫 manual 的 StorageClass 对象。这完全没有问题,这个时候 Kubernetes 进行的是 Static Provisioning,但在做绑定决策的时候,依然会考虑 PV 和 PVC 的 StorageClass 定义。

动态创建持久化卷 StorageClass

上面通过运维手工创建PV,我们称之为 Static Provisioning,而 k8s 提供了自动创建PV的机制,即 Dynamic Provisioning,它的工作核心即为 StorageClass。

StorageClass 其实充当创建PV的模版和创建PV的存储插件 :Provisioner,它会定义以下两部分内容:

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

当一个 StorageClass 被创建,Kubernetes 会根据用户提交的 PVC,找到一个对应的 StorageClass ,并调用该 StorageClass 声明的存储插件,创建出需要的 PV。

下面是一个SC的例子:

1
2
3
4
5
6
7
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: block-service # SC的名称
provisioner: kubernetes.io/gce-pd ## 第三方存储插件名称
parameters:
type: pd-ssd ## 类型为 SSD 的GCE远程磁盘

如果使用本地 k8s 集群及使用root做分布式存储,可使用下面的YAML定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
name: replicapool
namespace: rook-ceph
spec:
replicated:
size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: block-service
provisioner: ceph.rook.io/block
parameters:
pool: replicapool
#The value of "clusterNamespace" MUST be the same as the one in which your rook cluster exist
clusterNamespace: rook-ceph

那么容器在声明PVC的时候可以使用下面的样例:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: claim1
spec:
accessModes:
- ReadWriteOnce
storageClassName: block-service ## 匹配SC的名称
resources:
requests:
storage: 30Gi

我们可以通过 如下命令查看PV与PVC绑定的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ 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.

$ 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.

自动创建出来的 PV 的 StorageClass 字段的值,也是 block-service。这是因为,Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来

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

以上的关系可以总结成下面这幅图:

storage

PersistentVolumeController 原理

k8s 中有个 VolumeController 负责专门出来持久化存储的控制,它是kube-controller-manager的一部分,维护着多个控制循环, VolumeController 中就有一个 PersistentVolumeController 负责将PV 与 PVC 匹配, PersistentVolumeController 会不断查看集群中的PVC是否都已经被绑定,如果没有就查找可用的PV 并尝试与这些没有绑定的 PVC 绑定,即将PVC 的 volumeName 添加上相应的 PV 名。

持久化容器存储流程

玩过 Docker 的朋友肯定知道,我们可以通过docker run -v 参数实现宿主机目录映射,但是针对 kubernetes这种分布式集群系统来说,容器部署所在的节点是会变的,这就需要通过分布式存储或远程存储(如:Ceph、NFS、Gluster)想配合来实现更加可用的容器持久化存储功能,同时,在申请创建容器到容器被调度到一个节点之前,这个容器最终运行的宿主机对于运维人员都是未知的,所以当容器被调度到一个节点上之后,这个节点需要做一系列的准备流程,即准备持久化宿主机目录的流程,该流程分为两个阶段:

  • Attach 阶段,Kubernetes 提供的可用参数是 nodeName,即宿主机的名字,该阶段将盘、块设备附加到相应的宿主机(k8s工作节点),相当于执行:
1
$ gcloud compute instances attach-disk <虚拟机名字> --disk <远程磁盘名字>

PS:可以理解为相当于给宿主机加块磁盘- -

  • Mount 阶段,Kubernetes 提供的可用参数是 dir,即 Volume 的宿主机目录,该阶段将附加的磁盘格式化并挂载到相应的目录上,相当于执行:
1
2
3
4
5
6
# 通过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 类型是远程文件存储(比如 NFS)的话,kubelet 的处理过程会跳过 Attache阶段,直接将NFS的目录挂载到相应的宿主机目录上,因为一般来说,远程文件存储并没有一个“存储设备”(如磁盘)需要挂载在宿主机上。

经过这两个步骤后,Pod将会顺利的创建,并将 /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字> 挂载到Pod相应的目录上去,类似于:

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

对应地,在删除一个 PV 的时候,Kubernetes 也需要 Unmount 和 Dettach 两个阶段来处理,执行“反向操作”即可。

这个两个阶段独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现。

Attach(以及 Dettach)操作,由 Volume Controller 负责维护,这个控制循环的名字叫作:AttachDetachController。它会不断地检查每一个 Pod 对应的 PV,和这个 Pod 所在宿主机之间挂载情况。从而决定,是否需要对这个 PV 进行 Attach(或者 Dettach)操作。Attach具体的操作通过调用第三方存储服务的API实现。

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

Local Persistent Volume 特性

该特性即为 Kubernetes 能够直接使用宿主机上的本地磁盘目录,而不依赖于远程存储服务,来提供“持久化”的容器 Volume,因为本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多。Kubernetes 在 v1.10 之后,就逐渐依靠 PV、PVC 体系实现了这个特性。

高优先级的系统应用,需要在多个不同节点上存储数据,并且对 I/O 较为敏感的应用非常适用 Local Persistent Volume 。典型的应用包括:分布式数据存储比如 MongoDB、Cassandra 等,分布式文件系统比如 GlusterFS、Ceph 等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。相比于正常的 PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume 的数据就可能丢失。这就要求使用 Local Persistent Volume 的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。

Local Persistent Volume,并不是 hostPath 加 NodeAffinity,先来这种PV的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

apiVersion: v1
kind: PersistentVolume
metadata:
name: example-pv
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Delete
storageClassName: local-storage
local:
path: /mnt/disks/vol1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node-1

需要注意的是在生产环境中,/mnt/disks/vol1 这种目录必须是独立与系统目录的磁盘,因为它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的 I/O 隔离机制。

如果 Pod 要想使用这个 PV,那它就必须运行在 node-1 上。所以,在这个 PV 的定义里,需要有一个 nodeAffinity 字段指定 node-1 这个节点的名字。

那么下面创建一个 StorageClass 来描述这个 PV :

1
2
3
4
5
6
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer ## 延迟绑定

这里 provisioner: kubernetes.io/no-provisioner 目前尚不支持 Dynamic Provisioning ,所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV。也就是说,我们前面创建 PV 的操作,是不可以省略的。

当提交了 PV 和 PVC 的 YAML 文件之后,Kubernetes 就会根据它们俩的属性,以及它们指定的 StorageClass 来进行绑定。只有绑定成功后,Pod 才能通过声明这个 PVC 来使用对应的 PV。

这里的延迟绑定的作用我们使用下面一个小场景来说明:

  • 如果存在一个pod,Pod,它声明使用的 PVC 叫作 pvc-1,但是规定该Pod运行在node-2上。
  • 集群也存在两个local pv,第一个 PV 的名字叫作 pv-1,它对应的磁盘所在的节点是 node-1。而第二个 PV 的名字叫作 pv-2,它对应的磁盘所在的节点是 node-2,两个pv 类型相同。

当出现这种情况时时,如果不使用延迟绑定,Kubernetes 的 Volume 控制循环里过滤到第一个PV并与PVC绑定,到调度器过滤节点时候发现 Pod 根本不允许运行在 node-1 ,只能在node2运行的时候,调度失败。

volumeBindingMode=WaitForFirstConsumer 的含义即为虽然你已经发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但请不要现在就执行绑定操作(即:设置 PVC 的 VolumeName 字段)。要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置,来统一决定,这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。这样避免了出现调度失败的问题,在具体实现中,调度器实际上维护了一个与 Volume Controller 类似的控制循环,专门负责为那些声明了“延迟绑定”的 PV 和 PVC 进行绑定工作。

我们上面手动创建 PV 的方式,即 Static 的 PV 管理方式,在删除 PV 时需要按如下流程执行操作:

  • 删除使用这个 PV 的 Pod;
  • 从宿主机移除本地磁盘(比如:umount );
  • 删除 PVC;
  • 删除 PV。

由于上面这些创建 PV 和删除 PV 的操作比较繁琐,Kubernetes 其实提供了一个 Static Provisioner 来帮助你管理这些 PV。当 Static Provisioner 启动后,它就会通过 DaemonSet,自动检查每个宿主机的 /mnt/disks 目录。然后,调用 Kubernetes API,为这些目录下面的每一个挂载,创建一个对应的 PV 对象出来。

详情: https://github.com/kubernetes-incubator/external-storage/tree/master/local-volume

CATALOG
  1. 1. Kubernetes 持久化存储
    1. 1.1. PV 与 PVC
    2. 1.2. 动态创建持久化卷 StorageClass
    3. 1.3. PersistentVolumeController 原理
    4. 1.4. 持久化容器存储流程
    5. 1.5. Local Persistent Volume 特性