09-存储类型:如何挑选合适的存储插件?

发布时间 2024-01-11 13:33:43作者: miao酱

在以前玩虚拟机的时代,大家比较少考虑存储的问题,因为在通过底层 IaaS 平台申请虚拟机的时候,大多数情况下,我们都会事先预估好需要的容量,方便虚拟机起来后可以稳定的使用这些存储资源。

但是容器与生俱来就是按照可以“运行在任何地方”(run anywhere)这一想法来设计的,对外部存储有着天然的诉求和依赖,并且由于容器本身的生命周期很短暂,在容器内保存数据是件很危险的事情,所以 Docker 通过挂载 Volume 来解决这一问题,如下图所示。

Drawing 0.png

https://docs.docker.com/storage/images/types-of-mounts-volume.png

一般来说,这些 Volume 都是和容器的生命周期进行绑定的。当然也可以单独创建,然后按需挂载到容器中。大家有兴趣可以查看目前 Docker 都适配了哪些 volume plugins(卷插件)。

现在,我们先来看看 Kubernetes 中的 Volume 跟 Docker 中的设计有什么不同。

Kubernetes 中的 Volume 是如何设计的?

Kubernetes 中的 Volume 在设计上,跟 Docker 略有不同。

我们都知道在Kubernetes 中,Pod 里包含了一组容器,这些容器是可以共享存储的,如下图所示。同时 Pod 内的容器又受制于各自的重启策略(你可以回到 05 节课,回顾一下重启策略),我们需要保证容器重启不会对这些存储产生影响。因此 Kubernetes 中 Volume 的生命周期是直接和 Pod 挂钩的,而不是 Pod 内的某个容器,即 Pod 在 Volume 在。在 Pod 被删除时,才会对 Volume 进行解绑(unmount)、删除等操作。至于 Volume 中的数据是否会被删除,取决于Volume 的具体类型。

Drawing 1.png

为了丰富可以对接的存储后端,Kubernetes 中提供了很多volume plugin可供使用。我将目前的一些 plugins 做了如下的分类,方便你进行初步的了解和比较。

Drawing 2.png

如下图所示,Kubelet 内部调用相应的 plugin 实现,将外部的存储挂载到 Pod 内。类似于CephFS、NFS以及 awsEBS 这一类插件,是需要管理员提前在对应的存储系统中申请好的,Kubernetes 本身其实并不负责这些Volume 的申请。

Drawing 3.png
https://miro.medium.com/max/2400/1*xsxMjNGNd6C_L9Vd3zYMWA.png)

常见的几种内置 Volume 插件

我们在前文的表格中列举了很多插件,我们在此不一一讲述其具体用法,大家有兴趣,可以到官方文档中进行进一步学习。

这里我介绍几个日常工作和生产环境中经常使用到的几个插件。

ConfigMap 和 Secret

首先来看 ConfigMap 和 Secret,这两类对象都可以通过 Volume 形式挂载到 Pod 内,我们在上一节课中其实已经有过例子来讲述其作用和用法,在此不再赘述。

Downward API

再来看看DownwardAPI,这是个非常有用的插件,可以帮助你获取 Pod 对象中定义的字段,比如 Pod 的标签(Labels)、Pod 的 IP 地址及 Pod 所在的命名空间(namespace)等。Downward API 有两种使用方法,既支持环境变量注入,也支持通过 Volume 挂载。

我们来看个 Volume 挂载的例子,如下是一个 Pod 的 yaml 文件:

$ cat downwardapi-volume-demo.yaml
apiVersion: v1
kind: Pod
metadata:
  name: downwardapi-volume-demo
  namespace: demo
  labels:
    zone: us-east-coast
    cluster: downward-api-test-cluster1
    rack: rack-123
  annotations:
    annotation1: "345"
    annotation2: "456"
spec:
  containers:
    - name: volume-test-container
      image: busybox:1.28
      command: ["sh", "-c"]
      args:
      - while true; do
          if [[ -e /etc/podinfo/labels ]]; then
            echo -en '\n\n'; cat /etc/podinfo/labels; fi;
          if [[ -e /etc/podinfo/annotations ]]; then
            echo -en '\n\n'; cat /etc/podinfo/annotations; fi;
          sleep 5;
        done;
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
  volumes:
    - name: podinfo
      downwardAPI:
        items:
          - path: "labels"
            fieldRef:
              fieldPath: metadata.labels
          - path: "annotations"
            fieldRef:
              fieldPath: metadata.annotations

