通过hostPathemptyDir的方式来持久化数据,但是显然还需要更加可靠的存储来保存应用的持久化数据,这样容器在重建后,依然可以使用之前的数据。但是存储资源和CPU资源以及内存资源有很大不同,为了屏蔽底层的技术实现细节,让用户更加方便的使用,k8s变引入了PVPVC两个重要的资源对象来实现对存储的管理。

概念

PV的全称是:PersistentVolume(持久化卷),是对底层共享存储的一种抽象,PV由管理员进行创建和配置,它和具体的底层的共享存储技术的实现方式有关,比如Ceph、GlusterFS、NFS、hostPath等,都是通过插件机制完成与共享存储的对接。

PVC的全称是:PersistentVolumeClaim(持久化卷声明),PVC是用户存储的一种声明,PVC和Pod比较类似,Pod消耗的是节点,PVC消耗的是PV资源,Pod可以请求CPU和内存,而PVC可以请求特定的存储空间和访问模式。对于真正使用存储的用户不需要关心底层的存储实现细节,只需要直接使用PVC即可。

但是通过PVC请求到一定的存储空间也很有可能不足以满足应用对于存储设备的各种需求,而且不同的应用程序对于存储性能的要求可能也不尽相同,比如读写速度、并发性能等,为了解决这一问题,k8s又引入了一个新的资源对象:StorageClass,通过StorageClass的定义,管理员可以将存储资源定义为某种类型的资源,比如快速存储、慢速存储等,用户根据StorageClass的描述就可以非常直观的知道各种存储资源的具体特性了,这样就可以根据应用的特性去申请合适的存储资源了,此外StorageClass还可以自动生成PV,免去每次手动创建的麻烦。

hostPath

PV是对底层存储技术的一种抽象,PV一般由管理员来创建和配置的,首先常见一个hostPath类型的PersistentVolume。k8s支持hostPath类型的PersistentVolume使用节点上的文件或目录来模拟附带网络的存储,比如NFS共享卷或Ceph存储卷,集群管理员还可以使用StorageClass来设置动态提供存储。因为Pod并不是始终固定在某个节点上面的,所以要使用hostPath的话就需要将Pod固定在某个节点上,这就大大降低了应用的容错性。

比如测试的应用固定在节点node1上,首先在该节点上创建一个/data/k8s/test/hostPath的目录,然后在该目录创建一个index.html文件:

  1. $ echo 'Hello from Kubernetes hostpath storage' > /data/k8s/test/hostpath/index.html

然后创建一个hostPath类型的PV资源对象:(pv-hostpath.yaml)

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-hostpath
  labels:
    type: local
spec:
  storageClassName: manual
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/data/k8s/test/hostpath"

配置文件中指定了该卷位于集群节点上的/data/k8s/test/hostpath目录,还指定了10G大小的空间和ReadWriteOnce的访问模式,这意味着该卷可以在单个节点上以读写方式挂载,另外还定义了名称为manualStorageClass,该名称用来将PersistentVolumeClaim请求绑定到该PersistentVolume。下面是关于PV的这些配置属性的一些说明:

  • Capacity(存储能力):一般来说,一个PV对象都要指定一个存储能力,通过PV的capacity属性来设置的,目前只支持存储对象的设置,就是这里的storage=10Gi,不过未来可能会加入IOPS、吞吐量等指标配置。
  • AccessMode(访问模式):用来对PV进行访问模式的设置,用于描述用户应用对存储资源的访问权限,访问权限包括下面几种方式:
    • ReadWriteOnce(RWO):读写权限,但是只能被单个节点挂载
    • ReadOnlyMany(ROX):只读权限,可以被多个节点挂载
    • ReadWriteMany(RWX):读写权限,可以被多个节点挂载

      一些PV可能支持多种访问模式,但是在挂载的时候只能使用一种访问模式,多种访问模式是不会生效的。

下图是一些常用的Volume插件支持的访问模式:
image.png
直接创建上面的资源对象:

$ kubectl apply -f pv-hostpath.yaml
persistentvolume/pv-hostpath created

创建完成后查看PersistentVolume的信息,输出结果显示该PV的状态(STATUS)为Available。这意味着它还没有被绑定给PVC:

$ kubectl get pv pv-hostpath
NAME          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   REASON   AGE
pv-hostpath   10Gi       RWO            Retain           Available           manual                  58s

