This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -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 VolumePV和Persistent Volume ClaimPVC这套持久化存储体系。
其中,**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: &quot;/&quot;
```
而**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: &quot;/usr/share/nginx/html&quot;
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/&lt;Pod的ID&gt;/volumes/kubernetes.io~&lt;Volume类型&gt;/&lt;Volume名字&gt;
```
接下来kubelet要做的操作就取决于你的Volume类型了。
如果你的Volume类型是远程块存储比如Google Cloud的Persistent DiskGCE提供的远程磁盘服务那么kubelet就需要先调用Goolge Cloud的API将它所提供的Persistent Disk挂载到Pod所在的宿主机上。
>
备注:你如果不太了解块存储的话,可以直接把它理解为:一块**磁盘**。
这相当于执行:
```
$ gcloud compute instances attach-disk &lt;虚拟机名字&gt; --disk &lt;远程磁盘名字&gt;
```
这一步**为虚拟机挂载远程磁盘的操作对应的正是“两阶段处理”的第一阶段。在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/&lt;磁盘设备ID&gt;
# 挂载到挂载点
$ sudo mkdir -p /var/lib/kubelet/pods/&lt;Pod的ID&gt;/volumes/kubernetes.io~&lt;Volume类型&gt;/&lt;Volume名字&gt;
```
这个**将磁盘设备格式化并挂载到Volume宿主机目录的操作对应的正是“两阶段处理”的第二个阶段我们一般称为Mount。**
Mount阶段完成后这个Volume的宿主机目录就是一个“持久化”的目录了容器在它里面写入的内容会保存在Google Cloud的远程磁盘中。
而如果你的Volume类型是远程文件存储比如NFS的话kubelet的处理过程就会更简单一些。
因为在这种情况下kubelet可以跳过“第一阶段”Attach的操作这是因为一般来说远程文件存储并没有一个“存储设备”需要挂载在宿主机上。
所以kubelet会直接从“第二阶段”Mount开始准备宿主机上的Volume目录。
在这一步kubelet需要作为client将远端NFS服务器的目录比如“/”目录挂载到Volume的宿主机目录上即相当于执行如下所示的命令
```
$ mount -t nfs &lt;NFS服务器地址&gt;:/ /var/lib/kubelet/pods/&lt;Pod的ID&gt;/volumes/kubernetes.io~&lt;Volume类型&gt;/&lt;Volume名字&gt;
```
通过这个挂载操作Volume的宿主机目录就成为了一个远程NFS目录的挂载点后面你在这个目录里写入的所有文件都会被保存在远程NFS服务器上。所以我们也就完成了对这个Volume宿主机目录的“持久化”。
**到这里你可能会有疑问Kubernetes又是如何定义和区分这两个阶段的呢**
其实很简单在具体的Volume插件的实现接口上Kubernetes分别给这两个阶段提供了两种不同的参数列表
- 对于“第一阶段”AttachKubernetes提供的可用参数是nodeName即宿主机的名字。
- 而对于“第二阶段”MountKubernetes提供的可用参数是dir即Volume的宿主机目录。
所以,作为一个存储插件,你只需要根据自己的需求进行选择和实现即可。在后面关于编写存储插件的文章中,我会对这个过程做深入讲解。
而经过了“两阶段处理”我们就得到了一个“持久化”的Volume宿主机目录。所以接下来kubelet只要把这个Volume目录通过CRI里的Mounts参数传递给Docker然后就可以为Pod里的容器挂载这个“持久化”的Volume了。其实这一步相当于执行了如下所示的命令
```
$ docker run -v /var/lib/kubelet/pods/&lt;Pod的ID&gt;/volumes/kubernetes.io~&lt;Volume类型&gt;/&lt;Volume名字&gt;:/&lt;容器内的目标目录&gt; 我的镜像 ...
```
以上就是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 &quot;clusterNamespace&quot; 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: &lt;none&gt;
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: &lt;none&gt;
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的用法
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -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: &quot;http-server&quot;
volumeMounts:
- mountPath: &quot;/usr/share/nginx/html&quot;
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有冲突呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -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: &quot;k8s/nfs&quot;
fsType: &quot;nfs&quot;
options:
server: &quot;10.10.0.25&quot; # 改成你自己的NFS服务器地址
share: &quot;export&quot;
```
可以看到这个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 &lt;mount dir&gt; &lt;json param&gt;
```
其中,/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参数后面的两个字段`&lt;mount dir&gt;``&lt;json params&gt;`则是FlexVolume必须提供给这条命令的两个执行参数。
其中第一个执行参数`&lt;mount dir&gt;`正是kubelet调用SetUpAt()方法传递来的dir的值。它代表的是当前正在处理的Volume在宿主机上的目录。在我们的例子里这个路径如下所示
```
/var/lib/kubelet/pods/&lt;Pod ID&gt;/volumes/k8s~nfs/test
```
其中test正是我们前面定义的PV的名字而k8s~nfs则是插件的名字。可以看到插件的名字正是从你声明的driver="k8s/nfs"字段里解析出来的。
而第二个执行参数`&lt;json params&gt;`则是一个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} &amp;&gt; /dev/null
mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} &amp;&gt; /dev/null
if [ $? -ne 0 ]; then
err &quot;{ \&quot;status\&quot;: \&quot;Failure\&quot;, \&quot;message\&quot;: \&quot;Failed to mount ${NFS_SERVER}:${SHARE} at ${MNTPATH}\&quot;}&quot;
exit 1
fi
log '{&quot;status&quot;: &quot;Success&quot;}'
exit 0
}
```
可以看到当kubelet在宿主机上执行“`nfs mount &lt;mount dir&gt; &lt;json params&gt;`”的时候这个名叫nfs的脚本就可以直接从`&lt;mount dir&gt;`参数里拿到Volume在宿主机上的目录`MNTPATH=$1`。而你在PV的options字段里定义的NFS的服务器地址options.server和共享目录名字options.share则可以从第二个`&lt;json params&gt;`参数里解析出来。这里我们使用了jq命令来进行解析工作。
有了这三个参数之后,这个脚本最关键的一步,当然就是执行:`mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH}` 。这样一个NFS的数据卷就被挂载到了MNTPATH也就是Volume所在的宿主机目录上一个持久化的Volume目录就处理完了。
需要注意的是当这个mount -t nfs操作完成后你必须把一个JOSN格式的字符串比如{“status”: “Success”}返回给调用者也就是kubelet。这是kubelet判断这次调用是否成功的唯一依据。
综上所述在“Mount阶段”kubelet的VolumeManagerReconcile控制循环里的一次“调谐”操作的执行流程如下所示
```
kubelet --&gt; pkg/volume/flexvolume.SetUpAt() --&gt; /usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount &lt;mount dir&gt; &lt;json param&gt;
```
>
备注这个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 InterfaceCSI这样更完善、更编程友好的插件方式。
接下来我就来为你讲解一下开发存储插件的第二种方式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 ComponentsDriver 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这两种自定义存储插件的工作原理。
可以看到相比于FlexVolumeCSI的设计思想把插件的职责从“两阶段处理”扩展成了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插件都需要做哪些操作吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View 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: &quot;true&quot;
provisioner: com.digitalocean.csi.dobs
```
有了这个StorageClassExternal 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 := &amp;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 := &amp;godo.VolumeCreateRequest{
Region: d.region,
Name: volumeName,
Description: createdByDO,
SizeGigaBytes: size / GB,
}
...
vol, _, err := d.doClient.Storage.CreateVolume(ctx, volumeReq)
...
resp := &amp;csi.CreateVolumeResponse{
Volume: &amp;csi.Volume{
Id: vol.ID,
CapacityBytes: size,
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
&quot;region&quot;: 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(&quot;waiting until volume is attached&quot;)
if err := d.waitAction(ctx, req.VolumeId, action.ID); err != nil {
return nil, err
}
}
ll.Info(&quot;volume is attached&quot;)
return &amp;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(&quot;formatting the volume for staging&quot;)
if err := d.mounter.Format(source, fsType); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
} else {
ll.Info(&quot;source device is already formatted&quot;)
}
...
if !mounted {
if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
} else {
ll.Info(&quot;source device is already mounted to the target path&quot;)
}
...
return &amp;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(&quot;mounting the volume&quot;)
if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
} else {
ll.Info(&quot;volume is already mounted&quot;)
}
return &amp;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: &quot;a05dd2f26b9b9ac2asdas__REPLACE_ME____123cb5d1ec17513e06da&quot;
```
接下来我们通过一句指令就可以将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 :
- &quot;--endpoint=$(CSI_ENDPOINT)&quot;
- &quot;--token=$(DIGITALOCEAN_ACCESS_TOKEN)&quot;
- &quot;--url=$(DIGITALOCEAN_API_URL)&quot;
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: &quot;Always&quot;
securityContext:
privileged: true
capabilities:
add: [&quot;SYS_ADMIN&quot;]
allowPrivilegeEscalation: true
volumeMounts:
- name: plugin-dir
mountPath: /csi
- name: pods-mount-dir
mountPath: /var/lib/kubelet
mountPropagation: &quot;Bidirectional&quot;
- 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: &quot;csi-do&quot;
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 :
- &quot;--endpoint=$(CSI_ENDPOINT)&quot;
- &quot;--token=$(DIGITALOCEAN_ACCESS_TOKEN)&quot;
- &quot;--url=$(DIGITALOCEAN_API_URL)&quot;
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: &quot;Always&quot;
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插件对应的StorageClassdo-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
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。