【K8S 运维实战】04-存储体系梳理CSI

存储体系梳理: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)。绑定有两种方式:

  1. 静态绑定:管理员预先创建 PV,用户创建 PVC,系统按容量/访问模式匹配。
  2. 动态绑定(生产主流):用户创建 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 调用:

  1. CSI Provisioner :watch PVC 事件,调用 CSI Controller 的 CreateVolume/DeleteVolume
  2. CSI Attacher :watch VolumeAttachment 事件,调用 ControllerPublish/ControllerUnpublish(把云盘挂到目标节点)。
  3. CSI Resizer :watch PVC 扩容请求,调用 ExpandVolume(控制器侧)+ NodeExpandVolume(节点侧)。
  4. 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。流程:

  1. 用户改 PVC 的 spec.resources.requests.storage(从 100Gi 改 200Gi)。
  2. StorageClass 必须开 allowVolumeExpansion: true
  3. CSI Resizer watch 到变化,调 CSI Controller ExpandVolume(改云盘大小)。
  4. 云盘扩容后,PVC 的 status.conditions 显示 FileSystemResizePending
  5. 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 没明显报错。

原因:几种可能:

  1. CSI provisioner Pod 没起来或卡住。
  2. volumeBindingMode: WaitForFirstConsumer + 没有 Pod 使用 PVC,故意延迟绑定。
  3. 云盘配额/权限不够。

排查:

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 命令和日志,不要一上来就重启节点。

思考题

  1. 你的核心数据库(MySQL/PG)现在用什么存储方案?如果让你重新设计,会改哪些地方?
  2. 如果生产 PVC 被误删,但 reclaimPolicy 是 Retain,你怎么把数据恢复回来?完整步骤是什么?

延伸阅读

相关推荐
小秋求学记.20 小时前
Linux_Ubuntu的相关问题
linux·运维·ubuntu
熊猫钓鱼>_>20 小时前
ArkTS 方舟编程语言 · 原创快速入门教程
运维·架构·ts·harmonyos·arkts·鸿蒙·js
β添砖java20 小时前
黑马Linux笔记
linux·运维·笔记
在水一缸21 小时前
当 AI 拥有了“核按钮”:深入解析 MCP 服务器与命令执行护栏
运维·服务器·人工智能·命令执行·智能体·ai安全·mcp
爱莉希雅&&&21 小时前
Prometheus高可用(alertmanager+node_exporter+grafana)
运维·服务器·grafana·prometheus
增量星球21 小时前
《持续交付2.0系列六》业务需求协作管理
java·运维·自动化·devops·持续部署·持续集成
运维大师1 天前
【K8S 运维实战】03-网络模型实战CNI
运维·网络·kubernetes
Huangjin007_1 天前
【Linux 系统篇(四)】权限详解(一)
linux·运维·服务器
AOwhisky1 天前
Python 学习笔记(第十一期)——运维自动化(上·后篇):进程级监控与子进程管理——psutil进阶
运维·开发语言·python·学习·云原生·运维开发