mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<audio id="audio" title="28 | PV、PVC、StorageClass,这些到底在说啥?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/5c/327d67f77f2389f233559ebcf54a715c.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:PV、PVC、StorageClass,这些到底在说啥?
|
||||
|
||||
在前面的文章中,我重点为你分析了Kubernetes的各种编排能力。
|
||||
|
||||
在这些讲解中,你应该已经发现,容器化一个应用比较麻烦的地方,莫过于对其“状态”的管理。而最常见的“状态”,又莫过于存储状态了。
|
||||
|
||||
所以,从今天这篇文章开始,我会**通过4篇文章为你剖析Kubernetes项目处理容器持久化存储的核心原理**,从而帮助你更好地理解和使用这部分内容。
|
||||
|
||||
首先,我们来回忆一下我在第19篇文章[《深入理解StatefulSet(二):存储状态》](https://time.geekbang.org/column/article/41154)中,和你分享StatefulSet如何管理存储状态的时候,介绍过的Persistent Volume(PV)和Persistent Volume Claim(PVC)这套持久化存储体系。
|
||||
|
||||
其中,**PV描述的,是持久化存储数据卷**。这个API对象主要定义的是一个持久化存储在宿主机上的目录,比如一个NFS的挂载目录。
|
||||
|
||||
通常情况下,PV对象是由运维人员事先创建在Kubernetes集群里待用的。比如,运维人员可以定义这样一个NFS类型的PV,如下所示:
|
||||
|
||||
```
|
||||
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。
|
||||
|
||||
比如,开发人员可以声明一个1 GiB大小的PVC,如下所示:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: nfs
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: manual
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
```
|
||||
|
||||
而用户创建的PVC要真正被容器使用起来,就必须先和某个符合条件的PV进行绑定。这里要检查的条件,包括两部分:
|
||||
|
||||
- 第一个条件,当然是PV和PVC的spec字段。比如,PV的存储(storage)大小,就必须满足PVC的要求。
|
||||
- 而第二个条件,则是PV和PVC的storageClassName字段必须一样。这个机制我会在本篇文章的最后一部分专门介绍。
|
||||
|
||||
在成功地将PVC和PV进行绑定之后,Pod就能够像使用hostPath等常规类型的Volume一样,在自己的YAML文件里声明使用这个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
|
||||
|
||||
```
|
||||
|
||||
可以看到,Pod需要做的,就是在volumes字段里声明自己要使用的PVC名字。接下来,等这个Pod创建之后,kubelet就会把这个PVC所对应的PV,也就是一个NFS类型的Volume,挂载在这个Pod容器内的目录上。
|
||||
|
||||
不难看出,**PVC和PV的设计,其实跟“面向对象”的思想完全一致。**
|
||||
|
||||
PVC可以理解为持久化存储的“接口”,它提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分则由PV负责完成。
|
||||
|
||||
这样做的好处是,作为应用开发者,我们只需要跟PVC这个“接口”打交道,而不必关心具体的实现是NFS还是Ceph。毕竟这些存储相关的知识太专业了,应该交给专业的人去做。
|
||||
|
||||
而在上面的讲述中,其实还有一个比较棘手的情况。
|
||||
|
||||
比如,你在创建Pod的时候,系统里并没有合适的PV跟它定义的PVC绑定,也就是说此时容器想要使用的Volume不存在。这时候,Pod的启动就会报错。
|
||||
|
||||
但是,过了一会儿,运维人员也发现了这个情况,所以他赶紧创建了一个对应的PV。这时候,我们当然希望Kubernetes能够再次完成PVC和PV的绑定操作,从而启动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。
|
||||
|
||||
那么,这个PV对象,又是如何变成容器里的一个持久化存储的呢?
|
||||
|
||||
我在前面讲解容器基础的时候,已经为你详细剖析了容器Volume的挂载机制。用一句话总结,**所谓容器的Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。**(你可以借此机会,再回顾一下专栏的第8篇文章[《白话容器基础(四):重新认识Docker容器》](https://time.geekbang.org/column/article/18119)中的相关内容)
|
||||
|
||||
**而所谓的“持久化Volume”,指的就是这个宿主机上的目录,具备“持久性”**。即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个Volume,访问到这些内容。
|
||||
|
||||
显然,我们前面使用的hostPath和emptyDir类型的Volume并不具备这个特征:它们既有可能被kubelet清理掉,也不能被“迁移”到其他节点上。
|
||||
|
||||
所以,大多数情况下,持久化Volume的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。
|
||||
|
||||
而Kubernetes需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用。而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”。
|
||||
|
||||
**这个准备“持久化”宿主机目录的过程,我们可以形象地称为“两阶段处理”。**
|
||||
|
||||
接下来,我通过一个具体的例子为你说明。
|
||||
|
||||
当一个Pod调度到一个节点上之后,kubelet就要负责为这个Pod创建它的Volume目录。默认情况下,kubelet为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所在的宿主机上。
|
||||
|
||||
>
|
||||
备注:你如果不太了解块存储的话,可以直接把它理解为:一块**磁盘**。
|
||||
|
||||
|
||||
这相当于执行:
|
||||
|
||||
```
|
||||
$ gcloud compute instances attach-disk <虚拟机名字> --disk <远程磁盘名字>
|
||||
|
||||
```
|
||||
|
||||
这一步**为虚拟机挂载远程磁盘的操作,对应的正是“两阶段处理”的第一阶段。在Kubernetes中,我们把这个阶段称为Attach。**
|
||||
|
||||
Attach阶段完成后,为了能够使用这个远程磁盘,kubelet还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上。不难理解,这个挂载点,正是我在前面反复提到的Volume的宿主机目录。所以,这一步相当于执行:
|
||||
|
||||
```
|
||||
# 通过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。**
|
||||
|
||||
Mount阶段完成后,这个Volume的宿主机目录就是一个“持久化”的目录了,容器在它里面写入的内容,会保存在Google Cloud的远程磁盘中。
|
||||
|
||||
而如果你的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宿主机目录的“持久化”。
|
||||
|
||||
**到这里,你可能会有疑问,Kubernetes又是如何定义和区分这两个阶段的呢?**
|
||||
|
||||
其实很简单,在具体的Volume插件的实现接口上,Kubernetes分别给这两个阶段提供了两种不同的参数列表:
|
||||
|
||||
- 对于“第一阶段”(Attach),Kubernetes提供的可用参数是nodeName,即宿主机的名字。
|
||||
- 而对于“第二阶段”(Mount),Kubernetes提供的可用参数是dir,即Volume的宿主机目录。
|
||||
|
||||
所以,作为一个存储插件,你只需要根据自己的需求进行选择和实现即可。在后面关于编写存储插件的文章中,我会对这个过程做深入讲解。
|
||||
|
||||
而经过了“两阶段处理”,我们就得到了一个“持久化”的Volume宿主机目录。所以,接下来,kubelet只要把这个Volume目录通过CRI里的Mounts参数,传递给Docker,然后就可以为Pod里的容器挂载这个“持久化”的Volume了。其实,这一步相当于执行了如下所示的命令:
|
||||
|
||||
```
|
||||
$ docker run -v /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>:/<容器内的目标目录> 我的镜像 ...
|
||||
|
||||
```
|
||||
|
||||
以上,就是Kubernetes处理PV的具体原理了。
|
||||
|
||||
>
|
||||
备注:对应地,在删除一个PV的时候,Kubernetes也需要Unmount和Dettach两个阶段来处理。这个过程我就不再详细介绍了,执行“反向操作”即可。
|
||||
|
||||
|
||||
实际上,你可能已经发现,这个PV的处理流程似乎跟Pod以及容器的启动流程没有太多的耦合,只要kubelet在向Docker发起CRI请求之前,确保“持久化”的宿主机目录已经处理完毕即可。
|
||||
|
||||
所以,在Kubernetes中,上述**关于PV的“两阶段处理”流程,是靠独立于kubelet主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。**
|
||||
|
||||
其中,“第一阶段”的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的主循环解耦,Kubernetes就避免了这些耗时的远程挂载操作拖慢kubelet的主控制循环,进而导致Pod的创建效率大幅下降的问题。实际上,**kubelet的一个主要设计原则,就是它的主控制循环绝对不可以被block**。这个思想,我在后续的讲述容器运行时的时候还会提到。
|
||||
|
||||
在了解了Kubernetes的Volume处理机制之后,我再来为你介绍这个体系里最后一个重要概念:StorageClass。
|
||||
|
||||
我在前面介绍PV和PVC的时候,曾经提到过,PV这个对象的创建,是由运维人员完成的。但是,在大规模的生产环境里,这其实是一个非常麻烦的工作。
|
||||
|
||||
这是因为,一个大规模的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。
|
||||
|
||||
举个例子,假如我们的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
|
||||
|
||||
```
|
||||
|
||||
在这个YAML文件里,我们定义了一个名叫block-service的StorageClass。
|
||||
|
||||
这个StorageClass的provisioner字段的值是:`kubernetes.io/gce-pd`,这正是Kubernetes内置的GCE PD存储插件的名字。
|
||||
|
||||
而这个StorageClass的parameters字段,就是PV的参数。比如:上面例子里的type=pd-ssd,指的是这个PV的类型是“SSD格式的GCE远程磁盘”。
|
||||
|
||||
需要注意的是,由于需要使用GCE Persistent Disk,上面这个例子只有在GCE提供的Kubernetes服务里才能实践。如果你想使用我们之前部署在本地的Kubernetes集群以及Rook存储服务的话,你的StorageClass需要使用如下所示的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: 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
|
||||
|
||||
```
|
||||
|
||||
在这个YAML文件中,我们定义的还是一个名叫block-service的StorageClass,只不过它声明使的存储插件是由Rook项目。
|
||||
|
||||
有了StorageClass的YAML文件之后,运维人员就可以在Kubernetes里创建这个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里添加了一个叫作storageClassName的字段,用于指定该PVC所要使用的StorageClass的名字是:block-service。
|
||||
|
||||
以Google Cloud为例。
|
||||
|
||||
当我们通过kubectl create创建上述PVC对象之后,Kubernetes就会调用Google Cloud的API,创建出一块SSD格式的Persistent Disk。然后,再使用这个Persistent Disk的信息,自动创建出一个对应的PV对象。
|
||||
|
||||
我们可以一起来实践一下这个过程(如果使用Rook的话下面的流程也是一样的,只不过Rook创建出的是Ceph类型的PV):
|
||||
|
||||
```
|
||||
$ kubectl create -f pvc.yaml
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们创建的PVC会绑定一个Kubernetes自动创建的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的属性,你就可以看到它跟我们在PVC里声明的存储的属性是一致的,如下所示:
|
||||
|
||||
```
|
||||
$ 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绑定起来。**
|
||||
|
||||
有了Dynamic Provisioning机制,运维人员只需要在Kubernetes集群里创建出数量有限的StorageClass对象就可以了。这就好比,运维人员在Kubernetes集群里创建出了各种各样的PV模板。这时候,当开发人员提交了包含StorageClass字段的PVC之后,Kubernetes就会根据这个StorageClass创建出对应的PV。
|
||||
|
||||
>
|
||||
[Kubernetes的官方文档](https://kubernetes.io/docs/concepts/storage/storage-classes/#provisioner)里已经列出了默认支持Dynamic Provisioning的内置存储插件。而对于不在文档里的插件,比如NFS,或者其他非内置存储插件,你其实可以通过[kubernetes-incubator/external-storage](https://github.com/kubernetes-incubator/external-storage)这个库来自己编写一个外部插件完成这个工作。像我们之前部署的Rook,已经内置了external-storage的实现,所以Rook是完全支持Dynamic Provisioning特性的。
|
||||
|
||||
|
||||
需要注意的是,**StorageClass并不是专门为了Dynamic Provisioning而设计的。**
|
||||
|
||||
比如,在本篇一开始的例子里,我在PV和PVC里都声明了storageClassName=manual。而我的集群里,实际上并没有一个名叫manual的StorageClass对象。这完全没有问题,这个时候Kubernetes进行的是Static Provisioning,但在做绑定决策的时候,它依然会考虑PV和PVC的StorageClass定义。
|
||||
|
||||
而这么做的好处也很明显:这个PVC和PV的绑定关系,就完全在我自己的掌控之中。
|
||||
|
||||
这里,你可能会有疑问,我在之前讲解StatefulSet存储状态的例子时,好像并没有声明StorageClass啊?
|
||||
|
||||
实际上,如果你的集群已经开启了名叫DefaultStorageClass的Admission Plugin,它就会为PVC和PV自动添加一个默认的StorageClass;**否则,PVC的storageClassName的值就是“”,这也意味着它只能够跟storageClassName也是“”的PV进行绑定。**
|
||||
|
||||
## 总结
|
||||
|
||||
在今天的分享中,我为你详细解释了PVC和PV的设计与实现原理,并为你阐述了StorageClass到底是干什么用的。这些概念之间的关系,可以用如下所示的一幅示意图描述:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/d9/e8b2586e4e14eb54adf8ff95c5c18cd9.png" alt="" /><br />
|
||||
从图中我们可以看到,在这个体系中:
|
||||
|
||||
<li>
|
||||
PVC描述的,是Pod想要使用的持久化存储的属性,比如存储的大小、读写权限等。
|
||||
</li>
|
||||
<li>
|
||||
PV描述的,则是一个具体的Volume的属性,比如Volume的类型、挂载目录、远程存储服务器地址等。
|
||||
</li>
|
||||
<li>
|
||||
而StorageClass的作用,则是充当PV的模板。并且,只有同属于一个StorageClass的PV和PVC,才可以绑定在一起。
|
||||
</li>
|
||||
|
||||
当然,StorageClass的另一个重要作用,是指定PV的Provisioner(存储插件)。这时候,如果你的存储插件支持Dynamic Provisioning的话,Kubernetes就可以自动为你创建PV了。
|
||||
|
||||
基于上述讲述,为了统一概念和方便叙述,在本专栏中,我以后凡是提到“Volume”,指的就是一个远程存储服务挂载在宿主机上的持久化目录;而“PV”,指的是这个Volume在Kubernetes里的API对象。
|
||||
|
||||
需要注意的是,这套容器持久化存储体系,完全是Kubernetes项目自己负责管理的,并不依赖于docker volume命令和Docker的存储插件。当然,这套体系本身就比docker volume命令的诞生时间还要早得多。
|
||||
|
||||
## 思考题
|
||||
|
||||
在了解了PV、PVC的设计和实现原理之后,你是否依然觉得它有“过度设计”的嫌疑?或者,你是否有更加简单、足以解决你90%需求的Volume的用法?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<audio id="audio" title="29 | PV、PVC体系是不是多此一举?从本地持久化卷谈起" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/02/b2ecc008949240aaeffe3bafe427d102.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:PV、PVC体系是不是多此一举?从本地持久化卷谈起。
|
||||
|
||||
在上一篇文章中,我为你详细讲解了PV、PVC持久化存储体系在Kubernetes项目中的设计和实现原理。而在文章最后的思考题中,我为你留下了这样一个讨论话题:像PV、PVC这样的用法,是不是有“过度设计”的嫌疑?
|
||||
|
||||
比如,我们公司的运维人员可以像往常一样维护一套NFS或者Ceph服务器,根本不必学习Kubernetes。而开发人员,则完全可以靠“复制粘贴”的方式,在Pod的YAML文件里填上Volumes字段,而不需要去使用PV和PVC。
|
||||
|
||||
实际上,如果只是为了职责划分,PV、PVC体系确实不见得比直接在Pod里声明Volumes字段有什么优势。
|
||||
|
||||
不过,你有没有想过这样一个问题,如果[Kubernetes内置的20种持久化数据卷实现](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#types-of-persistent-volumes),都没办法满足你的容器存储需求时,该怎么办?
|
||||
|
||||
这个情况乍一听起来有点不可思议。但实际上,凡是鼓捣过开源项目的读者应该都有所体会,“不能用”“不好用”“需要定制开发”,这才是落地开源基础设施项目的三大常态。
|
||||
|
||||
而在持久化存储领域,用户呼声最高的定制化需求,莫过于支持“本地”持久化存储了。
|
||||
|
||||
也就是说,用户希望Kubernetes能够直接使用宿主机上的本地磁盘目录,而不依赖于远程存储服务,来提供“持久化”的容器Volume。
|
||||
|
||||
这样做的好处很明显,由于这个Volume直接使用的是本地磁盘,尤其是SSD盘,它的读写性能相比于大多数远程存储来说,要好得多。这个需求对本地物理服务器部署的私有Kubernetes集群来说,非常常见。
|
||||
|
||||
所以,Kubernetes在v1.10之后,就逐渐依靠PV、PVC体系实现了这个特性。这个特性的名字叫作:Local Persistent Volume。
|
||||
|
||||
不过,首先需要明确的是,**Local Persistent Volume并不适用于所有应用**。事实上,它的适用范围非常固定,比如:高优先级的系统应用,需要在多个不同节点上存储数据,并且对I/O较为敏感。典型的应用包括:分布式数据存储比如MongoDB、Cassandra等,分布式文件系统比如GlusterFS、Ceph等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。
|
||||
|
||||
其次,相比于正常的PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume的数据就可能丢失。这就要求**使用Local Persistent Volume的应用必须具备数据备份和恢复的能力**,允许你把这些数据定时备份在其他位置。
|
||||
|
||||
接下来,我就为你深入讲解一下这个特性。
|
||||
|
||||
不难想象,Local Persistent Volume的设计,主要面临两个难点。
|
||||
|
||||
**第一个难点在于**:如何把本地磁盘抽象成PV。
|
||||
|
||||
可能你会说,Local Persistent Volume,不就等同于hostPath加NodeAffinity吗?
|
||||
|
||||
比如,一个Pod可以声明使用类型为Local的PV,而这个PV其实就是一个hostPath类型的Volume。如果这个hostPath对应的目录,已经在节点A上被事先创建好了。那么,我只需要再给这个Pod加上一个nodeAffinity=nodeA,不就可以使用这个Volume了吗?
|
||||
|
||||
事实上,**你绝不应该把一个宿主机上的目录当作PV使用**。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的I/O隔离机制。
|
||||
|
||||
所以,一个Local Persistent Volume对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(“额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为“**一个PV一块盘**”。
|
||||
|
||||
**第二个难点在于**:调度器如何保证Pod始终能被正确地调度到它所请求的Local Persistent Volume所在的节点上呢?
|
||||
|
||||
造成这个问题的原因在于,对于常规的PV来说,Kubernetes都是先调度Pod到某个节点上,然后,再通过“两阶段处理”来“持久化”这台机器上的Volume目录,进而完成Volume目录与容器的绑定挂载。
|
||||
|
||||
可是,对于Local PV来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。
|
||||
|
||||
所以,这时候,调度器就必须能够知道所有节点与Local Persistent Volume对应的磁盘的关联关系,然后根据这个信息来调度Pod。
|
||||
|
||||
这个原则,我们可以称为“**在调度的时候考虑Volume分布**”。在Kubernetes的调度器里,有一个叫作VolumeBindingChecker的过滤条件专门负责这个事情。在Kubernetes v1.11中,这个过滤条件已经默认开启了。
|
||||
|
||||
基于上述讲述,在开始使用Local Persistent Volume之前,你首先需要在集群里配置好磁盘或者块设备。在公有云上,这个操作等同于给虚拟机额外挂载一个磁盘,比如GCE的Local SSD类型的磁盘就是一个典型例子。
|
||||
|
||||
而在我们部署的私有环境中,你有两种办法来完成这个步骤。
|
||||
|
||||
- 第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作;
|
||||
- 第二种,对于实验环境,你其实可以在宿主机上挂载几个RAM Disk(内存盘)来模拟本地磁盘。
|
||||
|
||||
接下来,我会使用第二种方法,在我们之前部署的Kubernetes集群上进行实践。
|
||||
|
||||
**首先**,在名叫node-1的宿主机上创建一个挂载点,比如/mnt/disks;**然后**,用几个RAM Disk来模拟本地磁盘,如下所示:
|
||||
|
||||
```
|
||||
# 在node-1上执行
|
||||
$ mkdir /mnt/disks
|
||||
$ for vol in vol1 vol2 vol3; do
|
||||
mkdir /mnt/disks/$vol
|
||||
mount -t tmpfs $vol /mnt/disks/$vol
|
||||
done
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,如果你希望其他节点也能支持Local Persistent Volume的话,那就需要为它们也执行上述操作,并且确保这些磁盘的名字(vol1、vol2等)都不重复。
|
||||
|
||||
接下来,我们就可以为这些本地磁盘定义对应的PV了,如下所示:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个PV的定义里:local字段,指定了它是一个Local Persistent Volume;而path字段,指定的正是这个PV对应的本地磁盘的路径,即:/mnt/disks/vol1。
|
||||
|
||||
当然了,这也就意味着如果Pod要想使用这个PV,那它就必须运行在node-1上。所以,在这个PV的定义里,需要有一个nodeAffinity字段指定node-1这个节点的名字。这样,调度器在调度Pod的时候,就能够知道一个PV与节点的对应关系,从而做出正确的选择。**这正是Kubernetes实现“在调度的时候就考虑Volume分布”的主要方法。**
|
||||
|
||||
**接下来**,我们就可以使用kubect create来创建这个PV,如下所示:
|
||||
|
||||
```
|
||||
$ 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-storage 16s
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个PV创建后,进入了Available(可用)状态。
|
||||
|
||||
而正如我在上一篇文章里所建议的那样,使用PV和PVC的最佳实践,是你要创建一个StorageClass来描述这个PV,如下所示:
|
||||
|
||||
```
|
||||
kind: StorageClass
|
||||
apiVersion: storage.k8s.io/v1
|
||||
metadata:
|
||||
name: local-storage
|
||||
provisioner: kubernetes.io/no-provisioner
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
|
||||
```
|
||||
|
||||
这个StorageClass的名字,叫作local-storage。需要注意的是,在它的provisioner字段,我们指定的是no-provisioner。这是因为Local Persistent Volume目前尚不支持Dynamic Provisioning,所以它没办法在用户创建PVC的时候,就自动创建出对应的PV。也就是说,我们前面创建PV的操作,是不可以省略的。
|
||||
|
||||
与此同时,这个StorageClass还定义了一个volumeBindingMode=WaitForFirstConsumer的属性。它是Local Persistent Volume里一个非常重要的特性,即:**延迟绑定**。
|
||||
|
||||
我们知道,当你提交了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的正常调度**。
|
||||
|
||||
当然,在具体实现中,调度器实际上维护了一个与Volume Controller类似的控制循环,专门负责为那些声明了“延迟绑定”的PV和PVC进行绑定工作。
|
||||
|
||||
通过这样的设计,这个额外的绑定操作,并不会拖慢调度器的性能。而当一个Pod的PVC尚未完成绑定时,调度器也不会等待,而是会直接把这个Pod重新放回到待调度队列,等到下一个调度周期再做处理。
|
||||
|
||||
在明白了这个机制之后,我们就可以创建StorageClass了,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl create -f local-sc.yaml
|
||||
storageclass.storage.k8s.io/local-storage created
|
||||
|
||||
```
|
||||
|
||||
接下来,我们只需要定义一个非常普通的PVC,就可以让Pod使用到上面定义好的Local Persistent Volume了,如下所示:
|
||||
|
||||
```
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: example-local-claim
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
storageClassName: local-storage
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个PVC没有任何特别的地方。唯一需要注意的是,它声明的storageClassName是local-storage。所以,将来Kubernetes的Volume Controller看到这个PVC的时候,不会为它进行绑定操作。
|
||||
|
||||
现在,我们来创建这个PVC:
|
||||
|
||||
```
|
||||
$ 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 local-storage 7s
|
||||
|
||||
```
|
||||
|
||||
可以看到,尽管这个时候,Kubernetes里已经存在了一个可以与PVC匹配的PV,但这个PVC依然处于Pending状态,也就是等待绑定的状态。
|
||||
|
||||
然后,我们编写一个Pod来声明使用这个PVC,如下所示:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
这个Pod没有任何特别的地方,你只需要注意,它的volumes字段声明要使用前面定义的、名叫example-local-claim的PVC即可。
|
||||
|
||||
而我们一旦使用kubectl create创建这个Pod,就会发现,我们前面定义的PVC,会立刻变成Bound状态,与前面定义的PV绑定在了一起,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl create -f local-pod.yaml
|
||||
pod/example-pv-pod created
|
||||
|
||||
$ kubectl get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
example-local-claim Bound example-pv 5Gi RWO local-storage 6h
|
||||
|
||||
```
|
||||
|
||||
也就是说,在我们创建的Pod进入调度器之后,“绑定”操作才开始进行。
|
||||
|
||||
这时候,我们可以尝试在这个Pod的Volume目录里,创建一个测试文件,比如:
|
||||
|
||||
```
|
||||
$ kubectl exec -it example-pv-pod -- /bin/sh
|
||||
# cd /usr/share/nginx/html
|
||||
# touch test.txt
|
||||
|
||||
```
|
||||
|
||||
然后,登录到node-1这台机器上,查看一下它的 /mnt/disks/vol1目录下的内容,你就可以看到刚刚创建的这个文件:
|
||||
|
||||
```
|
||||
# 在node-1上
|
||||
$ ls /mnt/disks/vol1
|
||||
test.txt
|
||||
|
||||
```
|
||||
|
||||
而如果你重新创建这个Pod的话,就会发现,我们之前创建的测试文件,依然被保存在这个持久化Volume当中:
|
||||
|
||||
```
|
||||
$ kubectl delete -f local-pod.yaml
|
||||
|
||||
$ kubectl create -f local-pod.yaml
|
||||
|
||||
$ kubectl exec -it example-pv-pod -- /bin/sh
|
||||
# ls /usr/share/nginx/html
|
||||
# touch test.txt
|
||||
|
||||
```
|
||||
|
||||
这就说明,像Kubernetes这样构建出来的、基于本地存储的Volume,完全可以提供容器持久化存储的功能。所以,像StatefulSet这样的有状态编排工具,也完全可以通过声明Local类型的PV和PVC,来管理应用的存储状态。
|
||||
|
||||
**需要注意的是,我们上面手动创建PV的方式,即Static的PV管理方式,在删除PV时需要按如下流程执行操作:**
|
||||
|
||||
<li>
|
||||
删除使用这个PV的Pod;
|
||||
</li>
|
||||
<li>
|
||||
从宿主机移除本地磁盘(比如,umount它);
|
||||
</li>
|
||||
<li>
|
||||
删除PVC;
|
||||
</li>
|
||||
<li>
|
||||
删除PV。
|
||||
</li>
|
||||
|
||||
如果不按照这个流程的话,这个PV的删除就会失败。
|
||||
|
||||
当然,由于上面这些创建PV和删除PV的操作比较繁琐,Kubernetes其实提供了一个Static Provisioner来帮助你管理这些PV。
|
||||
|
||||
比如,我们现在的所有磁盘,都挂载在宿主机的/mnt/disks目录下。
|
||||
|
||||
那么,当Static Provisioner启动后,它就会通过DaemonSet,自动检查每个宿主机的/mnt/disks目录。然后,调用Kubernetes API,为这些目录下面的每一个挂载,创建一个对应的PV对象出来。这些自动创建的PV,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl get pv
|
||||
NAME CAPACITY ACCESSMODES RECLAIMPOLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
local-pv-ce05be60 1024220Ki RWO Delete Available local-storage 26s
|
||||
|
||||
$ kubectl describe pv local-pv-ce05be60
|
||||
Name: local-pv-ce05be60
|
||||
...
|
||||
StorageClass: local-storage
|
||||
Status: Available
|
||||
Claim:
|
||||
Reclaim Policy: Delete
|
||||
Access Modes: RWO
|
||||
Capacity: 1024220Ki
|
||||
NodeAffinity:
|
||||
Required Terms:
|
||||
Term 0: kubernetes.io/hostname in [node-1]
|
||||
Message:
|
||||
Source:
|
||||
Type: LocalVolume (a persistent volume backed by local storage on a node)
|
||||
Path: /mnt/disks/vol1
|
||||
|
||||
```
|
||||
|
||||
这个PV里的各种定义,比如StorageClass的名字、本地磁盘挂载点的位置,都可以通过provisioner的[配置文件指定](https://github.com/kubernetes-incubator/external-storage/tree/master/local-volume/helm)。当然,provisioner也会负责前面提到的PV的删除工作。
|
||||
|
||||
而这个provisioner本身,其实也是一个我们前面提到过的[External Provisioner](https://github.com/kubernetes-incubator/external-storage/tree/master/local-volume),它的部署方法,在[对应的文档里](https://github.com/kubernetes-incubator/external-storage/tree/master/local-volume#option-1-using-the-local-volume-static-provisioner)有详细描述。这部分内容,就留给你课后自行探索了。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天这篇文章中,我为你详细介绍了Kubernetes里Local Persistent Volume的实现方式。
|
||||
|
||||
可以看到,正是通过PV和PVC,以及StorageClass这套存储体系,这个后来新添加的持久化存储方案,对Kubernetes已有用户的影响,几乎可以忽略不计。作为用户,你的Pod的YAML和PVC的YAML,并没有任何特殊的改变,这个特性所有的实现只会影响到PV的处理,也就是由运维人员负责的那部分工作。
|
||||
|
||||
而这,正是这套存储体系带来的“解耦”的好处。
|
||||
|
||||
其实,Kubernetes很多看起来比较“繁琐”的设计(比如“声明式API”,以及我今天讲解的“PV、PVC体系”)的主要目的,都是希望为开发者提供更多的“可扩展性”,给使用者带来更多的“稳定性”和“安全感”。这两个能力的高低,是衡量开源基础设施项目水平的重要标准。
|
||||
|
||||
## 思考题
|
||||
|
||||
正是由于需要使用“延迟绑定”这个特性,Local Persistent Volume目前还不能支持Dynamic Provisioning。你是否能说出,为什么“延迟绑定”会跟Dynamic Provisioning有冲突呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<audio id="audio" title="30 | 编写自己的存储插件:FlexVolume与CSI" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/77/bc308ea748a442563757ca59218d3777.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:编写自己的存储插件之FlexVolume与CSI。
|
||||
|
||||
在上一篇文章中,我为你详细介绍了Kubernetes里的持久化存储体系,讲解了PV和PVC的具体实现原理,并提到了这样的设计实际上是出于对整个存储体系的可扩展性的考虑。
|
||||
|
||||
而在今天这篇文章中,我就和你分享一下如何借助这些机制,来开发自己的存储插件。
|
||||
|
||||
在Kubernetes中,存储插件的开发有两种方式:FlexVolume和CSI。
|
||||
|
||||
接下来,我就先为你剖析一下Flexvolume的原理和使用方法。
|
||||
|
||||
举个例子,现在我们要编写的是一个使用NFS实现的FlexVolume插件。
|
||||
|
||||
对于一个FlexVolume类型的PV来说,它的YAML文件如下所示:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: pv-flex-nfs
|
||||
spec:
|
||||
capacity:
|
||||
storage: 10Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
flexVolume:
|
||||
driver: "k8s/nfs"
|
||||
fsType: "nfs"
|
||||
options:
|
||||
server: "10.10.0.25" # 改成你自己的NFS服务器地址
|
||||
share: "export"
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个PV定义的Volume类型是flexVolume。并且,我们**指定了这个Volume的driver叫作k8s/nfs**。这个名字很重要,我后面马上会为你解释它的含义。
|
||||
|
||||
而Volume的options字段,则是一个自定义字段。也就是说,它的类型,其实是map[string]string。所以,你可以在这一部分自由地加上你想要定义的参数。
|
||||
|
||||
在我们这个例子里,options字段指定了NFS服务器的地址(server: “10.10.0.25”),以及NFS共享目录的名字(share: “export”)。当然,你这里定义的所有参数,后面都会被FlexVolume拿到。
|
||||
|
||||
>
|
||||
备注:你可以使用[这个Docker镜像](https://github.com/ehough/docker-nfs-server)轻松地部署一个试验用的NFS服务器。
|
||||
|
||||
|
||||
像这样的一个PV被创建后,一旦和某个PVC绑定起来,这个FlexVolume类型的Volume就会进入到我们前面讲解过的Volume处理流程。
|
||||
|
||||
你应该还记得,这个流程的名字叫作“两阶段处理”,即“Attach阶段”和“Mount阶段”。它们的主要作用,是在Pod所绑定的宿主机上,完成这个Volume目录的持久化过程,比如为虚拟机挂载磁盘(Attach),或者挂载一个NFS的共享目录(Mount)。
|
||||
|
||||
>
|
||||
备注:你可以再回顾一下第28篇文章[《PV、PVC、StorageClass,这些到底在说啥?》](https://time.geekbang.org/column/article/42698)中的相关内容。
|
||||
|
||||
|
||||
而在具体的控制循环中,这两个操作实际上调用的,正是Kubernetes的pkg/volume目录下的存储插件(Volume Plugin)。在我们这个例子里,就是pkg/volume/flexvolume这个目录里的代码。
|
||||
|
||||
当然了,这个目录其实只是FlexVolume插件的入口。以“Mount阶段”为例,在FlexVolume目录里,它的处理过程非常简单,如下所示:
|
||||
|
||||
```
|
||||
// SetUpAt creates new directory.
|
||||
func (f *flexVolumeMounter) SetUpAt(dir string, fsGroup *int64) error {
|
||||
...
|
||||
call := f.plugin.NewDriverCall(mountCmd)
|
||||
|
||||
// Interface parameters
|
||||
call.Append(dir)
|
||||
|
||||
extraOptions := make(map[string]string)
|
||||
|
||||
// pod metadata
|
||||
extraOptions[optionKeyPodName] = f.podName
|
||||
extraOptions[optionKeyPodNamespace] = f.podNamespace
|
||||
|
||||
...
|
||||
|
||||
call.AppendSpec(f.spec, f.plugin.host, extraOptions)
|
||||
|
||||
_, err = call.Run()
|
||||
|
||||
...
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这个名叫SetUpAt()的方法,正是FlexVolume插件对“Mount阶段”的实现位置。而SetUpAt()实际上只做了一件事,那就是封装出了一行命令(即:NewDriverCall),由kubelet在“Mount阶段”去执行。
|
||||
|
||||
在我们这个例子中,**kubelet要通过插件在宿主机上执行的命令,如下所示**:
|
||||
|
||||
```
|
||||
/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>
|
||||
|
||||
```
|
||||
|
||||
其中,/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs就是插件的可执行文件的路径。这个名叫nfs的文件,正是你要编写的插件的实现。它可以是一个二进制文件,也可以是一个脚本。总之,只要能在宿主机上被执行起来即可。
|
||||
|
||||
而且这个路径里的k8s~nfs部分,正是这个插件在Kubernetes里的名字。它是从driver="k8s/nfs"字段解析出来的。
|
||||
|
||||
这个driver字段的格式是:vendor/driver。比如,一家存储插件的提供商(vendor)的名字叫作k8s,提供的存储驱动(driver)是nfs,那么Kubernetes就会使用k8s~nfs来作为插件名。
|
||||
|
||||
所以说,**当你编写完了FlexVolume的实现之后,一定要把它的可执行文件放在每个节点的插件目录下。**
|
||||
|
||||
而紧跟在可执行文件后面的“mount”参数,定义的就是当前的操作。在FlexVolume里,这些操作参数的名字是固定的,比如init、mount、unmount、attach,以及dettach等等,分别对应不同的Volume处理操作。
|
||||
|
||||
而跟在mount参数后面的两个字段:`<mount dir>`和`<json params>`,则是FlexVolume必须提供给这条命令的两个执行参数。
|
||||
|
||||
其中第一个执行参数`<mount dir>`,正是kubelet调用SetUpAt()方法传递来的dir的值。它代表的是当前正在处理的Volume在宿主机上的目录。在我们的例子里,这个路径如下所示:
|
||||
|
||||
```
|
||||
/var/lib/kubelet/pods/<Pod ID>/volumes/k8s~nfs/test
|
||||
|
||||
```
|
||||
|
||||
其中,test正是我们前面定义的PV的名字;而k8s~nfs,则是插件的名字。可以看到,插件的名字正是从你声明的driver="k8s/nfs"字段里解析出来的。
|
||||
|
||||
而第二个执行参数`<json params>`,则是一个JSON Map格式的参数列表。我们在前面PV里定义的options字段的值,都会被追加在这个参数里。此外,在SetUpAt()方法里可以看到,这个参数列表里还包括了Pod的名字、Namespace等元数据(Metadata)。
|
||||
|
||||
在明白了存储插件的调用方式和参数列表之后,这个插件的可执行文件的实现部分就非常容易理解了。
|
||||
|
||||
在这个例子中,我直接编写了一个简单的shell脚本来作为插件的实现,它对“Mount阶段”的处理过程,如下所示:
|
||||
|
||||
```
|
||||
domount() {
|
||||
MNTPATH=$1
|
||||
|
||||
NFS_SERVER=$(echo $2 | jq -r '.server')
|
||||
SHARE=$(echo $2 | jq -r '.share')
|
||||
|
||||
...
|
||||
|
||||
mkdir -p ${MNTPATH} &> /dev/null
|
||||
|
||||
mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} &> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
err "{ \"status\": \"Failure\", \"message\": \"Failed to mount ${NFS_SERVER}:${SHARE} at ${MNTPATH}\"}"
|
||||
exit 1
|
||||
fi
|
||||
log '{"status": "Success"}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,当kubelet在宿主机上执行“`nfs mount <mount dir> <json params>`”的时候,这个名叫nfs的脚本,就可以直接从`<mount dir>`参数里拿到Volume在宿主机上的目录,即:`MNTPATH=$1`。而你在PV的options字段里定义的NFS的服务器地址(options.server)和共享目录名字(options.share),则可以从第二个`<json params>`参数里解析出来。这里,我们使用了jq命令,来进行解析工作。
|
||||
|
||||
有了这三个参数之后,这个脚本最关键的一步,当然就是执行:`mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH}` 。这样,一个NFS的数据卷就被挂载到了MNTPATH,也就是Volume所在的宿主机目录上,一个持久化的Volume目录就处理完了。
|
||||
|
||||
需要注意的是,当这个mount -t nfs操作完成后,你必须把一个JOSN格式的字符串,比如:{“status”: “Success”},返回给调用者,也就是kubelet。这是kubelet判断这次调用是否成功的唯一依据。
|
||||
|
||||
综上所述,在“Mount阶段”,kubelet的VolumeManagerReconcile控制循环里的一次“调谐”操作的执行流程,如下所示:
|
||||
|
||||
```
|
||||
kubelet --> pkg/volume/flexvolume.SetUpAt() --> /usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
备注:这个NFS的FlexVolume的完整实现,在[这个GitHub库](https://github.com/kubernetes/examples/blob/master/staging/volumes/flexvolume/nfs)里。而你如果想用Go语言编写FlexVolume的话,我也有一个[很好的例子](https://github.com/kubernetes/frakti/tree/master/pkg/flexvolume)供你参考。
|
||||
|
||||
|
||||
当然,在前面文章中我也提到过,像NFS这样的文件系统存储,并不需要在宿主机上挂载磁盘或者块设备。所以,我们也就不需要实现attach和dettach操作了。
|
||||
|
||||
不过,**像这样的FlexVolume实现方式,虽然简单,但局限性却很大。**
|
||||
|
||||
比如,跟Kubernetes内置的NFS插件类似,这个NFS FlexVolume插件,也不能支持Dynamic Provisioning(即:为每个PVC自动创建PV和对应的Volume)。除非你再为它编写一个专门的External Provisioner。
|
||||
|
||||
再比如,我的插件在执行mount操作的时候,可能会生成一些挂载信息。这些信息,在后面执行unmount操作的时候会被用到。可是,在上述FlexVolume的实现里,你没办法把这些信息保存在一个变量里,等到unmount的时候直接使用。
|
||||
|
||||
这个原因也很容易理解:**FlexVolume每一次对插件可执行文件的调用,都是一次完全独立的操作**。所以,我们只能把这些信息写在一个宿主机上的临时文件里,等到unmount的时候再去读取。
|
||||
|
||||
这也是为什么,我们需要有Container Storage Interface(CSI)这样更完善、更编程友好的插件方式。
|
||||
|
||||
接下来,我就来为你讲解一下开发存储插件的第二种方式CSI。我们先来看一下CSI插件体系的设计原理。
|
||||
|
||||
其实,通过前面对FlexVolume的讲述,你应该可以明白,默认情况下,Kubernetes里通过存储插件管理容器持久化存储的原理,可以用如下所示的示意图来描述:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/ef/6a553321623f6b58f5494b25091592ef.png" alt=""><br>
|
||||
可以看到,在上述体系下,无论是FlexVolume,还是Kubernetes内置的其他存储插件,它们实际上担任的角色,仅仅是Volume管理中的“Attach阶段”和“Mount阶段”的具体执行者。而像Dynamic Provisioning这样的功能,就不是存储插件的责任,而是Kubernetes本身存储管理功能的一部分。
|
||||
|
||||
相比之下,**CSI插件体系的设计思想,就是把这个Provision阶段,以及Kubernetes里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件**。这些组件会通过Watch API监听Kubernetes里与存储相关的事件变化,比如PVC的创建,来执行具体的存储管理动作。
|
||||
|
||||
而这些管理动作,比如“Attach阶段”和“Mount阶段”的具体操作,实际上就是通过调用CSI插件来完成的。
|
||||
|
||||
这种设计思路,我可以用如下所示的一幅示意图来表示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d4/ad/d4bdc7035f1286e7a423da851eee89ad.png" alt=""><br>
|
||||
可以看到,这套存储插件体系多了三个独立的外部组件(External Components),即:Driver Registrar、External Provisioner和External Attacher,对应的正是从Kubernetes项目里面剥离出来的那部分存储管理功能。
|
||||
|
||||
需要注意的是,External Components虽然是外部组件,但依然由Kubernetes社区来开发和维护。
|
||||
|
||||
而图中最右侧的部分,就是需要我们编写代码来实现的CSI插件。一个CSI插件只有一个二进制文件,但它会以gRPC的方式对外提供三个服务(gRPC Service),分别叫作:CSI Identity、CSI Controller和CSI Node。
|
||||
|
||||
我先来为你讲解一下这三个External Components。
|
||||
|
||||
其中,**Driver Registrar组件,负责将插件注册到kubelet里面**(这可以类比为,将可执行文件放在插件目录下)。而在具体实现上,Driver Registrar需要请求CSI插件的Identity服务来获取插件信息。
|
||||
|
||||
而**External Provisioner组件,负责的正是Provision阶段**。在具体实现上,External Provisioner监听(Watch)了APIServer里的PVC对象。当一个PVC被创建时,它就会调用CSI Controller的CreateVolume方法,为你创建对应PV。
|
||||
|
||||
此外,如果你使用的存储是公有云提供的磁盘(或者块设备)的话,这一步就需要调用公有云(或者块设备服务)的API来创建这个PV所描述的磁盘(或者块设备)了。
|
||||
|
||||
不过,由于CSI插件是独立于Kubernetes之外的,所以在CSI的API里不会直接使用Kubernetes定义的PV类型,而是会自己定义一个单独的Volume类型。
|
||||
|
||||
**为了方便叙述,在本专栏里,我会把Kubernetes里的持久化卷类型叫作PV,把CSI里的持久化卷类型叫作CSI Volume,请你务必区分清楚。**
|
||||
|
||||
最后一个**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的部署方式非常高效。
|
||||
|
||||
接下来,我再为你讲解一下CSI插件的里三个服务:CSI Identity、CSI Controller和CSI Node。
|
||||
|
||||
其中,**CSI插件的CSI Identity服务,负责对外暴露这个插件本身的信息**,如下所示:
|
||||
|
||||
```
|
||||
service Identity {
|
||||
// return the version and name of the plugin
|
||||
rpc GetPluginInfo(GetPluginInfoRequest)
|
||||
returns (GetPluginInfoResponse) {}
|
||||
// reports whether the plugin has the ability of serving the Controller interface
|
||||
rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
|
||||
returns (GetPluginCapabilitiesResponse) {}
|
||||
// called by the CO just to check whether the plugin is running or not
|
||||
rpc Probe (ProbeRequest)
|
||||
returns (ProbeResponse) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而**CSI Controller服务,定义的则是对CSI Volume(对应Kubernetes里的PV)的管理接口**,比如:创建和删除CSI Volume、对CSI Volume进行Attach/Dettach(在CSI里,这个操作被叫作Publish/Unpublish),以及对CSI Volume进行Snapshot等,它们的接口定义如下所示:
|
||||
|
||||
```
|
||||
service Controller {
|
||||
// provisions a volume
|
||||
rpc CreateVolume (CreateVolumeRequest)
|
||||
returns (CreateVolumeResponse) {}
|
||||
|
||||
// deletes a previously provisioned volume
|
||||
rpc DeleteVolume (DeleteVolumeRequest)
|
||||
returns (DeleteVolumeResponse) {}
|
||||
|
||||
// make a volume available on some required node
|
||||
rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
|
||||
returns (ControllerPublishVolumeResponse) {}
|
||||
|
||||
// make a volume un-available on some required node
|
||||
rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
|
||||
returns (ControllerUnpublishVolumeResponse) {}
|
||||
|
||||
...
|
||||
|
||||
// make a snapshot
|
||||
rpc CreateSnapshot (CreateSnapshotRequest)
|
||||
returns (CreateSnapshotResponse) {}
|
||||
|
||||
// Delete a given snapshot
|
||||
rpc DeleteSnapshot (DeleteSnapshotRequest)
|
||||
returns (DeleteSnapshotResponse) {}
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不难发现,CSI Controller服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于Kubernetes里Volume Controller的逻辑,也就是属于Master节点的一部分。
|
||||
|
||||
需要注意的是,正如我在前面提到的那样,CSI Controller服务的实际调用者,并不是Kubernetes(即:通过pkg/volume/csi发起CSI请求),而是External Provisioner和External Attacher。这两个External Components,分别通过监听 PVC和VolumeAttachement对象,来跟Kubernetes进行协作。
|
||||
|
||||
而CSI Volume需要在宿主机上执行的操作,都定义在了CSI Node服务里面,如下所示:
|
||||
|
||||
```
|
||||
service Node {
|
||||
// temporarily mount the volume to a staging path
|
||||
rpc NodeStageVolume (NodeStageVolumeRequest)
|
||||
returns (NodeStageVolumeResponse) {}
|
||||
|
||||
// unmount the volume from staging path
|
||||
rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
|
||||
returns (NodeUnstageVolumeResponse) {}
|
||||
|
||||
// mount the volume from staging to target path
|
||||
rpc NodePublishVolume (NodePublishVolumeRequest)
|
||||
returns (NodePublishVolumeResponse) {}
|
||||
|
||||
// unmount the volume from staging path
|
||||
rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
|
||||
returns (NodeUnpublishVolumeResponse) {}
|
||||
|
||||
// stats for the volume
|
||||
rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
|
||||
returns (NodeGetVolumeStatsResponse) {}
|
||||
|
||||
...
|
||||
|
||||
// Similar to NodeGetId
|
||||
rpc NodeGetInfo (NodeGetInfoRequest)
|
||||
returns (NodeGetInfoResponse) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,“Mount阶段”在CSI Node里的接口,是由NodeStageVolume和NodePublishVolume两个接口共同实现的。我会在下一篇文章中,为你详细介绍这个设计的目的和具体的实现方式。
|
||||
|
||||
## 总结
|
||||
|
||||
在本篇文章里,我为你详细讲解了FlexVolume和CSI这两种自定义存储插件的工作原理。
|
||||
|
||||
可以看到,相比于FlexVolume,CSI的设计思想,把插件的职责从“两阶段处理”,扩展成了Provision、Attach和Mount三个阶段。其中,Provision等价于“创建磁盘”,Attach等价于“挂载磁盘到虚拟机”,Mount等价于“将该磁盘格式化后,挂载在Volume的宿主机目录上”。
|
||||
|
||||
在有了CSI插件之后,Kubernetes本身依然按照我在第28篇文章[《PV、PVC、StorageClass,这些到底在说啥?》](https://time.geekbang.org/column/article/42698)中所讲述的方式工作,唯一区别在于:
|
||||
|
||||
- 当AttachDetachController需要进行“Attach”操作时(“Attach阶段”),它实际上会执行到pkg/volume/csi目录中,创建一个VolumeAttachment对象,从而触发External Attacher调用CSI Controller服务的ControllerPublishVolume方法。
|
||||
- 当VolumeManagerReconciler需要进行“Mount”操作时(“Mount阶段”),它实际上也会执行到pkg/volume/csi目录中,直接向CSI Node服务发起调用NodePublishVolume方法的请求。
|
||||
|
||||
以上,就是CSI插件最基本的工作原理了。
|
||||
|
||||
在下一篇文章里,我会和你一起实践一个CSI存储插件的完整实现过程。
|
||||
|
||||
## 思考题
|
||||
|
||||
假设现在,你的宿主机是阿里云的一台虚拟机,你要实现的容器持久化存储,是基于阿里云提供的云盘。你能准确地描述出,在Provision、Attach和Mount阶段,CSI插件都需要做哪些操作吗?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
514
极客时间专栏/深入剖析Kubernetes/Kubernetes容器持久化存储/31 | 容器存储实践:CSI插件编写指南.md
Normal file
514
极客时间专栏/深入剖析Kubernetes/Kubernetes容器持久化存储/31 | 容器存储实践:CSI插件编写指南.md
Normal file
@@ -0,0 +1,514 @@
|
||||
<audio id="audio" title="31 | 容器存储实践:CSI插件编写指南" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/ab/4139f282750e199c1ade2843b7bbc0ab.mp3"></audio>
|
||||
|
||||
你好,我是张磊。今天我和你分享的主题是:容器存储实践之CSI插件编写指南。
|
||||
|
||||
在上一篇文章中,我已经为你详细讲解了CSI插件机制的设计原理。今天我将继续和你一起实践一个CSI插件的编写过程。
|
||||
|
||||
为了能够覆盖到CSI插件的所有功能,我这一次选择了DigitalOcean的块存储(Block Storage)服务,来作为实践对象。
|
||||
|
||||
DigitalOcean是业界知名的“最简”公有云服务,即:它只提供虚拟机、存储、网络等为数不多的几个基础功能,其他功能一概不管。而这,恰恰就使得DigitalOcean成了我们在公有云上实践Kubernetes的最佳选择。
|
||||
|
||||
我们这次编写的CSI插件的功能,就是:让我们运行在DigitalOcean上的Kubernetes集群能够使用它的块存储服务,作为容器的持久化存储。
|
||||
|
||||
>
|
||||
备注:在DigitalOcean上部署一个Kubernetes集群的过程,也很简单。你只需要先在DigitalOcean上创建几个虚拟机,然后按照我们在第11篇文章[《从0到1:搭建一个完整的Kubernetes集群》](https://time.geekbang.org/column/article/39724)中从0到1的步骤直接部署即可。
|
||||
|
||||
|
||||
而有了CSI插件之后,持久化存储的用法就非常简单了,你只需要创建一个如下所示的StorageClass对象即可:
|
||||
|
||||
```
|
||||
kind: StorageClass
|
||||
apiVersion: storage.k8s.io/v1
|
||||
metadata:
|
||||
name: do-block-storage
|
||||
namespace: kube-system
|
||||
annotations:
|
||||
storageclass.kubernetes.io/is-default-class: "true"
|
||||
provisioner: com.digitalocean.csi.dobs
|
||||
|
||||
```
|
||||
|
||||
有了这个StorageClass,External Provisoner就会为集群中新出现的PVC自动创建出PV,然后调用CSI插件创建出这个PV对应的Volume,这正是CSI体系中Dynamic Provisioning的实现方式。
|
||||
|
||||
>
|
||||
备注:`storageclass.kubernetes.io/is-default-class: "true"`的意思,是使用这个StorageClass作为默认的持久化存储提供者。
|
||||
|
||||
|
||||
不难看到,这个StorageClass里唯一引人注意的,是provisioner=com.digitalocean.csi.dobs这个字段。显然,这个字段告诉了Kubernetes,请使用名叫com.digitalocean.csi.dobs的CSI插件来为我处理这个StorageClass相关的所有操作。
|
||||
|
||||
那么,Kubernetes又是如何知道一个CSI插件的名字的呢?
|
||||
|
||||
**这就需要从CSI插件的第一个服务CSI Identity说起了。**
|
||||
|
||||
其实,一个CSI插件的代码结构非常简单,如下所示:
|
||||
|
||||
```
|
||||
tree $GOPATH/src/github.com/digitalocean/csi-digitalocean/driver
|
||||
$GOPATH/src/github.com/digitalocean/csi-digitalocean/driver
|
||||
├── controller.go
|
||||
├── driver.go
|
||||
├── identity.go
|
||||
├── mounter.go
|
||||
└── node.go
|
||||
|
||||
```
|
||||
|
||||
其中,CSI Identity服务的实现,就定义在了driver目录下的identity.go文件里。
|
||||
|
||||
当然,为了能够让Kubernetes访问到CSI Identity服务,我们需要先在driver.go文件里,定义一个标准的gRPC Server,如下所示:
|
||||
|
||||
```
|
||||
// Run starts the CSI plugin by communication over the given endpoint
|
||||
func (d *Driver) Run() error {
|
||||
...
|
||||
|
||||
listener, err := net.Listen(u.Scheme, addr)
|
||||
...
|
||||
|
||||
d.srv = grpc.NewServer(grpc.UnaryInterceptor(errHandler))
|
||||
csi.RegisterIdentityServer(d.srv, d)
|
||||
csi.RegisterControllerServer(d.srv, d)
|
||||
csi.RegisterNodeServer(d.srv, d)
|
||||
|
||||
d.ready = true // we're now ready to go!
|
||||
...
|
||||
return d.srv.Serve(listener)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,只要把编写好的gRPC Server注册给CSI,它就可以响应来自External Components的CSI请求了。
|
||||
|
||||
**CSI Identity服务中,最重要的接口是GetPluginInfo**,它返回的就是这个插件的名字和版本号,如下所示:
|
||||
|
||||
>
|
||||
备注:CSI各个服务的接口我在上一篇文章中已经介绍过,你也可以在这里找到[它的protoc文件](https://github.com/container-storage-interface/spec/blob/master/csi.proto)。
|
||||
|
||||
|
||||
```
|
||||
func (d *Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) {
|
||||
resp := &csi.GetPluginInfoResponse{
|
||||
Name: driverName,
|
||||
VendorVersion: version,
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,driverName的值,正是"com.digitalocean.csi.dobs"。所以说,Kubernetes正是通过GetPluginInfo的返回值,来找到你在StorageClass里声明要使用的CSI插件的。
|
||||
|
||||
>
|
||||
备注:CSI要求插件的名字遵守[“反向DNS”格式](https://en.wikipedia.org/wiki/Reverse_domain_name_notation)。
|
||||
|
||||
|
||||
另外一个**GetPluginCapabilities接口也很重要**。这个接口返回的是这个CSI插件的“能力”。
|
||||
|
||||
比如,当你编写的CSI插件不准备实现“Provision阶段”和“Attach阶段”(比如,一个最简单的NFS存储插件就不需要这两个阶段)时,你就可以通过这个接口返回:本插件不提供CSI Controller服务,即:没有csi.PluginCapability_Service_CONTROLLER_SERVICE这个“能力”。这样,Kubernetes就知道这个信息了。
|
||||
|
||||
最后,**CSI Identity服务还提供了一个Probe接口**。Kubernetes会调用它来检查这个CSI插件是否正常工作。
|
||||
|
||||
一般情况下,我建议你在编写插件时给它设置一个Ready标志,当插件的gRPC Server停止的时候,把这个Ready标志设置为false。或者,你可以在这里访问一下插件的端口,类似于健康检查的做法。
|
||||
|
||||
>
|
||||
备注:关于健康检查的问题,你可以再回顾一下第15篇文章[《深入解析Pod对象(二):使用进阶》](https://time.geekbang.org/column/article/40466)中的相关内容。
|
||||
|
||||
|
||||
然后,我们要开始编写CSI 插件的第二个服务,即CSI Controller服务了。它的代码实现,在controller.go文件里。
|
||||
|
||||
在上一篇文章中我已经为你讲解过,这个服务主要实现的就是Volume管理流程中的“Provision阶段”和“Attach阶段”。
|
||||
|
||||
**“Provision阶段”对应的接口,是CreateVolume和DeleteVolume**,它们的调用者是External Provisoner。以CreateVolume为例,它的主要逻辑如下所示:
|
||||
|
||||
```
|
||||
func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
|
||||
...
|
||||
|
||||
volumeReq := &godo.VolumeCreateRequest{
|
||||
Region: d.region,
|
||||
Name: volumeName,
|
||||
Description: createdByDO,
|
||||
SizeGigaBytes: size / GB,
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
vol, _, err := d.doClient.Storage.CreateVolume(ctx, volumeReq)
|
||||
|
||||
...
|
||||
|
||||
resp := &csi.CreateVolumeResponse{
|
||||
Volume: &csi.Volume{
|
||||
Id: vol.ID,
|
||||
CapacityBytes: size,
|
||||
AccessibleTopology: []*csi.Topology{
|
||||
{
|
||||
Segments: map[string]string{
|
||||
"region": d.region,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,对于DigitalOcean这样的公有云来说,CreateVolume需要做的操作,就是调用DigitalOcean块存储服务的API,创建出一个存储卷(d.doClient.Storage.CreateVolume)。如果你使用的是其他类型的块存储(比如Cinder、Ceph RBD等),对应的操作也是类似地调用创建存储卷的API。
|
||||
|
||||
而“**Attach阶段”对应的接口是ControllerPublishVolume和ControllerUnpublishVolume**,它们的调用者是External Attacher。以ControllerPublishVolume为例,它的逻辑如下所示:
|
||||
|
||||
```
|
||||
func (d *Driver) ControllerPublishVolume(ctx context.Context, req *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) {
|
||||
...
|
||||
|
||||
dropletID, err := strconv.Atoi(req.NodeId)
|
||||
|
||||
// check if volume exist before trying to attach it
|
||||
_, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)
|
||||
|
||||
...
|
||||
|
||||
// check if droplet exist before trying to attach the volume to the droplet
|
||||
_, resp, err = d.doClient.Droplets.Get(ctx, dropletID)
|
||||
|
||||
...
|
||||
|
||||
action, resp, err := d.doClient.StorageActions.Attach(ctx, req.VolumeId, dropletID)
|
||||
|
||||
...
|
||||
|
||||
if action != nil {
|
||||
ll.Info("waiting until volume is attached")
|
||||
if err := d.waitAction(ctx, req.VolumeId, action.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ll.Info("volume is attached")
|
||||
return &csi.ControllerPublishVolumeResponse{}, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,对于DigitalOcean来说,ControllerPublishVolume在“Attach阶段”需要做的工作,是调用DigitalOcean的API,将我们前面创建的存储卷,挂载到指定的虚拟机上(d.doClient.StorageActions.Attach)。
|
||||
|
||||
其中,存储卷由请求中的VolumeId来指定。而虚拟机,也就是将要运行Pod的宿主机,则由请求中的NodeId来指定。这些参数,都是External Attacher在发起请求时需要设置的。
|
||||
|
||||
我在上一篇文章中已经为你介绍过,External Attacher的工作原理,是监听(Watch)了一种名叫VolumeAttachment的API对象。这种API对象的主要字段如下所示:
|
||||
|
||||
```
|
||||
// VolumeAttachmentSpec is the specification of a VolumeAttachment request.
|
||||
type VolumeAttachmentSpec struct {
|
||||
// Attacher indicates the name of the volume driver that MUST handle this
|
||||
// request. This is the name returned by GetPluginName().
|
||||
Attacher string
|
||||
|
||||
// Source represents the volume that should be attached.
|
||||
Source VolumeAttachmentSource
|
||||
|
||||
// The node that the volume should be attached to.
|
||||
NodeName string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而这个对象的生命周期,正是由AttachDetachController负责管理的(这里,你可以再回顾一下第28篇文章[《PV、PVC、StorageClass,这些到底在说啥?》](https://time.geekbang.org/column/article/42698)中的相关内容)。
|
||||
|
||||
这个控制循环的职责,是不断检查Pod所对应的PV,在它所绑定的宿主机上的挂载情况,从而决定是否需要对这个PV进行Attach(或者Dettach)操作。
|
||||
|
||||
而这个Attach操作,在CSI体系里,就是创建出上面这样一个VolumeAttachment对象。可以看到,Attach操作所需的PV的名字(Source)、宿主机的名字(NodeName)、存储插件的名字(Attacher),都是这个VolumeAttachment对象的一部分。
|
||||
|
||||
而当External Attacher监听到这样的一个对象出现之后,就可以立即使用VolumeAttachment里的这些字段,封装成一个gRPC请求调用CSI Controller的ControllerPublishVolume方法。
|
||||
|
||||
最后,我们就可以编写CSI Node服务了。
|
||||
|
||||
CSI Node服务对应的,是Volume管理流程里的“Mount阶段”。它的代码实现,在node.go文件里。
|
||||
|
||||
我在上一篇文章里曾经提到过,kubelet的VolumeManagerReconciler控制循环会直接调用CSI Node服务来完成Volume的“Mount阶段”。
|
||||
|
||||
不过,在具体的实现中,这个“Mount阶段”的处理其实被细分成了NodeStageVolume和NodePublishVolume这两个接口。
|
||||
|
||||
这里的原因其实也很容易理解:我在第28篇文章[《PV、PVC、StorageClass,这些到底在说啥?》](https://time.geekbang.org/column/article/42698)中曾经介绍过,对于磁盘以及块设备来说,它们被Attach到宿主机上之后,就成为了宿主机上的一个待用存储设备。而到了“Mount阶段”,我们首先需要格式化这个设备,然后才能把它挂载到Volume对应的宿主机目录上。
|
||||
|
||||
在kubelet的VolumeManagerReconciler控制循环中,这两步操作分别叫作**MountDevice和SetUp。**
|
||||
|
||||
其中,MountDevice操作,就是直接调用了CSI Node服务里的NodeStageVolume接口。顾名思义,这个接口的作用,就是格式化Volume在宿主机上对应的存储设备,然后挂载到一个临时目录(Staging目录)上。
|
||||
|
||||
对于DigitalOcean来说,它对NodeStageVolume接口的实现如下所示:
|
||||
|
||||
```
|
||||
func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
|
||||
...
|
||||
|
||||
vol, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)
|
||||
|
||||
...
|
||||
|
||||
source := getDiskSource(vol.Name)
|
||||
target := req.StagingTargetPath
|
||||
|
||||
...
|
||||
|
||||
if !formatted {
|
||||
ll.Info("formatting the volume for staging")
|
||||
if err := d.mounter.Format(source, fsType); err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
} else {
|
||||
ll.Info("source device is already formatted")
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
if !mounted {
|
||||
if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
} else {
|
||||
ll.Info("source device is already mounted to the target path")
|
||||
}
|
||||
|
||||
...
|
||||
return &csi.NodeStageVolumeResponse{}, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在NodeStageVolume的实现里,我们首先通过DigitalOcean的API获取到了这个Volume对应的设备路径(getDiskSource);然后,我们把这个设备格式化成指定的格式( d.mounter.Format);最后,我们把格式化后的设备挂载到了一个临时的Staging目录(StagingTargetPath)下。
|
||||
|
||||
而SetUp操作则会调用CSI Node服务的NodePublishVolume接口。有了上述对设备的预处理工作后,它的实现就非常简单了,如下所示:
|
||||
|
||||
```
|
||||
func (d *Driver) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
|
||||
...
|
||||
source := req.StagingTargetPath
|
||||
target := req.TargetPath
|
||||
|
||||
mnt := req.VolumeCapability.GetMount()
|
||||
options := mnt.MountFlag
|
||||
...
|
||||
|
||||
if !mounted {
|
||||
ll.Info("mounting the volume")
|
||||
if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
} else {
|
||||
ll.Info("volume is already mounted")
|
||||
}
|
||||
|
||||
return &csi.NodePublishVolumeResponse{}, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这一步实现中,我们只需要做一步操作,即:将Staging目录,绑定挂载到Volume对应的宿主机目录上。
|
||||
|
||||
由于Staging目录,正是Volume对应的设备被格式化后挂载在宿主机上的位置,所以当它和Volume的宿主机目录绑定挂载之后,这个Volume宿主机目录的“持久化”处理也就完成了。
|
||||
|
||||
当然,我在前面也曾经提到过,对于文件系统类型的存储服务来说,比如NFS和GlusterFS等,它们并没有一个对应的磁盘“设备”存在于宿主机上,所以kubelet在VolumeManagerReconciler控制循环中,会跳过MountDevice操作而直接执行SetUp操作。所以对于它们来说,也就不需要实现NodeStageVolume接口了。
|
||||
|
||||
在编写完了CSI插件之后,我们就可以把这个插件和External Components一起部署起来。
|
||||
|
||||
首先,我们需要创建一个DigitalOcean client授权需要使用的Secret对象,如下所示:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: digitalocean
|
||||
namespace: kube-system
|
||||
stringData:
|
||||
access-token: "a05dd2f26b9b9ac2asdas__REPLACE_ME____123cb5d1ec17513e06da"
|
||||
|
||||
```
|
||||
|
||||
接下来,我们通过一句指令就可以将CSI插件部署起来:
|
||||
|
||||
```
|
||||
$ kubectl apply -f https://raw.githubusercontent.com/digitalocean/csi-digitalocean/master/deploy/kubernetes/releases/csi-digitalocean-v0.2.0.yaml
|
||||
|
||||
```
|
||||
|
||||
这个CSI插件的YAML文件的主要内容如下所示(其中,非重要的内容已经被略去):
|
||||
|
||||
```
|
||||
kind: DaemonSet
|
||||
apiVersion: apps/v1beta2
|
||||
metadata:
|
||||
name: csi-do-node
|
||||
namespace: kube-system
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: csi-do-node
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: csi-do-node
|
||||
role: csi-do
|
||||
spec:
|
||||
serviceAccount: csi-do-node-sa
|
||||
hostNetwork: true
|
||||
containers:
|
||||
- name: driver-registrar
|
||||
image: quay.io/k8scsi/driver-registrar:v0.3.0
|
||||
...
|
||||
- name: csi-do-plugin
|
||||
image: digitalocean/do-csi-plugin:v0.2.0
|
||||
args :
|
||||
- "--endpoint=$(CSI_ENDPOINT)"
|
||||
- "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
|
||||
- "--url=$(DIGITALOCEAN_API_URL)"
|
||||
env:
|
||||
- name: CSI_ENDPOINT
|
||||
value: unix:///csi/csi.sock
|
||||
- name: DIGITALOCEAN_API_URL
|
||||
value: https://api.digitalocean.com/
|
||||
- name: DIGITALOCEAN_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: digitalocean
|
||||
key: access-token
|
||||
imagePullPolicy: "Always"
|
||||
securityContext:
|
||||
privileged: true
|
||||
capabilities:
|
||||
add: ["SYS_ADMIN"]
|
||||
allowPrivilegeEscalation: true
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: pods-mount-dir
|
||||
mountPath: /var/lib/kubelet
|
||||
mountPropagation: "Bidirectional"
|
||||
- name: device-dir
|
||||
mountPath: /dev
|
||||
volumes:
|
||||
- name: plugin-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins/com.digitalocean.csi.dobs
|
||||
type: DirectoryOrCreate
|
||||
- name: pods-mount-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet
|
||||
type: Directory
|
||||
- name: device-dir
|
||||
hostPath:
|
||||
path: /dev
|
||||
---
|
||||
kind: StatefulSet
|
||||
apiVersion: apps/v1beta1
|
||||
metadata:
|
||||
name: csi-do-controller
|
||||
namespace: kube-system
|
||||
spec:
|
||||
serviceName: "csi-do"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: csi-do-controller
|
||||
role: csi-do
|
||||
spec:
|
||||
serviceAccount: csi-do-controller-sa
|
||||
containers:
|
||||
- name: csi-provisioner
|
||||
image: quay.io/k8scsi/csi-provisioner:v0.3.0
|
||||
...
|
||||
- name: csi-attacher
|
||||
image: quay.io/k8scsi/csi-attacher:v0.3.0
|
||||
...
|
||||
- name: csi-do-plugin
|
||||
image: digitalocean/do-csi-plugin:v0.2.0
|
||||
args :
|
||||
- "--endpoint=$(CSI_ENDPOINT)"
|
||||
- "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
|
||||
- "--url=$(DIGITALOCEAN_API_URL)"
|
||||
env:
|
||||
- name: CSI_ENDPOINT
|
||||
value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock
|
||||
- name: DIGITALOCEAN_API_URL
|
||||
value: https://api.digitalocean.com/
|
||||
- name: DIGITALOCEAN_ACCESS_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: digitalocean
|
||||
key: access-token
|
||||
imagePullPolicy: "Always"
|
||||
volumeMounts:
|
||||
- name: socket-dir
|
||||
mountPath: /var/lib/csi/sockets/pluginproxy/
|
||||
volumes:
|
||||
- name: socket-dir
|
||||
emptyDir: {}
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们编写的CSI插件只有一个二进制文件,它的镜像是digitalocean/do-csi-plugin:v0.2.0。
|
||||
|
||||
而我们**部署CSI插件的常用原则是:**
|
||||
|
||||
**第一,通过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而不是Deployment来运行这个CSI插件呢。
|
||||
|
||||
这是因为,由于StatefulSet需要确保应用拓扑状态的稳定性,所以它对Pod的更新,是严格保证顺序的,即:只有在前一个Pod停止并删除之后,它才会创建并启动下一个Pod。
|
||||
|
||||
而像我们上面这样将StatefulSet的replicas设置为1的话,StatefulSet就会确保Pod被删除重建的时候,永远有且只有一个CSI插件的Pod运行在集群中。这对CSI插件的正确性来说,至关重要。
|
||||
|
||||
而在今天这篇文章一开始,我们就已经定义了这个CSI插件对应的StorageClass(即:do-block-storage),所以你接下来只需要定义一个声明使用这个StorageClass的PVC即可,如下所示:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: csi-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
storageClassName: do-block-storage
|
||||
|
||||
```
|
||||
|
||||
当你把上述PVC提交给Kubernetes之后,你就可以在Pod里声明使用这个csi-pvc来作为持久化存储了。这一部分使用PV和PVC的内容,我就不再赘述了。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天这篇文章中,我以一个DigitalOcean的CSI插件为例,和你分享了编写CSI插件的具体流程。
|
||||
|
||||
基于这些讲述,你现在应该已经对Kubernetes持久化存储体系有了一个更加全面和深入的认识。
|
||||
|
||||
举个例子,对于一个部署了CSI存储插件的Kubernetes集群来说:
|
||||
|
||||
当用户创建了一个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的创建和挂载流程就结束了。
|
||||
|
||||
## 思考题
|
||||
|
||||
请你根据编写FlexVolume和CSI插件的流程,分析一下什么时候该使用FlexVolume,什么时候应该使用CSI?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
Reference in New Issue
Block a user