本节梳理Kubernetes存储架构的演进历程,讲解从早期in-tree插件到CSI容器存储接口的解耦设计。你将理解CSI的工作流程、存储驱动开发规范和动态卷供给机制,学会部署第三方CSI驱动和管理有状态应用的持久化存储卷,适合负责K8s存储运维和有状态应用部署的平台工程师学习参考。
需要学习Kubernetes 存储演进:in-tree → CSI 架构,云原生系统的可观测性是你的核心技术。本篇涵盖微服务、Service Mesh、Serverless、可观测性,帮助你设计现代软件架构。
目录
可视化演示
展示Kubernetes存储架构从in-tree插件到CSI容器存储接口的演进过程:解耦设计、卷生命周期管理、动态供给与StorageClass。切换模式查看不同环节。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CSI容器存储接口架构 CSI解耦存储驱动与K8s核心,通过gRPC接口通信 Identity/Controller/Node三组件分工管理卷全生命周期 K8s ←sidecar→ CSI ←gRPC→ Storage CSI架构实现存储驱动独立发布与升级
in-tree 存储插件的架构与局限
Kubernetes 早期的存储架构采用 in-tree(树内)插件模式,将各云厂商的存储逻辑代码直接编译进 Kubernetes 核心代码库。每个存储厂商(如 AWS EBS、Azure Disk、GCE PD)的卷管理代码都位于 kubernetes/pkg/volume 目录下,与 kubelet、kube-controller-manager 等核心组件紧密耦合。这种设计类似于一棵大树的树干上长出各种树枝,每加一家存储厂商就在树干上砍一刀嫁接新的代码分支。
in-tree 架构的核心问题在于耦合性。新增一个存储厂商需要修改 Kubernetes 源码、通过 Kubernetes 社区代码审查、等待下一个版本发布,整个迭代周期长达数月。存储驱动与 Kubernetes 版本绑定,驱动升级受限于 Kubernetes 发布节奏,无法独立迭代。此外,in-tree 代码中的安全漏洞影响整个 Kubernetes 核心,测试覆盖面极大,维护成本高昂。
具体来说,in-tree 插件存在三大局限。第一,厂商锁定严重:存储逻辑嵌入 Kubernetes 核心,厂商代码质量问题直接影响集群稳定性。第二,版本耦合僵化:存储驱动升级必须跟随 Kubernetes 版本发布,无法按需迭代。第三,代码膨胀风险:随着存储厂商增多,核心代码库不断膨胀,编译时间和维护负担持续增加。这些问题促使 Kubernetes 社区启动 CSI(Container Storage Interface)项目,将存储驱动从核心代码中解耦。
# in-tree 插件代码位置示例(已废弃)# kubernetes/pkg/volume/aws_ebs/aws_ebs.go# kubernetes/pkg/volume/azure_disk/azure_disk.go# kubernetes/pkg/volume/gce_pd/gce_pd.go # K8s 社区已将 in-tree 插件标记为 deprecated# 迁移路径:in-tree -> CSI Driver (Migration Feature Gate)
CSI 容器存储接口设计理念
CSI(Container Storage Interface)是 Kubernetes、Mesos、Docker 等容器编排系统联合制定的容器存储接口标准。其核心理念是将存储驱动从 Kubernetes 核心代码中剥离,变为独立运行的进程,通过标准 gRPC 接口与 Kubernetes 交互。存储厂商只需按照 CSI 规范实现 gRPC 接口,即可接入任何支持 CSI 的容器编排系统,无需修改编排系统核心代码。
CSI 定义了三个核心 gRPC 服务接口。Identity Service 提供驱动身份和能力信息(GetPluginInfo、GetPluginCapabilities)。Controller Service 负责集群级别的卷管理操作(CreateVolume、DeleteVolume、ControllerPublish/UnpublishVolume、CreateSnapshot 等)。Node Service 负责节点级别的卷挂载操作(NodeStageVolume、NodePublishVolume、NodeExpandVolume 等)。三个接口分工明确:Controller 操作底层存储后端,Node 操作节点本地文件系统。
Kubernetes 通过一组外部组件桥接 CSI 驱动与集群内部机制。CSI external-provisioner 监听 PVC 创建事件并调用 CSI CreateVolume。CSI external-attacher 监听 VolumeAttachment 对象并调用 ControllerPublishVolume。CSI external-snapshotter 监听 VolumeSnapshot 对象并调用 CreateSnapshot。CSI node-driver-registrar 在节点上注册 CSI 驱动到 kubelet。这些外部组件作为 Sidecar 容器与 CSI 驱动部署在同一个 Pod 中,将 Kubernetes API 对象的变更转换为 CSI gRPC 调用。
# CSI gRPC 接口定义(简化)# Identity Serviceservice Identity { rpc GetPluginInfo(GetPluginInfoRequest) returns (GetPluginInfoResponse) {} rpc GetPluginCapabilities(GetPluginCapabilitiesRequest) returns (GetPluginCapabilitiesResponse) {}} # Controller Serviceservice Controller { rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse) {} rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse) {} rpc ControllerPublishVolume(ControllerPublishVolumeRequest) returns (ControllerPublishVolumeResponse) {}} # Node Serviceservice Node { rpc NodeStageVolume(NodeStageVolumeRequest) returns (NodeStageVolumeResponse) {} rpc NodePublishVolume(NodePublishVolumeRequest) returns (NodePublishVolumeResponse) {}}
CSI 工作流程与卷生命周期管理
CSI 卷的生命周期从用户创建 PVC 开始,经历卷创建、卷挂载、卷使用、卷卸载、卷删除五个阶段。当用户创建 PVC 引用某个 StorageClass 时,external-provisioner 监听到 PVC 事件,调用 CSI 驱动的 CreateVolume 接口在存储后端创建逻辑卷,返回卷 ID 和可访问拓扑信息。Kubernetes 随后创建 PV 对象并与 PVC 绑定。
卷挂载分为 Controller 和 Node 两个阶段。Controller 阶段由 external-attacher 调用 ControllerPublishVolume,将底层存储卷附加(Attach)到目标节点(如将 EBS 卷挂载到 EC2 实例)。Node 阶段由 kubelet 调用 NodeStageVolume 和 NodePublishVolume:NodeStageVolume 将块设备格式化并挂载到全局目录(/var/lib/kubelet/plugins/kubernetes.io/csi/),NodePublishVolume 将全局目录 bind-mount 到 Pod 的容器路径。
卷扩容支持在线和离线两种模式。在线扩容通过 ControllerExpandVolume 扩大底层存储容量,再通过 NodeExpandVolume 扩展文件系统。快照功能通过 CreateSnapshot 创建底层存储的快照,可用于备份或创建新卷(VolumeSnapshot -> PVC)。卷克隆通过 CreateVolume 的 dataSource 字段从已有卷克隆数据。这些高级操作都通过扩展 CSI 接口实现,保持架构一致性。
# CSI 卷生命周期状态流转# Provisioning -> Attaching -> Mounting -> InUse -> Unmounting -> Detaching -> Deleting volume_states = { "PENDING": "PVC created, waiting for provisioner", "CREATED": "CreateVolume called, PV exists", "ATTACHED": "ControllerPublishVolume called", "STAGED": "NodeStageVolume called", "PUBLISHED": "NodePublishVolume called, Pod using volume", "NODE_UNPUBLISHED": "NodeUnpublishVolume called", "NODE_UNSTAGED": "NodeUnstageVolume called", "DETACHED": "ControllerUnpublishVolume called", "DELETED": "DeleteVolume called, PV removed"}
动态卷供给与 StorageClass
动态卷供给(Dynamic Volume Provisioning)是 CSI 架构的核心优势之一。与传统静态供给(管理员手动创建 PV)不同,动态供给让用户只需创建 PVC 声明存储需求(大小、类型、性能),系统自动调用 CSI 驱动在存储后端创建对应的 PV。StorageClass 是动态供给的关键配置对象,定义了存储类型(provisioner)、参数(parameters 如磁盘类型、IOPS)和绑定策略(volumeBindingMode)。
WaitForFirstConsumer 是 StorageClass 的重要绑定模式。Immediate 模式在 PVC 创建时立即创建并绑定 PV,但可能导致卷和 Pod 分布在不同可用区(跨可用区挂载会失败)。WaitForFirstConsumer 模式延迟到 Pod 被调度时才创建和绑定 PV,确保卷创建在 Pod 所在节点所在的拓扑域内(如同一可用区),避免拓扑不一致导致的挂载失败。
StorageClass 还支持卷扩容策略(allowVolumeExpansion: true)、回收策略(reclaimPolicy: Delete/Retain)和挂载选项(mountOptions)。通过 StorageClass 参数可以精确控制存储性能,如 AWS gp3 卷可指定 IOPS 和吞吐量,Azure Disk 可指定 SKU 和缓存策略。这些参数通过 CSI 驱动传递到 CreateVolume 请求中,由驱动翻译为底层存储 API 调用。
# StorageClass YAML 定义示例apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: fast-ssdprovisioner: ebs.csi.aws.com # CSI 驱动名parameters: type: gp3 iops: "3000" throughput: "125" encrypted: "true"reclaimPolicy: DeleteallowVolumeExpansion: truevolumeBindingMode: WaitForFirstConsumer
有状态应用存储最佳实践
StatefulSet 是管理有状态应用的核心工作负载。与 Deployment 不同,StatefulSet 为每个 Pod 提供稳定的网络标识(有序 Pod 名)和稳定的持久化存储。通过 volumeClaimTemplate 字段,StatefulSet 自动为每个 Pod 创建独立的 PVC,Pod 重建后 PVC 保持不变,数据自动恢复。例如 3 副本的 PostgreSQL 集群,StatefulSet 会创建 pg-data-postgres-0、pg-data-postgres-1、pg-data-postgres-2 三个独立 PVC。
拓扑感知调度(Topology Aware Scheduling)确保有状态 Pod 调度到有可用存储的节点。结合 WaitForFirstConsumer 绑定模式,调度器在调度 Pod 时考虑存储拓扑约束(如可用区),确保 Pod 和卷在同一拓扑域。对于跨可用区的高可用部署,需配置 PodAntiAffinity 将 Pod 分散到不同可用区,每个可用区创建独立的存储卷。
数据保护最佳实践包括三个层面。定期创建 VolumeSnapshot 备份关键数据,支持从快照恢复或创建新卷。使用 CSI 卷克隆功能创建数据副本用于测试。配置合理的回收策略(Retain 用于生产数据保护,Delete 用于临时数据)。对于数据库等应用,还需配置应用层备份(如 pg_dump)作为存储层快照的补充,确保逻辑一致性。
# StatefulSet volumeClaimTemplate 示例apiVersion: apps/v1kind: StatefulSetmetadata: name: postgresspec: serviceName: postgres replicas: 3 template: spec: containers: - name: postgres image: postgres:15 volumeMounts: - name: data mountPath: /var/lib/postgresql/data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast-ssd resources: requests: storage: 200Gi
AI 应用场景
AI 训练 CSI 存储动态供给
阶段:模型部署
解决的问题:AI 训练需要大规模持久化存储模型 checkpoint 和训练数据集,CSI 的动态卷供给和拓扑感知能力让 GPU Pod 按需挂载高性能存储,无需手动管理 PV。训练数据集通过 PVC 动态供给,Pod 重启后可从 checkpoint 恢复训练,解决存储手动管理和训练中断数据丢失问题。
具体案例:AI 训练平台使用 CSI 驱动挂载高性能 NVMe 存储卷给 GPU 训练 Pod,训练数据集通过 PVC 动态供给无需预创建 PV。模型训练过程中每 1000 步保存一次 checkpoint 到持久卷,Pod 因节点故障重启后自动从最近 checkpoint 恢复训练,避免数小时训练成果丢失。使用 WaitForFirstConsumer 拓扑感知调度确保 GPU Pod 调度到有可用 NVMe 存储的节点,避免跨可用区卷挂载失败导致训练启动延迟。训练完成后 checkpoint 数据通过 VolumeSnapshot 快速备份到对象存储。
Python 演示
import jsonfrom dataclasses import dataclass, fieldfrom typing import Dict, List, Optionalfrom enum import Enum class VolumeState(Enum): """CSI 卷生命周期状态。""" PENDING = "PENDING" CREATED = "CREATED" ATTACHED = "ATTACHED" STAGED = "STAGED" PUBLISHED = "PUBLISHED" UNPUBLISHED = "NODE_UNPUBLISHED" UNSTAGED = "NODE_UNSTAGED" DETACHED = "DETACHED" DELETED = "DELETED" @dataclassclass Topology: """存储拓扑信息。""" segments: Dict[str, str] = field(default_factory=dict) def matches_node(self, node_labels: Dict[str, str]) -> bool: """检查节点是否在拓扑域内。""" for key, value in self.segments.items(): if node_labels.get(key) != value: return False return True @dataclassclass CSIVolume: """CSI 卷对象。""" volume_id: str capacity_bytes: int accessible_topology: List[Topology] = field(default_factory=list) state: VolumeState = VolumeState.PENDING node_id: Optional[str] = None staging_path: Optional[str] = None publish_path: Optional[str] = None class CSIDriverSimulator: """CSI 驱动模拟器:模拟卷生命周期管理。""" def __init__(self, driver_name: str, version: str): self.driver_name = driver_name self.version = version self.volumes: Dict[str, CSIVolume] = {} self._counter = 0 def get_plugin_info(self) -> Dict: """Identity Service: 返回驱动信息。""" return { "name": self.driver_name, "vendor_version": self.version, "manifest": {"description": "CSI Driver Simulator"} } def create_volume(self, name: str, capacity_gb: int, topology: Optional[Dict] = None) -> CSIVolume: """Controller Service: 创建底层存储卷。""" self._counter += 1 volume_id = f"vol-{self._counter:04d}" topo = Topology(segments=topology or {}) volume = CSIVolume( volume_id=volume_id, capacity_bytes=capacity_gb * 1024**3, accessible_topology=[topo] if topology else [], state=VolumeState.CREATED ) self.volumes[volume_id] = volume print(f"[CreateVolume] 创建卷 {volume_id}, 容量 {capacity_gb}GB, 拓扑 {topology}") return volume def controller_publish_volume(self, volume_id: str, node_id: str) -> bool: """Controller Service: 将卷附加到节点。""" vol = self.volumes.get(volume_id) if not vol: print(f"[ControllerPublish] 错误: 卷 {volume_id} 不存在") return False vol.node_id = node_id vol.state = VolumeState.ATTACHED print(f"[ControllerPublish] 卷 {volume_id} 附加到节点 {node_id}") return True def node_stage_volume(self, volume_id: str, staging_path: str, fs_type: str = "ext4") -> bool: """Node Service: 格式化并挂载到全局目录。""" vol = self.volumes.get(volume_id) if not vol or vol.state != VolumeState.ATTACHED: print(f"[NodeStage] 错误: 卷 {volume_id} 未附加") return False vol.staging_path = staging_path vol.state = VolumeState.STAGED print(f"[NodeStage] 卷 {volume_id} 格式化为 {fs_type} 并挂载到 {staging_path}") return True def node_publish_volume(self, volume_id: str, target_path: str, access_mode: str = "ReadWriteOnce") -> bool: """Node Service: bind-mount 到 Pod 目录。""" vol = self.volumes.get(volume_id) if not vol or vol.state != VolumeState.STAGED: print(f"[NodePublish] 错误: 卷 {volume_id} 未暂存") return False vol.publish_path = target_path vol.state = VolumeState.PUBLISHED print(f"[NodePublish] 卷 {volume_id} 挂载到 {target_path} ({access_mode})") return True def create_snapshot(self, volume_id: str, snapshot_name: str) -> Dict: """创建卷快照。""" vol = self.volumes.get(volume_id) if not vol: return {"error": "volume not found"} snapshot_id = f"snap-{volume_id}-{snapshot_name}" print(f"[CreateSnapshot] 为卷 {volume_id} 创建快照 {snapshot_id}") return {"snapshot_id": snapshot_id, "source_volume_id": volume_id} def delete_volume(self, volume_id: str) -> bool: """Controller Service: 删除底层存储卷。""" vol = self.volumes.get(volume_id) if not vol: return False vol.state = VolumeState.DELETED print(f"[DeleteVolume] 删除卷 {volume_id}") return True def get_volume_status(self, volume_id: str) -> Dict: """获取卷状态。""" vol = self.volumes.get(volume_id) if not vol: return {"error": "not found"} return { "volume_id": vol.volume_id, "state": vol.state.value, "capacity_gb": vol.capacity_bytes // 1024**3, "node_id": vol.node_id, "topology": [t.segments for t in vol.accessible_topology] } if __name__ == "__main__": driver = CSIDriverSimulator("ebs.csi.aws.com", "v1.30.0") print("=== 驱动信息 ===") info = driver.get_plugin_info() print(json.dumps(info, indent=2)) print("\n=== 动态供给卷(拓扑感知)===") vol = driver.create_volume( "pg-data-postgres-0", capacity_gb=200, topology={"topology.kubernetes.io/zone": "us-east-1a"} ) print("\n=== 卷附加 ===") driver.controller_publish_volume(vol.volume_id, "i-0abc123") print("\n=== 节点暂存 ===") driver.node_stage_volume(vol.volume_id, "/var/lib/kubelet/plugins/ebs.csi.aws.com/staging") print("\n=== 发布到 Pod ===") driver.node_publish_volume(vol.volume_id, "/var/lib/kubelet/pods/pg-0/volumes/data") print("\n=== 创建快照 ===") snap = driver.create_snapshot(vol.volume_id, "daily-backup") print(json.dumps(snap, indent=2)) print("\n=== 卷状态 ===") status = driver.get_volume_status(vol.volume_id) print(json.dumps(status, indent=2, ensure_ascii=False))
class VolumeState(Enum):定义 CSI 卷生命周期的 9 个状态枚举,从 PENDING(等待创建)到 DELETED(已删除),覆盖完整生命周期。class Topology:定义存储拓扑信息类,封装 segments 字典(如可用区标签),用于拓扑感知调度确保卷和 Pod 在同一拓扑域。def matches_node(self, node_labels: Dict[str, str]) -> bool:检查节点标签是否匹配拓扑约束,确保 Pod 调度到有可用存储的节点,避免跨可用区挂载失败。class CSIDriverSimulator:CSI 驱动模拟器类,封装 Identity/Controller/Node 三个 gRPC 服务的核心方法,模拟真实 CSI 驱动的卷管理逻辑。def get_plugin_info(self) -> Dict:Identity Service 方法:返回驱动名称和版本信息,K8s 通过此接口识别 CSI 驱动身份。def create_volume(self, name: str, capacity_gb: int,Controller Service 方法:在存储后端创建逻辑卷,返回卷 ID 和可访问拓扑信息,对应 CSI CreateVolume gRPC 调用。topo = Topology(segments=topology or {})将拓扑参数转换为 Topology 对象,拓扑信息(如可用区)确保卷创建在正确的物理位置。def controller_publish_volume(self, volume_id: str, node_id: str) -> bool:Controller Service 方法:将底层存储卷附加到目标节点(如 EBS 卷挂载到 EC2 实例),对应 ControllerPublishVolume gRPC 调用。def node_stage_volume(self, volume_id: str, staging_path: str,Node Service 方法:将块设备格式化为文件系统并挂载到全局暂存目录,对应 NodeStageVolume gRPC 调用,一个卷在同一节点只 stage 一次。def node_publish_volume(self, volume_id: str, target_path: str,Node Service 方法:将全局暂存目录 bind-mount 到 Pod 的容器路径,对应 NodePublishVolume gRPC 调用,多个 Pod 可共享同一 staged 卷。def create_snapshot(self, volume_id: str, snapshot_name: str) -> Dict:创建卷快照方法:调用存储后端的快照 API 创建时间点副本,可用于备份或从快照创建新卷。vol = driver.create_volume(演示动态供给:创建 200GB 存储卷并指定拓扑(us-east-1a 可用区),模拟 StatefulSet 的 volumeClaimTemplate 触发的卷创建。driver.node_stage_volume(vol.volume_id,演示 Node 暂存:将卷格式化为 ext4 并挂载到 kubelet 的全局暂存路径,为后续 Pod 挂载做准备。
常见问答
CSI 驱动与 in-tree 插件的核心区别是什么?
WaitForFirstConsumer 和 Immediate 绑定模式有什么区别?
CSI 的 NodeStageVolume 和 NodePublishVolume 有什么区别?
如何从 VolumeSnapshot 恢复数据?
StatefulSet 的 volumeClaimTemplate 如何工作?
常见误区
误区CSI 驱动仍然是 Kubernetes 核心代码的一部分,升级需要等 Kubernetes 版本发布。
正解CSI 驱动是独立于 Kubernetes 的外部进程,通过标准 gRPC 接口与 K8s 交互。驱动以容器镜像形式独立部署和升级,只需更新 CSI Driver DaemonSet/Deployment 的镜像版本即可完成升级,完全不依赖 Kubernetes 版本发布节奏。这正是 CSI 设计的核心价值:解耦存储驱动与 K8s 版本。
为何常见:从 in-tree 时代过来的思维惯性,习惯了存储驱动与 K8s 版本绑定的模式。此外 CSI 驱动虽然部署在 K8s 集群中,但其代码不在 K8s 源码库内,容易产生仍在核心代码中的误解。
误区动态卷供给意味着不需要创建 PVC,存储会自动分配给所有 Pod。
正解动态卷供给是指 PVC 创建时自动调用 CSI 驱动生成 PV(底层存储资源自动创建),但用户仍需显式创建 PVC 来声明存储需求(大小、类型、访问模式)。PVC 是用户存储需求的声明,没有 PVC 系统不会自动创建存储。动态供给消除的是管理员手动预创建 PV 的工作,而非 PVC 声明步骤。
为何常见:混淆了 PV 自动创建和 PVC 声明的关系。看到存储自动出现就以为完全不需要声明,忽略了 PVC 作为用户存储需求声明的必要性。名称中的动态容易让人误以为一切都自动完成。
误区CSI 卷可以在任意节点挂载,不受拓扑限制。
正解CSI 卷受存储物理拓扑约束。底层存储资源(如 EBS 卷)创建在特定可用区,只能挂载到同一可用区的节点。跨可用区挂载会失败。WaitForFirstConsumer 绑定模式正是为解决此问题设计:延迟到 Pod 调度时才创建卷,确保卷和 Pod 在同一拓扑域。accessible_topology 字段由 CSI 驱动返回,告知 K8s 卷可在哪些拓扑域访问。
为何常见:不了解存储的物理拓扑约束,将逻辑卷等同于可自由迁移的虚拟资源。云原生的弹性理念让人误以为一切都可以跨域,但底层存储有物理位置约束。
误区CSI Migration 开启后 in-tree 插件就完全删除了。
正解CSI Migration 是渐进式迁移机制。开启后 K8s 将 in-tree 卷操作转发到对应的 CSI 驱动,但 in-tree 插件代码仍在源码中(标记为 deprecated)。已有 in-tree 卷通过 Migration 自动转换为 CSI 卷操作,新卷直接使用 CSI。完全移除 in-tree 代码需要多个版本的废弃周期,确保向后兼容。K8s 1.27+ 默认开启所有主流厂商的 CSI Migration。
为何常见:将 Migration 理解为一次性删除而非渐进迁移。看到 Migration Feature Gate 开启就以为 in-tree 代码已经移除,实际上 K8s 社区采用渐进废弃策略确保兼容性。
参考来源
- docKubernetes CSI Documentation - OfficialKubernetes CSI 官方文档,详细介绍 CSI 架构设计、Sidecar 组件(provisioner/attacher/snapshotter/resizer/node-driver-registrar)、驱动开发规范和部署指南,是 CSI 使用的权威参考。
- docContainer Storage Interface SpecificationCSI 规范官方 Protocol Buffer 定义文件,包含 Identity/Controller/Node 三个 gRPC 服务的完整接口定义、消息格式和错误码规范,是 CSI 驱动开发的接口标准。
- docKubernetes Storage Classes - Official DocumentationKubernetes 官方 StorageClass 文档,介绍动态卷供给机制、StorageClass 参数配置、WaitForFirstConsumer 绑定模式和卷扩容策略,是配置 CSI 动态供给的参考。
- docKubernetes CSI Volumes - Official DocumentationKubernetes 官方 CSI 卷文档,介绍 CSI 卷类型的使用方法、CSIDriver 和 CSINode 对象的作用,以及从 in-tree 到 CSI 的迁移机制(CSI Migration)。
- webpageCSI external-provisioner - GitHubCSI external-provisioner Sidecar 组件的 GitHub 仓库,包含源码、部署 YAML 和配置说明,展示如何监听 PVC 事件并调用 CSI CreateVolume 实现动态卷供给的完整实现。