Kubernetes 存储编排深度实战:从 CSI Sidecar 模式、快照与扩容到拓扑感知的工程全解

如果说容器编排解决的是"计算在哪里跑",那么存储编排解决的是一个更难的问题:当计算可以在任意节点上被重新调度时,数据如何既不丢失、又不成为调度的枷锁。计算是无状态的、可重放的;存储是有状态的、位置敏感的。这两者的张力几乎贯穿了 Kubernetes 存储子系统的全部设计。

本文从 CSI(Container Storage Interface)的架构动机出发,逐层拆解卷的供给、挂载、扩容、快照与拓扑感知,并给出生产环境中真正会咬人的那几处细节。

一、三层解耦:PVC、PV 与 StorageClass

Kubernetes 把"存储"拆成了三个彼此独立演进的对象:

  • PersistentVolumeClaim(PVC):用户意图——"我要 100Gi、可读写、能被单个节点挂载"。
  • PersistentVolume(PV):实际供给——一块真实存在的云盘、一个 NFS 导出目录、一个 Ceph RBD image。
  • StorageClass(SC):供给策略——用什么驱动、什么参数、什么时候创建、能不能扩容。

这种拆分的价值在于角色分离:应用开发者只写 PVC,不关心后端;集群管理员只管 SC 与驱动,不关心谁在用。但代价是引入了一条长长的控制链路——PVC 写下去之后,要由 PV controller 找 PV 或触发动态供给,再由 kubelet 完成节点侧挂载,最后由调度器保证节点可用。任何一个环节的状态不一致,都会表现为那个经典的 Pod stuck in ContainerCreating。

二、CSI 为什么长成"Sidecar 模式"

在 CSI 之前,卷插件是编译进 Kubernetes 源码的 in-tree 插件。这带来一个致命问题:存储厂商的发版节奏被 Kubernetes 的发版节奏绑架,任何一个驱动的 bug 都要等 K8s 发版才能修。CSI 的答案是一个 gRPC 契约 + 一组标准 sidecar 容器。

CSI 规范定义三组服务:

service Identity {
  rpc GetPluginInfo(GetPluginInfoRequest) returns (GetPluginInfoResponse);
  rpc GetPluginCapabilities(...) returns (...);
  rpc Probe(ProbeRequest) returns (ProbeResponse);
}

service Controller {
  rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
  rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse);
  rpc ControllerPublishVolume(...) returns (...);   // attach 到节点
  rpc ControllerUnpublishVolume(...) returns (...);
  rpc ControllerExpandVolume(...) returns (...);
  rpc CreateSnapshot(...) returns (...);
}

service Node {
  rpc NodeStageVolume(...) returns (...);   // 块设备级:格式化、挂载到 staging 路径
  rpc NodeUnstageVolume(...) returns (...);
  rpc NodePublishVolume(...) returns (...); // Pod 级:bind mount 到容器可见路径
  rpc NodeUnpublishVolume(...) returns (...);
  rpc NodeExpandVolume(...) returns (...);
}

关键在于:驱动作者只实现这三个服务,其余全部由社区维护的 sidecar 承担:

Sidecar监听对象调用的 CSI RPC
external-provisionerPVCCreateVolume / DeleteVolume
external-attacherVolumeAttachmentControllerPublishVolume / ControllerUnpublishVolume
external-resizerPVC 容量变更ControllerExpandVolume (+ NodeExpandVolume)
external-snapshotterVolumeSnapshotCreateSnapshot / DeleteSnapshot / ListSnapshots
node-driver-registrar—向 kubelet 注册插件 socket
livenessprobe—Probe,暴露健康指标

这是一个教科书级的控制反转:sidecar 负责"监听 Kubernetes API 并将其翻译成 CSI 调用",驱动负责"把 CSI 调用翻译成后端 API"。驱动因此完全不需要 client-go,也不需要 watch,只需要实现幂等的 RPC。

幂等性是 CSI 的硬要求。CreateVolume 必须按 name 幂等——同名卷已存在且参数一致时返回该卷而非报错;ControllerPublishVolume 对已 attach 的卷必须返回成功。原因很现实:sidecar 使用 workqueue 重试,网络抖动、API server 重启、controller 选举切换都会导致同一请求重放。任何不幂等的实现都会产生孤儿云盘——卷建出来了但 PV 没写成功,从此无人回收,安静地烧钱。

