一文读懂 Kubernetes 存储设计

1,573 阅读19分钟

在 Docker 的设计中,容器内的文件是临时存放的,并且随着容器的删除,容器内部的数据也会一同被清空。不过,我们可以通过在 docker run 启动容器时,使用 --volume/-v 参数来指定挂载卷,这样就能够将容器内部的路径挂载到主机,后续在容器内部存放数据时会就被同步到被挂载的主机路径中。这样做可以保证保证即便容器被删除,保存到主机路径中的数据也仍然存在。

与 Docker 通过挂载卷的方式就可以解决持久化存储问题不同,K8s 存储要面临的问题要复杂的多。因为 K8s 通常会在多个主机部署节点,如果 K8s 编排的 Docker 容器崩溃,K8s 可能会在其他节点上重新拉起容器,这就导致原来节点主机上挂载的容器目录无法使用。

当然也是有办法解决 K8s 容器存储的诸多限制,比如可以对存储资源做一层抽象,通常大家将这层抽象称为卷(Volume)。

K8s 支持的卷基本上可以分为三类:配置信息、临时存储、持久存储。

配置信息

无论何种类型的应用,都会用到配置文件或启动参数。而 K8s 将配置信息进行了抽象,定义成了几种资源,主要有以下三种:

  • ConfigMap

  • Secret

  • DownwardAPI

ConfigMap

ConfigMap 卷通常以一个或多个 key: value 形式存在,主要用来保存应用的配置数据。其中 value 可以是字面量或配置文件。

不过,因为ConfigMap 在设计上不是用来保存大量数据的,所以在 ConfigMap 中保存的数据不可超过 1 MiB(兆字节)。

ConfigMap 有两种创建方式:

  • 通过命令行创建

  • 通过 yaml 文件创建

通过命令行创建

在创建 Configmap 的时候可以通过 --from-literal 参数来指定 key: value,以下示例中 foo=bar 即为字面量形式,bar=bar.txt 为配置文件形式。

$ kubectl create configmap c1 --from-literal=foo=bar --from-literal=bar=bar.txt

bar.txt 内容如下:

baz

通过 kubectl describe 命令查看新创建的名称为 c1 的这个 Configmap 资源内容。

$ kubectl describe configmap c1
Name:         c1
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
bar:
----
baz
foo:
----
bar
Events:  <none>

通过 yaml 文件创建

创建 configmap-demo.yaml 内容如下:

kind: ConfigMap
apiVersion: v1
metadata:
  name: c2
  namespace: default
data:
  foo: bar
  bar: baz

通过 kubectl apply 命令应用这个文件。

$ kubectl apply -f configmap-demo.yaml

$ kubectl get configmap c2
NAME   DATA   AGE
c2     2      11s

$ kubectl describe configmap c2
Name:         c2
Namespace:    default
Labels:       <none>
Annotations:  <none>

Data
====
foo:
----
bar
bar:
----
baz
Events:  <none>

得到的结果跟通过命令行方式创建的 Configmap 没什么两样。

使用示例

完成 Configmap 创建后,来看下如何使用。

创建好的Configmap 有两种使用方法:

  • 通过环境变量将 Configmap 注入到容器内部

  • 通过卷挂载的方式直接将 Configmap 以文件形式挂载到容器。

通过环境变量方式引用

创建 use-configmap-env-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-configmap-env"
  namespace: default
spec:
  containers:
    - name: use-configmap-env
      image: "alpine"
      # 一次引用单个值
      env:
        - name: FOO
          valueFrom:
            configMapKeyRef:
              name: c2
              key: foo
      # 一次引用所有值
      envFrom:
        - prefix: CONFIG_  # 配置引用前缀
          configMapRef:
            name: c2
      command: ["echo", "$(FOO)", "$(CONFIG_bar)"]

可以看到我们创建了一个名为 use-configmap-env 的 Pod,Pod 的容器将使用两种方式引用 Configmap 的内容。

第一种是指定 spec.containers.env,它可以为容器引用一个 Configmap 的 key: value 对。其中valueFrom. configMapKeyRef 表明我们要引用 Configmap ,Configmap 的名称为 c2 ,引用的 key 为 foo 。

第二种是指定 spec.containers.envFrom ,只需要通过 configMapRef.name 指定 Configmap 的名称,它就可以一次将 Configmap 中的所有 key: value 传递给容器。其中 prefix 可以给引用的 key 前面增加统一前缀。

Pod 的容器启动命令为 echo (FOO)(FOO) (CONFIG_bar) ,可以分别打印通过 env 和 envFrom 两种方式引用的 Configmap 的内容。

