持久化存储

容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。

“持久化 Volume”,指的就是这个宿主机上的目录,具备“持久性。即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容。

大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。

Persistent Volume(PV)、 Persistent Volume Claim(PVC)和 StorageClass

Kubernetes 中 PVC 和 PV 的设计,类似于“接口”和“实现”的思想。开发者只要知道并会使用“接口”,即:PVC;而运维人员则负责给“接口”绑定具体的实现,即:PV。

PV

PV 定义的是一个持久化存储在宿主机上的目录,比如一个 NFS 的挂载目录。

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv
spec:
  storageClassName: manual
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: 10.244.1.4
    path: "/"

PV对象是如何变成容器里的一个持久化存储的呢?

显然,hostPath 和 emptyDir 类型的 Volume 并不具备持久性:它们既有可能被 kubelet 清理掉,也不能被“迁移”到其他节点上,所以上述例子是使用了 NFS 类型。

而 Kubernetes 需要做的工作,就是使用这些存储服务,把远程存储目录挂载到容器指定的目录,容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”(挂载远程磁盘)。

PVC

PVC 定义的,则是 Pod 所希望使用的持久化存储的属性。比如,Volume 存储的大小、可读写权限等等。以下定义一个 1 GiB 大小的 PVC:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-pvc
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi
  • PV 和 PVC 的 spec 字段。比如,PV 的存储(storage)大小,就必须满足 PVC 的要求。

  • PV 和 PVC 的 storageClassName 字段必须一样(PV 和 PVC是通过 StorageClass 来绑定的)。

PV 和 PVC 是如何绑定的呢?

Kubernetes 中有一个 PersistentVolumeController,会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态。如果不是,那它就会遍历所有的、可用的 PV,并尝试将其与这个“单身”的 PVC 进行绑定。这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态。

而所谓将一个 PV 与 PVC 进行“绑定”,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上(可通过kubectl describe pvc pvc-name查看)。所以,接下来 Kubernetes 只要获取到这个 PVC 对象,就一定能够找到它所绑定的 PV。

Pod如何使用它们呢?

在成功地将 PVC 和 PV 进行绑定之后,Pod 就能够像使用 hostPath 等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用这个 PVC 了,如下所示:

apiVersion: v1
kind: Pod
metadata:
  labels:
    role: web-frontend
spec:
  containers:
  - name: web
    image: nginx
    ports:
      - name: web
        containerPort: 80
    volumeMounts:
        - name: nfs
          mountPath: "/usr/share/nginx/html"
  volumes:
  - name: nfs
    persistentVolumeClaim:
      claimName: nfs

StorageClass

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd

如果没有 StorageClass,那么一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV,非常的麻烦。

而 StorageClass 是一套可以自动创建PV的机制。

sc定义绑定的是一类pv和pvc(不是一个)

pv,sc、pvc是一对,bound之后就找不到已经被使用的那个sc了

subPath

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    name: backend-service
  name: backend-service
spec:
  replicas: 1
  selector:
    matchLabels:
      name: backend-service
  strategy: {}
  template:
    metadata:
      labels:
        name: backend-service
    spec:
      volumes:
        - name: app-data
          persistentVolumeClaim:
            claimName: local-app-claim
      containers:
       - image: registry.cn-qingdao.aliyuncs.com/zw_private/bio-r:v0.2
         name: bio-r
         imagePullPolicy: IfNotPresent
         volumeMounts:
           - name: app-data
             mountPath: "/workspace"
             subPath: "r"  # 子路径,代表存储卷下面的子目录
       - image: registry.cn-qingdao.aliyuncs.com/zw_private/bio-api:v0.01
         name: bio-api
         imagePullPolicy: IfNotPresent
         volumeMounts:
           - name: app-data
             mountPath: "/workspace/r/static"
             subPath: "r/static"  # 存储卷下面的多级子路径
           - name: app-data
             mountPath: "/workspace/config.yaml"
             subPath: "config.yaml"  # 只挂载这一个文件,使这个文件覆盖容器内的配置文件启动!