三、挂载的两阶段:Stage 与 Publish

为什么挂载要拆成 NodeStageVolume 和 NodePublishVolume 两步?

  • NodeStageVolume 是卷粒度的:把块设备格式化(如果需要)并挂载到一个全局 staging 路径,例如 /var/lib/kubelet/plugins/kubernetes.io/csi/pv/<pv-name>/globalmount。
  • NodePublishVolume 是 Pod 粒度的:把 staging 路径 bind-mount 到该 Pod 可见的路径。

这个拆分让多个 Pod 共享同一个卷时,昂贵的文件系统挂载只做一次,后续 Pod 只是一次廉价的 bind mount。反过来说,如果你实现的是 ReadWriteOnce 语义但省略了 stage 阶段,多 Pod 场景下会重复挂载同一个文件系统——对 XFS/ext4 这类非集群文件系统,结果就是元数据损坏。

节点侧还有一个容易忽略的角色:CSINode 对象。它由 node-driver-registrar 填充,记录该节点上已注册的驱动、node ID、以及驱动声明的拓扑段。调度器和 attacher 都依赖它判断"这个节点能不能跑这个卷"。

四、在线扩容:被误解最多的能力

卷扩容要求 StorageClass.allowVolumeExpansion: true,且过程分为两个阶段:

  1. 控制面扩容:external-resizer 发现 PVC.spec.resources.requests.storage 变大,调用 ControllerExpandVolume 扩大后端云盘。
  2. 节点面扩容:kubelet 调用 NodeExpandVolume 扩展文件系统。

两者分离是因为后端设备的容量变化不会自动传播到文件系统。云盘从 100Gi 扩到 200Gi,节点上的 ext4 仍然只知道 100Gi。是否需要节点扩容由 ControllerExpandVolume 返回的 node_expansion_required 决定:块设备卷(raw block PVC)不需要,文件系统卷需要。

# 触发扩容
kubectl patch pvc my-data -p '{"spec":{"resources":{"requests":{"storage":"200Gi"}}}}'

# 观察扩容状态:文件系统扩容未完成时会留下 condition
kubectl get pvc my-data -o jsonpath='{.status.conditions}' | jq
# [{"type":"FileSystemResizePending","status":"true",
#   "message":"Waiting for user to (re-)start a pod to finish file system resize"}]

这里有个生产上的经典陷阱:如果 PVC 处于 FileSystemResizePending 且 Pod 已删除,文件系统扩容永远不会发生——你必须重新启动一个挂载该卷的 Pod,kubelet 才会在 setup 阶段补上 NodeExpandVolume。另外,PVC 只能增大不能缩小,这是 API 层的显式校验,缩容只能靠"快照 → 新建小卷 → 迁移"。

五、快照:与 PV/PVC 同构的第二套抽象

快照体系几乎是 PV/PVC 的镜像:

  • VolumeSnapshotClass:对应 StorageClass,声明 driver 与删除策略。
  • VolumeSnapshot:用户意图,对应 PVC。
  • VolumeSnapshotContent:实际快照对象,对应 PV。
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: db-snapshot-20261001
spec:
  volumeSnapshotClassName: csi-snapclass
  source:
    persistentVolumeClaimName: my-data

从快照恢复则是把 PVC 的 dataSource 指向快照,provisioner 会带上 VolumeContentSource 调用 CreateVolume,由后端做卷克隆或快照还原:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-data-restored
spec:
  dataSource:
    name: db-snapshot-20261001
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 100Gi

必须清醒的一点:Kubernetes 快照默认是崩溃一致性(crash-consistent),不是应用一致性。 CSI 只保证"此刻的磁盘位图被冻结",不保证数据库缓冲区已落盘。要拿到可用的数据库备份,需要在快照前 quiesce——最常见做法是在同一个 Pod 里通过 pre-stop hook 或 sidecar 执行 fsfreeze -f / 数据库 BACKUP STAGE / FLUSH TABLES WITH READ LOCK,快照完成后解冻。这个编排逻辑 Kubernetes 不替你做,它属于应用层责任。

六、拓扑感知:为什么延迟绑定是必选项

这是存储编排里最反直觉、也最容易在上线后才暴露的问题。