# 创建 Pod
$ kubectl apply -f use-configmap-env-demo.yaml
# 通过查看 Pod 日志来观察容器内部引用 Configmap 结果
$ kubectl logs use-configmap-env
bar baz

结果表明,容器内部可以通过环境变量的方式引用到 Configmap 的内容。

通过卷挂载方式引用

创建 use-configmap-volume-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-configmap-volume"
  namespace: default
spec:
  containers:
    - name: use-configmap-volume
      image: "alpine"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: configmap-volume
          mountPath: /usr/share/tmp  # 容器挂载目录
  volumes:
    - name: configmap-volume
      configMap:
        name: c2

这里创建一个名为 use-configmap-volume 的 Pod。通过 spec.containers.volumeMounts 指定容器挂载,name 指定挂载的卷名,mountPath 指定容器内部挂载路径(也就是将 Configmap 挂载到容器内部的指定目录下)。spec.volumes 声明一个卷,而configMap.name 表明了这个卷要引用的 Configmap 名称。

然后可通过如下命令创建 Pod 并验证容器内部能否引用到 Configmap。

# 创建 Pod
$ kubectl apply -f use-configmap-volume-demo.yaml
# 进入 Pod 容器内部
$ kubectl exec -it use-configmap-volume -- sh
# 进入容器挂载目录
/ # cd /usr/share/tmp/
# 查看挂载目录下的文件
/usr/share/tmp # ls
bar  foo
# 查看文件内容
/usr/share/tmp # cat foo
bar
/usr/share/tmp # cat bar
baz

创建完成后,通过 kubectl exec 命令可以进入容器内部。查看容器 /usr/share/tmp/ 目录,可以看到两个以 Configmap 中 key 为名称的文本文件(foo 、bar), key 所对应的 value 内容即为文件内容。

以上就是两种将 Configmap 的内容注入到容器内部的方式。容器内部的应用则可以分别通过读取环境变量、文件内容的方式使用配置信息。

Secret

熟悉了 Configmap 的用法,接下来看下 Secret 的使用。Secret 卷用来给 Pod 传递敏感信息,例如密码、SSH 密钥等。因为虽然Secret 与 ConfigMap 非常类似,但是它会对存储的数据进行 base64 编码。

Secret 同样有两种创建方式:

  • 通过命令行创建

  • 通过 yaml 文件创建

通过命令行创建

Secret 除了通过 --from-literal 参数来指定 key: value,还有另一种方式。即通过 --form-file 参数直接从文件加载配置,文件名即为 key,文件内容作为 value。

# generic 参数对应 Opaque 类型,既用户定义的任意数据
$ kubectl create secret generic s1 --from-file=foo.txt

foo.txt 内容如下:

foo=bar
bar=baz

可以看到与 Configmap 不同,创建 Secret 需要指明类型。上面的示例为命令通过指定 generic 参数来创建类型为 Opaque 的 Secret ,这也是 Secret 默认类型。需要注意的是除去默认类型,Secret 还支持其他类型,可以通过官方文档查看。不过初期学习阶段只使用默认类型即可,通过默认类型就能够实现其他几种类型的功能

$ kubectl describe secret s1
Name:         s1
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
foo.txt:  16 bytes

另外一点与 Configmap 不同的是,Secret 仅展示 value 的字节大小,而不直接展示数据,这是为了保存密文,也是Secret 名称的含义。

通过 yaml 文件创建

创建 secret-demo.yaml 内容如下:

apiVersion: v1
kind: Secret
metadata:
  name: s2
  namespace: default
type: Opaque  # 默认类型
data:
  user: cm9vdAo=
  password: MTIzNDU2Cg==

通过 kubectl apply 命令应用这个文件。


$ kubectl apply -f secret-demo.yaml

$ kubectl get secret s2
NAME   TYPE     DATA   AGE
s2     Opaque   2      59s

$ kubectl describe secret s2
Name:         s2
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
password:  7 bytes
user:      5 bytes

同样能够正确创建出 Secret 资源。但是可以看到通过 yaml 文件创建 Secret 时,指定的 data 内容必须经过 base64 编码,比如我们指定的 user 和 password 都是编码后的结果。

data:
  user: cm9vdAo=
  password: MTIzNDU2Cg==

除此外也可以使用原始字符串方式,这两种方式是等价,示例如下:

data:
  stringData:
   user: root
   password: "123456"

相对而言,我更推荐使用 base64 编码的方式。

使用示例

