..

Persistent Volume学习记录

k8s的Persistent Volume(PV)总体感觉相关的概念比较多,其中有一些太细节了未必需要全部掌握。

基本元素

  • Persistent Volume(持久卷,简称PV)。PV是k8s里的存储资源,或者说是对存储资源的一种抽象、一种接口。就好像是k8s对计算资源的抽象——节点一样。
  • StorageClass(存储类)。StorageClass就像是PV的类型,可以通过 kubectl get storageclasses 来获取。但是其内部实现有点太细节,尤其是它的provisioner,基本是交由集群构建者来实现的,而k8s只是提供了接口标准。我们一般也只是根据所以用的集群服务商的接口配置出一些StorageClass以供创建PV使用。
  • Persistent Volume Claim(简称PVC)。Claim /kleɪm/有"声称;索要"的意思。PVC就是对PV的使用请求。继续与计算资源做类比,PV就好像是计算资源中的Node,而PVC就好像是计算资源中的Pod,Pod使用了Node,而PVC使用了PV。

生命周期

这里讨论下PV与PVC的主要生命周期

  • Provisioning(制备)。制备过程就是制备一个PV,有两种方式:静态制备、动态制备。这里只讨论下静态制备。所有静态制备,就是需要集群管理员提前创建一个存储资源(可能是NFS、可能是云磁盘),然后创建出其对应的PV。这个过程是比较手工的,这种手工也是比较不可避免的。
  • Binding(绑定)。创建好PV后,再创建一个PVC,二者只要相匹配,k8s就会将它们绑定起来。它们的绑定通过一系列声明的属性进行匹配,只有这些声明都相互兼容匹配时,k8s才会将二者绑定。这种绑定是双向的,一对一的。另外,其实未必需要先创建PV再创建PVC,k8s的这种匹配有点类似于监听模式。
  • Using(使用)。某个Node上的某个Pod会声明使用这个PVC,并且把它挂载到内部容器的某个(些)路径下,进行读写。
  • Reclaim(回收)。当没有Pods在使用PVC的时候,就可以删除PVC,PV进入回收阶段。回收策略只要有两种:一种是Delete,会直接删除PV包括PV对应的存储资源;一种是Retain,会保留PV以及其对应的存储资源。

PV与PVC的主要生命周期就这些,还有一些其它的阶段(如扩容、防删除保护等),我感觉暂时不需要太深入讨论,具体可以看文档

生命周期的实践

我们来走一遍基本的流程:从手动创建PV开始,到挂载并且使用。以minikube环境为例,参照官方Stateful Application的例子,我们手动创建一个PV跟一个PVC,然后把PVC挂载到一个拥有三个Replica的Nginx的服务上。

官方的例子采用的是动态制备的方式,我们改成静态手动制备。

第一步:查看StorageClass

首先,让我们来看看minikube的k8s环境下有什么StorageClass:

$ kubectl get storageclasses
NAME                 PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
standard (default)   k8s.io/minikube-hostpath   Delete          Immediate           false                  5h6m

# 可以进一步的describe下
$ kubectl describe storageclasses/standard
Name:            standard
IsDefaultClass:  Yes
Annotations:     kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"storage.k8s.io/v1","kind":"StorageClass","metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"},"labels":{"addonmanager.kubernetes.io/mode":"EnsureExists"},"name":"standard"},"provisioner":"k8s.io/minikube-hostpath"}
,storageclass.kubernetes.io/is-default-class=true
Provisioner:           k8s.io/minikube-hostpath
Parameters:            <none>
AllowVolumeExpansion:  <unset>
MountOptions:          <none>
ReclaimPolicy:         Delete
VolumeBindingMode:     Immediate
Events:                <none>

可以看到,minikube内建了一个叫做standard的默认StorageClass,从其描述中的Provisioner字段,大概可以看出这是用来提供绑定到主机路径上的PV的StorageClass,更细节的可以看minikube的说明文档,这里不再深究。在实际使用中,使用什么StorageClass?每一种都是关联(甚至自动创建)什么外部存储资源?如何创建StorageClass?这些都要看对应k8s供应平台的文档,如AWS的EKS、阿里云的ACK

第二步:创建PV

新建个文件pv.yaml,内容如下:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-pv