StorageClass.volumeBindingMode 有两个取值:

  • Immediate:PVC 一创建就立刻供给 PV。
  • WaitForFirstConsumer:推迟到第一个使用该 PVC 的 Pod 被调度时才供给。

在多可用区集群里,Immediate 是结构性错误的。设想:provisioner 在 PVC 创建的瞬间于 zone-a 建了一块云盘,而调度器随后因为 CPU/内存资源把 Pod 放到了 zone-b。云盘无法跨可用区挂载,Pod 永久 Pending,事件里写着 node(s) had volume node affinity conflict。

延迟绑定把决策顺序颠倒过来:

  1. 调度器先为 Pod 选出候选节点,并把"假设该节点可用"的信息写入 PVC 的 annotation(volume.kubernetes.io/selected-node)。
  2. external-provisioner 看到 selected-node,读取该节点的 CSINode 拓扑标签,结合 StorageClass.allowedTopologies 与驱动上报的 accessible_topology,在正确的 zone 建卷。
  3. 卷就绪后调度器才最终确认 Pod 绑定。
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-zone-aware
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer   # 关键
allowedTopologies:
  - matchLabelExpressions:
      - key: topology.kubernetes.io/zone
        values: [cn-north-1a, cn-north-1b]
parameters:
  type: gp3
reclaimPolicy: Delete
allowVolumeExpansion: true

驱动侧通过 accessible_topology 声明每个卷位于哪些拓扑段:

resp := &csi.CreateVolumeResponse{
    Volume: &csi.Volume{
        VolumeId:      volID,
        CapacityBytes: req.GetCapacityRange().GetRequiredBytes(),
        AccessibleTopology: []*csi.Topology{
            {Segments: map[string]string{
                "topology.kubernetes.io/zone": "cn-north-1a",
            }},
        },
    },
}

对本地盘(Local PV、OpenEBS、TopoLVM 这类)而言,延迟绑定不是优化而是唯一可行路径——因为容量本身就是绑定到具体节点的。

七、生产踩坑清单

  • 卸载卡在 device busy:常见于卷上仍有进程持有文件描述符、或嵌套挂载(/dev、/proc、子卷)未清理。kubelet 会反复重试 NodeUnstageVolume,Pod 删除因此挂住。排查手段是 lsof + findmnt -R 检查挂载树。
  • Attach 风暴:节点失联时大量 VolumeAttachment 同时进入 detach 流程,云厂商 API 有 QPS 限制,容易触发限流雪崩。务必开启 sidecar 的指数退避与 --workers 限流,并给云 API 客户端配置独立的限流器。
  • RWO 语义的真实含义:ReadWriteOnce 是"可被单个节点读写",不是"单个 Pod"。同一节点上两个 Pod 可以同时挂载。要实现"单 Pod 独占",用 1.29 起 GA 的 ReadWriteOncePod。
  • RWX 不等于 POSIX:多数 RWX 实现(NFS、对象存储 FUSE)在文件锁、原子 rename、mmap 一致性上与本地文件系统存在语义差异。把 SQLite 或 etcd 放在这类卷上是明确的数据损坏风险。
  • 孤儿资源:PV 的 reclaimPolicy: Retain 加上误删 PVC,会产生无人认领的 PV 和后端云盘。建议对非关键数据使用 Delete,并定期用云厂商的标签扫描做孤儿卷对账。
  • 可观测性:优先告警 PVC 处于 Pending 超过 N 分钟、以及 VolumeAttachment 长时间未 attached,而不是等 Pod 起不来才回溯。sidecar 默认暴露 Prometheus 指标(--http-endpoint),包含各 CSI 调用的延迟直方图。

八、小结

Kubernetes 存储编排的复杂度,本质上来自有状态资源被放进了一个为无状态工作负载设计的调度系统。CSI 用 gRPC 契约 + sidecar 模式把这种复杂度隔离在了驱动层之外,代价是引入了长链路的最终一致性:PVC、PV、VolumeAttachment、VolumeSnapshotContent、CSINode 五个对象共同描述同一个卷的状态,任何一个卡住都会表现为"Pod 起不来"。

理解这套系统的正确心智模型是:把它当作一个分布式状态机来读,而不是一组 YAML 模板来抄。知道每个 sidecar 在 watch 什么、调用什么 RPC、要求什么幂等性,你才能在凌晨三点快速判断出——到底是云 API 限流了,还是拓扑选错了可用区。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部