同 Configmap 使用方式一样,我们也可以通过环境变量或卷挂载的方式来使用 Secret 。以卷挂载方式为例。首先创建 use-secret-volume-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "use-secret-volume-demo"
  namespace: default
spec:
  containers:
    - name: use-secret-volume-demo
      image: "alpine"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: secret-volume
          mountPath: /usr/share/tmp # 容器挂载目录
  volumes:
    - name: secret-volume
      secret:
        secretName: s2

即创建一个名为 use-secret-volume-demo 的 Pod,而 Pod 的容器通过卷挂载方式引用 Secret 的内容。

# 创建 Pod
$ kubectl apply -f use-secret-volume-demo.yaml

# 进入 Pod 容器内部
$ kubectl exec -it use-secret-volume-demo -- sh
# 进入容器挂载目录
/ # cd /usr/share/tmp/
# 查看挂载目录下的文件
/usr/share/tmp # ls
password  user
# 查看文件内容
/usr/share/tmp # cat password 
123456
/usr/share/tmp # cat user 
root

可以发现被挂载到容器内部以后,Secret 的内容将变成明文存储。容器内部应用可以同使用 Configmap 一样来使用 Secret 。

作为可以存储配置信息的 Configmap 和 Secret , Configmap 通常存放普通配置, Secret 则存放敏感配置。

值得一提的是,使用环境变量方式引用 Configmap 或 Secret ,当 Configmap 或 Secret 内容变更时,容器内部引用的内容不会自动更新;使用卷挂载方式引用 Configmap 或 Secret ,当 Configmap 或 Secret 内容变更时,容器内部引用的内容会自动更新。如果容器内部应用支持配置文件热加载,那么通过卷挂载对的方式引用 Configmap 或 Secret 内容将是推荐方式。

DownwardAPI

DownwardAPI 可以将 Pod 对象自身的信息注入到 Pod 所管理的容器内部。

使用示例

创建 downwardapi-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: downwardapi-volume-demo
  labels:
    app: downwardapi-volume-demo
  annotations:
    foo: bar
spec:
  containers:
    - name: downwardapi-volume-demo
      image: alpine
      command: ["sleep", "3600"]
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
  volumes:
    - name: podinfo
      downwardAPI:
        items:
          # 指定引用的 labels
          - path: "labels"
            fieldRef:
              fieldPath: metadata.labels
          # 指定引用的 annotations
          - path: "annotations"
            fieldRef:
              fieldPath: metadata.annotations
# 创建 Pod
$ kubectl apply -f downwardapi-demo.yaml
pod/downwardapi-volume-demo created

# 进入 Pod 容器内部
$ kubectl exec -it downwardapi-volume-demo -- sh
# 进入容器挂载目录
/ # cd /etc/podinfo/
# 查看挂载目录下的文件
/etc/podinfo # ls
annotations  labels
# 查看文件内容
/etc/podinfo # cat annotations 
foo="bar"
kubectl.kubernetes.io/last-applied-configuration="{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{\"foo\":\"bar\"},\"labels\":{\"app\":\"downwardapi-volume-demo\"},\"name\":\"downwardapi-volume-demo\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"command\":[\"sleep\",\"3600\"],\"image\":\"alpine\",\"name\":\"downwardapi-volume-demo\",\"volumeMounts\":[{\"mountPath\":\"/etc/podinfo\",\"name\":\"podinfo\"}]}],\"volumes\":[{\"downwardAPI\":{\"items\":[{\"fieldRef\":{\"fieldPath\":\"metadata.labels\"},\"path\":\"labels\"},{\"fieldRef\":{\"fieldPath\":\"metadata.annotations\"},\"path\":\"annotations\"}]},\"name\":\"podinfo\"}]}}\n"
kubernetes.io/config.seen="2022-03-12T13:06:50.766902000Z"
/etc/podinfo # cat labels
app="downwardapi-volume-demo"

不难发现,DownwardAPI 的使用方式同 Configmap 和 Secret 一样,都可以通过卷挂载方式挂载到容器内部以后,在容器挂载的目录下生成对应文件,用来存储 key: value。不同的是 ,因为DownwardAPI能引用的内容已经都在当前 yaml 文件中定义好了,所以DownwardAPI 不需要预先定义,可以直接使用。

小结

ConfigMap 、Secret 、DownwardAPI 这三种 Volume 存在的意义不是为了保存容器中的数据,而是为了给容器传递预先定义好的数据。

临时卷

接下来我们要关注的是临时卷,即临时存储。K8s 支持的临时存储中最常用的就是如下两种:

  • EmptyDir

  • HostPath