spec:
  # 指定storage class,minikube默认只提供了standard这种storage class
  storageClassName: "standard"

  # 指定Reclaim Policy为Retain(保留)。话说这里为什么不能用reclaimPolicy这样的短名?
  persistentVolumeReclaimPolicy: Retain

  # 指定pv被access的mode是ReadWriteOnce
  accessModes:
    - ReadWriteOnce

  # 声明,这个PV只有1Gi的容量。话说如果实际挂载的云盘小于这里声明的容量,会如何?
  capacity:
    storage: 1Gi

  # hostPath指定了PV对应的外部存储资源的路径。
  #
  # 这是因为"standard"这种StorageClass背后的Provisioner——k8s.io/minikube-hostpath,需要
  # 这个参数。
  #
  # 如果换成AWS的gp2这种StorageClass,它需要的是一个名为awsElasticBlockStore的参数。
  #
  # 如果换成AKS的managed-csi,它需要的则是名为csi的参数。
  # 见https://learn.microsoft.com/en-us/azure/aks/azure-csi-disk-storage-provision?source=recommendations#mount-disk-as-a-volume
  hostPath:
    path: /data/pv-example/

让我们来创建这个pv,废话少说,apply,get,describe三连:

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

$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   REASON   AGE
example-pv   1Gi        RWO            Retain           Available           standard                51s

$ kubectl describe pv/example-pv
Name:            example-pv
Labels:          <none>
Annotations:     <none>
Finalizers:      [kubernetes.io/pv-protection]
StorageClass:    standard
Status:          Available
Claim:           
Reclaim Policy:  Retain
Access Modes:    RWO
VolumeMode:      Filesystem
Capacity:        1Gi
Node Affinity:   <none>
Message:         
Source:
    Type:          HostPath (bare host directory volume)
    Path:          /data/pv-example/
    HostPathType:  
Events:            <none>

上面就是一个Provisioning的过程,其结果是创建出了一个状态为Available的PV。如果是一些云计算服务,一般还需要手动创建一个块云盘,然后在PV里带上对应的参数。

第三步:创建PVC

新建个文件pvc.yaml,内容如下:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: example-pvc

# 整个pvc的spec字段几乎都是为了跟pv进行匹配
spec:
  # 没找到相同的storageClass的pv,则匹配失败
  storageClassName: "standard"

  # 没找到accessMode一致的pv,则匹配失败
  accessModes:
    - ReadWriteOnce

  # 对应pv的capacity。如果所需要的资源pv无法满足,则匹配失败
  resources:
    requests:
      storage: 1Gi

  # pv的名字。找不到对应的pv,则匹配失败
  volumeName: example-pv

我多少有点感觉PVC Spec里的很多字段都是冗余的,感觉只需要spec.volumeName就可以进行匹配。这种冗余可能是为动态制备PV准备的吧。

我们把这个pvc创建出来,然后看下它与pv的状态变化:

$ kubectl apply -f pvc.yaml
persistentvolumeclaim/example-pvc created

# 先看下pvc本身的信息
$ kubectl get pvc
NAME          STATUS   VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS   AGE
example-pvc   Bound    example-pv   1Gi        RWO            standard       16s

$ kubectl describe pvc/example-pvc
Name:          example-pvc
Namespace:     default
StorageClass:  standard
Status:        Bound
Volume:        example-pv
Labels:        <none>
Annotations:   pv.kubernetes.io/bind-completed: yes
Finalizers:    [kubernetes.io/pvc-protection]
Capacity:      1Gi
Access Modes:  RWO
VolumeMode:    Filesystem
Used By:       <none>
Events:        <none>


# 再看下对应pv的信息
$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                 STORAGECLASS   REASON   AGE
example-pv   1Gi        RWO            Retain           Bound    default/example-pvc   standard                36m

可以看到pv与pvc已经绑定好了,其状态都是Bound。并且你会看到,二者相互都包含对方的一些信息,进一步体现了上文说的双向绑定。你可以自己试试一些异常流,比如故意让两者无法匹配,然后再看看二者的状态是什么样子的。

第四步:使用

存储资源的准备工作已经全部完成啦~ 让我们来使用它。创建一个文件名为deployment.yaml,内容为:

---
apiVersion: v1
kind: Service
metadata:
  name: pv-example
  labels:
    app: pv-example

spec:
  type: LoadBalancer
  selector:
    app: pv-example
  ports:
    - port: 80

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pv-example
  labels:
    app: pv-example

