要想让存储卷真正能被 Pod 任意挂载,我们需要变更存储的方式,不能限定在本地磁盘,而是要改成网络存储,这样 Pod 无论在哪里运行,只要知道 IP 地址或者域名,就可以通过网络通信访问存储设备。

1. 安装 NFS 服务器和客户端

NFS 采用的是 Client/Server 架构,需要选定一台主机作为 Server,安装 NFS 服务端;其他要使用存储的主机作为 Client,安装 NFS 客户端工具。

我们在自己的 Kubernetes 集群里再增添一台名字叫 Storage 的服务器,在上面安装 NFS,实现网络存储、共享网盘的功能。不过这台 Storage 也只是一个逻辑概念,我们在实际安装部署的时候完全可以把它合并到集群里的某台主机里,比如这里的 Console
 
具体安装请参考:
https://blog.csdn.net/wohu1104/article/details/121051526

2. 使用 NFS 存储卷

Kubernetes 配置好了 NFS 存储系统,就可以使用它来创建新的 PV 存储对象了。

先来手工分配一个存储卷,需要指定 storageClassNamenfs,而 accessModes 可以设置成 ReadWriteMany,这是由 NFS 的特性决定的,它支持多个节点同时访问一个共享目录。

因为这个存储卷是 NFS 系统,所以我们还需要在 YAML 里添加 nfs字段,指定 NFS 服务器的 IP 地址和共享目录名。

这里在NFS 服务器的 /tmp/nfs 目录里又创建了一个新的目录 1g-pv,表示分配了 1GB 的可用存储空间,相应的,PV 里的 capacity 也要设置成同样的数值,也就是 1Gi。

把这些字段都整理好后,我们就得到了一个使用 NFS 网络存储的 YAML 描述文件:

# nfs-static-pv.yml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-1g-pv

spec:
  storageClassName: nfs
  accessModes:
    - ReadWriteMany
  capacity:
    storage: 1Gi

  nfs:
    path: /home/dev/multipass/k8s/data/1g-pv
    server: 192.168.10.208

现在就可以用命令 kubectl apply 来创建 PV对象,再用 kubectl get pv 查看它的状态:


kubectl apply -f nfs-static-pv.yml
kubectl get pv

 

spec.nfs 里的 IP 地址一定要正确,路径一定要存在(事先创建好),否则 Kubernetes 按照 PV 的描述会无法挂载 NFS 共享目录,PV 就会处于“pending”状态无法使用。

有了PV,我们就可以定义申请存储的 PVC 对象了,它的内容和 PV 差不多,但不涉及 NFS 存储的细节,只需要用 resources.request 来表示希望要有多大的容量,这里我写成 1GB,和 PV 的容量相同:

# nfs-static-pvc.yml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-static-pvc

spec:
  storageClassName: nfs
  accessModes:
    - ReadWriteMany

  resources:
    requests:
      storage: 1Gi

创建PVC 对象之后,Kubernetes 就会根据 PVC 的描述,找到最合适的 PV,把它们“绑定”在一起,也就是存储分配成功:

 

我们再创建一个 Pod,把 PVC 挂载成它的一个 volume,具体的做法和上节是一样的,用 persistentVolumeClaim 指定 PVC 的名字就可以了:

# nfs-static-pod.yml

apiVersion: v1
kind: Pod
metadata:
  name: nfs-static-pod

spec:
  volumes:
  - name: nfs-pvc-vol
    persistentVolumeClaim:
      claimName: nfs-static-pvc

  containers:
    - name: nfs-pvc-test
      image: nginx:alpine
      ports:
      - containerPort: 80

      volumeMounts:
        - name: nfs-pvc-vol
          mountPath: /tmp

PodPVCPVNFS 存储的关系可以用下图来形象地表示,可以对比一下 HostPath PV 的用法,看看有什么不同:
 
因为我们在 PV/PVC 里指定了 storageClassNamenfs,节点上也安装了 NFS 客户端,所以 Kubernetes就会自动执行 NFS 挂载动作,把 NFS 的共享目录 /home/dev/multipass/k8s/data/1g-pv 挂载到 Pod 里的 /tmp,完全不需要我们去手动管理。