临时存储卷会遵从 Pod 的生命周期,与 Pod 一起创建和删除。

EmptyDir

先来看 emptyDir 如何使用。EmptyDir 相当于通过 --volume/-v 挂载时的隐式 Volume 形式使用 Docker。K8s 会在宿主机上创建一个临时目录,被挂载到容器所声明的 mountPath 目录上,即不显式的声明在宿主机上的目录。

使用示例

创建 emptydir-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "emptydir-nginx-pod"
  namespace: default
  labels:
    app: "emptydir-nginx-pod"
spec:
  containers:
    - name: html-generator
      image: "alpine:latest"
      command: ["sh", "-c"]
      args:
       - while true; do
          date > /usr/share/index.html;
          sleep 1;
         done
      volumeMounts:
        - name: html
          mountPath: /usr/share
    - name: nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          # nginx 容器 index.html 文件所在目录
          mountPath: /usr/share/nginx/html
          readOnly: true
  volumes:
    - name: html
      emptyDir: {}

这里创建一个名为 emptydir-nginx-pod 的 Pod,它包含两个容器 html-generator 和 nginx 。html-generator 用来不停的生成 html 文件,nginx 则是用来展示 html-generator 生成的 index.html 文件的 Web 服务。

具体流程为,html-generator 不停的将当前时间写入到 /usr/share/index.html 下,并将 /usr/share 目录挂载到名为 html 的卷中,而 nginx 容器则将 /usr/share/nginx/html 目录挂载到名为 html 的卷中,这样两个容器通过同一个卷 html 挂载到了一起。

现在通过 kubectl apply 命令应用这个文件:

# 创建 Pod
$ kubectl apply -f emptydir-demo.yaml
pod/emptydir-nginx-pod created

# 进入 Pod 容器内部
$ kubectl exec -it pod/emptydir-nginx-pod -- sh
# 查看系统时区
/ # curl 127.0.0.1
Sun Mar 13 08:40:01 UTC 2022
/ # curl 127.0.0.1
Sun Mar 13 08:40:04 UTC 2022

根据 nginx 容器内部 curl 127.0.0.1 命令输出结果可以发现,nginx 容器 /usr/share/nginx/html/indedx.html 文件内容即为 html-generator 容器 /usr/share/index.html 文件内容。

能够实现此效果的原理是,当我们声明卷类型为 emptyDir: {} 后,K8s 会自动在主机目录上创建一个临时目录。然后将 html-generator 容器 /usr/share/ 目录和 nginx 容器 /usr/share/nginx/html/ 同时挂载到这个临时目录上。这样两个容器的目录就能够实现数据同步。

需要注意的是,容器崩溃并不会导致 Pod 被从节点上移除,因此容器崩溃期间 emptyDir 卷中的数据是安全的。另外,emptyDir.medium 除了可以设成 {},还可以设成 Memory 表示内存挂载。

HostPath

与 emptyDir 不同,hostPath 卷能将主机节点文件系统上的文件或目录挂载到指定的 Pod 中。并且当 Pod 删除时,与之绑定的 hostPath 并不会随之删除。新创建的 Pod挂载到上一个 Pod 使用过的 hostPath时,原 hostPath 中的内容仍然存在。但这仅限于新的 Pod 和已经删除的 Pod 被调度到同一节点上,所以严格来讲 hostPath 仍然属于临时存储。

hostPath 卷的典型应用是将主机节点上的时区通过卷挂载的方式注入到容器内部, 进而保证启动的容器和主机节点时间同步。

使用示例

创建 hostpath-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "hostpath-volume-pod"
  namespace: default
  labels:
    app: "hostpath-volume-pod"
spec:
  containers:
    - name: hostpath-volume-container
      image: "alpine:latest"
      command: ["sleep", "3600"]
      volumeMounts:
        - name: localtime
          mountPath: /etc/localtime
  volumes:
    - name: localtime
      hostPath:
        path: /usr/share/zoneinfo/Asia/Shanghai

要实现时间同步,只需要将主机目录 /usr/share/zoneinfo/Asia/Shanghai 通过卷挂载的方式挂载到容器内部的 /etc/localtime 目录即可。

可以使用 kubectl apply 命令应用这个文件,然后进入 Pod 容器内部使用 date 命令查看容器当前时间。

# 创建 Pod
$ kubectl apply -f hostpath-demo.yaml
pod/hostpath-volume-pod created
# 进入 Pod 容器内部
$ kubectl exec -it hostpath-volume-pod -- sh
# 执行 date 命令输出当前时间
/ # date
Sun Mar 13 17:00:22 CST 2022  # 上海时区