spec:
  replicas: 3
  selector:
    matchLabels:
      app: pv-example
  template:
    metadata:
      labels:
        app: pv-example
    spec:

      # volumes是pods的属性,而不是container的。
      # 我们在这里描述,pods需要用到哪个pvc?并且给它起个名字方便container去引用它。
      # 那么这里,我们用到名为example-pvc的pvc,给它起个名字为www
      volumes:
        - name: www
          persistentVolumeClaim:
            claimName: example-pvc

      containers:
        - name: nginx
          image: nginx:1.21.5
          ports:
            - containerPort: 80
              name: web
          
          # container里,把pods定义的www这个volume,挂载到/usr/share/nginx/html路径下
          volumeMounts:
            - mountPath: /usr/share/nginx/html
              name: www

关于Deployment与Service的内容,上一篇的笔记里都提到了,注释里就不再细说了。上面的文件起了一个nginx服务集群,有三个实例(Pods),每个pod都使用前面创建的PVC。操作三连:

$ kubectl apply -f deployment.yaml
service/pv-example created
deployment.apps/pv-example created

$ kubectl get pods -l app=pv-example
NAME                          READY   STATUS    RESTARTS   AGE
pv-example-6cd86476d7-bmvr4   1/1     Running   0          38s
pv-example-6cd86476d7-sdmtq   1/1     Running   0          38s
pv-example-6cd86476d7-wxrhq   1/1     Running   0          38s

# describe一下pvc,跟之前相比,Used By字段有内容了
$ kubectl describe pvc/example-pvc
Name:          example-pvc
Namespace:     default
StorageClass:  standard
Status:        Bound
Volume:        example-pv
Labels:        <none>
Annotations:   pv.kubernetes.io/bind-completed: yes
Finalizers:    [kubernetes.io/pvc-protection]
Capacity:      1Gi
Access Modes:  RWO
VolumeMode:    Filesystem
Used By:       pv-example-6cd86476d7-bmvr4
               pv-example-6cd86476d7-sdmtq
               pv-example-6cd86476d7-wxrhq
Events:        <none>


# 访问这个nginx集群,会发现访问403
$ minikube service pv-example

# 另起一个终端,随意选个pods,然后在nginx目录下创建个index.html文件
$ kubectl exec pv-example-6cd86476d7-bmvr4 -- sh -c "echo 'hello pv' > /usr/share/nginx/html/index.html"

这时候再去浏览器里访问这个服务,就会出现hello pv。如果我们进节点的机器上看下/data/pv-exampe/目录,就能找到这个index.html文件:

# ssh到节点机器上
$ minikube ssh

# ls下pv对应的目录,可以看到这个index.html文件
docker@minikube:~$ ls /data/pv-example/
index.html

你可以在节点机器上搞点破坏,看看服务会变成什么样子,比如:

  • 修改index.html的权限:chmod 000 index.html
  • 删除pv-example目录

第五步:回收

回收的步骤几乎是固定的,必须是先停止pods对pvc的使用,然后才能删除pvc,进而删除pv:

$ kubectl delete service/pv-example deployment/pv-example
service "pv-example" deleted
deployment.apps "pv-example" deleted

$ kubectl delete pvc/example-pvc
persistentvolumeclaim "example-pvc" deleted

$ kubectl delete pv/example-pv
persistentvolume "example-pv" deleted

你也可以试试把整个过程反着进行,你会发现PV、PVC会处于一个Terminaling的状态。只有依赖它们的资源释放了,它们才能被成功Terminal。

回收结束后,你可以再登录节点上看看/data/pv-example目录下还有没有内容(如果你前面没有搞破坏,手动把整个文件夹删除的话)。

另外我还试验了一下Recalim Policy为Delete的PV与PVC,回收之后并没有把/data/pv-example目录删除。这貌似与官方文档的说明有出入:

