1. 从Pod重启丢数据说起:为什么必须搞懂CSI
如果你在生产环境跑过有状态服务,大概率经历过这种场景:一个数据库Pod因为节点故障被调度到新节点,起来之后发现数据目录空空如也,日志里全是"file not found"。这不是Kubernetes的bug,而是存储卷没有正确挂载。Kubernetes本身并不直接管理存储,它把这件事交给了一套叫CSI(Container Storage Interface)的规范。理解CSI,本质上就是理解"Pod里的数据到底存在哪、怎么挂上去、挂了之后怎么保证不丢"。
CSI的核心价值在于解耦。在CSI出现之前,Kubernetes的存储插件是"in-tree"的,也就是存储代码直接编译进Kubernetes主仓库。这带来一个致命问题:每加一种存储后端,就要改Kubernetes核心代码,发版周期被存储厂商绑架。CSI把这个接口抽出来,定义成一组gRPC服务,存储厂商只要实现这套接口,就能以独立组件的形式接入任意支持CSI的编排系统。对运维来说,这意味着升级存储驱动不再需要升级整个集群。
这篇文章面向的是需要在生产环境落地Kubernetes存储的工程师。我会从CSI的底层原理讲起,拆解它到底由哪几个组件构成、一次卷挂载背后发生了什么;然后横向对比当前主流的几类存储方案,给出选型时真正该看的指标;最后重点讲生产环境里最容易踩的坑——那些文档里不会写、但会让你半夜被叫起来的问题。全文基于我在多个有状态服务集群上的实操经验,涉及具体参数和配置的地方都会给出可直接参考的写法。
2. CSI的底层机制:一次卷挂载到底经历了什么
2.1 三个核心组件与它们的职责边界
CSI在Kubernetes里的落地并不是一个单一进程,而是由三个角色协作完成的。很多人配了StorageClass能用就以为懂了,其实一旦出问题根本不知道从哪查。先把这三个角色理清楚。
CSI Driver是存储厂商提供的实现,通常以DaemonSet和Deployment两种形态部署。它内部又分两个部分:Node Plugin跑在每个节点上,负责节点本地的挂载操作,比如把块设备格式化、挂到/var/lib/kubelet/pods/...下面;Controller Plugin通常以单副本或主备形式跑在控制面,负责创建、删除、attach、detach这些集群级别的卷操作。为什么这么分?因为创建卷是全局操作,跟具体节点无关;而挂载必须在本节点执行,所以必须每节点一份。
Kubelet是节点上的"执行者"。它通过一个叫csi.sock的Unix Domain Socket跟Node Plugin通信。当Pod调度到某节点,kubelet会调用Node Plugin的NodeStageVolume和NodePublishVolume两个接口,前者把卷挂到一个全局临时目录,后者再bind mount到Pod的具体目录。这个两阶段设计是为了支持一个卷被多个Pod共享的场景。
External Components是一组辅助控制器,包括external-provisioner、external-attacher、external-resizer、external-snapshotter等。它们本质上是"翻译官":监听Kubernetes的PVC、VolumeAttachment等资源变化,翻译成CSI的gRPC调用发给Controller Plugin。比如你创建一个PVC,external-provisioner监听到之后调用CSI的CreateVolume,拿到卷ID再回写成一个PV对象。
提示:排查挂载问题时,先确认是哪个环节卡住。PVC一直Pending通常是provisioner的问题;Pod一直ContainerCreating通常是attach或node stage的问题。用
kubectl describe pvc和kubectl describe pod能看到对应的event。
2.2 从PVC到挂载完成:完整调用链路拆解
光知道组件还不够,得把一次完整的卷供给和挂载串起来。假设你提交了一个PVC和一个使用它的Pod,背后发生的事大致是这样的。
第一步,external-provisioner监听到PVC创建,检查它的StorageClass和accessModes,调用Controller Plugin的CreateVolume。这里会传入容量、访问模式、以及StorageClass里的parameters。存储后端创建好卷之后返回一个volume ID和卷的拓扑信息(比如它属于哪个可用区)。
第二步,provisioner根据返回信息创建一个PV对象,并把PV和PVC绑定。此时PVC状态变成Bound。
第三步,Pod被调度到某个节点。kubelet发现Pod引用了这个PVC,于是通过external-attacher触发Controller Plugin的ControllerPublishVolume,把卷attach到目标节点。这一步在云环境里特别关键,因为云盘只能attach到同可用区的节点,跨区attach会直接失败。
第四步,节点上的kubelet调用Node Plugin的NodeStageVolume,把设备挂到一个全局路径,比如/var/lib/kubelet/plugins/kubernetes.io/csi/...。如果是文件系统类型的卷,这一步会执行格式化。
第五步,NodePublishVolume把上一步的全局挂载点bind mount到Pod的target路径。至此容器才能看到数据。
第六步,Pod删除时反向执行:NodeUnpublishVolume、NodeUnstageVolume、ControllerUnpublishVolume,最后根据回收策略决定是否DeleteVolume。
理解这条链路的意义在于:任何一环超时或报错,Pod就起不来。我见过最典型的是ControllerPublishVolume卡住,原因是云API限流,结果一批Pod全部卡在ContainerCreating。知道链路,你就能定位到是云侧限流还是驱动侧问题。
2.3 访问模式与卷模式:选错就是数据损坏
CSI定义了三种访问模式,很多人随手写ReadWriteOnce,结果多副本Pod同时挂载导致文件系统损坏。这三种模式必须搞清楚。
- ReadWriteOnce (RWO):卷只能被单个节点以读写方式挂载。注意是"单节点",不是"单Pod"。同一节点上的多个Pod可以共享。
- ReadOnlyMany (ROX):多个节点只读挂载。
- ReadWriteMany (RWX):多个节点同时读写。这需要底层存储支持共享文件系统,比如NFS、CephFS、某些云的文件存储。
关键坑点在于:块存储(如云盘、本地盘)通常只支持RWO。如果你用Deployment跑多副本又挂了RWO的云盘,调度器会把所有副本挤到同一节点,或者干脆有的副本起不来。想要多副本共享数据,必须用支持RWX的文件存储,或者改用StatefulSet让每个副本有独立PVC。
卷模式(volumeMode)也容易忽略。默认是Filesystem,CSI会帮你格式化;如果设成Block,则直接把裸块设备给容器,由应用自己管理。数据库场景下有些团队会用Block模式配合自己的文件系统,减少一层开销,但运维复杂度陡增,没有明确收益不建议用。
3. 主流存储方案横向对比:别只看性能数字
3.1 四类方案的适用边界
市面上的Kubernetes存储方案大致分四类,每类的定位完全不同,选型时先看你的场景属于哪一类。
第一类是云厂商块存储,比如各类云盘。优点是开箱即用、和云平台集成好、支持快照和扩容;缺点是绑定云厂商、只支持RWO、跨可用区有约束。适合大多数跑在单一云上的有状态服务。
第二类是分布式文件/块存储,比如Ceph RBD、CephFS、Longhorn、各类商业SDS。优点是自建可控、支持RWX、能跨节点;缺点是运维成本高,Ceph这种没几个人专职维护根本玩不转。适合有专门存储团队或对数据主权有要求的环境。
第三类是网络文件系统,比如NFS、各类NAS。优点是简单、支持RWX、成本低;缺点是性能受网络和单点限制,元数据操作多的小文件场景会很惨。适合共享配置、日志、CI缓存这类场景。
第四类是本地存储,比如local PV、OpenEBS的LocalPV。优点是性能最好、延迟最低;缺点是没有冗余,节点挂了数据就没了,且Pod和节点强绑定。适合能容忍重建的缓存层,或者上层应用自己做了多副本(如某些分布式数据库)。
3.2 选型时必须量化的几个指标
光看"高性能""高可靠"这种形容词没用,选型要落到具体数字上。下面这张表是我实际评估时会填的维度。
| 评估维度 | 关键问题 | 量化方式 |
|---|---|---|
| 延迟 | 随机读写P99延迟多少 | fio压测,区分4K随机和1M顺序 |
| 吞吐 | 单卷带宽上限 | 顺序读写MB/s,注意是否受网络限制 |
| IOPS | 单卷和集群总IOPS | 云盘通常有IOPS上限,超了会限流 |
| 可用性 | 单节点故障是否影响 | 看副本数和故障域 |
| 扩容 | 是否支持在线扩容 | 很多方案要卸载才能扩 |
| 快照 | 是否支持一致性快照 | 数据库场景必须 |
| 拓扑 | 是否感知可用区 | 跨区attach会失败 |
我特别想强调IOPS上限这个点。很多云盘标称性能很好,但那是突发性能,持续跑一段时间后会被限流到基线值。数据库这种持续高IOPS的负载,一定要看基线IOPS而不是突发值。曾经有个集群,压测时一切正常,上线后每天下午高峰期就变慢,最后查出来是云盘突发额度用完了。
3.3 一个真实的选型决策过程
说个具体案例。有个团队要部署一套有状态服务,需求是:三副本、每副本独立数据、需要快照备份、跑在单一云上。他们最初想用Ceph自建,理由是"可控"。
我帮他们算了一笔账:Ceph至少需要3个存储节点,每个节点多块盘,还要考虑mon和mgr的部署,运维人力按0.5个人算,一年成本远超直接用云盘。而且他们的数据量并不大,云盘的性能和可靠性完全够用。最后选了云盘方案,用StatefulSet的volumeClaimTemplates给每个副本自动创建独立PVC,配合快照做备份。
反过来,另一个场景是跨多云部署,要求数据不绑定单一厂商,那就必须上自建方案,CephFS或Longhorn这类。这时候运维成本是必须接受的代价。
选型的本质是权衡,没有银弹。先明确你的约束条件——预算、人力、数据量、性能要求、合规要求——再往里套方案,而不是先选方案再找理由。
4. 生产级实施:从StorageClass到备份的完整配置
4.1 StorageClass的参数怎么填才不踩雷
StorageClass是存储的"模板",PVC通过它来申请卷。很多人直接抄网上的配置,结果参数和实际存储后端对不上。下面是一个云盘类StorageClass的典型配置,我逐行解释关键参数。
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: diskplugin.csi.example.com parameters: type: ssd zone: auto iops: "5000" reclaimPolicy: Delete allowVolumeExpansion: true volumeBindingMode: WaitForFirstConsumerprovisioner必须和实际部署的CSI驱动名一致,写错了PVC会一直Pending。parameters里的字段是驱动自定义的,不同厂商完全不同,必须查对应驱动的文档,不能照搬。
reclaimPolicy有两个值:Delete和Retain。生产环境我强烈建议对有数据的卷用Retain。为什么?因为Delete意味着PVC一删,底层卷直接销毁。万一有人误删PVC,数据就没了。用Retain的话,PV会变成Released状态,数据还在,可以手动恢复。代价是需要人工清理,但数据安全优先。
volumeBindingMode是最容易被忽略但最重要的参数。默认是Immediate,意思是PVC一创建就立刻供给卷。这在多可用区集群里会出大问题:卷可能被创建在A区,而Pod被调度到B区,attach失败。设成WaitForFirstConsumer后,供给会等到Pod调度完成,根据Pod所在节点决定卷创建在哪个区。多可用区集群必须用这个模式,这是血泪教训。
allowVolumeExpansion决定能否在线扩容。开了之后可以改PVC的容量来扩容,但要注意:不是所有驱动都支持,且扩容后有些文件系统需要重启Pod才能识别新容量。
4.2 StatefulSet的volumeClaimTemplates实战
有状态服务用StatefulSet,每个副本需要独立存储,靠的是volumeClaimTemplates。它会在每个Pod创建时自动生成一个PVC,命名规则是<template-name>-<statefulset-name>-<ordinal>。
volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: fast-ssd resources: requests: storage: 100Gi这里有个关键点:volumeClaimTemplates创建后不能修改。你想改容量或StorageClass,直接改StatefulSet是没用的,Kubernetes会拒绝。正确做法是手动扩容每个PVC,或者删掉StatefulSet(用--cascade=orphan保留Pod)再重建。这个限制坑过很多人,规划容量时一定要留足余量。
另一个坑是Pod删除后PVC不会自动删除。这是有意设计的,防止数据丢失。但长期下来会积累一堆Released的PV占着存储。建议配一个定期的清理脚本,或者用Retain策略配合人工审核。
4.3 快照与备份:别等出事才想起来
CSI快照是卷级别的,通过VolumeSnapshot资源来操作。它需要集群里部署external-snapshotter和对应的CRD。基本用法是先定义VolumeSnapshotClass,再创建VolumeSnapshot引用PVC。
apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: db-snapshot-20240101 spec: volumeSnapshotClassName: csi-snapclass source: persistentVolumeClaimName:>