看到输出结果为 Sun Mar 13 17:00:22 CST 2022 ,其中 CST 代表了上海时区,也就是主机节点的时区。如果不通过卷挂载的方式将主机时区挂载到容器内部,则容器默认时区为 UTC 时区。

小结

临时卷内容介绍了 K8s 的临时存储方案以及应用,其中emptyDir 适用范围较少,可以当作临时缓存或者耗时任务检查点等。

需要注意的是,绝大多数 Pod 应该忽略主机节点,不应该访问节点上的文件系统。尽管有时候 DaemonSet 可能需要访问主机节点的文件系统,而且hostPath 可以用来同步主机节点时区到容器,但其他情况下使用较少,特别hostPath 的最佳实践就是尽量不使用 hostPath。

持久卷

临时卷的生命周期与 Pod 相同,当 Pod 被删除时,K8s 会自动删除 Pod 挂载的临时卷。而当 Pod 中的应用需要将数据保存到磁盘,且即使 Pod 被调度到其他节点数据也应该存在时,我们就需要一个真正的持久化存储了。

K8s 支持的持久卷类型非常多,以下是 v1.24 版本支持的卷类型的一部分:

  • awsElasticBlockStore - AWS 弹性块存储(EBS)

  • azureDisk - Azure Disk

  • azureFile - Azure File

  • cephfs - CephFS volume

  • csi - 容器存储接口 (CSI)

  • fc - Fibre Channel (FC) 存储

  • gcePersistentDisk - GCE 持久化盘

  • glusterfs - Glusterfs 卷

  • iscsi - iSCSI (SCSI over IP) 存储

  • local - 节点上挂载的本地存储设备

  • nfs - 网络文件系统 (NFS) 存储

  • portworxVolume - Portworx 卷

  • rbd - Rados 块设备 (RBD) 卷

  • vsphereVolume - vSphere VMDK 卷

看到这么多持久卷类型不必恐慌,因为 K8s 为了让开发者不必关心这背后的持久化存储类型,所以对持久卷有一套独有的思想,即开发者无论使用哪种持久卷,其用法都是一致的。

K8s 持久卷设计架构如下:

1.png

Node1 和 Node2 分别代表两个工作节点,当我们在工作节点创建 Pod 时,可以通过 spec.containers.volumeMounts 来指定容器挂载目录,通过 spec.volumes 来指定挂载卷。之前我们用挂载卷挂载了配置信息和临时卷,而挂载持久卷也可以采用同样的方式。每个 volumes 则指向的是下方存储集群中不同的存储类型。

为了保证高可用,我们通常会搭建一个存储集群。通常通过 Pod 来操作存储, 因为 Pod 都会部署在 Node 中,所以存储集群最好跟 Node 集群搭建在同一内网,这样速度更快。而存储集群内部可以使用任何 K8s 支持的持久化存储,如上图的 NFS 、CephFS 、CephRBD 。

2.png

使用NFS

持久化挂载方式与临时卷大同小异,我们同样使用一个 Nginx 服务来进行测试。这次我们用 NFS 存储来演示 K8s 对持久卷的支持(NFS 测试环境搭建过程可以参考文章结尾的附录部分),创建 nfs-demo.yaml 内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: "nfs-nginx-pod"
  namespace: default
  labels:
    app: "nfs-nginx-pod"
spec:
  containers:
    - name: nfs-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html-volume
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html-volume
      nfs:
        server: 192.168.99.101  # 指定 nfs server 地址
        path: /nfs/data/nginx  # 目录必须存在

将容器 index.html 所在目录 /usr/share/nginx/html/ 挂载到 NFS 服务的 /nfs/data/nginx 目录下,在 spec.volumes 配置项中指定 NFS 服务。其中server 指明了 NFS 服务器地址,path 指明了 NFS 服务器中挂载的路径,当然这个路径必须是已经存在的路径。然后通过 kubectl apply 命令应用这个文件。

$ kubectl apply -f nfs-demo.yaml

接下来我们查看这个 Pod 使用 NFS 存储的结果:

在 NFS 节点中我们准备一个 index.html 文件,其内容为 hello nfs。

3.png

使用 curl 命令直接访问 Pod 的 IP 地址,即可返回 Nginx 服务的 index.html 内容,结果输出为 hello nfs ,证明 NFS 持久卷挂载成功。

4.png

