0.1. 持久化存储 0.2. 持久化宿主机目录的过程,两步走 0.2.1. GCP 0.2.2. NFS 0.3. StorageClass 0.4. 总结 0.5. 本地持久化存储 0.6. Local Persistent Volume 0.6.1. 难点一 0.6.2. 难点二 0.6.3. 例子 0.7. 自定义存储插件 0.1. 持久化存储 容器化一个应用比较复杂的地方是状态的管理,常见的状态就是存储状态。 PV:描述持久化存储数据卷,这个API对象主要定义的是持久化存储在宿主机上的目录,比如一个NFS挂载目录。 PVC:描述的Pod所希望使用的持久化存储的属性。比如Volume存储的大小、可读写权限等。 通常情况,PV对象是由运维人员事先创建在kubernetes集群中待用,可以定义如下的PV: apiVersion: v1 kind: PersistentVolume metadata: name: nfs spec: storageClassName: manual capacity: storage: 1Gi accessModes: - ReadWriteMany nfs: server: 10.244.1.4 path: "/" PVC对象通常由开发人员创建,或者以PVC模板的方式成为StatefulSet的一部分,然后由StatefulSet控制器负责创建带编号的PVC,如下: apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs spec: accessModes: - ReadWriteMany storageClassName: manual resources: requests: storage: 1Gi 用户创建的PVC要真正被容器使用起来,就必须先和某个符合条件的PV进行绑定,这需要检查两部分: PV和PVC的spec字段,比如PV的存储(Storage)大小,必须满足PVC的要求。 PV和PVC的storageClassName字段必须一样 绑定成功后,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 Pod在volumes字段中声明要使用的PV名字,等这个Pod创建后,kubelet就会把这个PVC所对应的PV挂载到Pod容器内的目录上。 PV与PVC的设计,和面向对象的思想完全一样。开发人员只需要和PVC这个接口打交道,不必关心具体的实现细节。 PVC可以理解为持久化存储的“接口”,提供了对某种持久化存储的描述,但不提供具体的实现, PV负责持久化存储的具体实现 创建Pod时,集群中没有适合的PV与PVC进行绑定,即容器需要的volume不存在,那么Pod的启动就会报错。当需要的PV被创建后,PVC可以再次与之绑定,从而使得Pod能够顺利启动。 Volume Controller专门处理持久化存储的控制器,维护多个控制循环,其中之一就是帮助PV和PVC进行绑定,即PersistenVolumeController。它会不断的查看当前每一个PVC是否处于Bound状态,如果没有处于绑定状态,则会遍历全部可用的PV,并尝试将PVC与PV进行绑定。 这样kubernetes就能保证,用户提交的每一个PVC,只要有合适的PV出现,就能够很快进入绑定状态。 所谓的PV与PVC的绑定,就是将这个PV对象的名字填写在PVC对象spec.volumeName字段,这样获取了PVC就能知道绑定的PV。 所谓容器的Volume,其实就是一个将宿主机上的目录,跟一个容器里的目录绑定挂载在一起。所谓的持久化Volume,就是这个宿主机的目录具备持久性(目录中的内容不会因为容器删除而被清理掉,也不跟当前宿主机绑定)。当容器被重启或者其他节点上重建出来,它仍然可以通过挂载这个Volume访问到这些内容。 hostPath、emptyDir类型的volume都不具备这样的特性:既会被kubelet清理掉,也不能被迁移到其他节点上。大多数持久化volume的实现依赖于远程存储服务: 远程文件存储(NFS、GlusterFS) 远程块存储(公有云提供的远程磁盘)等。 kubernetes的工作就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载使用。所谓持久化是容器在这个目录里写的文件,都会保存在远程存储中,从而使得这个目录具备了持久性。 0.2. 持久化宿主机目录的过程,两步走 当pod调度到某个节点,kubelet就会负责为这个pod创建它的Volume目录,默认情况下,kubelet为volume创建的目录是如下所示的宿主机上的路径: /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 > 0.2.1. GCP kubelet根据volume的类型进行具体的操作,如果volume是远程块存储,如GCE的Persistent Disk,kubelet需要先调用GCP的API,将它所提供的Persistent Disk挂载到Pod所在的宿主机上。相当于执行如下命令: $ gcloud compute instances attach-disk < 虚拟机名字 > --disk < 远程磁盘名字 > # 为虚拟机挂载远程磁盘的操作 以上操作是第一个阶段,在kubernetes中称为Attach。 为了能够使用这个远程磁盘,kubelet进行第二个操作,格式化这个磁盘设备,然后挂载到宿主机指定的挂载点(宿主机的volume目录)上。相当于执行如下操作: # 通过 lsblk 命令获取磁盘设备 ID $ sudo lsblk # 格式化成 ext4 格式 $ sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/< 磁盘设备 ID> # 挂载到挂载点 $ sudo mkdir -p /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 > # 将磁盘设备格式化并挂载到宿主机目录 以上操作是第二个节点,称为Mount。 Mount阶段完成后,这个Volume的宿主机目录就是一个“持久化”目录,容器在它里面写入的内容就会被保存在GCP的远程磁盘中。 0.2.2. NFS 如果volume的类型是远程文件存储NFS,kubelet的处理过程比较简单,直接进行Mount阶段即可。因为远程文件存储一般没有存储设备需要挂载到宿主机。 在Mount的过程中kubelet需要作为client,将远端NFS服务器的目录(如/目录)挂载到Volume的宿主机目录上,即相当于执行如下命令: mount -t nfs <NFS 服务器地址 >:/ /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 > 通过这个操作volume的宿主机目录就成为了一个远程NFS目录的挂载点,在这个目录下写入的所有文件就会被保存在远程NFS服务上。 在具体的Volume插件的实现接口上,kubernetes分别为这两个阶段提供了两种不同的参数列表: Attach:kubernetes提供的可用参数是nodeName,即宿主机的名字 Mount:kubernetes提供的可用参数的dir,即 Volume的宿主机目录 得到持久化的Volume宿主机目录后,kubelet只要把这个Volume目录通过CRI里的Mounts参数,传递给Docker,然后就可以为Pod里的容器挂载这个持久化的Volume,相当于执行如下操作: docker run -v /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >:/< 容器内的目标目录 > 我的镜像 ... 相应的,在删除PV的时候,kubernetes也需要umount和Dettach两个阶段。 在这个PV的处理过程中,与Pod和容器的启动流程没有太多的耦合,只要kubelet在向Docker发起CRI请求之前,确保持久化的宿主机目录已经处理完毕即可。在kubernetes中,关于PV的处理过程,是独立与kubelet主控制循环(kubelet sync loop)之外的两个控制循环实现的。 Attach和Dettach操作,由Volume Controller负责维护,这个控制循环的名字叫AttachDettchController。它的作用就是不断地检查每一个Pod对应的PV,和这个Pod所在宿主机之间的挂载情况,从而决定是否需要对这个PV进行Attach或者Dettach操作。 kubernetes内置的控制器,Volume Controller是kube-controller-manager的一部分,所有AttachDettach也是运行在Master节点,Attach操作只需要调用公有云或者具体存储项目的API,并不需要在具体宿主机上执行操作。 Mount和Umount操作,必须发生在Pod对应的宿主机上,所以必须是kubelet组件的一部分,这个控制循环的名字,叫作VolumeManagerReconciler,运行起来之后,是一个独立于kubelet主循环goroutine。 通过将Volume的处理同kubelet的主循环解耦,避免了耗时的远程挂载操作拖慢kubelet的主控制循环,进而导致Pod的创建效率大幅下降的问题。 kubelet的一个主要设计原则就是它的主控制循环绝对不能被block。 0.3. StorageClass PV的创建需要运维人员完成,在大规模的生产环境中,这个工作太麻烦,需要预先创建很多PV,随着需求的变化需要继续添加新的PV。 kubernetes提供了一套可以自动创建PV的机制,Dynamic Provisioning,它的核心在于StorageClass API对象。 StorageClass对象的作用,就是创建PV的模板。它会定义 如下两个部分: PV的属性,如存储类型,Volume大小等 创建这个PV所需要用到的存储插件,如Ceph等 有了这两个信息之后,kubernetes就能够根据用户提交的PVC找到一个对应的StorageClass,然后kubernetes就会调用该StorageClass声明的存储插件,创建出需要的PV。 如下所示,volume的类型是GCE的Persistent Disk: apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: block-service provisioner: kubernetes.io/gce-pd # kubernetes内置的GCE PD存储插件的名字 parameters: # 这里就是PV的参数 type: pd-ssd # PV的类型是SSD格式的GCE远程磁盘 如下所示,在本地集群使用rook存储服务: 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: block-service provisioner: ceph.rook.io/block # 存储插件是rook parameters: pool: replicapool #The value of "clusterNamespace" MUST be the same as the one in which your rook cluster exist clusterNamespace: rook-ceph 在StorageClass的YAML文件之后,运维人员就可以创建StorageClass了,开发人员只需要在PVC中指定storage即可: apiVersion: v1 kind: PersistentVolumeClaim metadata: name: claim1 spec: accessModes: - ReadWriteOnce storageClassName: block-service #指定为storageclass的名字 resources: requests: storage: 30Gi kubernetes只会将storageclass相同的PVC和PV绑定起来。 注意 StorageClass并不是专门为了Dynamic Provisioning而设计的。 上面的例子中在 PV 和 PVC 里都声明了 storageClassName=manual。而集群里,实际上并没有一个名叫 manual 的StorageClass 对象。这完全没有问题,这个时候 Kubernetes 进行的是 Static Provisioning,但在做绑定决策的时候,它依然会考虑 PV 和PVC 的 StorageClass 定义。 而这么做的好处也很明显:这个 PVC 和 PV 的绑定关系,就完全在我自己的掌控之中。 如果集群已经开启了名叫 DefaultStorageClass 的 Admission Plugin,它就会为PVC 和 PV 自动添加一个默认的StorageClass;否则,PVC 的 storageClassName 的值就是“”,这也意味着它只能够跟 storageClassName 也是“”的 PV 进行绑定。 0.4. 总结 如下图: PVC 描述的是Pod想要使用的持久化存储的属性,比如存储的大小、读写权限等 PV 描述的是具体的Volume的属性,比如volume的类型,挂载的目录,远程存储服务器地址等 StorageClass的作用是充当PV的模板,并且只有属于同一个StorageClass的PV和PVC才可以绑定 StorageClass的另一个重要的作用是指定Provisioner(存储插件),这时候,如果存储插件支持Dynamic Provisioning的话,kubernetes就可以自动创建PV。 0.5. 本地持久化存储 如何解决kubernetes内置的20种持久化数据集不能满足需求的问题?在开源项目中,“不能用、不好用、需定制开发”是开源项目落地的三大常态。 在持久化存储中,本地持久化存储的需求最高,这样的好处很明显。Volume直接使用本地磁盘(SSD),读写性能相比于大多数远程存储来说,要好很多。 kubernetes v1.10之后,依靠PV、PVC实现了本地持久化存储,Local Persistent Volume。 Local Persistent Volume并不适用于所有应用。它的使用范围非常固定,如高优先级的系统应用,需要在多个不同节点上存储数据,并且对I/0较为敏感。典型的应用包括: 分布式数据存储:MongoDB、Cassandra等 分布式文件系统:GlusterFS、Ceph等 需要在本地磁盘上进行大量数据缓存的分布式应用 相比于正常的PV,一旦这些节点宕机且不能恢复,Local Persistent Volume的数据就可能丢失,这就要求使用Local Persistent Volume的应用必须具备数据备份和恢复能力,允许把这些数据定时备份到其他位置。 0.6. Local Persistent Volume 设计的难点: 如何把本地磁盘抽象成PV? 调度如何保证Pod始终能被正确地调度到它所请求的Local Persistent Volume所在节点? 0.6.1. 难点一 假设将一个Pod声明使用类型为Local的PV,而这个PV其实是一个hostPath类型的Volume,如果这个hostPath对应的目录在A节点上被事先创建好,那么只需要给这个Pod加上nodeAffinity=nodeA。 事实上,绝不能把一个宿主机上的目录当做PV使用。因为: 本地目录的存储行为完全不可控:它所在磁盘随时可能被应用写满,甚至造成整个宿主机宕机 不同的本地目录之间也缺乏哪怕最基础的I/O隔离机制 所以,一个Local Persistent Volume对应的存储介质,一定是一块额外(非宿主机根目录使用的主硬盘)挂载在宿主机的错或者块设备,即一个PV一块盘。 0.6.2. 难点二 调度器如何保证Pod始终能被正确地调度到它所请求的Local Persistent Volume。 对于常规的PV,kubernetes都是先调度Pod到某个节点,然后再通过Attach和Mount两个阶段来持久化这台机器上的Volume目录,进而完成Volume目录与容器的绑定挂载。 对于Local PV来说,节点上可供使用的磁盘(块设备),必须是运维人员提前准备好(在不同节点上挂载情况可能完全不同,甚至有的节点上没有这种磁盘)。 所以调度器必须知道所有节点与Local PV对应的磁盘的关联关系,然后根据这个信息来调度Pod,即调度的时候考虑Volume分布。在kubernetes的调度器中,VolumeBindingChecker过滤条件负责在调度时考虑Volume的分布情况,在v1.11中,这个过滤条件默认开启。 因此在使用Local PV前,需要在集群里配置好磁盘或块设备: 在公有云上等同于给虚拟机额外挂载一个磁盘(如GCE的Local SSD类型的磁盘)。 在私有集群中,有两个方法来解决: 给宿主机挂载并格式化一个可用的本地磁盘 对于实验环境,在宿主机上挂载几个RAM Disk来模拟本地磁盘 0.6.3. 例子 以内存盘为例子进行挂载。 在节点上创建挂载点/mnt/disks 用RAM Disk模拟本地磁盘 # 在 node-1 上执行 $ mkdir /mnt/disks $ for vol in vol1 vol2 vol3; do mkdir /mnt/disks/$vol mount -t tmpfs $vol /mnt/disks/$vol done # 其他节点需要同样的操作来支持Local PV,需要确保这些起床的名字都不重复 为本地磁盘定义PV: apiVersion: v1 kind: PersistentVolume metadata: name: example-pv spec: capacity: storage: 5Gi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Delete storageClassName: local-storage local: # 表示这是一个Local PV path: /mnt/disks/vol1 # 指定本地磁盘的路径 nodeAffinity: # 定义节点亲和性,如果Pod要使用这个PV,就必须调度到node-1 required: # kubernetes实现调度时考虑Volume分布的主要方法 nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - node-1 创建PV: $ 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-storage 16s # PV创建成功并几区Available状态 使用PV和PVC的最佳实践,创建一个StorageClass来描述这个PV: apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-storage provisioner: kubernetes.io/no-provisioner # 目前不支持Dynamic Provisioning,所有使用这个,因为它没饭在用户创建PVC的时候自动创建对应的PV volumeBindingMode: WaitForFirstConsumer # 延迟绑定,告诉kubernetes的volume控制循环,虽然已经发现了StorageClass关联的PV和PVC,但是不要现在就进行绑定(设置PVC的VolumeName字段)操作 通常,在提交了PV和PVC的YAML后,kubernetes会根据它们的属性以及StorageClass来进行绑定,绑定成功后Pod才能通过PVC使用这个PV,但是在Local PV中这个流程不行。 举个例子,有个Pod声明使用pvc-1,规定只能运行在node-2,此时集群中有两个属性(大小和读写权限)相同的Local PV(PV-1对应磁盘在node-1,PV-2对应磁盘在node-2)。假设kubernetes的Volume控制循环里,首先检查到PVC-1和PV-1匹配,直接绑定在一起。然后kubernetes创建pod,问题来了,Pod声明的PVC-1与node-1的PV-1绑定了,根据调度器必须考虑Volume分布的原则,pod被调度到node-1,但是我们规定是pod要运行在node-2,所有pod调度失败。 所以使用Local PV的时候,必须要推迟绑定PV和PVC。具体而言就是推迟到调度的时候进行绑定。等待第一个声明使用该PVC的Pod出现在调度器之后,调度器再综合考虑所有的调度规则(包括每个PV所在的节点位置),来统一决定,这个Pod声明的PVC应该跟哪个PV进行绑定。 通过延迟绑定机制,原本实时发生在PVC和PV的绑定过程,就被延迟到了Pod第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响Pod正常调度。在具体实现中,调度器实际上维护了一个与Volume Controller类似的控制循环,专门负责为那些声明了“延迟绑定”的PV和PVC进行绑定工作。 当一个Pod的PVC尚未完成绑定时,调度器也不会等待,而是直接把这个Pod重新放回调度队列,等到下一个调度周期再做处理。 创建StorageClass: $ kubectl create -f local-sc.yaml storageclass.storage.k8s.io/local-storage created 然后创建普通的PVC就能让pod来使用Local PV: apiVersion: v1 kind: PersistentVolumeClaim metadata: name: example-local-claim spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi storageClassName: local-storage #kubernetes检测到这个PVC不会直接给它绑定 创建这个PVC: $ 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 local-storage 7s PVC会一直处于pending状态,等待绑定。 编写一个Pod来声明使用这个PVC: apiVersion: v1 kind: Pod metadata: name: example-pv-pod spec: containers: - name: example-pv-container image: nginx ports: - containerPort: 80 name: "http-server" volumeMounts: - mountPath: "/usr/share/nginx/html" name: example-pv-storage volumes: - name: example-pv-storage persistentVolumeClaim: claimName: example-local-claim 一旦创建这个pod之前处于pending的pvc就会编程Bound状态: $ kubectl create -f local-pod.yaml pod/example-pv-pod created $ kubectl get pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE example-local-claim Bound example-pv 5Gi RWO local-storage 6h 在创建的Pod进入调度器之后,绑定操作就开始进行。 像kubernetes这样构建出来的、基于本地存储的Volume,完全可以提供容器持久化存储的功能,所以像StatefulSet这样的有状态编排工具也完全可以通过声明Local类型的pv和pvc来管理应用的存储状态。 手动创建PV(即static的PV管理方式)在删除PV时需要按如下流程执行操作,否则PV删除失败: 删除使用这个PV的Pod 从宿主机移除本地磁盘(如umount) 删除pvc 删除pv 上述操作比较繁琐,kubernetes提供了Static Provision来帮助管理这些PV。 比如,所有磁盘都挂载在宿主机的/mnt/disks目录下,当Static Provision启动后,通过DaemonSet自动检查每个宿主机的/mnt/disks目录,然后调用kubernetes API,为这些目录下的每一个挂载,创建一个对应的PV对象出来,如下所示: $ kubectl get pv NAME CAPACITY ACCESSMODES RECLAIMPOLICY STATUS CLAIM STORAGECLASS REASON AGE local-pv-ce05be60 1024220Ki RWO Delete Available local-storage 26s $ kubectl describe pv local-pv-ce05be60 Name: local-pv-ce05be60 ... StorageClass: local-storage Status: Available Claim: Reclaim Policy: Delete Access Modes: RWO Capacity: 1024220Ki NodeAffinity: Required Terms: Term 0: kubernetes.io/hostname in [node-1] Message: Source: Type: LocalVolume (a persistent volume backed by local storage on a node) Path: /mnt/disks/vol1 这个PV里面的各种定义,StorageClass、本地片挂载点位置,都是通过Provision的配置文件指定的。这个Provision本身也是一个External Provision。 0.7. 自定义存储插件 PV和PVC的实现原理,是处于整个存储提醒的可扩展性的考虑,在kubernetes中,存储插件的开发有两种方式:FlexVolume和CSI。