最后还是测试一下,用 kubectl apply 创建 Pod 之后,我们用 kubectl exec 进入 Pod,再试着操作 NFS 共享目录:

 

退出Pod,再看一下 NFS 服务器的 /home/dev/multipass/k8s/data/1g-pv 目录,你就会发现 Pod 里创建的文件确实写入了共享目录:

 

而且更好的是,因为 NFS 是一个网络服务,不会受 Pod 调度位置的影响,所以只要网络通畅,这个 PV 对象就会一直可用,数据也就实现了真正的持久化存储。

3. 部署 NFS Provisoner

因为PV 还是需要人工管理,必须要由系统管理员手动维护各种存储设备,再根据开发需求逐个创建 PV,而且 PV 的大小也很难精确控制,容易出现空间不足或者空间浪费的情况。

那么能不能让创建 PV 的工作也实现自动化呢?或者说,让计算机来代替人类来分配存储卷呢?

这个在Kubernetes 里就是“动态存储卷”的概念,它可以用 StorageClass 绑定一个 Provisioner 对象,而这个 Provisioner 就是一个能够自动管理存储、创建 PV 的应用,代替了原来系统管理员的手工劳动。

有了“动态存储卷”的概念,前面我们讲的手工创建的 PV 就可以称为“静态存储卷”。

目前,Kubernetes 里每类存储设备都有相应的 Provisioner 对象,对于 NFS 来说,它的 Provisioner 就是“NFS subdir external provisioner”,你可以在 GitHub 上找到这个项目
https://github.com/kubernetes-sigs/nfs-subdir-external-provisioner

NFS Provisioner 也是以 Pod 的形式运行在 Kubernetes 里的,在 GitHub 的 deploy 目录里是部署它所需的 YAML 文件,一共有三个,分别是 rbac.yamlclass.yamldeployment.yaml

不过这三个文件只是示例,想在我们的集群里真正运行起来还要修改其中的两个文件。

第一个要修改的是 rbac.yaml,它使用的是默认的 default 名字空间,应该把它改成其他的名字空间,避免与普通应用混在一起,你可以用“查找替换”的方式把它统一改成 kube-system

第二个要修改的是 deployment.yaml,它要修改的地方比较多。首先要把名字空间改成和 rbac.yaml 一样,比如是 kube-system,然后重点要修改 volumesenv 里的 IP 地址和共享目录名,必须和集群里的 NFS 服务器配置一样。

按照我们当前的环境设置,就应该把 IP 地址改成 192.168.10.208,目录名改成 /tmp/nfs


spec:
  template:
    spec:
      serviceAccountName: nfs-client-provisioner
      containers:
      ...
          env:
            - name: PROVISIONER_NAME
              value: k8s-sigs.io/nfs-subdir-external-provisioner
            - name: NFS_SERVER
              value: 192.168.10.208       改IP地址
            - name: NFS_PATH
              value: /tmp/nfs             改共享目录名
      volumes:
        - name: nfs-client-root
          nfs:
            server: 192.168.10.208        改IP地址
            Path: /tmp/nfs                改共享目录名

还有一件麻烦事,deployment.yaml 的镜像仓库用的是 gcr.io,拉取很困难,而国内的镜像网站上偏偏还没有它,为了让实验能够顺利进行,我不得不“曲线救国”,把它的镜像转存到了 Docker Hub 上。

所以你还需要把镜像的名字由原来的“k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2”改成“chronolaw/nfs-subdir-external-provisioner:v4.0.2”,其实也就是变动一下镜像的用户名而已。

把这两个 YAML 修改好之后,具体内容如下:

