存储体系梳理:PV/PVC/StorageClass 与 CSI 插件实战
一句话定位:把 PV/PVC/StorageClass 三层模型和 CSI 链路拆透,有状态服务存储选型不再抓瞎。
写在前面
K8s 存储是很多人又爱又恨的部分。爱的是 PVC 一行声明,卷就出来了,看起来简单;恨的是生产上一出事就是大事------PVC 卡在 Pending、Pod 起不来、扩容失败、快照恢复数据丢失、节点磁盘满整个节点雪崩。我见过太多团队把有状态服务(MySQL、Kafka、ES)贸然上 K8s,出事才发现存储选型完全不对,数据差点没了。
存储的本质问题是:K8s 的存储抽象(PV/PVC)是声明式的,但底层存储设备是命令式的、有状态的、有物理约束的。两者之间的鸿沟,就是 CSI 插件要填的。理解了 CSI 的工作机制,你才能理解为什么 PVC 会卡、为什么扩容要等、为什么快照不是万能的。
这篇我会把 K8s 存储体系从抽象到实现讲清楚:PV/PVC/StorageClass 三层模型、CSI 插件的完整链路、动态供给/扩容/快照/拓扑调度这些关键流程、本地盘/网络盘/分布式存储怎么选、有状态服务的存储实践、生产故障怎么排查。看完这篇,你应该能给团队的 MySQL/Redis/Kafka 选对存储方案,出问题知道从 CSI 哪一步查。
版本基线:K8s 1.30,CSI spec 1.9,Longhorn 1.6,TopoLVM 0.16。
核心问题
- PV/PVC/StorageClass 三层到底什么关系?谁绑谁?
- CSI 插件怎么工作?provisioner、attacher、resizer、snapshotter 各干啥?
- 动态供给、扩容、快照的完整链路是什么?
- 本地盘、网络盘(NFS/CBS)、分布式存储(Ceph/Longhorn)怎么选?
- 有状态服务上 K8s,存储方案怎么设计才稳?
一、原理剖析
1.1 PV/PVC/StorageClass 三层模型
K8s 存储抽象分三层,理解这三层的关系是基础:
┌──────────────────────────────────────────────────────────────┐
│ 用户的 Pod │
│ └─ volumeMounts: { name: data, mountPath: /var/lib/mysql }│
│ └─ volumes: │
│ persistentVolumeClaim: │
│ claimName: mysql-data ◄── PVC(用户声明) │
└──────────────────────────────────────────────────────────────┘
▲
│ 绑定(1:1)
▼
┌──────────────────────────────────────────────────────────────┐
│ PVC: mysql-data │
│ spec.resources.requests.storage: 100Gi │
│ spec.storageClassName: fast-ssd ◄── 指向 StorageClass│
│ spec.accessModes: ["ReadWriteOnce"] │
└──────────────────────────────────────────────────────────────┘
▲
│ 动态供给(StorageClass 触发)
│ 或静态绑定(预先创建)
▼
┌──────────────────────────────────────────────────────────────┐
│ PV: pvc-xxx-xxx-xxx │
│ spec.capacity.storage: 100Gi │
│ spec.accessModes: ["ReadWriteOnce"] │
│ spec.persistentVolumeReclaimPolicy: Retain │
│ spec.csi: │
│ driver: disk.csi.aliyun.com │
│ volumeHandle: d-xxx │
└──────────────────────────────────────────────────────────────┘
▲
│ 实际挂载
▼
┌──────────────────────────────────────────────────────────────┐
│ 物理存储:云盘/NFS/Ceph RBD/本地盘 │
└──────────────────────────────────────────────────────────────┘
各层职责:
- PVC(PersistentVolumeClaim):用户视角的"我要多大、什么类型的存储"。是 namespace 内资源。
- PV(PersistentVolume):集群视角的"一块实际存储"。是集群级资源,不属于任何 namespace。
- StorageClass:把 PVC 和 PV 桥接起来的"模板",定义动态供给的参数(磁盘类型、CSI driver、reclaimPolicy)。
绑定关系:PVC 和 PV 是 1:1 绑定,一旦绑定不可解绑(除非删 PVC)。绑定有两种方式:
- 静态绑定:管理员预先创建 PV,用户创建 PVC,系统按容量/访问模式匹配。
- 动态绑定(生产主流):用户创建 PVC(指定 StorageClass),CSI 控制器根据 StorageClass 模板动态创建 PV。
访问模式(AccessModes)是新手必踩的坑:
ReadWriteOnce(RWO):单节点读写。云盘、本地盘默认。注意:Once 指节点,不是 Pod!同节点多 Pod 可共享。ReadOnlyMany(ROX):多节点只读。NFS/共享存储支持。ReadWriteMany(RWX):多节点读写。NFS/CephFS/Longhorn 支持。云盘不支持。ReadWriteOncePod(RWOP,1.22+):单 Pod 读写。比 RWO 更严格。
很多新手以为云盘能 RWX,写了个 MySQL 用云盘 PVC 让两个 Pod 共享,结果第二个 Pod 死活起不来。云盘本质是块存储,只能挂一个节点,这点必须记牢。
1.2 CSI 链路与 provisioner 工作机制
CSI(Container Storage Interface)是 K8s 和存储厂商之间的标准接口。K8s 自己不实现存储,只定义接口,厂商实现接口。
CSI 的核心是"三个组件 + 两个工作流":
┌─────────────────────────────────────────────────────────────┐
│ K8s 控制面 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │PV Controller │ │AD Controller │ │ CSI Driver │ │
│ │(provisioner │ │(attacher) │ │ Controller │ │
│ │ + resizer + │ │ │ │ (厂商实现) │ │
│ │ snapshotter) │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ gRPC │ gRPC │ gRPC │
└─────────┼──────────────────┼──────────────────┼──────────────┘
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ CSI Identity/Controller Service │
│ CreateVolume / DeleteVolume / ControllerPublishVolume │
│ CreateSnapshot / DeleteSnapshot / ExpandVolume │
└─────────────────────────────────────────────────────────────┘
│
│ 存储后端 API
▼
┌─────────────────────────────────────────────────────────────┐
│ 云厂商云盘 API / Ceph RBD / NFS Server / Longhorn │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ K8s 工作节点 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ kubelet │ │ kubelet │ │ kubelet │ │
│ │ volume mount │ │ volume mount │ │ volume mount │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ gRPC │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ CSI Node Service(厂商实现) │ │
│ │ NodeStageVolume / NodePublishVolume │ │
│ │ NodeUnpublishVolume / NodeUnstageVolume │ │
│ │ NodeExpandVolume │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
四个 sidecar 容器(都是 K8s 维护的)负责把 K8s 的 watch 事件翻译成 CSI gRPC 调用:
- CSI Provisioner :watch PVC 事件,调用 CSI Controller 的
CreateVolume/DeleteVolume。 - CSI Attacher :watch VolumeAttachment 事件,调用
ControllerPublish/ControllerUnpublish(把云盘挂到目标节点)。 - CSI Resizer :watch PVC 扩容请求,调用
ExpandVolume(控制器侧)+NodeExpandVolume(节点侧)。 - CSI Snapshotter :watch VolumeSnapshot 事件,调用
CreateSnapshot/DeleteSnapshot。
1.3 动态供给的完整链路(以云盘为例)
这是最常用、也最该搞懂的流程。
云盘后端 CSI Node Plugin kubelet CSI Controller(厂商) CSI Provisioner apiserver User(kubectl) 云盘后端 CSI Node Plugin kubelet CSI Controller(厂商) CSI Provisioner apiserver User(kubectl) #mermaid-svg-y0vYS8tVJHQPbmRG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-y0vYS8tVJHQPbmRG .error-icon{fill:#552222;}#mermaid-svg-y0vYS8tVJHQPbmRG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-y0vYS8tVJHQPbmRG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-y0vYS8tVJHQPbmRG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-y0vYS8tVJHQPbmRG .marker.cross{stroke:#333333;}#mermaid-svg-y0vYS8tVJHQPbmRG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-y0vYS8tVJHQPbmRG p{margin:0;}#mermaid-svg-y0vYS8tVJHQPbmRG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-y0vYS8tVJHQPbmRG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-y0vYS8tVJHQPbmRG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-y0vYS8tVJHQPbmRG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-y0vYS8tVJHQPbmRG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-y0vYS8tVJHQPbmRG .sequenceNumber{fill:white;}#mermaid-svg-y0vYS8tVJHQPbmRG #sequencenumber{fill:#333;}#mermaid-svg-y0vYS8tVJHQPbmRG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-y0vYS8tVJHQPbmRG .messageText{fill:#333;stroke:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-y0vYS8tVJHQPbmRG .labelText,#mermaid-svg-y0vYS8tVJHQPbmRG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .loopText,#mermaid-svg-y0vYS8tVJHQPbmRG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-y0vYS8tVJHQPbmRG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-y0vYS8tVJHQPbmRG .noteText,#mermaid-svg-y0vYS8tVJHQPbmRG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-y0vYS8tVJHQPbmRG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-y0vYS8tVJHQPbmRG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-y0vYS8tVJHQPbmRG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-y0vYS8tVJHQPbmRG .actorPopupMenu{position:absolute;}#mermaid-svg-y0vYS8tVJHQPbmRG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-y0vYS8tVJHQPbmRG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-y0vYS8tVJHQPbmRG .actor-man circle,#mermaid-svg-y0vYS8tVJHQPbmRG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-y0vYS8tVJHQPbmRG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Pod 被调度到 Node X 1. 创建 PVC(sc=fast-ssd, 100Gi)2. 写入 PVC(status.phase=Pending)3. watch 收到新 PVC,sc 里指定 CSI driver4. CreateVolume(name, capacity, params)5. 调云 API 创建云盘 d-xxx6. 云盘就绪7. 返回 volumeId=d-xxx8. 创建 PV(绑定 PVC,volumeHandle=d-xxx)9. PVC.phase=Bound, PV.phase=Bound10. watch 收到 Pod,开始 mount volume11. NodeStageVolume(格式化+全局挂载到 /var/lib/kubelet/plugins/...)12. NodePublishVolume(从全局挂载点 bind mount 到 Pod 路径)13. 启动 Pod 容器,把路径挂到容器 namespace
注意几个细节:
- CreateVolume 是异步的:云盘创建可能要几十秒,期间 PVC 一直是 Pending。新手以为卡住了删 PVC 重试,结果云盘创建了但 K8s 不知道,产生孤儿盘(计费!)。
- NodeStage 和 NodePublish 是两步:Stage 是"格式化 + 全局挂载"(一个磁盘只做一次),Publish 是"bind mount 到 Pod 路径"(同磁盘可给多个 Pod)。这是为了支持同节点多 Pod 共享 RWO 卷。
- VolumeAttachment 对象 :attacher 创建,记录"某 PV 当前挂载到哪个节点"。可以用
kubectl get volumeattachment看。
1.4 扩容、快照、拓扑调度
扩容(Volume Expansion)
K8s 1.24+ GA。流程:
- 用户改 PVC 的
spec.resources.requests.storage(从 100Gi 改 200Gi)。 - StorageClass 必须开
allowVolumeExpansion: true。 - CSI Resizer watch 到变化,调 CSI Controller
ExpandVolume(改云盘大小)。 - 云盘扩容后,PVC 的
status.conditions显示FileSystemResizePending。 - kubelet 在节点上调
NodeExpandVolume(扩文件系统,ext4/xfs online resize)。
注意:扩容不能缩容!改大可以,改小直接报错。所以规划存储容量时,宁可先小再扩,别一上来给太大。
快照(Volume Snapshot)
K8s 1.20+ GA。快照是某时刻卷的只读副本,用于备份/恢复。
yaml
# 创建快照
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: mysql-snap-20240101
spec:
volumeSnapshotClassName: csi-snapshot
source:
persistentVolumeClaimName: mysql-data
快照恢复是新建 PVC 从快照创建,不是覆盖原 PVC:
yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-restored
spec:
storageClassName: fast-ssd
resources:
requests:
storage: 100Gi
dataSource:
name: mysql-snap-20240101
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
注意:快照不是备份!云盘快照通常是增量的、存在同区域,区域故障会一起没。真正的备份要跨区域/跨账号。
拓扑调度(Topology)
某些存储有"位置约束"------比如本地盘只在特定节点,云盘在特定可用区。CSI 通过 volumeBindingMode: WaitForFirstConsumer 实现:
Immediate(默认):PVC 创建时立刻绑定(可能绑到Pod 调度不到的节点)。WaitForFirstConsumer:PVC 延迟绑定,等 Pod 调度决策后,根据 Pod 选定的节点动态创建 PV。
生产环境本地盘、跨可用区云盘必须用 WaitForFirstConsumer,否则 PVC 绑了 A 区的盘,Pod 调度到 B 区,直接死锁。
二、实战操作
2.1 环境准备
我们用本地盘 + Longhorn 两种方案演示。先准备 3 节点集群,每节点加一块 100GB 数据盘(/dev/vdb)。
bash
# 1. 在每个节点格式化数据盘(给 Longhorn 用)
sudo mkfs.ext4 /dev/vdb
sudo mkdir -p /var/lib/longhorn
sudo mount /dev/vdb /var/lib/longhorn
# 持久化挂载
echo '/dev/vdb /var/lib/longhorn ext4 defaults 0 2' | sudo tee -a /etc/fstab
# 2. 装 CSI snapshot controller(用快照必备)
# 1.30 对应 snapshot controller v6.3.x
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v6.3.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v6.3.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v6.3.0/client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v6.3.0/deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/v6.3.0/deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml
# 验证
kubectl -n kube-system get pods -l app=snapshot-controller
2.2 部署 Longhorn(分布式块存储)
Longhorn 是 Rancher 开源的分布式块存储,适合自建集群,部署简单、可观测性强。
bash
# 1. 装 Longhorn( Helm 3.14)
helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn --version 1.6.2 \
--namespace longhorn-system \
--create-namespace \
--set defaultSettings.defaultDataPath="/var/lib/longhorn/" \
--set defaultSettings.defaultReplicaCount=2 \
--set persistence.defaultClassReplicaCount=2 \
--set defaultSettings.backupTarget="s3://longhorn-backup@us-east-1/" \
--set defaultSettings.backupTargetCredentialSecret="aws-secret"
# 2. 等待就绪
kubectl -n longhorn-system wait --for=condition=Ready pod --all --timeout=600s
# 3. 验证 StorageClass
kubectl get sc
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION
# longhorn driver.longhorn.io Delete Immediate true
# 4. 打开 Longhorn UI(本地端口转发)
kubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80
# 浏览器访问 http://localhost:8080,能看到节点、卷、副本的实时状态
Longhorn 的核心概念是"卷 = 多副本",每个卷默认 2-3 副本分布在节点上,写时同步。节点故障副本数减少会自动重建,保证可用性。
2.3 动态供给实战
bash
# 1. 创建 PVC
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data
namespace: default
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
EOF
# 2. 观察 PVC 绑定过程
kubectl get pvc mysql-data --watch
# NAME STATUS VOLUME CAPACITY ACCESS MODES
# mysql-data Pending (动态供给中)
# mysql-data Bound pvc-xxx-xxx-xxx 50Gi RWO
# 3. 看 PV 详情
kubectl get pv pvc-xxx-xxx-xxx -o yaml | grep -A5 csi
# 4. 起个 MySQL Pod 用这个 PVC
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels: { app: mysql }
template:
metadata:
labels: { app: mysql }
spec:
containers:
- name: mysql
image: mysql:8.4
env:
- name: MYSQL_ROOT_PASSWORD
value: "RootPwd2024"
ports:
- containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
EOF
# 5. 验证数据写入
kubectl exec mysql-0 -- mysql -uroot -pRootPwd2024 -e "CREATE DATABASE testdb; USE testdb; CREATE TABLE t(id INT PRIMARY KEY); INSERT INTO t VALUES(1); SELECT * FROM t;"
2.4 扩容、快照、恢复
bash
# 1. 扩容 PVC
kubectl patch pvc data-mysql-0 -p '{"spec":{"resources":{"requests":{"storage":"100Gi"}}}}'
kubectl get pvc data-mysql-0 --watch
# 等 FileSystemResizePending 消失,CAPACITY 变 100Gi
# 2. 创建快照
cat <<EOF | kubectl apply -f -
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: mysql-snap-$(date +%Y%m%d)
spec:
volumeSnapshotClassName: longhorn-snapshot
source:
persistentVolumeClaimName: data-mysql-0
EOF
# 注意:Longhorn 自带 VolumeSnapshotClass,需要先创建
cat <<EOF | kubectl apply -f -
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
name: longhorn-snapshot
driver: driver.longhorn.io
deletionPolicy: Delete
parameters:
type: snapshot
EOF
# 3. 看快照
kubectl get volumesnapshot
kubectl get volumesnapshotcontent
# 4. 从快照恢复(新建 PVC)
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-restored
spec:
storageClassName: longhorn
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi
dataSource:
name: mysql-snap-$(date +%Y%m%d)
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
EOF
2.5 TopoLVM(本地盘方案)
TopoLVM 是基于 LVM 的本地盘 CSI 插件,适合"低延迟、单节点"场景(如 Redis、本地缓存)。
bash
# 1. 每个节点准备 VG(Volume Group)
# 假设节点有空闲盘 /dev/vdc
sudo pvcreate /dev/vdc
sudo vgcreate topo-vg /dev/vdc
# 2. 装 TopoLVM(用 Helm)
helm repo add topolvm https://topolvm.github.io/topolvm
helm repo update
helm install topolvm topolvm/topolvm --version 0.16.0 \
--namespace topolvm-system \
--create-namespace \
--set lvmd.managed=false \
--set controller.nodeSelector."node-role\.kubernetes\.io/control-plane"=
# 3. 创建 StorageClass(关键:WaitForFirstConsumer)
cat <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: topo-lvm-ssd
provisioner: topolvm.io
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
parameters:
"csi.storage.k8s.io/fstype": "xfs"
"topolvm.io/device-class": "ssd"
reclaimPolicy: Delete
EOF
# 4. 用法
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
spec:
storageClassName: topo-lvm-ssd
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
EOF
三、踩坑与排查
坑 1:PVC 一直 Pending,但 StorageClass 没问题
现象 :kubectl get pvc 显示 Pending,kubectl describe pvc 没明显报错。
原因:几种可能:
- CSI provisioner Pod 没起来或卡住。
volumeBindingMode: WaitForFirstConsumer+ 没有 Pod 使用 PVC,故意延迟绑定。- 云盘配额/权限不够。
排查:
bash
# 1. 看 provisioner 日志
kubectl -n kube-system get pods | grep provisioner
kubectl -n kube-system logs <provisioner-pod> | grep <pvc-name>
# 2. 看是不是 WaitForFirstConsumer
kubectl get sc <sc-name> -o jsonpath='{.volumeBindingMode}'
# 如果是 WaitForFirstConsumer,创建一个使用该 PVC 的 Pod,才会触发绑定
# 3. 看事件
kubectl get events -n <pvc-namespace> --field-selector reason=ProvisioningFailed
# 4. 看 CSI 控制器是否健康
kubectl get csidriver
kubectl get csinode
坑 2:Pod 卡在 ContainerCreating,报 Unable to attach or mount volumes
现象 :Pod 一直 ContainerCreating,事件报 timeout expired waiting for volumes to attach。
原因:CSI attacher 没把云盘挂到节点,常见于:
- 云盘和 Pod 在不同可用区。
- 云盘已达挂载数上限(阿里云一台 ECS 最多挂 17 块云盘)。
- 节点 kubelet 卡住,csi-node-driver-registrar 没注册。
排查:
bash
# 1. 看 VolumeAttachment
kubectl get volumeattachment
# 看 ATTACHER 是否 true
# 2. 看 Pod 事件
kubectl describe pod <pod> | grep -A20 Events
# 3. 看 attacher 日志
kubectl -n kube-system logs <attacher-pod> | grep <pv-name>
# 4. 在节点上看磁盘是否真的挂上
lsblk
# 应该看到云盘设备 /dev/vdx
# 5. 看 csi-node-driver-registrar
kubectl -n kube-system get pods -o wide | grep csi-node
坑 3:扩容 PVC 后文件系统没变大
现象 :PVC status 显示 100Gi,但 df -h 在 Pod 里看还是 50Gi。
原因:ControllerExpand 完成了(云盘大了),但 NodeExpand 没触发(文件系统没扩)。常见原因:Pod 没重启,kubelet 没重新 mount;或 CSI 版本不支持 online 扩容。
解决:
bash
# 1. 看 PVC 状态
kubectl get pvc <pvc> -o jsonpath='{.status.conditions}'
# 如果有 FileSystemResizePending,说明 controller 扩完了,等 node 扩
# 2. 重启 Pod(让 kubelet 重新 mount,触发 NodeExpand)
kubectl delete pod <pod>
# StatefulSet 会自动重建
# 3. 验证
kubectl exec <pod> -- df -h /var/lib/mysql
# 4. 如果还不行,在节点上手动扩
# ext4:
sudo resize2fs /dev/vdx
# xfs:
sudo xfs_growfs /var/lib/kubelet/.../mount
坑 4:Longhorn 卷性能差,IO 延迟高
现象:MySQL on Longhorn,IO 延迟比物理机高 5 倍,业务慢。
原因:Longhorn 默认 2 副本,每次写要同步到 2 个节点,网络 IO 开销大。而且 Longhorn 是用户态存储引擎,不如内核态(Ceph RBD)快。
解决:
bash
# 1. 评估是否真需要副本
# 测试/日志类数据:1 副本够(性能优先)
# 生产关键数据:2-3 副本(可用性优先)
# 2. 创建单副本 StorageClass
cat <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn-single
provisioner: driver.longhorn.io
parameters:
numberOfReplicas: "1"
staleReplicaTimeout: "30"
reclaimPolicy: Delete
allowVolumeExpansion: true
EOF
# 3. 优化:开启 replica auto-balance、data locality
# 在 Longhorn UI 里调
坑 5:删 PVC 后云盘没释放,继续计费
现象:删了 PVC,云控制台还看到云盘,继续扣费。
原因 :reclaimPolicy: Retain 时,PV 不会自动删除,云盘保留。或 Delete 但 CSI 删除失败(权限/网络问题)。
排查:
bash
# 1. 看 PV 的 reclaimPolicy
kubectl get pv -o custom-columns=NAME:.metadata.name,POLICY:.spec.persistentVolumeReclaimPolicy
# 2. Retain 的 PV 删 PVC 后还在,要手动删
kubectl get pv # 找到 Released 状态的
kubectl delete pv <pv-name>
# 3. 如果 CSI 没删云盘,手动到云控制台删
# 4. 长期:把不重要的 StorageClass 改成 Delete
kubectl patch sc <sc-name> -p '{"reclaimPolicy":"Delete"}'
四、最佳实践与选型
4.1 存储类型选型表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 云上自建 MySQL/PG | 云厂商云盘 SSD + CSI | 性能稳定,快照原生支持 |
| 自建 IDC MySQL/PG | Ceph RBD / LVM + TopoLVM | 分布式或本地盘,可控 |
| Redis 缓存 | 本地盘 TopoLVM | 低延迟,数据可重建 |
| Kafka/ES 日志 | 本地盘 + 副本在应用层 | 大 IO,应用层已有副本 |
| 共享文件(多 Pod 同读) | NFS / CephFS / Longhorn(RWX) | 必须共享访问 |
| CI/CD 临时空间 | emptyDir + 内存盘 | 快,随 Pod 生命周期 |
| 大数据分析 | 对象存储 OSS/S3 + S3CSI | 海量、便宜 |
4.2 有状态服务存储实践
-
MySQL/PostgreSQL:
- 用 RWO 云盘/分布式块存储,不要用 NFS(数据库对 fsync 延迟敏感)。
- 容量提前规划,扩容可以但不能缩。
- 备份用快照 + 异地转储,不要依赖 PVC 复制。
- 谨慎用 StatefulSet,主从架构建议用 Operator(Vitess、Postgres-operator)。
-
Redis:
- 缓存场景用本地盘(TopoLVM),数据丢了能从源头重建。
- 持久化场景用云盘,但注意 AOF 对 IO 敏感。
- 单 Pod 单盘,避免共享。
-
Kafka/ES:
- 应用层有副本,存储用本地盘即可(性能优先)。
- JVM 堆和磁盘 IO 是瓶颈,选高 IO 实例 + 大本地盘。
- 不要用 Longhorn 这种用户态存储,IO 放大严重。
-
通用规范:
- 每个有状态 Pod 一个独立 PVC,不要共享。
- PVC 名用 StatefulSet 的 volumeClaimTemplates 自动生成,避免冲突。
reclaimPolicy: Retain用于生产数据,Delete仅测试环境。- 监控 PVC 使用率,80% 告警,避免打满。
- 关键数据定期快照 + 跨区域备份。
4.3 存储故障排查清单
| 现象 | 检查项 |
|---|---|
| PVC Pending | sc 是否存在 / provisioner Pod / WaitForFirstConsumer / 配额 |
| Pod ContainerCreating | VolumeAttachment / attacher 日志 / 节点磁盘 / csi-node-driver |
| PVC 扩容无效 | sc 是否 allowVolumeExpansion / FileSystemResizePending / Pod 重启 |
| 快照创建失败 | VolumeSnapshotClass / snapshot-controller / 源 PVC 状态 |
| IO 性能差 | 存储类型 / 副本数 / 文件系统 mount 选项 / 内核 IO 调度器 |
| 数据丢失 | reclaimPolicy / PV 是否被误删 / 快照备份策略 |
| 节点磁盘满 | 监控 / imageGC / emptyDir 清理 / Pod 驱逐 |
五、小结
K8s 存储的三层抽象(PV/PVC/StorageClass)是声明式的,但底层存储是命令式有状态的。CSI 是连接两边的桥梁,理解 CSI 的"provisioner/attacher/resizer/snapshotter"四个 sidecar 工作流,就理解了存储的所有异步行为------为什么 PVC 要等、为什么扩容要 Pod 重启、为什么快照不是即时的。
存储选型没有银弹,核心原则是"匹配场景":数据库要稳定低延迟用云盘/分布式块存储,缓存要极低延迟用本地盘,共享文件用 NFS/CephFS,大数据用对象存储。不要用一种方案打天下,尤其别把 Longhorn 这类用户态存储给数据库用,IO 放大会让你怀疑人生。
有状态服务上 K8s 务必谨慎:存储方案设计先行,备份恢复预案必须演练。reclaimPolicy: Retain 是生产数据的最后一道保险,误删 PVC 至少还能从 PV 恢复。快照不是备份,跨区域转储才是。
生产排障顺着 CSI 链路查:PVC → PV → VolumeAttachment → 节点 mount → 文件系统。每一步都有对应的 kubectl 命令和日志,不要一上来就重启节点。
思考题
- 你的核心数据库(MySQL/PG)现在用什么存储方案?如果让你重新设计,会改哪些地方?
- 如果生产 PVC 被误删,但 reclaimPolicy 是 Retain,你怎么把数据恢复回来?完整步骤是什么?
延伸阅读
- K8s 存储概念:https://kubernetes.io/docs/concepts/storage/
- CSI 规范:https://github.com/container-storage-interface/spec
- Longhorn 文档:https://longhorn.io/docs/
- TopoLVM:https://github.com/topolvm/topolvm
- Volume Snapshot:https://kubernetes.io/docs/concepts/storage/volume-snapshots/