本文档深入解析 Kubernetes 存储系统中的动态供应(Dynamic Provisioning)机制、PV/PVC 绑定流程以及 StorageClass 的核心作用。
静态供应是一种预先规划式的存储管理模式。管理员需要提前准备好物理存储资源,然后手动创建 PV(PersistentVolume)对象来对应这些物理存储。当用户提交 PVC(PersistentVolumeClaim)申请时,Kubernetes 会检查是否存在空闲的 PV 符合要求——如果有就直接绑定,如果没有就让 PVC 处于 Pending 状态,等待管理员创建新的 PV。
这种模式的问题在于资源利用率低、响应速度慢、运维成本高,并且无法应对突发性的大规模存储需求。
动态供应彻底改变了存储资源的交付模式。管理员只需要定义一个 StorageClass(存储类),用户提交 PVC 时指定这个 StorageClass,动态供应程序(Provisioner)就会自动监听到 PVC 请求,然后在后端存储系统上分配空间,自动创建对应的 PV 对象并完成绑定。
动态供应的核心优势体现在按需分配、自动化程度高和弹性扩展能力强。这两种模式的本质区别在于:静态供应是”先买后用”,动态供应是”边用边创”。
StorageClass 是 Kubernetes 存储体系中的关键抽象层次,它位于 PV 和 PVC 之上,起到了”模板”和”分类”的双重作用。
在动态供应的场景中,StorageClass 承担了标识 Provisioner 身份的核心职责。每个 StorageClass 都有一个 provisioner 字段,这个字段指明了哪个动态供应程序负责处理这类存储请求。当一个 PVC 被创建并且指定了这个 StorageClass 时,Kubernetes 会自动寻找名称匹配的 Provisioner 来处理这个请求。
在动态供应模式下,StorageClass、PVC 和 PV 三者之间形成了一个清晰的依赖链条:管理员创建 StorageClass 定义存储类型,用户创建 PVC 并引用 StorageClass 声明所需的存储容量和访问模式,Provisioner 监听到 PVC 事件后检测到有新的 PVC 处于 Pending 状态且 StorageClassName 匹配自己,然后创建 PV 并绑定 PVC。
这个过程的关键在于:PVC 不再被动等待现有的 PV,而是主动触发新 PV 的创建。
动态 Provisioner 的本质是一个持续运行的后台服务,它的核心任务是监听 Kubernetes API Server 中 PVC 对象的变化事件。
在启动时,Provisioner 需要完成初始化 k8s 客户端的准备工作。这和任何其他 Kubernetes 控制器一样,需要加载 kubeconfig 文件,然后通过 client-go 库创建出能够与 API Server 通信的 Clientset 对象。这个客户端是所有后续操作的基础设施。
其次是准备本地存储根目录。对于基于 HostPath 的实现,需要在宿主机上预设一个目录作为所有 PV 数据的存放位置。这个目录会在程序启动时确保存在。
Provisioner 的主体是一个死循环监听(Watch Loop),在每次迭代中都会重新建立一个 Watch 连接,监听所有命名空间的 PVC 变化事件。
Watch 机制的工作原理是这样的:当调用 Watch 方法时,会与 API Server 建立一个长连接,API Server 会在这个连接上推送所有 PVC 的新增、修改、删除事件。每个事件都包含了事件的类型以及最新的 PVC 对象副本。
这种设计的精妙之处在于:Provisioner 不需要轮询查询,而是被动接收通知,既减少了不必要的 API 调用,又能做到近乎实时的响应速度。
并不是每一个 PVC 事件都需要 Provisioner 处理。为了避免误操作和重复劳动,Provisioner 会对收到的每个 PVC 事件进行严格的筛选。
只有当 PVC 处于 Pending 状态时,才说明这个 PVC 正在等待绑定。如果 PVC 已经是 Bound 状态,说明已经有其他 PV 与之绑定了,自然就不需要再插手。
这里涉及到一个重要的设计哲学:Kubernetes 的许多控制器都是基于状态机工作的,只关注特定状态的资源。这样可以避免重复处理和资源竞争。
通过比较 PVC 的 StorageClassName 和自己注册的 PROVISIONER_NAME 是否一致,来确定这个 PVC 是不是”点名要我处理的”。如果不匹配,说明有其他 Provisioner 应该负责这个请求,自己不应该越俎代庖。
这一步体现了 Kubernetes 的可插拔设计理念:多个 Provisioner 可以和平共处,各自服务于不同类型的存储需求。
PVC 的 VolumeName 字段如果被填入了值,说明已经有一个特定的 PV 被指派给了这个 PVC(可能是静态绑定的结果)。只有当这个字段为空时,才意味着需要从众多候选 PV 中选一个,或者创建一个全新的 PV。这是为了避免重复处理。
这三重判断构成了一个严密的防护网,确保 Provisioner 只会处理那些真正属于自己职责范围内、尚未被满足的存储请求。
当确认需要为一个 PVC 创建 PV 后,Provisioner 的第一步是在实际的存储后端上分配空间。对于基于 HostPath 的实现来说,这个”分配”的动作就是在宿主机的预定目录下创建一个以 PVC UID 命名的子目录。
使用 PVC UID 作为 PV 名字的好处是全局唯一性和可追溯性:通过 PV 名字可以直接反推出对应的 PVC 是谁。
目录创建成功后,Provisioner 会确保根目录存在,方便后续排查问题时定位实际的数据存放位置。
接下来需要在 Kubernetes 中正式创建一个 PV 对象。这个对象本质上是一段 YAML 格式的元数据,描述了存储卷的各种属性。
PV 的容量直接从 PVC 的请求中复制过来,复用 PVC 的请求值。这种做法确保了用户申请的容量能够得到精确满足,不会出现”缺斤少两”的情况。
AccessModes 定义了存储卷可以被如何挂载。最常见的模式是 ReadWriteOnce(RWO),表示这个卷可以被单个节点以读写方式挂载。
PersistentVolumeReclaimPolicy 决定了当 PVC 被删除时 PV 的命运。Delete 策略会让 Provisioner 同时删除 PV 对象和后端的实际存储资源,而 Retain 策略则会保留这些资源,留待管理员手动处理。回收策略可以是 Retain 或 Delete,需要根据实际需求选择合适的常量配置。
ClaimRef 是整个 PV 创建过程中最关键也最容易被忽视的一个字段。它是一个指向 PVC 的对象引用,包含了 PVC 的身份信息。
设置 ClaimRef 的真正含义是:”这个 PV 是专门为那个 PVC 准备的,别人不能用”。这就是预绑定的关键点:指定这就是给那个 PVC 用的。当 Kubernetes 看到一个 PV 已经有了 ClaimRef,而且引用的确实是一个存在的 PVC 时,它就会自动完成两者的绑定,将 PVC 的状态从 Pending 改为 Bound。
在 PV 中也必须填写 StorageClassName,指明这个 PV 是由哪个 StorageClass 动态生成的。
最后是 PersistentVolumeSource 的具体选型。在这种实现中使用的是 HostPath,也就是直接将宿主机上的一个目录暴露给容器使用。HostPath 作为后端驱动,配置非常简单,只需要提供一个 Path 字段,指向之前创建的那个目录即可。
一切准备就绪后,调用 Create 方法将这个新建的 PV 对象提交给 API Server。一旦创建成功,前面提到的自动绑定机制就会立即生效,PVC 的状态随之改变,整个动态供应流程宣告完成。
此时 Pod 就可以正常引用这个 PVC 了,Kubelet 会在挂载时将宿主机上的那个目录映射到容器内部,从而实现数据的持久化保存。
值得注意的是,k8s 存储驱动已经被移出核心代码了。从 Kubernetes 1.5 版本开始,各种存储驱动程序就已经被移出了核心代码仓库,转而托管在官方的通用库中。
这个库定义了一个名为 Provisioner 的标准接口,要求所有的外部供应器都必须实现相应的方法。这个方法接收一个包含 PVC 信息的结构体,返回一个新创建的 PV 对象或者错误信息。
虽然在实际应用中可以不直接使用这个库,但其内部的供应 PV 函数实际上就是对上述接口的一个简化实现。理解了这一点,也就掌握了开发生产级动态供应器的入门钥匙。
通过对 Kubernetes 动态供应机制的深度剖析,我们可以清晰地看到 Kubernetes 如何通过巧妙的分层设计和事件驱动架构来实现存储资源的自动化供给。
StorageClass 的出现打破了传统 IT 基础设施管理中”先采购后分配”的线性思维,引入了”按需即时创建”的云服务理念。PV 和 PVC 的解耦让用户只需关心自己想要什么(容量、访问模式),而不必操心这些东西是从哪里来的、怎么来的。
动态 Provisioner 作为连接用户需求与底层存储系统的桥梁,扮演着智能调度者的角色。它既要准确理解上层应用的诉求,又要熟悉下层硬件的特性,还要能够在两者之间找到最优的匹配方案。
掌握这套机制不仅有助于日常的开发运维工作,更为进一步探索 CSI(Container Storage Interface)标准、Operator 模式等高级主题打下了坚实的基础。
CSI(Container Storage Interface)标准定义了一套统一的接口规范,使得Kubernetes能够与各种存储后端进行标准化交互。在生产环境中,一个完整的Pod挂载持久化卷的过程需要经过三个关键阶段,每个阶段都由不同的CSI接口负责。
这个阶段由Controller Plugin(控制器插件)负责执行,发生在控制平面层面:
触发时机:当Pod被调度到某个节点后,Attach/Detach Controller会调用此接口
核心职责:
publish_context信息,包含后续节点侧挂载所需的连接参数工程意义:这一步完成了存储层面的”软连接”,但还没有在操作系统层面进行任何挂载操作。相当于在存储设备的ACL列表中添加了目标节点的白名单。
这个阶段由Node Plugin(节点插件)在目标工作节点上执行:
触发时机:Kubelet的VolumeManager reconciler检测到卷已发布到本节点
核心职责:
/var/lib/kubelet/plugins/kubernetes.io/csi/pv/<pv-name>/globalmount)执行第一次挂载工程意义:NodeStageVolume实现了”一次挂载,多次使用”的优化。如果一个卷在同一节点上的多个Pod中使用,只需要执行一次昂贵的挂载操作,后续的Pod可以直接复用这个全局挂载点。
这个阶段同样由Node Plugin执行,但是针对每个Pod单独操作:
触发时机:Kubelet准备启动Pod时,为Pod内的容器挂载卷
核心职责:
/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~csi/<pv-name>/mount工程意义:NodePublishVolume实现了卷的隔离和定制化。即使是同一个卷,不同的Pod也可以有不同的访问权限(只读/读写)和不同的子路径视图。
1 | 时间线:Pod创建 → 调度完成 → 卷挂载 → 容器启动 |
当Pod被删除时,会按照相反的顺序执行卸载操作:
这种三阶段分离的设计体现了Kubernetes的几个核心工程智慧:
性能优化:通过将耗时的挂载操作(尤其是网络存储的初始化和格式化)放在NodeStage阶段并全局复用,避免了同一节点上多个Pod重复执行相同操作的开销。
职责分离:Controller Plugin运行在控制平面,负责与存储系统的管理API交互;Node Plugin运行在工作节点,负责操作系统层面的挂载操作。这种分离使得存储供应商可以独立开发和部署两个组件。
灵活性:不是所有的存储都需要三个阶段。例如,对于某些简单的存储类型,可以跳过NodeStage阶段直接进入NodePublish阶段。CSI规范允许通过GetPluginCapabilities接口声明支持的能力集。
安全性:ControllerPublishVolume阶段可以在存储层面实施细粒度的访问控制,确保只有被授权的节点才能访问特定的卷。
理解这套三阶段挂载机制,对于诊断存储挂载故障、优化存储性能以及开发自定义CSI驱动都具有至关重要的意义。