我们先创建这个 Pod,并通过kubectl logs来查看它的输出日志:

$ kubectl create -f downwardapi-volume-demo.yaml
pod/downwardapi-volume-demo created
$ kubectl get pod -n demo
NAME                      READY   STATUS    RESTARTS   AGE
downwardapi-volume-demo   1/1     Running   0          5s
$ kubectl logs -n demo -f downwardapi-volume-demo

cluster="downward-api-test-cluster1"
rack="rack-123"
zone="us-east-coast"

annotation1="345"
annotation2="456"
kubernetes.io/config.seen="2020-09-03T12:01:58.1728583Z"
kubernetes.io/config.source="api"

cluster="downward-api-test-cluster1"
rack="rack-123"
zone="us-east-coast"

annotation1="345"
annotation2="456"
kubernetes.io/config.seen="2020-09-03T12:01:58.1728583Z"
kubernetes.io/config.source="api"

从上面的日志输出,我们可以看到 Downward API 可以通过 Volume 挂载到 Pod 里面,并被容器获取。

EmptyDir

在 Kubernetes 中,我们也可以使用临时存储,类似于创建一个 temp dir。我们将这种类型的插件叫作 EmptyDir,从名字就可以知道,在刚开始创建的时候,就是空的临时文件夹。在 Pod 被删除后,也一同被删除,所以并不适合保存关键数据。

在使用的时候,可以参照如下的方式使用 EmptyDir:

apiVersion: v1
kind: Pod
metadata:
  name: empty-dir-vol-demo
  namespace: demo
spec:
  containers:
  - image: busybox:1.28
    name: volume-test-container
    volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
  - name: cache-volume
    emptyDir: {}

一般来说,EmptyDir 可以用来做一些临时存储,比如为耗时较长的计算任务存储中间结果或者作为共享卷为同一个 Pod 内的容器提供数据等等。

除此之外,我们也可以将emptyDir.medium字段设置为“Memory”,来挂载 tmpfs (一种基于内存的文件系统)类型的 EmptyDir。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: empty-dir-vol-memory-demo
  namespace: demo
spec:
  containers:
  - image: busybox:1.28
    imagePullPolicy: IfNotPresent
    name: myvolumes-container
    command: ['sh', '-c', 'echo container is Running ; df -h ; sleep 3600']
    volumeMounts:
    - mountPath: /demo
      name: demo-volume
  volumes:
  - name: demo-volume
    emptyDir:
      medium: Memory   

HostPath

我们再来看 HostPath,它和 EmptyDir 一样,都是利用宿主机的存储为容器分配资源。但是两者有个很大的区别,就是 HostPath 中的数据并不会随着 Pod 被删除而删除,而是会持久地存放在该节点上。

使用 HostPath 非常方便,既不需要依赖外部的存储系统,也不需要复杂的配置,还能持续存储数据。但是这里我要提醒你避免滥用:

  1. 避免通过容器恶意修改宿主机上的文件内容;

  2. 避免容器恶意占用宿主机上的存储资源而打爆宿主机;

  3. 要考虑到 Pod 自身的声明周期,而且 Pod 是会“漂移”重新“长”到别的节点上的,所以要避免过度依赖本地的存储。

同时使用的时候也需要额外注意,因为 Hostpath 中定义的路径是宿主机上真实的绝对路径,那么就会存在同一节点上的多个 Pod 共用一个 Hostpath 的情形,比如同一工作负载的不同实例调度到同一节点上,这会造成数据混乱,读写异常。这个时候我们就需要额外设置一些调度策略,避免这种情况发生。我们会在后面的课程中,来介绍相关的调度策略。

下面是一个使用 HostPath 的例子:

apiVersion: v1
kind: Pod
metadata:
  name: hostpath-demo
  namespace: demo
spec:
  containers:
  - image: nginx:1.19.2
    name: container-demo
    volumeMounts:
      - mountPath: /test-pd
        name: hostpath-volume
  volumes:
  - name: hostpath-volume 
    hostPath:
      path: /data  # 对应宿主机上的绝对路径
      type: Directory # 可选字段,默认是 Directory

在上面的例子中,我们要注意hostpath.type这个可以缺省的字段。为了保证后向兼容性,默认值是 Directory。目前这个字段还支持 DirectoryOrCreate、FileOrCreate 、File 、Socket 、CharDevice 和 BlockDevice,你可以到官方文档中去了解这几个类型的具体含义。