登入 Pod 容器,通过 df -Th 命令查看容器目录挂载信息。可以发现,容器的 /usr/share/nginx/html/ 目录被挂载到 NFS 服务的 /nfs/data/nginx 目录。

5.png

现在,当我们执行 kubectl delete -f nfs-demo.yaml 删除 Pod 后,NFS 服务器上数据盘中的数据依然存在,这就是持久卷。

持久卷使用痛点

虽然通过使用持久卷,可以解决临时卷数据易丢失的问题。但目前持久卷的使用方式还存在以下痛点:

  • Pod 开发人员可能对存储不够了解,却要对接多种存储

  • 安全问题,有些存储可能需要账号密码,这些信息不应该暴露给 Pod

因此为了解决这些不足,K8s 又针对持久化存储抽象出了三种资源 PV、PVC、StorageClass。三种资源定义如下:

  • PV 描述的是持久化存储数据卷

  • PVC 描述的是 Pod 想要使用的持久化存储属性,既存储卷申明

  • StorageClass 作用是根据 PVC 的描述,申请创建对应的 PV

PV 和 PVC 的概念可以对应编程语言中的面向对象思想,PVC 是接口,PV 是具体实现。

有了这三种资源类型后,Pod 就可以通过静态供应和动态供应这两种方式来使用持久卷。

静态供应

静态供应不涉及 StorageClass,只涉及到 PVC 和 PV。其使用流程图如下:

6.png

使用静态供应时,Pod 不再直接绑定持久存储,而是会绑定到 PVC 上,然后再由 PVC 跟 PV 进行绑定。这样就实现了 Pod 中的容器可以使用由 PV 真正去申请的持久化存储。

使用示例

创建 pv-demo.yaml 内容如下:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-1g
  labels:
    type: nfs
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  storageClassName: nfs-storage
  nfs:
    server: 192.168.99.101
    path: /nfs/data/nginx1
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-100m
  labels:
    type: nfs
spec:
  capacity:
    storage: 100m
  accessModes:
    - ReadWriteOnce
  storageClassName: nfs-storage
  nfs:
    server: 192.168.99.101
    path: /nfs/data/nginx2
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-500m
  labels:
    app: pvc-500m
spec:
  storageClassName: nfs-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 500m
---
apiVersion: v1
kind: Pod
metadata:
  name: "pv-nginx-pod"
  namespace: default
  labels:
    app: "pv-nginx-pod"
spec:
  containers:
    - name: pv-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html
      persistentVolumeClaim:
        claimName: pvc-500m

其中 yaml 文件定义了如下内容:

  • 两个 PV:申请容量分别为 1Gi 、100m ,通过 spec.capacity.storage 指定,并且他们通过 spec.nfs 指定了 NFS 存储服务的地址和路径。

  • 一个 PVC :申请 500m 大小的存储。

  • 一个 Pod:spec.volumes 绑定名为 pvc-500m 的 PVC,而不是直接绑定 NFS 存储服务。

通过 kubectl apply 命令应用该文件:

$ kubectl apply -f pv-demo.yaml

以上完成创建,结果查看操作则如下:

首先通过 kubectl get pod 命令查看新创建的 Pod,并通过 curl 命令访问 Pod 的 IP 地址,得到 hello nginx1 的响应结果。

7.png

然后通过 kubectl get pvc 查看创建的 PVC:

  • STATUS 字段:标识 PVC 已经处于绑定(Bound)状态,也就是与 PV 进行了绑定。

  • CAPACITY 字段:标识 PVC 绑定到了 1Gi 的 PV 上,尽管我们申请的 PVC 大小是 500m ,但由于我们创建的两个 PV 大小分别是 1Gi 和 100m ,K8s 会帮我们选择满足条件的最优解。因为没有刚好等于 500m 大小的 PV 存在,而 100m 又不满足,所以 PVC 会自动与 1Gi 大小的 PV 进行绑定。

8.png

通过 kubectl get pv 来查询创建的 PV 资源,可以发现 1Gi 大小的 PV STATUS 字段为 Bound 状态。CLAIM 的值,则标识的是与之绑定的 PVC 的名字。

9.png

现在我们登录 NFS 服务器,确认NFS 存储上不同持久卷(PV)挂载的目录下文件内容。

10.png

可以看到,/nfs/data/nginx1 目录下的 index.html 内容为 hello nginx1 ,即为上面通过 curl 命令访问 Pod 服务的响应结果。