For volume plugins that support the Delete reclaim policy, deletion removes both the PersistentVolume object from Kubernetes, as well as the associated storage asset in the external infrastructure, such as an AWS EBS or GCE PD volume. Volumes that were dynamically provisioned inherit the reclaim policy of their StorageClass, which defaults to Delete. The administrator should configure the StorageClass according to users’ expectations; otherwise, the PV must be edited or patched after it is created. See Change the Reclaim Policy of a PersistentVolume. 对于支持 Delete 回收策略的卷插件,删除动作会将 PersistentVolume 对象从 Kubernetes 中移除,同时也会从外部基础设施(如 AWS EBS 或 GCE PD 卷)中移除所关联的存储资产。 动态制备的卷会继承其 StorageClass 中设置的回收策略, 该策略默认为 Delete。管理员需要根据用户的期望来配置 StorageClass; 否则 PV 卷被创建之后必须要被编辑或者修补。 参阅更改 PV 卷的回收策略。

可能是我理解错误,也可能是minikube提供的standard StorageClass虽然声明了支持Delete Policy,但其实不支持,但是总之在搞清楚之前,谨慎使用Delete Policy。

Q&A

多个Pods能否同时使用一个PV?

可以。从上面的实践例子就可以看出这一点,三个Pods连向同一个PVC。

我初看文档的时候,被PV与PVC是一一绑定的关系给迷惑了,以为一个PV只能服务于一个Pod。官网的例子又是采用动态制备,并且每个pods单独分配一个pv的方式,无法验证我心中的疑惑。所以我才自己修改了下实践方式。

虽然多个Pods可以连向同一个PVC & PV,但是多个Pods之间存在资源竞争的时候(比如,同时打开某个文件)估计是会出异常的,或者可能意外的相互覆盖文件内容等问题存在。

AccessMode为ReadOnlyMany的PV&PVC,是否能保证Pods只读存储资源上的内容?

不能。上面的实践例子中有提到,可以在节点上删除/data/pv-example/目录,其实也可以把这个目录权限改为只读。在上面的例子中,文件夹只读的权限,显然与PV & PVC里声明的ReadWriteOnce相悖,但我们就是可以这样做。

也就是说,PV & PVC里所说的AccessMode与存储资源的读写权限不是一回事情。它的作用大概有两个:

  • 用来匹配PV与PVC
  • 用来描述PV & PVC能否被多个k8s节点访问。

PV里声明StorageClass为空字符串,而PVC里声明StorageClass为集群的默认值,二者有没有可能匹配?

不可能。虽然k8s说空字符串的情况,取集群中的默认值,但是在匹配的时候,还是严格的进行了声明面量值匹配。

如何查看一个pv & pvc被哪些nodes使用?

我们可以查看pvc的Used By字段,然后再通过查看pods来查看被哪些nodes使用。

k8s中其实还有一种资源——VolumeAttachments,它承载着pv & pvc attach到一个node上的信息。所以可以通过命令:kubectl get volumeattachments 来获取。我在本地的minikube集群里试了下,完全获取不到。在eks上试了下,倒是work,其信息大概如下:

$ kubectl get volumeattachments
NAME       ATTACHER          PV            NODE    ATTACHED   AGE
csi-xxx1   ebs.csi.aws.com   xxxxx1-data   node1   true       11d
csi-xxx2   ebs.csi.aws.com   xxxxx2-data   node2   true       11d

这里的NAME应该是只云盘的ID,而不是PVC。

假设一个PV的回收策略是Retain,那么在其释放后,是否能再创建个PVC与其绑定?

不能。这玩意有点啰嗦,需要收到删除PV,然后重建这个PV才能进行新的绑定。所以,我还是觉得Delete策略是真的会把数据删除的,否则这个Retain策略岂不是毫无存在的意义?

如果上述实践中的pvc,只提供volumeName字段,它能否与pv匹配上?

试了下,不能。报错:

# pvc.yaml的spec内容已经被我删得只剩 volumeName: example-pv 这一行了
$ kubectl apply -f pvc.yaml 
The PersistentVolumeClaim "example-pvc" is invalid: 
* spec.accessModes: Required value: at least 1 access mode is required
* spec.resources[storage]: Required value

感受

我认为操作存储资源要比操作计算资源复杂一些,大部分人的使用场景下,k8s还不能把存储资源当"牲口养"。处理这些跟存储资源有联系的服务的时候,需要小心翼翼。难怪k8s在Deployment之上,要提供StatefulSet这种部署模式。

目前也只是学习了一下正常流是怎么操作的,还只是很浅的掌握。真正难的是异常情况要如何应对,这方面目前还两眼一抹黑。

还有很多知识暂时只能是猜测性的"掌握",因为暂时没时间深入其细节已经进行验证,如有错误欢迎指正。