其中有一项RECLAIM POLICY的配置,同样可以通过PV的persistentVolumeReclaimPolicy(回收策略)属性进行配置,目前PV支持的策略有三种:

  • Retain(保留):保留数据,需要管理员手动清理数据
  • Recycle(回收):清楚PV中的数据,效果相当于执行rm -rf /thevolume/*
  • Delete(删除):与PV相连的后端存储完成volume的删除操作,常见与云服务商的存储服务,如AWS EBS。

不过需要注意的是,目前只有NFS和hostPath两种类型支持回收策略,一般还是设置为Retain保险点。

Recycle策略会通过运行一个busybox容器来执行删除命令,默认定义的busybox镜像是:gcr.io/google_containers/busybox:latest,并且imagePullPolicy: Always,如果需要调整配置,需要增加kube-controller-manage启动参数:--pv-recycler-pod-template-filepath-hostpath来进行配置。

关于PV的状态,实际上描述的是PV的生命周期的某个阶段,一个PV的生命周期中,可能会处于4种不同的阶段:

  • Available(可用):表示可用状态,还未被任何PVC绑定
  • Bound(已绑定):表示PV已经被PVC绑定
  • Released(已释放):PVC被删除,但是资源还未被集群重新声明
  • Failed(失败):表示该PV的自动回收失败

现在已创建了一个PV,如果想使用这个PV的话,就需要创建一个相应的PVC和他进行绑定,就类似我们的服务是通过Pod来运行的,而不是Node,只是Pod跑在Node上而已。

现在创建一个PersistentVolumeClaim,Pod使用PVC来请求物理存储,这里创建的PVC请求至少3G容量的卷,该卷至少可以为一个节点提供读写访问,下面是PVC的配置文件:(pvc-hostpath.yaml)

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-hostpath
spec:
  storageClassName: manual
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 3Gi

直接创建这个PVC对象:

$ kubectl create -f pvc-hostpath.yaml
persistentvolumeclaim/pvc-hostpath created

创建PVC之后,k8s就会去查找满足声明要求的PV,比如StorageClassName、accessModes以及容量这些是否满足要求,如果满足要求就会将PV和PVC绑定在一起。

目前PV和PVC之间是一对一的关系,也就是说一个PV只能被一个PVC绑定

再次查看PV信息:

$ kubectl get pv -l type=local
NAME          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                  STORAGECLASS   REASON   AGE
pv-hostpath   10Gi       RWO            Retain           Bound    default/pvc-hostpath   manual                  81m

现在输出的STATUS为Bound,查看PVC的信息:

$ kubectl get pvc pvc-hostpath
NAME           STATUS   VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc-hostpath   Bound    pv-hostpath   10Gi       RWO            manual         6m47s

输出结果表明该PVC绑定到了上面创建的pv-hostpath这个pv上,虽然声明3G的容量,但是由于PV里面是10G,显然也是满足要求的。

PVC准备好过后,接下来就可以创建Pod,还Pod使用上面声明的PVC作为存储卷:(pv-hostpath-pod.yaml)

apiVersion: v1
kind: Pod
metadata:
  name: pv-hostpath-pod
spec:
  volumes:
  - name: pv-hostpath
    persistentVolumeClaim:
      claimName: pvc-hostpath
  nodeSelector:
    kubernetes.io/hostname: master1
  containers:
  - name: task-pv-container
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: "/usr/share/nginx/html"
      name: pv-hostpath
  tolerations:
  - key: "node-role.kubernetes.io/master"
    operator: "Exists"
    effect: "NoSchedule"

由于创建的PV真正的存储在节点master上,所以这里必须做容忍且把Pod固定在这个节点上,另外可以注意到Pod的配置文件指定了PersistentVolumeClaim,但是没有指定PersistentVolume,对Pod而言,PVC就是一个存储卷。直接创建这个Pod对象:

$ kubectl create -f pv-hostpath-pod.yaml
pod/pv-hostpath-pod created
$ kubectl get pod pv-hostpath-pod
NAME              READY   STATUS    RESTARTS   AGE
pv-hostpath-pod   1/1     Running   0          105s

运行成功后,打开一个shell访问Pod中的容器

$ kubectl exec -it pv-hostpath-pod -- /bin/bash

root@pv-hostpath-pod:/# apt-get update
root@pv-hostpath-pod:/# apt-get install curl -y
root@pv-hostpath-pod:/# curl localhost
Hello from Kubernetes hostpath storage

可以看到输出结果是前面写到hostPath卷中的index.html文件中的内容,同样可以把Pod删除,然后在重建一次,可以发现内容还是之前的。

在持久化容器数据的时候使用PV/PVC有什么好处呢,比如之前直接在Pod下面使用hostpath来持久化数据,为什么还要费劲去常见PV/PVC对象来引用呢?PVC和PV的设计,其实跟”面向对象”的思想完全一致,PVC可以理解为持久化存储的”接口”,他提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分由PV负责完成。这样做的好处是,作为应用开发者,我们只需要跟PVC这个”接口”打交道,而不必关心具体的实现是hostPath、NFS还是Ceph。毕竟这些存储相关的只是太专业了,应该交给专业的人去做这样对于Pod来说就不用管具体的细节了,只需要提供一个可用的PVC即可,这样就完全屏蔽了细节和解耦。

Local PV

上面我们创建了后端是 hostPath 类型的 PV 资源对象,我们也提到了,使用 hostPath 有一个局限性就是,我们的 Pod 不能随便漂移,需要固定到一个节点上,因为一旦漂移到其他节点上去了宿主机上面就没有对应的数据了,所以我们在使用 hostPath 的时候都会搭配 nodeSelector 来进行使用。但是使用 hostPath 明显也有一些好处的,因为 PV 直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多,所以对于一些对磁盘 IO 要求比较高的应用比如 etcd 就非常实用了。不过呢,相比于正常的 PV 来说,使用了 hostPath 的这些节点一旦宕机数据就可能丢失,所以这就要求使用 hostPath 的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。

所以在hostPath的基础上,k8s依靠PV、PVC实现了一个新的特性,这个特性叫做:Local Persistent Volume,也就是Local PV。

其实Local PV实现的功能非常类似与hostPath加上nodeAffinity,比如一个Pod可以声明使用类型为Local的PV,而这个PV其实就是一个hostPath类型的Volume。如果这个hostPath对应的目录,已经在节点A上被实现创建好了,那么只需再给这个Pod加上一个nodeAffinity=nodeA,就可以使用这个Volume,理论上确实是可行的,但事实上,我们绝不应该把一个宿主机上的目录当做PV来使用,因为本地目录的存储行为是完全不可控,它所在的磁盘随时都可能被应用写满,甚至导致整个宿主机宕机。所以,一般来说Local PV对应的存储介质是一块额外挂载在宿主机的磁盘或块设备,我们可以认为”一个PV 一块盘”。

另外一个Local PV和普通的PV有一个很大的不同,在于Local PV可以保证Pod始终能够被正确的调度到他所请求的Local PV所在的节点上,对于普通的PV来说,k8s都是先调度Pod到某个节点上,然后在持久化节点上的Volume目录,进而完成Volume目录与容器的绑定挂载,但是对于Local PV来说,节点上可供使用的磁盘必须是提前准备好的,因为它们在不同节点上的挂载情况可能完全不同,甚至有的节点可能没这种磁盘,所以这时候调度器必须能够知道所有节点与Local PV对应的磁盘的关联关系,然后根据这个信息来调度Pod,实际上就是在调度的时候考虑Volume的分布。

测试下Local PV的使用,按照上面分析应该给宿主机挂载并格式化一个可用的磁盘,这里暂时将node1节点上的/data/k8s/localpv这个目录看成挂载的一个独立的磁盘,声明一个Local PV类型的PV,如下:(pv-local.yaml)

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-local
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /data/k8s/localpv
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node1

和前面定义的PV不同,这里定义了一个local’字段,表明它是一个Local PV,而Path字段,指定的正是这个PV对应的本地磁盘的路径,这也就意味着如果Pod要想使用这个PV,那它就必须运行在node1节点上。所以在这个PV的定义里,添加了一个节点亲和性nodeAffinity字段指定node1这个节点。这样调度器在调度Pod的时候,就能知道一个PV与节点的对应的关系,从而做出正确的选择。

创建上面的资源对象:

$ kubectl apply -f pv-local.yaml 
persistentvolume/pv-local created
$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS  CLAIM      STORAGECLASS      REASON   AGE
pv-local  5Gi        RWO            Delete           Available          local-storage              24s

可以看到,这个PV创建后,进入了Available状态,这个时候如果按照前面提到的,要使用这个Local PV的话就需要去常见一个PVC和它进行绑定:(pvc-local.yaml)

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-local
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-storage

直接创建这个资源对象:

$ kubectl apply -f pvc-local.yaml 
persistentvolumeclaim/pvc-local created
$ kubectl get pvc
NAME           STATUS   VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local      Bound    pv-local      5Gi        RWO            local-storage   38s

可以看到现在PVC和PV已经处于Bound绑定状态了。但实际上这是不符合需求的,比如现在的Pod声明使用这个pvc-local,并且也明确规定,这个pod只能运行在node2节点,如果按照这种操作,这个pvc-local和这里的pv-local这个Local PV绑定在一起,但是这个PV的存储卷又在node1节点上,显然就会出现冲突,那这个Pod的调度肯定就会失败,所以在使用Local PV的时候,必须想办法延迟这个”绑定”的操作。

可以通过创建StorageClass来指定这个动作,在StorageClass中有一个volumeBindingMode=WaitForFirstConsumer的属性,就是告诉k8s在发现这个StorageClass关联的PVC和PV可以绑定在一起,但不要现在就立刻执行绑定操作(即:设置PVC的VolumeName字段),而是要等到第一个声明使用该PVC的Pod出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个PV所在的节点位置,来统一决定这个Pod声明的PVC到底应该跟哪个PV进行绑定。通过这个延迟绑定机制,原本实时发生的PVC和PV的绑定过程,就被延迟到了Pod第一次调度的时候在调度器中进行,从而保证了绑定结果不会影响Pod的正常调度。

所以我们需要创建对应的StorageClass对象:(local-storageclass.yaml)

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

这个StorageClass的名字,叫做local-storage,也就是在pv中声明的,需要注意的是,在它的provisioner字段指定的是no-provisioner。这是因为这里是手动创建的PV,所以不需要动态来生成PV,另外这个StorageClass还定义了一个volumeBindingMode=WaitForFirstConsumer的属性,它是Local PV里一个非常重要的特性,即延迟绑定。通过这个延迟绑定机制,原本实时发生的PVC和PV的绑定过程,就被延迟到了Pod第一次被调度的时候在调度器中进行,从而保证了这个绑定结果不会影响Pod的正常调度。

创建这个StorageClass资源对象:

$ kubectl apply -f local-storageclass.yaml 
storageclass.storage.k8s.io/local-storage created

现在删除上面声明的PVC对象,重新创建:

$ kubectl delete -f pvc-local.yaml 
persistentvolumeclaim "pvc-local" deleted
$ kubectl create -f pvc-local.yaml
persistentvolumeclaim/pvc-local created
$ kubectl get pvc
NAME           STATUS    VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local      Pending                                           local-storage   3s

可以发现这个时候,集群中及时已经存在一个可与PVC匹配的PV了,但这个PVC依然处于Pending状态,这就是因为上面配置了延迟绑定,需要在真正的Pod使用的时候才会来做绑定。

声明一个Pod来使用这个名为pvc-local这个PVC,资源对象如下:(pv-local-pod.yaml)

apiVersion: v1
kind: Pod
metadata:
  name: pv-local-pod
spec:
  volumes:
  - name: example-pv-local
    persistentVolumeClaim:
      claimName: pvc-local
  containers:
  - name: example-pv-local
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: /usr/share/nginx/html
      name: example-pv-local

直接创建Pod:

$ kubectl apply -f pv-local-pod.yaml 
pod/pv-local-pod created

创建完成后我们这个时候去查看前面我们声明的 PVC,会立刻变成 Bound 状态,与前面定义的 PV 绑定在了一起:

$ kubectl get pvc
NAME           STATUS   VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local      Bound    pv-local      5Gi        RWO            local-storage   4m59s

这时候,我们可以尝试在这个 Pod 的 Volume 目录里,创建一个测试文件,比如:

$ kubectl exec -it pv-local-pod /bin/sh
# cd /usr/share/nginx/html
# echo "Hello from Kubernetes local pv storage" > test.txt

然后,登录到 ydzs-node1 这台机器上,查看一下它的 /data/k8s/localpv 目录下的内容,你就可以看到刚刚创建的这个文件:

# 在ydzs-node1节点上
$ ls /data/k8s/localpv
test.txt
$ cat /data/k8s/localpv/test.txt 
Hello from Kubernetes local pv storage

如果重新创建这个 Pod 的话,就会发现,我们之前创建的测试文件,依然被保存在这个持久化 Volume 当中:

$ kubectl delete -f pv-local-pod.yaml  
$ kubectl apply -f pv-local-pod.yaml 
$ kubectl exec -it pv-local-pod /bin/sh
# ls /usr/share/nginx/html
test.txt
# cat /usr/share/nginx/html/test.txt
Hello from Kubernetes local pv storage
#

到这里就说明基于本地存储的 Volume 是完全可以提供容器持久化存储功能的,对于 StatefulSet 这样的有状态的资源对象,也完全可以通过声明 Local 类型的 PV 和 PVC,来管理应用的存储状态。
需要注意的是,我们上面手动创建 PV 的方式,即静态的 PV 管理方式,在删除 PV 时需要按如下流程执行操作:

  • 删除使用这个 PV 的 Pod
  • 从宿主机移除本地磁盘
  • 删除 PVC
  • 删除 PV

如果不按照这个流程的话,这个 PV 的删除就会失败。