到此持久卷完成使用,我们总结下整个持久卷使用流程。首先创建一个 Pod, Pod 的 spec.volumes 中绑定 PVC。这里的 PVC 只是一个存储申明,代表我们的 Pod 需要什么样的持久化存储,它不需要标明 NFS 服务地址,也不需要明确要和哪个 PV 进行绑定,只是创建出这个 PVC 即可。接着我们创建两个 PV,PV 也没有明确指出要与哪个 PVC 进行绑定,只需要指出它的大小和 NFS 存储服务地址即可。此时 K8s 会自动帮我们进行 PVC 和 PV 的绑定,这样 Pod 就和 PV 产生了联系,也就可以访问持久化存储了。

其他

细心的你可能已经发现,前文提到静态供应不涉及 StorageClass,但是在定义 PVC 和 PV 的 yaml 文件时,还是都为其指定了 spec.storageClassName 值为 nfs-storage。因为这是一个便于管理的操作方式,只有具有相同 StorageClass 的 PVC 和 PV 才可以进行绑定,这个字段标识了持久卷的访问模式。在 K8s 持久化中支持四种访问模式:

  • RWO - ReadWriteOnce —— 卷可以被一个节点以读写方式挂载

  • ROX - ReadOnlyMany —— 卷可以被多个节点以只读方式挂载

  • RWX - ReadWriteMany —— 卷可以被多个节点以读写方式挂载

  • RWOP - ReadWriteOncePod —— 卷可以被单个 Pod 以读写方式挂载( K8s 1.22 以上版本)

只有具有相同读写模式的 PVC 和 PV 才可以进行绑定。

现在我们来继续实验,通过命令 kubectl delete pod pv-nginx-pod 删除 Pod,再次查看 PVC 和 PV 状态。

11.png