# rbac.yml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: nfs-client-provisioner
  replace with namespace where provisioner is deployed
  namespace: kube-system
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: nfs-client-provisioner-runner
rules:
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["persistentvolumes"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["persistentvolumeclaims"]
    verbs: ["get", "list", "watch", "update"]
  - apiGroups: ["storage.k8s.io"]
    resources: ["storageclasses"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "update", "patch"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: run-nfs-client-provisioner
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    replace with namespace where provisioner is deployed
    namespace: kube-system
roleRef:
  kind: ClusterRole
  name: nfs-client-provisioner-runner
  apiGroup: rbac.authorization.k8s.io
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
  replace with namespace where provisioner is deployed
  namespace: kube-system
rules:
  - apiGroups: [""]
    resources: ["endpoints"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
  replace with namespace where provisioner is deployed
  namespace: kube-system
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    replace with namespace where provisioner is deployed
    namespace: kube-system
roleRef:
  kind: Role
  name: leader-locking-nfs-client-provisioner
  apiGroup: rbac.authorization.k8s.io
# deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nfs-client-provisioner
  labels:
    app: nfs-client-provisioner
  replace with namespace where provisioner is deployed
  namespace: kube-system
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: nfs-client-provisioner
  template:
    metadata:
      labels:
        app: nfs-client-provisioner
    spec:
      serviceAccountName: nfs-client-provisioner
      containers:
        - name: nfs-client-provisioner
#          image: k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2
          image: chronolaw/nfs-subdir-external-provisioner:v4.0.2
          volumeMounts:
            - name: nfs-client-root
              mountPath: /persistentvolumes
          env:
            - name: PROVISIONER_NAME
              value: k8s-sigs.io/nfs-subdir-external-provisioner
            - name: NFS_SERVER
              value: 172.16.19.54
            - name: NFS_PATH
              value: /home/dev/multipass/k8s/data
      volumes:
        - name: nfs-client-root
          nfs:
            server: 172.16.19.54
            path: /home/dev/multipass/k8s/data
# class.yml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-client
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner or choose another name, must match deployment's env PROVISIONER_NAME'
parameters:
  archiveOnDelete: "false"

我们就可以在 Kubernetes 里创建 NFS Provisioner 了:


kubectl apply -f rbac.yml
kubectl apply -f class.yml
kubectl apply -f deployment.yml

使用命令 kubectl get,再加上名字空间限定 -n kube-system,就可以看到 NFS ProvisionerKubernetes 里运行起来了。

 

4. 使用 NFS 动态存储卷

比起静态存储卷,动态存储卷的用法简单了很多。因为有了 Provisioner,我们就不再需要手工定义 PV 对象了,只需要在 PVC 里指定 StorageClass 对象,它再关联到 Provisioner

我们来看一下 NFS 默认的 StorageClass 定义:


apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-client

provisioner: k8s-sigs.io/nfs-subdir-external-provisioner 
parameters:
  archiveOnDelete: "false"

YAML 里的关键字段是 provisioner,它指定了应该使用哪个 Provisioner。另一个字段 parameters 是调节 Provisioner 运行的参数,需要参考文档来确定具体值,在这里的 archiveOnDelete: "false" 就是自动回收存储空间。

理解了StorageClassYAML 之后,你也可以不使用默认的 StorageClass,而是根据自己的需求,任意定制具有不同存储特性的 StorageClass,比如添加字段 onDelete: "retain" 暂时保留分配的存储,之后再手动删除:


apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-client-retained

provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
parameters:
  onDelete: "retain"

接下来我们定义一个 PVC,向系统申请 10MB 的存储空间,使用的 StorageClass 是默认的 nfs-client


apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-dyn-10m-pvc

spec:
  storageClassName: nfs-client
  accessModes:
    - ReadWriteMany

  resources:
    requests:
      storage: 10Mi

写好了PVC,我们还是在 Pod 里用 volumesvolumeMounts 挂载,然后 Kubernetes 就会自动找到 NFS Provisioner,在 NFS 的共享目录上创建出合适的 PV 对象:


apiVersion: v1
kind: Pod
metadata:
  name: nfs-dyn-pod

spec:
  volumes:
  - name: nfs-dyn-10m-vol
    persistentVolumeClaim:
      claimName: nfs-dyn-10m-pvc

  containers:
    - name: nfs-dyn-test
      image: nginx:alpine
      ports:
      - containerPort: 80

      volumeMounts:
        - name: nfs-dyn-10m-vol
          mountPath: /tmp

使用kubectl apply 创建好 PVCPod,让我们来查看一下集群里的 PV 状态:

 

从截图你可以看到,虽然我们没有直接定义 PV 对象,但由于有 NFS Provisioner,它就自动创建一个 PV,大小刚好是在 PVC 里申请的 10MB。如果你这个时候再去 NFS 服务器上查看共享目录,也会发现多出了一个目录,名字与这个自动创建的 PV 一样,但加上了名字空间和 PVC 的前缀:
 

我还是把 PodPVCStorageClassProvisioner 的关系画成了一张图,你可以清楚地看出来这些对象的关联关系,还有 Pod 是如何最终找到存储设备的:

 

5. 总结

本节我们引入了网络存储系统,以 NFS 为例研究了静态存储卷和动态存储卷的用法,其中的核心对象是 StorageClassProvisioner

1、Kubernetes集群里,网络存储系统更适合数据持久化,NFS是最容易使用的一种网络存储系统,要事先安装好服务端和客户端;
2、 可以编写PV手工定义NFS静态存储卷,要指定NFS服务器的IP地址和共享目录名;
3、 使用NFS动态存储卷必须要部署相应的Provisioner,在YAML里正确配置NFS服务器;
4、 动态存储卷不需要手工定义PV,而是要定义StorageClass,由关联的Provisioner自动创建PV完成绑定;
5、 StorageClass里面的OnDeletearchiveOnDelete源自PV的存储回收策略,指定PV被销毁时数据是保留Retain还是删除Delete

动态存储卷相比静态存储卷有什么好处?有没有缺点?

  • 首先,静态存储卷 PV 这个动作是要由运维手动处理的,如果是处在大规模集群的成千上万个 PVC 的场景下,这个工作量是难以想象的;
  • 再者,业务的迭代变更是动态的,这也就意味着随时会有新的 PVC 被创建,或者就的 PVC 被删除,这也就要求运维每碰到 PVC 的变更,就要跟着去手动维护一个新的 PV 。来满足业务的新需求。
  • 最后,动态存储卷的好处还在于分层和解耦,对于简单的 localPath 或者 NFS 这种存储卷或许相对来说还比较简单一些,但是像类似于远程存储磁盘这种就相对来说比较复杂了,动态存储可以让我们只关注于需求点,至于怎么把这些东西创建出来,就交由各个类型的 provisioner 去处理就行了。

缺点:缺点的话就是在于资源的管控方面,比如原本我可能只需要2Gi的空间,但是业务人员对容量把握不够申请了10Gi,就会有8Gi空间的浪费。

StorageClass 在动态存储卷的分配过程中起到了什么作用?
StorageClass 作用是帮助指定特定类型的 provisioner,这决定了你要使用的具体某种类型的存储插件;另外它还限定了 PVPVC 的绑定关系,只有从属于同一 StorageClassPVPVC 才能做绑定动作,比如指定 GlusterFS 类型的 PVC 对象不能绑定到另外一个 PVC 定义的 NFS 类型的 StorageClass 模版创建出的 VolumePV 对象上面去。

StorageClass 应证了“所有问题都可以通过增加一层来解决”。作用是解决了特定底层存储与 K8S 上资源的解耦问题,通过 SC 统一接口,具体厂商负责具体的存储实现。

因为存储它涉及到了对物理机文件系统绑定的操作,因此 K8S 做了一系列抽象。PV 在这个抽象里,其实就指代了主机文件系统的路径,当然至于再往实现层面走,是网络文件系统还是主机文件系统,这就全由 PV 的绑定类型决定。而往抽象层走,作为 K8S 的核心系统,K8S 想尽可能屏蔽掉底层,也就是主机文件系统的概念,所以它抽象了 StorageClass ,用来统一指代/管理 PV 。至此,K8S 持久化存储就可以分两个部分:

  • 第一部分是由 主机文件系统+PV+StorageClass组成的,用来将抽象对象绑定到真实文件系统的生产者部分;
  • 第二部分就是 Volume+PVC+StorageClass,完全被抽象为K8S核心业务的消费者部分,而StorageClass,可以看作是两部分连接的桥梁。