容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。
“持久化 Volume”,指的就是这个宿主机上的目录,具备“持久性。即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容。
大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。
Persistent Volume(PV)、 Persistent Volume Claim(PVC)和 StorageClass
Kubernetes 中 PVC 和 PV 的设计,类似于“接口”和“实现”的思想 。开发者只要知道并会使用“接口”,即:PVC;而运维人员则负责给“接口”绑定具体的实现,即:PV。
PV
PV 定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。
Copy apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
nfs:
server: 10.244.1.4
path: "/"
PV对象是如何变成容器里的一个持久化存储的呢?
显然,hostPath 和 emptyDir 类型的 Volume 并不具备持久性:它们既有可能被 kubelet 清理掉,也不能被“迁移”到其他节点上,所以上述例子是使用了 NFS 类型。
而 Kubernetes 需要做的工作,就是使用这些存储服务,把远程存储目录挂载到容器指定的目录,容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”(挂载远程磁盘)。
PVC
PVC 定义的,则是 Pod 所希望使用的持久化存储的属性。比如,Volume 存储的大小、可读写权限等等。以下定义一个 1 GiB 大小的 PVC:
Copy apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-pvc
spec:
accessModes:
- ReadWriteMany
storageClassName: manual
resources:
requests:
storage: 1Gi
PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就必须满足 PVC 的要求。
PV 和 PVC 的 storageClassName 字段必须一样(PV 和 PVC是通过 StorageClass 来绑定的)。
PV 和 PVC 是如何绑定的呢?
Kubernetes 中有一个 PersistentVolumeController,会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态。如果不是,那它就会遍历所有的、可用的 PV,并尝试将其与这个“单身”的 PVC 进行绑定。这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态。
而所谓将一个 PV 与 PVC 进行“绑定”,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上(可通过kubectl describe pvc pvc-name
查看)。所以,接下来 Kubernetes 只要获取到这个 PVC 对象,就一定能够找到它所绑定的 PV。
Pod如何使用它们呢?
在成功地将 PVC 和 PV 进行绑定之后,Pod 就能够像使用 hostPath 等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用这个 PVC 了,如下所示:
Copy 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
StorageClass
Copy apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-ssd
如果没有 StorageClass,那么一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV,非常的麻烦。
而 StorageClass 是一套可以自动创建PV的机制。
sc定义绑定的是一类pv和pvc(不是一个)
pv,sc、pvc是一对,bound之后就找不到已经被使用的那个sc了
subPath
Copy apiVersion: apps/v1
kind: Deployment
metadata:
labels:
name: backend-service
name: backend-service
spec:
replicas: 1
selector:
matchLabels:
name: backend-service
strategy: {}
template:
metadata:
labels:
name: backend-service
spec:
volumes:
- name: app-data
persistentVolumeClaim:
claimName: local-app-claim
containers:
- image: registry.cn-qingdao.aliyuncs.com/zw_private/bio-r:v0.2
name: bio-r
imagePullPolicy: IfNotPresent
volumeMounts:
- name: app-data
mountPath: "/workspace"
subPath: "r" # 子路径,代表存储卷下面的子目录
- image: registry.cn-qingdao.aliyuncs.com/zw_private/bio-api:v0.01
name: bio-api
imagePullPolicy: IfNotPresent
volumeMounts:
- name: app-data
mountPath: "/workspace/r/static"
subPath: "r/static" # 存储卷下面的多级子路径
- name: app-data
mountPath: "/workspace/config.yaml"
subPath: "config.yaml" # 只挂载这一个文件,使这个文件覆盖容器内的配置文件启动!
k8s挂载的文件或目录都会直接全覆盖容器内相应的挂载目标,类似于docker的-v
Local Persistent Volume
适用场景
高优先级的系统应用,需要在多个不同节点上存储数据,并且对 I/O 较为敏感。
典型的应用包括:分布式数据存储比如 MongoDB、Cassandra 等,分布式文件系统比如 GlusterFS、Ceph 等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。
相比于正常的 PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume 的数据就可能丢失。这就要求使用 Local Persistent Volume 的应用必须具备数据备份和恢复的能力 ,允许你把这些数据定时备份在其他位置。
经典误区:Local PV,不就等同于 hostPath 加 NodeAffinity 吗?
比如,一个 Pod 可以声明使用类型为 Local 的 PV,而这个 PV 其实就是一个 hostPath 类型的 Volume。如果这个 hostPath 对应的目录,已经在节点 A 上被事先创建好了。那么,我只需要再给这个 Pod 加上一个 nodeAffinity=nodeA,不就可以使用这个 Volume 了吗?
事实上,你绝不应该把一个宿主机上的目录当作 PV 使用 。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的 I/O 隔离机制。
所以,一个 Local Persistent Volume 对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(“额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为“一个 PV 一块盘 ”。
为什么这么设计?
对于常规的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后,再通过“两阶段处理”来“持久化”这台机器上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载。
可是,对于 Local PV 来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。
所以,这时候,调度器就必须能够知道所有节点与 Local Persistent Volume 对应的磁盘的关联关系,然后根据这个信息来调度 Pod(把Pod调度到满足条件的node上)。
操作实践
第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作;
第二种,对于实验环境,你其实可以在宿主机上挂载几个 RAM Disk(内存盘)来模拟本地磁盘。
以下,按第二种操作实践。
准备工作
挂载 RAM Disk(内存盘)来模拟本地磁盘,在Linux下的命令示例:
Copy # 在 node-1 上执行
$ mkdir /mnt/disks
$ for vol in vol1 vol2 vol3; do
mkdir /mnt/disks/$vol
mount -t tmpfs $vol /mnt/disks/$vol # 在不同的节点定义的磁盘名字要不同
done
mac 不支持 tmpfs,可用 diskutil。
RAM disk 把内存当硬盘使,推出该 Ramdisk 即可释放内存,然后绑定在本地磁盘地址的数据也会全部丢失。
格式如下:
Copy diskutil erasevolume HFS+ "<名称>" `hdiutil attach -nomount ram://$((<容量(GB)>*2097152))`
例如:
Copy $ diskutil erasevolume HFS+ "k8s-disk" `hdiutil attach -nomount ram://$((1*2097152))`
Started erase on disk2
Unmounting disk
Erasing
Initialized /dev/rdisk2 as a 1024 MB case-insensitive HFS Plus volume
Mounting disk
Finished erase on disk2 (k8s-disk)
操作成功后的挂载路径:/Volumes/k8s-disk
创建 k8s node 标签,指令格式:
Copy $ kubectl label nodes <node-name> <label-key>=<label-value>
例如:
Copy $ kubectl get nodes
NAME STATUS ROLES AGE VERSION
docker-desktop Ready master 153d v1.19.7
# 可以看到有多组标签,可以找到kubernetes.io/hostname=docker-desktop
# 可以找到InternalIP:192.168.65.4
$ kubectl describe node docker-desktop
$ kubectl label node 192.168.65.4 custom_label=test # 给指定node添加自定义标签
创建pv、pvc、storageClass
Copy $ cat local_pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: example-pv
spec:
capacity:
storage: 0.5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Delete
storageClassName: local-storage
local:
path: /Volumes/k8s-disk
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- docker-desktop
$ kubectl create -f local_pv.yaml
persistentvolume/example-pv created
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
example-pv 5Gi RWO Delete Available
# 因为 Local Persistent Volume 不支持 Dynamic Provisioning
# provisioner 指定的是 no-provisioner
# 所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV
# 也就是说,前面创建 PV 的操作,是不可以省略的
# volumeBindingMode=WaitForFirstConsumer 延迟绑定
$ cat local_sc.yaml
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
$ kubectl create -f local_sc.yaml
storageclass.storage.k8s.io/local-storage created
# storageClassName: local-storage
# 所以,local_pvc 创建之后 k8s 的 Volume Controller 不会为它进行绑定操作
$ catlocal_pvc.yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: example-local-claim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 0.5Gi
storageClassName: local-storage
$ kubectl create -f local_pvc.yaml
persistentvolumeclaim/example-local-claim created
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
example-local-claim Pending
创建Pod
Copy $ cat local_pod.yaml
kind: Pod
apiVersion: v1
metadata:
name: example-pv-pod
spec:
volumes:
- name: example-pv-storage
persistentVolumeClaim:
claimName: example-local-claim
containers:
- name: example-pv-container
image: nginx
ports:
- containerPort: 80
name: "http-server"
volumeMounts:
- mountPath: "/usr/share/nginx/html"
name: example-pv-storage
$ kubectl create -f local_pod.yaml
pod/example-pv-pod created
$ kubectl get pvc # 创建pod后STATUS就变成了Bound
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
example-local-claim Bound example-pv 5Gi RWO local-storage 6h
测试localPV的功能
进入容器创建文件
Copy $ kubectl exec -it example-pv-pod -- /bin/sh
# cd /usr/share/nginx/html # Pod 把volume挂载到了 /usr/share/nginx/html
# touch test.txt
然后可以检查宿主机目录,尝试重建该Pod,发现该创建的文件还在。
延迟绑定
当提交了 PV 和 PVC 的 YAML 文件之后,Kubernetes 就会根据它们俩的属性,以及它们指定的 StorageClass 来进行绑定。只有绑定成功后,Pod 才能通过声明这个 PVC 来使用对应的 PV。
可是,如果你使用的是 Local Persistent Volume 的话,就会发现,这个流程根本行不通。
比如,现在你有一个 Pod,它声明使用的 PVC 叫作 pvc-1。并且,我们规定,这个 Pod 只能运行在 node-2 上。
而在 Kubernetes 集群中,有两个属性(比如:大小、读写权限)相同的 Local 类型的 PV。
其中,第一个 PV 的名字叫作 pv-1,它对应的磁盘所在的节点是 node-1。而第二个 PV 的名字叫作 pv-2,它对应的磁盘所在的节点是 node-2。
假设现在,Kubernetes 的 Volume 控制循环里,首先检查到了 pvc-1 和 pv-1 的属性是匹配的,于是就将它们俩绑定在一起。
然后,你用 kubectl create 创建了这个 Pod。
这时候,问题就出现了。
调度器看到,这个 Pod 所声明的 pvc-1 已经绑定了 pv-1,而 pv-1 所在的节点是 node-1,根据“调度器必须在调度的时候考虑 Volume 分布”的原则,这个 Pod 自然会被调度到 node-1 上。
可是,我们前面已经规定过,这个 Pod 根本不允许运行在 node-1 上。所以。最后的结果就是,这个 Pod 的调度必然会失败。
这就是为什么,在使用 Local Persistent Volume 的时候,我们必须想办法推迟这个“绑定”操作。
那么,具体推迟到什么时候呢?
答案是:推迟到调度的时候。
所以说,StorageClass 里的 volumeBindingMode=WaitForFirstConsumer 的含义,就是告诉 Kubernetes 里的 Volume 控制循环(“红娘”):虽然你已经发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但请不要现在就执行绑定操作(即:设置 PVC 的 VolumeName 字段)。
而要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置,来统一决定,这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。
这样,在上面的例子里,由于这个 Pod 不允许运行在 pv-1 所在的节点 node-1,所以它的 PVC 最后会跟 pv-2 绑定,并且 Pod 也会被调度到 node-2 上。
所以,通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响 Pod 的正常调度。
存储插件Rook
安装
Rook 项目巧妙地依赖了 Kubernetes 提供的编排能力,合理的使用了很多诸如 Operator、CRD 等重要的扩展特性,属于“云原生”项目(也就是“Kubernetes 原生”)。Rook 具备水平扩展、迁移、灾难备份、监控等大量的企业级功能,是一个完整的、生产级别可用的容器存储插件。
很遗憾,有的镜像拉不下来,比如 k8s.gcr.io 下的镜像,用如下方法替换之:
Copy # 拉取国内镜像
$ docker pull registry.aliyuncs.com/google_containers/csi-node-driver-registrar:v2.2.0
$ docker pull registry.aliyuncs.com/google_containers/csi-attacher:v3.2.1
$ docker pull registry.aliyuncs.com/google_containers/csi-provisioner:v2.2.2
$ docker pull registry.aliyuncs.com/google_containers/csi-snapshotter:v4.1.1
$ docker pull registry.aliyuncs.com/google_containers/csi-resizer:v1.2.0
# 手动将镜像做tag,否则依然会去远端下载
$ docker tag registry.aliyuncs.com/google_containers/csi-node-driver-registrar:v2.2.0 k8s.gcr.io/sig-storage/csi-node-driver-registrar:v2.2.0
$ docker tag registry.aliyuncs.com/google_containers/csi-attacher:v3.2.1 k8s.gcr.io/sig-storage/csi-attacher:v3.2.1
$ docker tag registry.aliyuncs.com/google_containers/csi-provisioner:v2.2.2 k8s.gcr.io/sig-storage/csi-provisioner:v2.2.2
$ docker tag registry.aliyuncs.com/google_containers/csi-snapshotter:v4.1.1 k8s.gcr.io/sig-storage/csi-snapshotter:v4.1.1
$ docker tag registry.aliyuncs.com/google_containers/csi-resizer:v1.2.0 k8s.gcr.io/sig-storage/csi-resizer:v1.2.0
依赖的 k8s.gcr.io 镜像都类似处理,前缀换成 registry.aliyuncs.com/google_containers,然后打 tag。
Copy $ kubectl get pods -n rook-ceph
NAME READY STATUS RESTARTS AGE
csi-cephfsplugin-fj452 3/3 Running 0 9m23s
csi-cephfsplugin-provisioner-789d69957d-64rf5 6/6 Running 0 9m22s
csi-rbdplugin-6ft6j 3/3 Running 0 9m24s
csi-rbdplugin-provisioner-d8bcc5fc4-qc2r2 6/6 Running 0 9m24s
rook-ceph-operator-9bf8b5959-lvns9 1/1 Running 0 10m
$ kubectl patch storageclass rook-ceph-block -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' # 设置成默认的storageclass
$ kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
hostpath docker.io/hostpath Delete Immediate false 130d
rook-ceph-block (default) ceph.rook.io/block Delete Immediate false 2m15s
这样,一个基于 Rook 持久化存储集群就以容器的方式运行起来了,而接下来在 Kubernetes 项目上创建的所有 Pod 就能够通过 Persistent Volume(PV)和 Persistent Volume Claim(PVC)的方式,在容器里挂载由 Ceph 提供的数据卷了。
使用
Copy $ kubectl create -f rook-storage.yaml
$ cat rook-storage.yaml
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: rook-ceph-block
provisioner: ceph.rook.io/block
parameters:
pool: replicapool
clusterNamespace: rook-ceph
StorageClass 的作用,是自动地为集群里存在的每一个 PVC,调用存储插件(Rook)创建对应的 PV,从而省去了手动创建 PV 的机械劳动。
注:在使用 Rook 的情况下,statefulset.yaml 里的 volumeClaimTemplates.spec字段下需要加上声明 storageClassName:rook-ceph-block,才能使用到这个 Rook 提供的持久化存储。