作者 | 孙志恒(惠志) 阿里巴巴开发工程师
导读: 众所周知, K8s 的持久化存储 (Persistent Storage) 保证了应用数据独立于应用生命周期而存在, 但其内部实现却少有人提及. K8s 内部的存储流程到底是怎样的? PV,PVC,StorageClass,Kubelet,CSI 插件等之间的调用关系又如何, 这些谜底将在本文中一一揭晓.
K8s 持久化存储基础
在进行 K8s 存储流程讲解之前, 先回顾一下 K8s 中持久化存储的基础概念.
1. 名词解释
in-tree : 代码逻辑在 K8s 官方仓库中;
out-of-tree : 代码逻辑在 K8s 官方仓库之外, 实现与 K8s 代码的解耦;
PV :PersistentVolume, 集群级别的资源, 由 集群管理员 or External Provisioner 创建. PV 的生命周期独立于使用 PV 的 Pod,PV 的 .Spec 中保存了存储设备的详细信息;
PVC :PersistentVolumeClaim, 命名空间 (namespace) 级别的资源, 由 用户 or StatefulSet 控制器(根据 VolumeClaimTemplate) 创建. PVC 类似于 Pod,Pod 消耗 Node 资源, PVC 消耗 PV 资源. Pod 可以请求特定级别的资源(CPU 和内存), 而 PVC 可以请求特定存储卷的大小及访问模式(Access Mode);
StorageClass :StorageClass 是集群级别的资源, 由集群管理员创建. SC 为管理员提供了一种动态提供存储卷的 "类" 模板, SC 中的 .Spec 中详细定义了存储卷 PV 的不同服务质量级别, 备份策略等等;
CSI :Container Storage Interface, 目的是定义行业标准的 "容器存储接口", 使存储供应商 (SP) 基于 CSI 标准开发的插件可以在不同容器编排 (CO) 系统中工作, CO 系统包括 Kubernetes,Mesos,Swarm 等.
2. 组件介绍
PV Controller : 负责 PV/PVC 绑定及周期管理, 根据需求进行数据卷的 Provision/Delete 操作;
AD Controller : 负责数据卷的 Attach/Detach 操作, 将设备挂接到目标节点;
Kubelet:Kubelet 是在每个 Node 节点上运行的主要 "节点代理", 功能是 Pod 生命周期管理, 容器健康检查, 容器监控等;
Volume Manager :Kubelet 中的组件, 负责管理数据卷的 Mount/Umount 操作(也负责数据卷的 Attach/Detach 操作, 需配置 kubelet 相关参数开启该特性), 卷设备的格式化等等;
Volume Plugins : 存储插件, 由存储供应商开发, 目的在于扩展各种存储类型的卷管理能力, 实现第三方存储的各种操作能力, 即是 上面蓝色操作 的实现. Volume Plugins 有 in-tree 和 out-of-tree 两种;
External Provioner :External Provioner 是一种 sidecar 容器, 作用是调用 Volume Plugins 中的 CreateVolume 和 DeleteVolume 函数来执行 Provision/Delete 操作. 因为 K8s 的 PV 控制器无法直接调用 Volume Plugins 的相关函数, 故由 External Provioner 通过 gRPC 来调用;
External Attacher :External Attacher 是一种 sidecar 容器, 作用是调用 Volume Plugins 中的 ControllerPublishVolume 和 ControllerUnpublishVolume 函数来执行 Attach/Detach 操作. 因为 K8s 的 AD 控制器无法直接调用 Volume Plugins 的相关函数, 故由 External Attacher 通过 gRPC 来调用.
3. 持久卷使用
Kubernetes 为了使应用程序及其开发人员能够正常请求存储资源, 避免处理存储设施细节 , 引入了 PV 和 PVC. 创建 PV 有两种方式:
一种是集群管理员通过手动方式 静态创建 应用所需要的 PV;
另一种是用户手动创建 PVC 并由 Provisioner 组件 动态创建 对应的 PV.
下面我们以 NFS 共享存储为例来看二者区别.
静态创建存储卷
静态创建存储卷流程如下图所示:
第一步: 集群管理员创建 NFS PV,NFS 属于 K8s 原生支持的 in-tree 存储类型. YAML 文件如下:
- YAML
- apiVersion: v1
- kind: PersistentVolume
- metadata:
- name: nfs-pv
- spec:
- capacity:
- storage: 10Gi
- accessModes:
- - ReadWriteOnce
- persistentVolumeReclaimPolicy: Retain
- nfs:
- server: 192.168.4.1
- path: /nfs_storage
第二步: 用户创建 PVC,YAML 文件如下:
YAML apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi
通过 kubectl get pv 命令可看到 PV 和 PVC 已绑定:
bash [root@huizhi ~]# kubectl get pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE nfs-pvc Bound nfs-pv-no-affinity 10Gi RWO 4s
第三步: 用户创建应用, 并使用第二步创建的 PVC.
YAML apiVersion: v1 kind: Pod metadata: name: test-nfs spec: containers: - image: nginx:alpine imagePullPolicy: IfNotPresent name: nginx volumeMounts: - mountPath: /data name: nfs-volume volumes: - name: nfs-volume persistentVolumeClaim: claimName: nfs-pvc
此时 NFS 的远端存储就挂载了到 Pod 中 nginx 容器的 /data 目录下.
动态创建存储卷
> 动态创建存储卷, 要求集群中部署有 nfs-client-provisioner 以及对应的 storageclass .
动态创建存储卷相比静态创建存储卷, 少了集群管理员的干预, 流程如下图所示:
集群管理员只需要保证环境中有 NFS 相关的 storageclass 即可:
YAML kind: StorageClass apiVersion: storage.k8s.io/v1 metadata: name: nfs-sc provisioner: example.com/nfs mountOptions: - vers=4.1
第一步: 用户创建 PVC, 此处 PVC 的 storageClassName 指定为上面 NFS 的 storageclass 名称:
YAML kind: PersistentVolumeClaim apiVersion: v1 metadata: name: nfs annotations: volume.beta.kubernetes.io/storage-class: "example-nfs" spec: accessModes: - ReadWriteMany resources: requests: storage: 10Mi storageClassName: nfs-sc
第二步: 集群中的 nfs-client-provisioner 会动态创建相应 PV. 此时可看到环境中 PV 已创建, 并与 PVC 已绑定.
bash [root@huizhi ~]# kubectl get pv NAME CAPACITY ACCESSMODES RECLAIMPOLICY STATUS CLAIM REASON AGE pvc-dce84888-7a9d-11e6-b1ee-5254001e0c1b 10Mi RWX Delete Bound default/nfs 4s
第三步: 用户创建应用, 并使用第二步创建的 PVC, 同静态创建存储卷的第三步.
K8s 持久化存储流程
1. 流程概览
> 此处借鉴 @郡宝 在 云原生存储课程 https://edu.aliyun.com/lesson_1651_13092#_13092 中的流程图
流程如下:
用户创建了一个包含 PVC 的 Pod, 该 PVC 要求使用动态存储卷;
Scheduler 根据 Pod 配置, 节点状态, PV 配置等信息, 把 Pod 调度到一个合适的 Worker 节点上;
PV 控制器 watch 到该 Pod 使用的 PVC 处于 Pending 状态, 于是调用 Volume Plugin (in-tree)创建存储卷, 并创建 PV 对象(out-of-tree 由 External Provisioner 来处理);
AD 控制器 发现 Pod 和 PVC 处于待挂接状态, 于是调用 Volume Plugin 挂接存储设备到目标 Worker 节点上
在 Worker 节点上, Kubelet 中的 Volume Manager 等待存储设备挂接完成, 并通过 Volume Plugin 将设备挂载到 全局目录 :
/var/lib/kubelet/pods/[pod uid]/volumes/kubernetes.io~iscsi/[PV name] (以 iscsi 为例);
Kubelet 通过 Docker 启动 Pod 的 Containers , 用 bind mount 方式将已挂载到本地全局目录的卷映射到 容器 中.
更详细的流程如下:
2. 流程详解
> 不同 K8s 版本, 持久化存储流程略有区别. 本文基于 Kubernetes 1.14.8 版本.
从上述流程图中可看到, 存储卷从创建到提供应用使用共分为三个阶段: Provision/Delete,Attach/Detach,Mount/Unmount.
provisioning volumes
PV 控制器中有两个 Worker:
ClaimWorker : 处理 PVC 的 add / update / delete 相关事件以及 PVC 的状态迁移;
VolumeWorker : 负责 PV 的状态迁移.
PV 状态迁移(UpdatePVStatus):
PV 初始状态为 Available, 当 PV 与 PVC 绑定后, 状态变为 Bound;
与 PV 绑定的 PVC 删除后, 状态变为 Released;
当 PV 回收策略为 Recycled 或手动删除 PV 的 .Spec.ClaimRef 后, PV 状态变为 Available;
当 PV 回收策略未知或 Recycle 失败或存储卷删除失败, PV 状态变为 Failed;
手动删除 PV 的 .Spec.ClaimRef,PV 状态变为 Available.
PVC 状态迁移(UpdatePVCStatus):
当集群中不存在满足 PVC 条件的 PV 时, PVC 状态为 Pending. 在 PV 与 PVC 绑定后, PVC 状态由 Pending 变为 Bound;
与 PVC 绑定的 PV 在环境中被删除, PVC 状态变为 Lost;
再次与一个 同名 PV 绑定后, PVC 状态变为 Bound.
Provisioning 流程如下(此处模拟用户创建一个新 PVC):
静态存储卷流程(FindBestMatch): PV 控制器 首先在环境中筛选一个状态为 Available 的 PV 与新 PVC 匹配.
DelayBinding :PV 控制器判断该 PVC 是否需要 延迟绑定: 1. 查看 PVC 的 annotation 中是否包含 volume.kubernetes.io/selected-node, 若存在则表示该 PVC 已经被调度器指定好了节点(属于 ProvisionVolume ), 故不需要延迟绑定; 2. 若 PVC 的 annotation 中不存在 volume.kubernetes.io/selected-node, 同时没有 StorageClass, 默认表示不需要延迟绑定; 若有 StorageClass, 查看其 VolumeBindingMode 字段, 若为 WaitForFirstConsumer 则需要延迟绑定, 若为 Immediate 则不需要延迟绑定;
FindBestMatchPVForClaim :PV 控制器尝试找一个满足 PVC 要求的环境中现有的 PV.PV 控制器会将 所有的 PV 进行一次筛选, 并会从满足条件的 PV 中选择一个最佳匹配的 PV. 筛选规则: 1. VolumeMode 是否匹配; 2. PV 是否已绑定到 PVC 上; 3. PV 的 .Status.Phase 是否为 Available;4. LabelSelector 检查, PV 与 PVC 的 label 要保持一致; 5. PV 与 PVC 的 StorageClass 是否一致; 6. 每次迭代更新最小满足 PVC requested size 的 PV, 并作为最终结果返回;
Bind :PV 控制器对选中的 PV,PVC 进行绑定: 1. 更新 PV 的 .Spec.ClaimRef 信息为当前 PVC;2. 更新 PV 的 .Status.Phase 为 Bound;3. 新增 PV 的 annotation : pv.kubernetes.io/bound-by-controller: "yes";4. 更新 PVC 的 .Spec.VolumeName 为 PV 名称; 5. 更新 PVC 的 .Status.Phase 为 Bound;6. 新增 PVC 的 annotation:pv.kubernetes.io/bound-by-controller: "yes" 和 pv.kubernetes.io/bind-completed: "yes";
动态存储卷流程(ProvisionVolume): 若环境中没有合适的 PV, 则进入动态 Provisioning 场景:
Before Provisioning :1. PV 控制器首先判断 PVC 使用的 StorageClass 是 in-tree 还是 out-of-tree: 通过查看 StorageClass 的 Provisioner 字段是否包含 "kubernetes.io/" 前缀来判断; 2. PV 控制器更新 PVC 的 annotation:claim.Annotations["volume.beta.kubernetes.io/storage-provisioner"] = storageClass.Provisioner;
in-tree Provisioning(internal provisioning): 1. in-tree 的 Provioner 会实现 ProvisionableVolumePlugin 接口的 NewProvisioner 方法, 用来返回一个新的 Provisioner;2. PV 控制器调用 Provisioner 的 Provision 函数, 该函数会返回一个 PV 对象; 3. PV 控制器创建上一步返回的 PV 对象, 将其与 PVC 绑定, Spec.ClaimRef 设置为 PVC,.Status.Phase 设置为 Bound,.Spec.StorageClassName 设置为与 PVC 相同的 StorageClassName; 同时新增 annotation:"pv.kubernetes.io/bound-by-controller"="yes" 和 "pv.kubernetes.io/provisioned-by"=plugin.GetPluginName();
out-of-tree Provisioning(external provisioning): 1. External Provisioner 检查 PVC 中的 claim.Spec.VolumeName 是否为空, 不为空则直接跳过该 PVC;2. External Provisioner 检查 PVC 中的 claim.Annotations["volume.beta.kubernetes.io/storage-provisioner"] 是否等于自己的 Provisioner Name(External Provisioner 在启动时会传入 --provisioner 参数来确定自己的 Provisioner Name);3. 若 PVC 的 VolumeMode=Block, 检查 External Provisioner 是否支持块设备; 4. External Provisioner 调用 Provision 函数: 通过 gRPC 调用 CSI 存储插件的 CreateVolume 接口; 5. External Provisioner 创建一个 PV 来代表该 volume, 同时将该 PV 与之前的 PVC 做绑定.
deleting volumes
Deleting 流程为 Provisioning 的反操作:
用户删除 PVC, 删除 PV 控制器改变 PV.Status.Phase 为 Released.
<br />当 PV.Status.Phase == Released 时, PV 控制器首先检查 Spec.PersistentVolumeReclaimPolicy 的值, 为 Retain 时直接跳过, 为 Delete 时:
in-tree Deleting: 1. in-tree 的 Provioner 会实现 DeletableVolumePlugin 接口的 NewDeleter 方法, 用来返回一个新的 Deleter;2. 控制器调用 Deleter 的 Delete 函数, 删除对应 volume;3. 在 volume 删除后, PV 控制器会删除 PV 对象;
out-of-tree Deleting: 1. External Provisioner 调用 Delete 函数, 通过 gRPC 调用 CSI 插件的 DeleteVolume 接口; 2. 在 volume 删除后, External Provisioner 会删除 PV 对象
Attaching Volumes
> Kubelet 组件和 AD 控制器都可以做 attach/detach 操作, 若 Kubelet 的启动参数中指定了 --enable-controller-attach-detach, 则由 Kubelet 来做; 否则默认由 AD 控制起来做. 下面以 AD 控制器为例来讲解 attach/detach 操作.
AD 控制器中有两个核心变量:
DesiredStateOfWorld(DSW) : 集群中预期的数据卷挂接状态, 包含了 nodes->volumes->pods 的信息;
ActualStateOfWorld(ASW) : 集群中实际的数据卷挂接状态, 包含了 volumes->nodes 的信息.
Attaching 流程如下:
AD 控制器根据集群中的资源信息, 初始化 DSW 和 ASW.
AD 控制器内部有三个组件周期性更新 DSW 和 ASW:
Reconciler. 通过一个 GoRoutine 周期性运行, 确保 volume 挂接 / 摘除完毕 . 此期间不断更新 ASW:
in-tree attaching:1. in-tree 的 Attacher 会实现 AttachableVolumePlugin 接口的 NewAttacher 方法, 用来返回一个新的 Attacher;2. AD 控制器调用 Attacher 的 Attach 函数进行设备挂接; 3. 更新 ASW.
out-of-tree attaching:1. 调用 in-tree 的 CSIAttacher 创建一个 VolumeAttachement(VA) 对象, 该对象包含了 Attacher 信息, 节点名称, 待挂接 PV 信息; 2. External Attacher 会 watch 集群中的 VolumeAttachement 资源, 发现有需要挂接的数据卷时, 调用 Attach 函数, 通过 gRPC 调用 CSI 插件的 ControllerPublishVolume 接口.
DesiredStateOfWorldPopulator. 通过一个 GoRoutine 周期性运行, 主要功能是 更新 DSW:
findAndRemoveDeletedPods - 遍历所有 DSW 中的 Pods, 若其已从集群中删除则从 DSW 中移除;
findAndAddActivePods - 遍历所有 PodLister 中的 Pods, 若 DSW 中不存在该 Pod 则添加至 DSW.
PVC Worker. watch PVC 的 add/update 事件, 处理 PVC 相关的 Pod, 并实时更新 DSW.
Detaching Volumes
Detaching 流程如下:
当 Pod 被删除, AD 控制器会 watch 到该事件. 首先 AD 控制器检查 Pod 所在的 Node 资源是否包含 "volumes.kubernetes.io/keep-terminated-pod-volumes" 标签, 若包含则不做操作; 不包含则从 DSW 中去掉该 volume;
AD 控制器通过 Reconciler 使 ActualStateOfWorld 状态向 DesiredStateOfWorld 状态靠近, 当发现 ASW 中有 DSW 中不存在的 volume 时, 会做 Detach 操作:
in-tree detaching:1. AD 控制器会实现 AttachableVolumePlugin 接口的 NewDetacher 方法, 用来返回一个新的 Detacher;2. 控制器调用 Detacher 的 Detach 函数, detach 对应 volume;3. AD 控制器更新 ASW.
out-of-tree detaching:1. AD 控制器调用 in-tree 的 CSIAttacher 删除相关 VolumeAttachement 对象; 2. External Attacher 会 watch 集群中的 VolumeAttachement(VA)资源, 发现有需要摘除的数据卷时, 调用 Detach 函数, 通过 gRPC 调用 CSI 插件的 ControllerUnpublishVolume 接口; 3. AD 控制器更新 ASW.
Volume Manager 中同样也有两个核心变量:
DesiredStateOfWorld(DSW) : 集群中预期的数据卷挂载状态, 包含了 volumes->pods 的信息;
ActualStateOfWorld(ASW) : 集群中实际的数据卷挂载状态, 包含了 volumes->pods 的信息.
Mounting/UnMounting 流程如下:
全局目录 (global mount path) 存在的目的: 块设备在 Linux 上只能挂载一次, 而在 K8s 场景中, 一个 PV 可能被挂载到同一个 Node 上的多个 Pod 实例中. 若块设备格式化后先挂载至 Node 上的一个临时全局目录, 然后再使用 Linux 中的 bind mount 技术把这个全局目录挂载进 Pod 中对应的目录上, 就可以满足要求. 上述流程图中, 全局目录即 /var/lib/kubelet/pods/[pod uid]/volumes/kubernetes.io~iscsi/[PV
name]
VolumeManager 根据集群中的资源信息, 初始化 DSW 和 ASW.
VolumeManager 内部有两个组件周期性更新 DSW 和 ASW:
DesiredStateOfWorldPopulator : 通过一个 GoRoutine 周期性运行, 主要功能是 更新 DSW;
Reconciler : 通过一个 GoRoutine 周期性运行, 确保 volume 挂载 / 卸载完毕 . 此期间不断更新 ASW:
unmountVolumes: 确保 Pod 删除后 volumes 被 unmount. 遍历一遍所有 ASW 中的 Pod, 若其不在 DSW 中(表示 Pod 被删除), 此处以 VolumeMode=FileSystem 举例, 则执行如下操作:
Remove all bind-mounts: 调用 Unmounter 的 TearDown 接口(若为 out-of-tree 则调用 CSI 插件的 NodeUnpublishVolume 接口);
Unmount volume: 调用 DeviceUnmounter 的 UnmountDevice 函数(若为 out-of-tree 则调用 CSI 插件的 NodeUnstageVolume 接口);
更新 ASW.
mountAttachVolumes: 确保 Pod 要使用的 volumes 挂载成功. 遍历一遍所有 DSW 中的 Pod, 若其不在 ASW 中(表示目录待挂载映射到 Pod 上), 此处以 VolumeMode=FileSystem 举例, 执行如下操作:
等待 volume 挂接到节点上(由 External Attacher or Kubelet 本身挂接);
挂载 volume 到全局目录: 调用 DeviceMounter 的 MountDevice 函数(若为 out-of-tree 则调用 CSI 插件的 NodeStageVolume 接口);
更新 ASW: 该 volume 已挂载到全局目录;
bind-mount volume 到 Pod 上: 调用 Mounter 的 SetUp 接口(若为 out-of-tree 则调用 CSI 插件的 NodePublishVolume 接口);
更新 ASW.
unmountDetachDevices: 确保需要 unmount 的 volumes 被 unmount. 遍历一遍所有 ASW 中的 UnmountedVolumes, 若其不在 DSW 中(表示 volume 已无需使用), 执行如下操作:
Unmount volume: 调用 DeviceUnmounter 的 UnmountDevice 函数(若为 out-of-tree 则调用 CSI 插件的 NodeUnstageVolume 接口);
更新 ASW.
总结
本文先对 K8s 持久化存储基础概念及使用方法进行了介绍, 并对 K8s 内部存储流程进行了深度解析. 在 K8s 上, 使用任何一种存储都离不开上面的流程(有些场景不会用到 attach/detach), 环境上的存储问题也一定是其中某个环节出现了故障.
容器存储的坑比较多, 专有云环境下尤其如此. 不过挑战越多, 机遇也越多! 目前国内专有云市场在存储领域也是群雄逐鹿, 我们敏捷 PaaS 容器团队欢迎大侠的加入, 一起共创未来!
参考链接
Kubernetes 社区源码 https://github.com/kubernetes/kubernetes
[云原生公开课] Kubernetes 存储架构及插件使用(郡宝) https://edu.aliyun.com/lesson_1651_13092#_13092
[云原生公开课] 应用存储和持久化数据卷 - 核心知识(至天) https://edu.aliyun.com/lesson_1651_13085#_13085
[kubernetes-design-proposals] volume-provisioning [kubernetes-design-proposals] CSI Volume Plugins in Kubernetes Design Doc
云原生应用团队招人啦!
阿里云原生应用平台团队目前求贤若渴, 如果你满足:
对容器和基础设施相关领域的云原生技术充满热情, 在相关领域如 Kubernetes,Serverless 平台, 容器网络与存储, 运维平台等云原生基础设施其中某一方向有丰富的积累和突出成果(如产品落地, 创新技术实现, 开源贡献, 领先的学术成果);
优秀的表达能力, 沟通能力和团队协作能力; 对技术和业务有前瞻性思考; 具备较强的 ownership, 以结果为导向, 善于决策;
至少熟悉 Java,Golang 中的一项编程语言;
本科及以上学历, 3 年以上工作经验.
简历可投递至邮箱: mailto:huizhi.szh@alibaba-inc.com , 如有疑问欢迎加微信咨询: TheBeatles1994.
云原生网络研讨会邀您参加
点击立即预约直播 https://yq.aliyun.com/live/2626
"阿里巴巴云原生 https://mp.weixin.qq.com/s/jpopq16BOA_vrnLmejwEdQ 关注微服务, Serverless, 容器, Service Mesh 等技术领域, 聚焦云原生流行技术趋势, 云原生大规模的落地实践, 做最懂云原生开发者的公众号."
来源: http://www.tuicool.com/articles/FJnmie6