这个 type 可以帮助你做一些预检查,比如你期望挂载的是单个文件,如果检测到挂载路径是个目录,这个时候就会报异常,这样可以有效地避免一些误配置。

上述介绍的这几款插件,目前依然能够照常使用,也是社区自身稳定支持的插件。但是对于一些云厂商和第三方的插件,社区已经不推荐继续使用内置的方式了,而是推荐你通过 CSI(Container Storage Interface,容器存储接口)来使用这些插件。

为什么社区要采用 CSI

一开始,上述这些云厂商以及第三方的卷插件(volume plugin),都是直接内置在 Kubernetes 代码库中进行开发的,目前代码库中包含 20 多个插件。但这种方式带来了很多问题。

  1. 这些插件对 Kubernetes 代码本身的稳定性以及安全性引入了很多未知的风险,一个很小的 Bug 都有可能导致集群受到攻击或者无法工作。

  2. 这些插件的维护和 Kubernetes 的正常迭代紧密耦合在一起,一起打包和编译。即便是某个单一插件出现了 Bug,都需要通过升级 Kubernetes 的版本来修复。

  3. 社区需要维护所有的 volume plugin,并且要经过完整的测试验证流程,来保证可用性,这给社区的正常迭代平添了很多麻烦。

  4. 各个卷插件依赖的包也都要算作 Kubernetes 项目的一部分,这会让 Kubernetes 的依赖变得臃肿。

  5. 开发者被迫要将这些插件代码进行开源。

为此,社区早在 v1.2 版本就开始尝试用 FlexVolume 插件来解决,在 v1.8 版本 GA,并停止接收任何新增的内置 volume plugin 了。用户需要遵循 FlexVolume 约定的接口规范,自己开发可执行的程序,比如二进制程序、Shell脚本等,以命令行参数作为输入,并返回 JSON 格式的结果,这样 Kubelet 就可以通过 exec 的方式调用用户的插件程序了,如下图所示。这种方式方便了各个插件的开发、更新、维护和升级,同时也和 Kubernetes 进行了解耦。在使用的时候,需要用户提前将这些二进制的文件放到各个节点上指定的目录里面(默认是/usr/libexec/kubernetes/kubelet-plugins/volume/exec/),方便 Kubelet 可以动态发现和调用。

Drawing 4.png
https://miro.medium.com/max/840/0*OlfKs7Isngbm5mhO)

但是在实际使用中,FlexVolume 还是有很多局限性的。比如:

  • 需要一些前置依赖包,像 ceph 就需要安装ceph-common等依赖包;

  • 部署很麻烦,而且往往需要很高的执行权限,要可以访问宿主机上的根文件系统。

为了彻底解决FlexVolume 开发过程中遇到的问题,CSI采用了容器化的方式进行部署,并基于 Kubernetes 的语义使用各种已定义的对象,比如下节课我们要讲的 PV、PVC、StorageClass,等等。各家厂商只需要按照 CSI 的规范实现各自的接口即可。大家可以通过这份官方的清单列表,来查看可以用于生产环境的 CSI Driver。

社区也希望用户可以尽快体会到 CSI 带来的好处,但是并不会强迫用户立马迁移并使用新的 CSI,所以社区也着手开发了CSIMIgration功能,来帮助用户进行“无感”迁移。目前CSIMigration在 v1.17 时已经变为了 Beta 版本。你可以根据自己使用的volume plugin 选择开启对应的功能。

CSI 为了能更通用,在设计的时候,也变得相对复杂了一些。幸好这些开发工作都由各个厂商进行开发了,我们只需要按照各家的要求进行部署即可。由于篇幅所限,我们在此不详细解释 CSI 的工作流程了。如果你想进一步了解如何开发一个 CSI driver,可以参考这份官方开发文档

写在最后

本节课讲的 Configmap、Secret、Downward API、EmptyDir 以及 Hostpath 都是日常频繁会使用到的 volume plugin,数据都会放在 Pod 所在的宿主机上。但是对于一些云厂商或者第三方的存储系统,我建议你直接通过 CSI 来使用。

如果你需要持久化的存储,请关注我们下一节课的内容。好的,如果你对本节课有什么想法或者疑问,欢迎你在留言区留言,我们一起讨论。