K8s存储:PV/PVC/StorageClass与不同存储类型的生产选型
K8s存储:PV/PVC/StorageClass与不同存储类型的生产选型
适读人群:在K8s上部署有状态应用的工程师 | 阅读时长:约20分钟 | 适用版本:K8s 1.22+
开篇故事
2022年末,我们把一个MySQL实例迁到K8s,用的是本地SSD(hostPath类型的PV)。跑了三个月,有天那台Node因为磁盘控制器故障宕机了,K8s把MySQL Pod调度到另一台Node,但PV绑定的是旧Node的本地磁盘,新Node上没有这块磁盘,Pod一直处于Pending状态,数据库宕机将近两小时。
这次事故让我深刻认识到:存储选型直接决定了有状态应用的可用性。hostPath/本地存储虽然性能最好,但Node故障就意味着服务不可用(除非数据能跟着Pod迁移);网络存储(如Ceph、云磁盘)性能稍差但可用性高,Pod可以在任意Node上启动并挂载同一块存储。
今天把K8s存储体系的几个核心概念讲清楚,重点讲生产场景下的选型决策。
一、核心问题分析
K8s存储的三层抽象
K8s存储体系用三层抽象分离了存储的使用和管理:
PersistentVolume(PV):集群级别的存储资源,由管理员预先创建或StorageClass动态创建。PV描述了存储的具体实现(如NFS服务器地址、云磁盘ID)。
PersistentVolumeClaim(PVC):用户对存储的申请。用户声明需要多少容量、什么访问模式,K8s找到匹配的PV进行绑定。用户只和PVC打交道,不需要关心底层存储的实现细节。
StorageClass:动态存储供应的模板。定义了存储的提供者(Provisioner)、参数(如磁盘类型、IOPS)和回收策略。当PVC引用StorageClass时,K8s自动调用Provisioner创建对应的PV。
二、原理深度解析
存储类型与适用场景
不同场景的存储选型建议:
| 场景 | 推荐存储类型 | 访问模式 | 理由 |
|---|---|---|---|
| MySQL/PostgreSQL主节点 | 云块存储/Ceph RBD | RWO | 高IOPS,单节点写,自动冗余 |
| MySQL从节点(只读) | 云块存储 | RWO | 独立存储,主从通过复制同步 |
| Redis持久化 | 云块存储 | RWO | 需要快速读写 |
| 应用日志归档 | NFS/CephFS | RWX | 多Pod写入,低IOPS要求 |
| 静态文件(图片/CSS) | NFS/对象存储 | RWX或外部 | 多Pod读,写入少 |
| CI/CD缓存(Maven仓库) | NFS/CephFS | RWX | 多Pod共享读写 |
| 临时计算数据 | emptyDir | - | Pod级别,重启丢失 |
三、完整配置实现
StorageClass配置(多种后端)
# storageclass-configs.yaml
---
# 阿里云高效云盘(普通场景)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: alicloud-disk-efficiency
annotations:
storageclass.kubernetes.io/is-default-class: "false"
provisioner: diskplugin.csi.alibabacloud.com
parameters:
type: cloud_efficiency # 高效云盘
regionId: cn-hangzhou
zoneId: cn-hangzhou-h
reclaimPolicy: Retain # 重要:PVC删除后PV保留,防止数据丢失
volumeBindingMode: WaitForFirstConsumer # 等到Pod调度完再创建,确保在同一AZ
allowVolumeExpansion: true # 允许PVC扩容
---
# 阿里云SSD云盘(高性能场景,如数据库)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: alicloud-disk-ssd
provisioner: diskplugin.csi.alibabacloud.com
parameters:
type: cloud_ssd
regionId: cn-hangzhou
fsType: ext4
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
# NFS(共享存储场景)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-client
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
parameters:
server: "nfs-server.production.svc.cluster.local"
path: "/exports/k8s"
onDelete: "archive" # 删除时归档而不是直接删除
reclaimPolicy: Retain
volumeBindingMode: Immediate # NFS可以立即绑定(不受AZ限制)
---
# 本地SSD(仅用于测试或特定场景)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-ssd
provisioner: kubernetes.io/no-provisioner # 需要手动预创建PV
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: RetainPVC配置(数据库场景)
# pvc-mysql.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-pvc
namespace: production
labels:
app: mysql
annotations:
# 记录PVC用途,便于管理
purpose: "MySQL primary node data"
created-by: "ops-team"
spec:
# 引用StorageClass,由Provisioner动态创建PV
storageClassName: alicloud-disk-ssd
# 访问模式:单节点读写(MySQL主节点)
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Gi # 根据实际数据量预留,可以后续扩容StatefulSet中的volumeClaimTemplates(推荐)
# mysql-statefulset-storage.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: production
spec:
serviceName: mysql-headless
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0.36
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
volumeMounts:
- name: data
mountPath: /var/lib/mysql
- name: config
mountPath: /etc/mysql/conf.d
resources:
requests:
cpu: "1000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "8Gi"
volumes:
- name: config
configMap:
name: mysql-config
# volumeClaimTemplates:StatefulSet专用,为每个Pod自动创建PVC
# PVC命名格式:data-mysql-0, data-mysql-1, ...
volumeClaimTemplates:
- metadata:
name: data
annotations:
purpose: "MySQL data directory"
spec:
storageClassName: alicloud-disk-ssd
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 200Gi手动创建PV(本地存储场景)
# pv-local.yaml
# 本地存储的PV需要手动创建,并添加nodeAffinity
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-node1-ssd1
labels:
storage-type: local-ssd
spec:
capacity:
storage: 500Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
# 重要:本地PV必须设置节点亲和性
# 确保使用这个PV的Pod只调度到这台Node
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- worker-node-1
# 本地存储的配置
local:
path: /mnt/ssd1/k8s-data
# 本地存储推荐Retain,数据无价
persistentVolumeReclaimPolicy: Retain
storageClassName: local-ssdPVC扩容操作
# 在线扩容PVC(需要StorageClass支持allowVolumeExpansion: true)
# 查看当前PVC大小
kubectl get pvc mysql-data-pvc -n production
# 编辑PVC,修改storage大小
kubectl patch pvc mysql-data-pvc \
-n production \
-p '{"spec":{"resources":{"requests":{"storage":"300Gi"}}}}'
# 观察扩容进度
kubectl describe pvc mysql-data-pvc -n production
# 等待状态从 FileSystemResizePending 变成 Bound
# 如果扩容失败需要手动扩展文件系统(部分存储后端需要)
kubectl exec -it mysql-0 -n production -- \
resize2fs /dev/vdb # 具体设备名根据实际情况四、生产最佳实践
存储的备份策略
PV里的数据是最有价值的,备份不能依赖K8s本身:
云块存储:定期创建快照(云厂商提供,自动化配置),快照频率建议每日全量快照,保留30天。
自建存储(Ceph):结合Velero做K8s资源 + PV数据的整体备份,支持跨集群恢复。
数据库专项备份:不管底层存储怎么做,数据库自身的逻辑备份(mysqldump、pg_dump)一定要有,这是最后的救命稻草。
StorageClass的回收策略选择
reclaimPolicy是个关键设置:
Retain:PVC删除后,PV和实际数据保留。数据安全,但需要手动清理。生产推荐。Delete:PVC删除后,PV和实际存储(如云盘)一起删除。危险!误删PVC就是丢数据。Recycle:已废弃,不建议使用。
生产环境一律用Retain,哪怕增加一些手动清理工作,也比误删数据要安全得多。
五、踩坑实录
坑一:多AZ集群下PVC和Pod调度到不同AZ
云厂商的块存储是AZ级别的,一块云盘只能挂载到同一AZ的机器上。如果PVC先在AZ-a创建了云盘,但Pod被调度到了AZ-b,Pod就一直Pending,报no matching nodes available。
解决方案是把StorageClass的volumeBindingMode设为WaitForFirstConsumer(等第一个消费者调度完再创建云盘,确保在同一AZ):
volumeBindingMode: WaitForFirstConsumer坑二:PVC删了但云盘账单还在计费
有次清理测试环境,删了Deployment和PVC,以为存储也一起清理了。结果月底账单发现还有好几块云盘在计费。
原因是PVC删了,但由于reclaimPolicy是Retain,PV还在,对应的云盘也还在。K8s只是把PV状态改成了Released,实际资源没有释放。
删PVC时需要同时检查并手动删除PV:
# 查看Released状态的PV
kubectl get pv | grep Released
# 手动删除不需要的PV(会触发Delete策略或需要手动删云盘)
kubectl delete pv <pv-name>坑三:emptyDir被JVM当做临时文件目录,磁盘爆满
某个服务的JVM会在临时目录写大量的GC日志和heap dump文件,而容器的临时目录用的是emptyDir,受Node磁盘空间限制。Node磁盘快满时,kubelet触发节点压力驱逐,把这个Node上的Pod驱逐出去,影响到了其他正常服务。
解决方案:为JVM的临时文件指定专用的emptyDir并设置大小限制:
volumes:
- name: jvm-tmp
emptyDir:
sizeLimit: 2Gi # 限制emptyDir大小,防止耗尽Node磁盘
containers:
- name: order-service
volumeMounts:
- name: jvm-tmp
mountPath: /tmp
env:
- name: JAVA_OPTS
value: "-Djava.io.tmpdir=/tmp -XX:HeapDumpPath=/tmp"六、总结
K8s存储选型的核心取舍是:性能 vs 可用性。本地存储性能最好,但Node故障意味着服务中断;网络存储(云盘、Ceph)性能稍差,但Pod可以在不同Node间漂移,可用性更高。
对于生产环境的数据库,推荐云块存储(AWS EBS、阿里云SSD云盘)或自建的高性能分布式存储(Ceph RBD),配合WaitForFirstConsumer调度策略和Retain回收策略,确保数据安全和可用性。
存储的备份是独立话题,K8s的存储层不提供数据备份能力,必须在应用层或存储层单独配置备份方案。