k8s挂载的文件或目录都会直接全覆盖容器内相应的挂载目标,类似于docker的-v

Local Persistent Volume

适用场景

高优先级的系统应用,需要在多个不同节点上存储数据,并且对 I/O 较为敏感。

典型的应用包括:分布式数据存储比如 MongoDB、Cassandra 等,分布式文件系统比如 GlusterFS、Ceph 等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。

相比于正常的 PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume 的数据就可能丢失。这就要求使用 Local Persistent Volume 的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。

经典误区:Local PV,不就等同于 hostPath 加 NodeAffinity 吗?

比如,一个 Pod 可以声明使用类型为 Local 的 PV,而这个 PV 其实就是一个 hostPath 类型的 Volume。如果这个 hostPath 对应的目录,已经在节点 A 上被事先创建好了。那么,我只需要再给这个 Pod 加上一个 nodeAffinity=nodeA,不就可以使用这个 Volume 了吗?

事实上,你绝不应该把一个宿主机上的目录当作 PV 使用。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的 I/O 隔离机制。

所以,一个 Local Persistent Volume 对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(“额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为“一个 PV 一块盘”。

为什么这么设计?

对于常规的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后,再通过“两阶段处理”来“持久化”这台机器上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载。

可是,对于 Local PV 来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。

所以,这时候,调度器就必须能够知道所有节点与 Local Persistent Volume 对应的磁盘的关联关系,然后根据这个信息来调度 Pod(把Pod调度到满足条件的node上)。

操作实践

  • 第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作;

  • 第二种,对于实验环境,你其实可以在宿主机上挂载几个 RAM Disk(内存盘)来模拟本地磁盘。

以下,按第二种操作实践。

准备工作

挂载 RAM Disk(内存盘)来模拟本地磁盘,在Linux下的命令示例:

# 在 node-1 上执行
$ mkdir /mnt/disks
$ for vol in vol1 vol2 vol3; do
    mkdir /mnt/disks/$vol
    mount -t tmpfs $vol /mnt/disks/$vol  # 在不同的节点定义的磁盘名字要不同
done

mac 不支持 tmpfs,可用 diskutil。

RAM disk 把内存当硬盘使,推出该 Ramdisk 即可释放内存,然后绑定在本地磁盘地址的数据也会全部丢失。

格式如下:

diskutil erasevolume HFS+ "<名称>" `hdiutil attach -nomount ram://$((<容量(GB)>*2097152))`

例如:

$ diskutil erasevolume HFS+ "k8s-disk" `hdiutil attach -nomount ram://$((1*2097152))`
Started erase on disk2
Unmounting disk
Erasing
Initialized /dev/rdisk2 as a 1024 MB case-insensitive HFS Plus volume
Mounting disk
Finished erase on disk2 (k8s-disk)

操作成功后的挂载路径:/Volumes/k8s-disk

创建 k8s node 标签,指令格式:

$ kubectl label nodes <node-name> <label-key>=<label-value> 

例如:

$ kubectl get nodes
NAME             STATUS   ROLES    AGE    VERSION
docker-desktop   Ready    master   153d   v1.19.7
# 可以看到有多组标签,可以找到kubernetes.io/hostname=docker-desktop
# 可以找到InternalIP:192.168.65.4
$ kubectl describe node docker-desktop 
$ kubectl label node 192.168.65.4 custom_label=test # 给指定node添加自定义标签

创建pv、pvc、storageClass

$ cat local_pv.yaml 
apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-pv
spec:
  capacity:
    storage: 0.5Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /Volumes/k8s-disk
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - docker-desktop

$ kubectl create -f local_pv.yaml 
persistentvolume/example-pv created
 
$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY  STATUS      CLAIM             STORAGECLASS    REASON    AGE
example-pv   5Gi        RWO            Delete           Available  

# 因为 Local Persistent Volume 不支持 Dynamic Provisioning
# provisioner 指定的是 no-provisioner
# 所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV
# 也就是说,前面创建 PV 的操作,是不可以省略的
# volumeBindingMode=WaitForFirstConsumer 延迟绑定
$ cat local_sc.yaml 
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

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

# storageClassName: local-storage
# 所以,local_pvc 创建之后 k8s 的 Volume Controller 不会为它进行绑定操作
$ catlocal_pvc.yaml 
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: example-local-claim
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 0.5Gi
  storageClassName: local-storage

$ kubectl create -f local_pvc.yaml 
persistentvolumeclaim/example-local-claim created

$ kubectl get pvc
NAME                  STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Pending 

创建Pod

$ cat local_pod.yaml 
kind: Pod
apiVersion: v1
metadata:
  name: example-pv-pod
spec:
  volumes:
    - name: example-pv-storage
      persistentVolumeClaim:
        claimName: example-local-claim
  containers:
    - name: example-pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: example-pv-storage

$ kubectl create -f local_pod.yaml 
pod/example-pv-pod created
 
$ kubectl get pvc # 创建pod后STATUS就变成了Bound
NAME                  STATUS    VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Bound     example-pv   5Gi        RWO            local-storage   6h

测试localPV的功能

进入容器创建文件

$ kubectl exec -it example-pv-pod -- /bin/sh
# cd /usr/share/nginx/html  # Pod 把volume挂载到了 /usr/share/nginx/html 
# touch test.txt

然后可以检查宿主机目录,尝试重建该Pod,发现该创建的文件还在。

延迟绑定

当提交了 PV 和 PVC 的 YAML 文件之后,Kubernetes 就会根据它们俩的属性,以及它们指定的 StorageClass 来进行绑定。只有绑定成功后,Pod 才能通过声明这个 PVC 来使用对应的 PV。

可是,如果你使用的是 Local Persistent Volume 的话,就会发现,这个流程根本行不通。

比如,现在你有一个 Pod,它声明使用的 PVC 叫作 pvc-1。并且,我们规定,这个 Pod 只能运行在 node-2 上。

而在 Kubernetes 集群中,有两个属性(比如:大小、读写权限)相同的 Local 类型的 PV。

其中,第一个 PV 的名字叫作 pv-1,它对应的磁盘所在的节点是 node-1。而第二个 PV 的名字叫作 pv-2,它对应的磁盘所在的节点是 node-2。

假设现在,Kubernetes 的 Volume 控制循环里,首先检查到了 pvc-1 和 pv-1 的属性是匹配的,于是就将它们俩绑定在一起。

然后,你用 kubectl create 创建了这个 Pod。

这时候,问题就出现了。

调度器看到,这个 Pod 所声明的 pvc-1 已经绑定了 pv-1,而 pv-1 所在的节点是 node-1,根据“调度器必须在调度的时候考虑 Volume 分布”的原则,这个 Pod 自然会被调度到 node-1 上。

可是,我们前面已经规定过,这个 Pod 根本不允许运行在 node-1 上。所以。最后的结果就是,这个 Pod 的调度必然会失败。

这就是为什么,在使用 Local Persistent Volume 的时候,我们必须想办法推迟这个“绑定”操作。

那么,具体推迟到什么时候呢?

答案是:推迟到调度的时候。

所以说,StorageClass 里的 volumeBindingMode=WaitForFirstConsumer 的含义,就是告诉 Kubernetes 里的 Volume 控制循环(“红娘”):虽然你已经发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但请不要现在就执行绑定操作(即:设置 PVC 的 VolumeName 字段)。

而要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置,来统一决定,这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。

这样,在上面的例子里,由于这个 Pod 不允许运行在 pv-1 所在的节点 node-1,所以它的 PVC 最后会跟 pv-2 绑定,并且 Pod 也会被调度到 node-2 上。

所以,通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响 Pod 的正常调度。

存储插件Rook

安装

Rook 项目巧妙地依赖了 Kubernetes 提供的编排能力,合理的使用了很多诸如 Operator、CRD 等重要的扩展特性,属于“云原生”项目(也就是“Kubernetes 原生”)。Rook 具备水平扩展、迁移、灾难备份、监控等大量的企业级功能,是一个完整的、生产级别可用的容器存储插件。

在此目录获取Rook-v1.7.4版本的yaml,逐个安装 crds.yaml、common.yaml、operator.yaml、cluster.yaml,然后检查部署状态,是正常的 Running 状态即可。

很遗憾,有的镜像拉不下来,比如 k8s.gcr.io 下的镜像,用如下方法替换之:

# 拉取国内镜像
$ docker pull registry.aliyuncs.com/google_containers/csi-node-driver-registrar:v2.2.0
$ docker pull registry.aliyuncs.com/google_containers/csi-attacher:v3.2.1
$ docker pull registry.aliyuncs.com/google_containers/csi-provisioner:v2.2.2
$ docker pull registry.aliyuncs.com/google_containers/csi-snapshotter:v4.1.1
$ docker pull registry.aliyuncs.com/google_containers/csi-resizer:v1.2.0
# 手动将镜像做tag,否则依然会去远端下载
$ docker tag registry.aliyuncs.com/google_containers/csi-node-driver-registrar:v2.2.0 k8s.gcr.io/sig-storage/csi-node-driver-registrar:v2.2.0
$ docker tag registry.aliyuncs.com/google_containers/csi-attacher:v3.2.1 k8s.gcr.io/sig-storage/csi-attacher:v3.2.1
$ docker tag registry.aliyuncs.com/google_containers/csi-provisioner:v2.2.2 k8s.gcr.io/sig-storage/csi-provisioner:v2.2.2
$ docker tag registry.aliyuncs.com/google_containers/csi-snapshotter:v4.1.1 k8s.gcr.io/sig-storage/csi-snapshotter:v4.1.1
$ docker tag registry.aliyuncs.com/google_containers/csi-resizer:v1.2.0 k8s.gcr.io/sig-storage/csi-resizer:v1.2.0

依赖的 k8s.gcr.io 镜像都类似处理,前缀换成 registry.aliyuncs.com/google_containers,然后打 tag。

$ kubectl get pods -n rook-ceph
NAME                                            READY   STATUS    RESTARTS   AGE
csi-cephfsplugin-fj452                          3/3     Running   0          9m23s
csi-cephfsplugin-provisioner-789d69957d-64rf5   6/6     Running   0          9m22s
csi-rbdplugin-6ft6j                             3/3     Running   0          9m24s
csi-rbdplugin-provisioner-d8bcc5fc4-qc2r2       6/6     Running   0          9m24s
rook-ceph-operator-9bf8b5959-lvns9              1/1     Running   0          10m
$ kubectl patch storageclass rook-ceph-block -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' # 设置成默认的storageclass
$ kubectl get storageclass
NAME                        PROVISIONER          RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
hostpath                    docker.io/hostpath   Delete          Immediate           false                  130d
rook-ceph-block (default)   ceph.rook.io/block   Delete          Immediate           false                  2m15s

这样,一个基于 Rook 持久化存储集群就以容器的方式运行起来了,而接下来在 Kubernetes 项目上创建的所有 Pod 就能够通过 Persistent Volume(PV)和 Persistent Volume Claim(PVC)的方式,在容器里挂载由 Ceph 提供的数据卷了。

使用

$ kubectl create -f rook-storage.yaml
$ cat rook-storage.yaml
apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
  name: replicapool
  namespace: rook-ceph
spec:
  replicated:
    size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rook-ceph-block
provisioner: ceph.rook.io/block
parameters:
  pool: replicapool
  clusterNamespace: rook-ceph

StorageClass 的作用,是自动地为集群里存在的每一个 PVC,调用存储插件(Rook)创建对应的 PV,从而省去了手动创建 PV 的机械劳动。

注:在使用 Rook 的情况下,statefulset.yaml 里的 volumeClaimTemplates.spec字段下需要加上声明 storageClassName:rook-ceph-block,才能使用到这个 Rook 提供的持久化存储。

Last updated

Was this helpful?