从上图可以看到, Pod 删除后 PVC 和 PV 还在,这说明 Pod 删除并不影响 PVC 的存在。而当 PVC 删除时 PV 是否删除,则可以通过设置回收策略来决定。PV 回收策略(pv.spec.persistentVolumeReclaimPolicy)有三种:

  • Retain —— 手动回收,也就是说删除 PVC 后,PV 依然存在,需要管理员手动进行删除

  • Recycle —— 基本擦除 (相当于 rm -rf /*)(新版已废弃不建议使用,建议使用动态供应)

  • Delete —— 删除 PV,即级联删除

现在通过命令 kubectl delete pvc pvc-500m 删除 PVC,查看 PV 状态。

12.png

可以看到 PV 依然存在,其 STATUS 已经变成 Released ,此状态下的 PV 无法再次绑定到 PVC,需要由管理员手动删除,这是由回收策略决定的。

注意:绑定了 Pod 的 PVC,如果 Pod 正在运行中,PVC 无法删除。

静态供应的不足

我们一起体验了静态供应的流程,虽然比直接在 Pod 中绑定 NFS 服务更加清晰,但静态供应依然存在不足。

  • 首先会造成资源浪费,如上面示例中,PVC 申请 500m,而没有刚好等于 500m 的 PV 存在,这 K8s 会将 1Gi 的 PV 与之绑定

  • 还有一个致命的问题,如果当前没有满足条件的 PV 存在,则这 PVC 一直无法绑定到 PV 处于 Pending 状态,Pod 也将无法启动,所以就需要管理员提前创建好大量 PV 来等待新创建的 PVC 与之绑定,或者管理员时刻监控是否有满足 PVC 的 PV 存在,如果不存在则马上进行创建,这显然是无法接受的

动态供应

因为静态供应存在不足,K8s 推出一种更加方便的持久卷使用方式,即动态供应。动态供的应核心组件就是 StorageClass——存储类。StorageClass 主要作用有两个:

  • 一是资源分组,我们上面使用静态供应时指定 StorageClass 的目前就是对资源进行分组,便于管理

  • 二是 StorageClass 能够帮我们根据 PVC 请求的资源,自动创建出新的 PV,这个功能是 StorageClass 中 provisioner 存储插件帮我们来做的。

其使用流程图如下:

13.png

相较于静态供应,动态供应在 PVC 和 PV 之间增加了存储类。这次 PV 并不需要提前创建好,只要我们申请了 PVC 并且绑定了有 provisioner 功能的 StorageClass,StorageClass 会帮我们自动创建 PV 并与 PVC 进行绑定。

我们可以根据提供的持久化存储类型,分别创建对应的 StorageClass,比如:

  • nfs-storage

  • cephfs-storage

  • rbd-storage

也可以设置一个默认 StorageClass, 通过在创建 StorageClass 资源时指定对应的 annotations 实现:

apiVersion: storage.K8s.io/v1
kind: StorageClass
metadata:
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
...

当创建 PVC 时不指定 spec.storageClassName ,这个 PVC 就会使用默认 StorageClass。

使用示例

仍然使用 NFS 来作为持久化存储。

首先需要有一个能够支持自动创建 PV 的 provisioner ,这可以在 GitHub 中找到一些开源的实现。示例使用 nfs-subdir-external-provisioner 这个存储插件,具体安装方法非常简单,只需要通过 kubectl apply 命令应用它提供的几个 yaml 文件即可。完成存储插件安装后,可以创建如下 StorageClass:

apiVersion: storage.K8s.io/v1
kind: StorageClass
metadata:
  name: nfs-storage
provisioner: K8s-sigs.io/nfs-subdir-external-provisioner
parameters:
  archiveOnDelete: "true"

这个 StorageClass 指定了 provisioner 为我们安装好的 K8s-sigs.io/nfs-subdir-external-provisioner。 Provisioner 本质上也是一个 Pod,可以通过 kubectl get pod 来查看。指定了 provisioner 的 StorageClass 就有了自动创建 PV 的能力,因为 Pod 能够自动创建 PV。

14.png

创建好 provisioner 和 StorageClass 就可以进行动态供应的实验了。首先创建 nfs-provisioner-demo.yaml 内容如下:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: test-claim
spec:
  storageClassName: nfs-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: "test-nginx-pod"
  namespace: default
  labels:
    app: "test-nginx-pod"
spec:
  containers:
    - name: test-nginx
      image: "nginx:latest"
      ports:
        - containerPort: 80
          name: http
      volumeMounts:
        - name: html
          mountPath: /usr/share/nginx/html/
  volumes:
    - name: html
      persistentVolumeClaim:
        claimName: test-claim

这里我们只定义了一个 PVC 和一个 Pod,并没有定义 PV。其中 PVC 的 spec.storageClassName 指定为上面创建好的 StorageClass nfs-storage ,然后只需要通过 kubectl apply 命令来创建出 PVC 和 Pod 即可:

$ kubectl apply -f nfs-provisioner-demo.yaml
persistentvolumeclaim/test-claim created
pod/test-nginx-pod created

现在查看 PV、PVC 和 Pod,可以看到 PV 已经被自动创建出来了,并且它们之间实现了绑定关系。

15.png

然后登录 NFS 服务,给远程挂载的卷写入 hello nfs 数据。

16.png

在 K8s 侧,就可以使用 curl 命令验证挂载的正确性了。

17.png

此时如果你通过 kubectl delete -f nfs-provisioner-demo.yaml 删除 Pod 和 PVC,PV 也会跟着删除,因为 PV 的删除策略是 Delete 。不过删除后NFS 卷中的数据还在,只不过被归档成了以 archived 开头的目录。这是 K8s-sigs.io/nfs-subdir-external-provisioner 这个存储插件所实现的功能,这就是存储插件的强大。

18.png

完成全部操作后,我们可以发现通过定义指定了 provisioner 的 StorageClass,不仅实现了 PV 的自动化创建,甚至实现了数据删除时自动归档的功能,这就是 K8s 动态供应存储设计的精妙。也可以说动态供应是持久化存储最佳实践。

附录:NFS 实验环境搭建

NFS 全称 Network File System,是一种分布式存储,它能够通过局域网实现不同主机间目录共享。

以下为 NFS 的架构图:由一个 Server 节点和两个 Client 节点组成。

19.png

下面列出 NFS 在 Centos 系统中的搭建过程。

Server 节点

# 安装 nfs 工具
yum install -y nfs-utils

# 创建 NFS 目录
mkdir -p /nfs/data/

# 创建 exports 文件,* 表示所有网络上的 IP 都可以访问
echo "/nfs/data/ *(insecure,rw,sync,no_root_squash)" > /etc/exports

# 启动 rpc 远程绑定功能、NFS 服务功能
systemctl enable rpcbind
systemctl enable nfs-server
systemctl start rpcbind
systemctl start nfs-server

# 重载使配置生效
exportfs -r
# 检查配置是否生效
exportfs
# 输出结果如下所示
# /nfs/data      

Client 节点

# 关闭防火墙
systemctl stop firewalld
systemctl disable firewalld

# 安装 nfs 工具
yum install -y nfs-utils

# 挂载 nfs 服务器上的共享目录到本机路径 /root/nfsmount
mkdir /root/nfsmount
mount -t nfs 192.168.99.101:/nfs/data /root/nfsmount