在 Docker 的设计中,容器内的文件是临时存放的,并且随着容器的删除,容器内部的数据也会一同被清空。不过,我们可以通过在 docker run 启动容器时,使用 --volume/-v 参数来指定挂载卷,这样就能够将容器内部的路径挂载到主机,后续在容器内部存放数据时会就被同步到被挂载的主机路径中。这样做可以保证保证即便容器被删除,保存到主机路径中的数据也仍然存在。
与 Docker 通过挂载卷的方式就可以解决持久化存储问题不同,K8s 存储要面临的问题要复杂的多。因为 K8s 通常会在多个主机部署节点,如果 K8s 编排的 Docker 容器崩溃,K8s 可能会在其他节点上重新拉起容器,这就导致原来节点主机上挂载的容器目录无法使用。
当然也是有办法解决 K8s 容器存储的诸多限制,比如可以对存储资源做一层抽象,通常大家将这层抽象称为卷(Volume)。
K8s 支持的卷基本上可以分为三类:配置信息、临时存储、持久存储。
无论何种类型的应用,都会用到配置文件或启动参数。而 K8s 将配置信息进行了抽象,定义成了几种资源,主要有以下三种:
ConfigMap
Secret
DownwardAPI
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) $(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 的内容注入到容器内部的方式。容器内部的应用则可以分别通过读取环境变量、文件内容的方式使用配置信息。
熟悉了 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 可以将 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 相当于通过 --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 表示内存挂载。
与 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 持久卷设计架构如下:
Node1 和 Node2 分别代表两个工作节点,当我们在工作节点创建 Pod 时,可以通过 spec.containers.volumeMounts 来指定容器挂载目录,通过 spec.volumes 来指定挂载卷。之前我们用挂载卷挂载了配置信息和临时卷,而挂载持久卷也可以采用同样的方式。每个 volumes 则指向的是下方存储集群中不同的存储类型。
为了保证高可用,我们通常会搭建一个存储集群。通常通过 Pod 来操作存储,因为 Pod 都会部署在 Node 中,所以存储集群最好跟 Node 集群搭建在同一内网,这样速度更快。而存储集群内部可以使用任何 K8s 支持的持久化存储,如上图的 NFS、CephFS、CephRBD。
持久化挂载方式与临时卷大同小异,我们同样使用一个 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。
使用 curl 命令直接访问 Pod 的 IP 地址,即可返回 Nginx 服务的 index.html 内容,结果输出为 hello nfs,证明 NFS 持久卷挂载成功。
登入 Pod 容器,通过 df -Th 命令查看容器目录挂载信息。可以发现,容器的 /usr/share/nginx/html/ 目录被挂载到 NFS 服务的 /nfs/data/nginx 目录。
现在,当我们执行 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。其使用流程图如下:
使用静态供应时,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 的响应结果。
然后通过 kubectl get pvc 查看创建的 PVC:
STATUS 字段:标识 PVC 已经处于绑定(Bound)状态,也就是与 PV 进行了绑定。
CAPACITY 字段:标识 PVC 绑定到了 1Gi 的 PV 上,尽管我们申请的 PVC 大小是 500m,但由于我们创建的两个 PV 大小分别是 1Gi 和 100m,K8s 会帮我们选择满足条件的最优解。因为没有刚好等于 500m 大小的 PV 存在,而 100m 又不满足,所以 PVC 会自动与 1Gi 大小的 PV 进行绑定。
通过 kubectl get pv 来查询创建的 PV 资源,可以发现 1Gi 大小的 PV STATUS 字段为 Bound 状态。CLAIM 的值,则标识的是与之绑定的 PVC 的名字。
现在我们登录 NFS 服务器,确认 NFS 存储上不同持久卷(PV)挂载的目录下文件内容。
可以看到,/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 状态。
从上图可以看到,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 状态。
可以看到 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 存储插件帮我们来做的。
其使用流程图如下:
相较于静态供应,动态供应在 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。
创建好 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 已经被自动创建出来了,并且它们之间实现了绑定关系。
然后登录 NFS 服务,给远程挂载的卷写入 hello nfs 数据。
在 K8s 侧,就可以使用 curl 命令验证挂载的正确性了。
此时如果你通过 kubectl delete -f nfs-provisioner-demo.yaml 删除 Pod 和 PVC,PV 也会跟着删除,因为 PV 的删除策略是 Delete。不过删除后 NFS 卷中的数据还在,只不过被归档成了以 archived 开头的目录。这是 K8s-sigs.io/nfs-subdir-external-provisioner 这个存储插件所实现的功能,这就是存储插件的强大。
完成全部操作后,我们可以发现通过定义指定了 provisioner 的 StorageClass,不仅实现了 PV 的自动化创建,甚至实现了数据删除时自动归档的功能,这就是 K8s 动态供应存储设计的精妙。也可以说动态供应是持久化存储最佳实践。
附录:NFS 实验环境搭建
NFS 全称 Network File System,是一种分布式存储,它能够通过局域网实现不同主机间目录共享。
以下为 NFS 的架构图:由一个 Server 节点和两个 Client 节点组成。
下面列出 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