接手这套Kubernetes集群时,第一眼看到清单我就知道,存储这一关避不开。集群里跑着Kafka、Redis、Hadoop、Doris这一堆有状态服务,大部分还是从裸机迁移过来的,数据目录的管理方式千奇百怪。有人直接用hostPath,有人用本地目录挂NFS,还有人压根没配持久化,Pod一重建数据就没了。当时的状态基本属于“能跑就行”,但线上每次发布、每次故障转移都像开盲盒。这篇文章就把我在这个集群里做存储管理的完整思路和实操过程整理出来,从StorageClass设计到PV/PVC生命周期管理,再到被坑过的典型案例,全部是基于实际踩坑后的复盘。内容适合正在维护K8s集群、尤其是集群里跑着大数据或中间件组件的运维和开发工程师,看完可以直接回自己环境对照调整。
1. 整体设计与存储架构拆解
1.1 为什么存储管理是K8s集群的隐形骨架
很多人刚上手Kubernetes时,注意力全放在Pod、Deployment、Service这些工作负载概念上,存储往往被当成“挂个盘”的小事。但只有真正维护过生产级集群的人才会明白,存储管理才是整个集群稳定性的分水岭。无状态应用随便滚动重启,影响面小;但一旦涉及Kafka的日志目录、Redis的AOF文件、Hadoop的DataNode数据块、Doris的BE节点存储,存储的任何一个环节出问题,都会直接演变成数据丢失或集群长时间不可用的灾难。
K8s本身对存储的设计非常清晰,用三层抽象把存储和业务解耦:最底层是实际的存储介质或存储系统,中间是PV(PersistentVolume)描述存储资源的静态供给,上层是PVC(PersistentVolumeClaim)由业务方声明存储需求。在这之上还有一个StorageClass,负责把“按需创建存储”这件事自动化,也就是动态供给。理解这三层的关系,是做好集群存储管理的起点。
我在这套集群里接手时,发现大部分应用根本没有走这套抽象,直接就是hostPath挂宿主机目录。hostPath不是不能用,但调度器无法感知存储分布,Pod被调度到其他节点时,数据目录就丢了。这本质上是把K8s的调度能力亲手废掉了。后来我做的第一件事,就是分批次把核心有状态服务迁到以PVC为核心的存储模型上。
1.2 有些坑是架构选型时就埋下的
再说一个真实的架构判断过程。集群里跑着大量大数据组件,如果全部走NFS,那性能一定会出问题。Kafka和Doris这种对磁盘顺序读写和延迟敏感的服务,放在远程网络存储上,吞吐和延迟直接不可用。但Redis和部分元数据服务对容量的要求不高,对可用性要求又极高,放在分布式存储上反而能获得更好的跨节点调度能力。
所以我做了一个分级存储的方案设计:第一级是本地SSD,通过本地PV插件实现,专门给Kafka、Doris、HBase这类追求极致IOPS和低延迟的组件用;第二级是分布式存储,通过CSI驱动的RWO卷,给Redis、ZooKeeper以及部分需要跨节点漂移的服务用;第三级才是NFS,只跑一些日志采集、离线分析这类对延迟不敏感的Pod。
这个分级的思路,本质上不是选一个“最好”的存储,而是根据应用的状态特征、性能敏感度和跨节点漂移需求,匹配最合适的存储层级。后面我所有StorageClass和PV策略的设计,都是在这个大框架下展开的。
2. 存储方案选型与核心机制详解
2.1 五种常见存储方案横向对比
实战中接触过太多存储方案,我把在K8s里真正会用到的几类做了个对比表,方便判断选型。这里尽量说大实话,把适用场景和坑都写清楚。
| 存储方案 | 供给方式 | 性能水平 | 跨节点共享 | 典型场景 | 主要坑点 |
|---|---|---|---|---|---|
| emptyDir | 临时卷 | 依赖节点磁盘 | 不支持 | 缓存、临时计算 | Pod删除数据即消失 |
| hostPath | 静态手动 | 依赖节点磁盘 | 不支持 | 单机调试、日志采集 | 调度漂移后数据丢失 |
| 本地PV(Local PV) | 静态或动态 | 最高,直连本地盘 | 不支持 | Kafka、数据库、Doris BE | 节点故障=数据风险 |
| NFS | 动态/静态 | 中等,网络延迟 | 支持 | 日志、文件共享 | 高并发下锁和延迟问题 |
| CSI分布式存储 | 动态 | 取决于后端 | 支持 | Redis、ZooKeeper、通用负载 | 运维成本高,版本兼容需注意 |
选型时我习惯先问三个问题:应用能不能容忍节点级故障?应用是读多写多还是追求低延迟?应用是否需要跨节点共享数据?这三个问题的答案基本就能圈定方案范围。
2.2 StorageClass动态供给的核心逻辑
StorageClass的作用很多人理解成“一个模板”,其实它更像一条自动化流水线。你提交PVC时,K8s会去找匹配的StorageClass,然后调用对应的Provisioner,让底层存储系统真正创建出一个卷,再生成PV对象并绑定到PVC。这个流程把传统运维手动创建磁盘、格式化、挂载的步骤全部自动化了。
用我集群里的YAML举个例子,一个本地盘动态供给的StorageClass大概长这样:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-ssd provisioner: kubernetes.io/no-provisioner volumeBindingMode: WaitForFirstConsumer这里的provisioner用的是kubernetes.io/no-provisioner,配合本地PV的静态供给方式。真正动态创建本地卷,需要额外部署像OpenEBS、TopoLVM这类支持本地的CSI插件。如果你用云厂商的K8s,通常直接用云盘驱动做动态供给就行。
StorageClass里有两个参数非常关键。第一个是reclaimPolicy,它决定PV被释放后的处理方式,Delete表示删除PVC时连带存储一起删掉,Retain表示保留数据,需要人工处理。第二个是volumeBindingMode,Immediate表示PVC创建时就立即绑定存储,WaitForFirstConsumer表示等Pod调度后绑定。后者对本地盘和需要节点亲和性的存储至关重要。
2.3 有状态工作负载的配套组件
存储管理永远不是单独存在的,它和StatefulSet这套编排机制是配合关系。StatefulSet和Deployment最大的区别在于,它为每个Pod提供了稳定的网络标识和稳定的存储标识。稳定存储就靠volumeClaimTemplates实现,Pod每次重建,都会重新绑定到原来那个PVC上,数据不丢。
看一个Redis StatefulSet的片段,就能直观理解volumeClaimTemplates的用法:
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster spec: serviceName: redis-headless replicas: 6 selector: matchLabels: app: redis-cluster template: metadata: labels: app: redis-cluster spec: containers: - name: redis image: redis:7.0 volumeMounts: - name: data mountPath: /data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: csi-distributed resources: requests: storage: 10Gi这个模板和Deployment的Pod模板是两回事。Pod模板里的存储是共享的,所有副本用相同配置但实际上是同一个PVC;volumeClaimTemplates是每个副本自动生成独立的PVC,命名规则是<volumeClaimTemplates名称>-<StatefulSet名称>-<序号>,比如>apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-ssd provisioner: topolvm.io volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true
注意我开启了allowVolumeExpansion,这是为后续扩容留的口子。然后提交这个YAML,再创建一个测试PVC:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-local-pvc spec: accessModes: - ReadWriteOnce storageClassName: local-ssd resources: requests: storage: 5Gi创建完PVC,它会停留在Pending状态,这是正常现象,因为WaitForFirstConsumer正在等Pod。然后创建一个挂载这个PVC的Pod:
apiVersion: v1 kind: Pod metadata: name: test-pod spec: containers: - name: app image: busybox command: ["sleep", "3600"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: test-local-pvcPod一旦被调度到某个节点,PVC会自动完成绑定。此时你执行kubectl get pvc可以看到状态从Pending变成Bound。整个过程不需要手动干预,这就是动态供给的价值。如果是静态的hostPath方案,你得自己创建PV、自己指定节点,还得管理一堆资源清单,动态供给把这一切都自动化了。
3.3 用volumeClaimTemplates部署Kafka的完整实录
前面讲过StatefulSet和volumeClaimTemplates的配合,这里用Kafka做一个完整的落地实例。Kafka对存储的要求在性能之外,更关键的是数据目录的稳定性和容灾能力。我选型用的是本地SSD StorageClass,副本数为3。
关键配置如下,缩略了与存储无关的探针和资源限制部分:
apiVersion: apps/v1 kind: StatefulSet metadata: name: kafka spec: serviceName: kafka-hs replicas: 3 selector: matchLabels: app: kafka template: metadata: labels: app: kafka spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: kafka topologyKey: kubernetes.io/hostname containers: - name: kafka image: bitnami/kafka:3.5 env: - name: KAFKA_CFG_LOG_DIRS value: /var/lib/kafka/data volumeMounts: - name: data mountPath: /var/lib/kafka/data volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: local-ssd resources: requests: storage: 500Gi这里有一个隐藏的设计细节:podAntiAffinity配置了同一应用的不同副本尽量调度到不同节点。因为本地卷和节点强绑定,如果三个Kafka副本落在同一个节点,那节点故障等于三个副本一起挂。加了反亲和性之后,每个副本落在不同节点,配合Kafka自身的副本机制,容灾能力才真正建立起来。
volumeClaimTemplates创建出来的PVC,命名会是>kubectl patch pvc>kubectl describe pvc <pvc-name>
Events里会直接告诉你卡在哪一步。常见提示是waiting for first consumer to be created before binding,这是正常的等待状态;如果提示storageclass.storage.k8s.io "xxx" not found,那就是SC名字写错了;如果是Failed to provision volume with StorageClass "xxx",则要去检查CSI驱动Pod的状态。
另一个容易被忽略的点是:PV的节点亲和性冲突。本地PV会绑定具体节点,如果该节点被打了污点或者处于NotReady状态,调度器无法把Pod调度过去,PVC自然绑定不了。这种问题在describe事件里不一定直接体现,需要看Pod的调度事件才能定位。
4.3 Kafka和Doris跑在NFS上,延迟直接崩盘
这是压测阶段踩过的一个大坑。当时为了省事,把Kafka的日志目录放到了NFS共享存储上,结果压测一上去,生产者的延迟指标就开始剧烈抖动,TOP命令看到大量IO等待。原因其实很直白:Kafka要求的是本地磁盘级别的顺序写性能,NFS多了一层网络协议栈,还有锁竞争。Kafka的日志段文件要频繁调用fsync,NFS每次fsync都要走一次网络往返,性能瓶颈立刻显现。
这个案例给我们的教训是:选存储方案之前,一定要先做应用性能画像。Kafka、Doris BE节点、ES这类对IOPS和延迟敏感的应用,老老实实上本地盘,别偷懒。反过来,像ZooKeeper这种虽然也要求低延迟,但它同时要求跨节点调度,本地盘反而绑定太死,这时候用分布式存储更合适。
4.4 Pod反复CrashLoopBackOff,StorageClass却“看起来正常”
这是另一个类型的坑。现象是Redis Pod反复重启,PVC是Bound状态,存储看起来完全正常,但容器一直起不来。最后看日志发现挂在/data时出现的Permission denied错误。原因很隐蔽:PV对应的后端存储文件系统属主和权限不对,容器里的Redis用户没有写权限。
很多人会用fsGroup来解决,但fsGroup不是万能的。它需要CSI驱动支持对卷做递归chown,一些网络存储或本地卷驱动在性能上不划算或默认不开启。更可控的做法是直接在应用的SecurityContext里指定runAsUser和runAsGroup,和存储目录的属主保持一致。比如存储目录属主是UID 1001,那容器进程就用UID 1001跑,彻底绕开权限问题。
fsGroup只有在卷驱动支持时才可靠,对本地盘和部分CSI存储来说,时机和范围都不好控制。我建议能用runAsUser解决的就不要依赖fsGroup,减少一层不确定性。4.5 StatefulSet的PVC不清理,扩容把旧盘也带上了
还有一次踩坑是关于StatefulSet扩容的。原来的副本数是3,扩容到6时,新Pod挂上了PVC,但PVC名字已经暴露了问题。因为volumeClaimTemplates创建的PVC命名是按序号递增的,扩容前旧副本的PVC是>