引言:为什么很多团队把 Pod 跑稳了,却把数据跑丢了
无状态服务迁进 Kubernetes 往往不难,真正把团队拦住的,通常是存储。
应用开发习惯把“写文件”看成一件理所当然的小事,但在 Kubernetes 里,文件写到哪里、由谁分配、何时挂载、节点挂了以后还能不能找回来、跨可用区怎么调度、扩容和快照由谁负责,这些都不再是一个 mountPath 能解释清楚的问题。
很多线上事故并不是因为团队不会写 YAML,而是没有把 Kubernetes 存储看成一条完整链路:
- Pod 里声明的是
Volume
- 租户申请的是
PersistentVolumeClaim
- 集群交付的是
PersistentVolume
- 平台抽象的是
StorageClass
- 真正和后端存储打交道的是
CSI
这篇文章不再停留在“Volume 有哪些类型”的入门层面,而是从生产视角把这条链路讲透:对象关系、控制面协作、节点挂载路径、调度约束、动态供给、快照扩容、故障恢复,以及高并发场景下的工程治理。
全文围绕一个真实可落地的业务场景展开。如果你希望深入探讨 Kubernetes 存储架构,可以来 云栈社区 与同行交流。
1. 业务场景:电商交易平台为什么会被存储拖垮
我们以一个区域型电商平台为例。平台日常订单峰值约 1.8 万 QPS,大促时翻 4 到 6 倍,核心服务全部运行在 Kubernetes 1.30 集群中。
系统中和存储强相关的组件有四类:
- 订单服务
order-service:Deployment,无状态,但会写本地临时文件、失败重试队列和导出中间结果。
- 支付服务
payment-service:Deployment,需要共享证书、风控规则和审计归档目录。
- MySQL 主从集群:StatefulSet,要求低延迟、可扩容、可备份、节点故障后可恢复。
- Kafka 集群:StatefulSet,高吞吐写入,对顺序写和磁盘吞吐敏感,自身具备副本机制。
团队最初的方案并不复杂:
- 临时数据全部写
emptyDir
- 共享目录直接上 NFS
- MySQL 先用
hostPath
- Kafka 也先挂云盘
这套方案在测试环境跑得很好,但一上生产就暴露出四类典型问题。
1.1 第一类问题:把临时卷当持久卷
订单服务把“待上传图片分片”和“导出任务中间文件”写进 emptyDir。Pod 重建后,任务元数据还在数据库里,但文件已经没了,导致大量任务进入“状态存在、结果不存在”的半失败状态。
1.2 第二类问题:把共享文件系统当成通用解法
团队让多个服务共享 NFS 目录用于日志、配置和中间文件。大促期间 NFS 出现短抖动,应用线程大量阻塞在 IO 上,JVM 堆外内存增长,最终多个 Pod 同时被驱逐或 OOMKilled。
1.3 第三类问题:把节点本地路径当成数据库盘
MySQL 初期使用 hostPath,平时看不出问题,但节点维护或宕机后,Pod 被调度到新节点,数据根本不在,导致数据库实例无法恢复。
1.4 第四类问题:只会创建 PVC,不理解绑定与拓扑
某次新建 StatefulSet 时,PVC 始终 Pending。排查发现云盘 StorageClass 使用了立即绑定,卷提前创建在可用区 A,而 Pod 被调度候选节点主要在可用区 B,结果卷和节点永远对不上。
这些问题背后不是某个 YAML 写错了,而是团队没有把 Kubernetes 存储模型当成一套资源协作系统来理解。
2. 先建立全景图:Kubernetes 存储到底分几层
如果只记一句话,我建议记住这一句:
Pod 负责声明“我要怎么用存储”,PVC 负责声明“我要多少存储”,PV 负责代表“平台交付了什么存储”,StorageClass 负责定义“如何交付存储”,CSI 负责真正“把存储做出来并挂上去”。
对应关系如下。
| 层次 |
资源/组件 |
主要职责 |
谁来关注 |
| 应用层 |
volumeMounts、volumes |
容器内挂载点与使用方式 |
应用开发 |
| 申请层 |
PersistentVolumeClaim |
容量、访问模式、存储类、卷模式 |
应用开发/中间件团队 |
| 供给层 |
PersistentVolume |
具体卷实例、后端卷标识、回收策略 |
平台/存储团队 |
| 模板层 |
StorageClass |
动态供给参数、拓扑、回收、扩容能力 |
平台团队 |
| 插件层 |
CSI Driver |
创建卷、附加卷、挂载卷、扩容、快照 |
平台/存储团队 |
| 节点层 |
kubelet + OS 文件系统 |
格式化、挂载、卸载、路径维护 |
节点与容器运行时 |
很多人把 Volume 和 PV 混在一起讲,这是理解错误的起点。
2.1 Volume 是 Pod 视角,不一定持久
Pod 里的 volumes 只是“容器要使用的卷定义”。它可能来自:
emptyDir
configMap
secret
projected
persistentVolumeClaim
ephemeral 卷
也就是说,Volume 只是入口,不等于持久化。
2.2 PVC 是需求单,不是存储本体
PVC 只描述意图:
- 需要多少容量
- 需要什么访问模式
- 用哪个
StorageClass
- 是文件系统还是裸块设备
PVC 本身不存数据。真正的数据落点在绑定后的 PV 对应的后端存储上。
2.3 PV 是平台交付物,不属于某个 Pod
PV 的生命周期独立于 Pod。只要回收策略不是直接删除,Pod 删了,PV 依然可以存在。也正因为如此,PV 才能承担“持久化”的语义。
2.4 StorageClass 决定动态供给行为
StorageClass 里最关键的不是名字,而是这几类能力:
provisioner:谁负责创建卷
parameters:创建卷时传给后端驱动的参数
reclaimPolicy:PVC 删除后卷怎么处理
allowVolumeExpansion:是否允许在线扩容
volumeBindingMode:立即绑定还是延迟到消费者出现后绑定
mountOptions:挂载选项
2.5 CSI 才是 Kubernetes 存储插件化的核心
Kubernetes 早期有大量 in-tree 存储插件,云厂商和开源存储每家一套实现,升级成本高,耦合严重。CSI 的意义就在于把“存储能力接入 Kubernetes”标准化,让 Kubernetes 不再内置所有卷插件逻辑。
简单说,CSI 解决的是两件事:
- 让 Kubernetes 不必了解每一种后端存储的细节
- 让存储厂商只需要实现一套标准接口就能被 Kubernetes 调用
3. Pod 数据生命线:从提交 YAML 到容器真正写盘,中间发生了什么
大多数文章讲到 PVC 绑定就结束了,但真正决定系统稳定性的,是后面的控制面与节点协作过程。
下面按时间顺序走一遍完整链路。
3.1 第一步:开发者提交 Pod 和 PVC
应用侧通常会同时提交:
- Deployment/StatefulSet
- 直接引用 PVC 的 Pod 模板
- 或者在
volumeClaimTemplates 中声明自动创建 PVC
此时 API Server 只是存储对象,数据还没有挂到任何节点。
3.2 第二步:PVC 绑定或触发动态供给
persistentvolume-controller 会观察 PVC:
- 如果集群里已有匹配 PV,就直接绑定
- 如果没有,但 PVC 指定了 StorageClass,就走动态供给
动态供给时,真正创建卷的不是 controller 本体,而是 CSI sidecar 中的 external-provisioner。它会调用 CSI CreateVolume 接口,让后端存储创建实际卷,然后再回写 PV 对象。
3.3 第三步:调度器把“算力约束”和“存储约束”一起算
这里最容易被忽略。
Pod 调度从来不只看 CPU 和内存。只要 Pod 依赖 PVC,调度器还要同时考虑:
- 该卷能否挂到某个节点
- 该节点所在拓扑区域是否满足卷约束
- 卷是否已经绑定到别的节点或可用区
- 本地盘是否就在该节点上
如果 StorageClass 使用 WaitForFirstConsumer,调度器会先选节点,再由供给逻辑在该节点可达的拓扑域内创建卷。对云盘、多可用区、本地盘,这几乎是生产必选项。
3.4 第四步:Attach 阶段把卷“送到节点边上”
对于需要附加的块存储,比如 EBS、云硬盘、Ceph RBD,控制面会先做 Attach。
这一步通常由:
external-attacher
VolumeAttachment
- 存储后端 API
共同完成。
如果后端是 NFS、CephFS 这类网络文件系统,通常没有“附加设备”这个动作,节点可以直接挂载远端文件系统。
3.5 第五步:kubelet 在节点上完成 Stage 和 Publish
卷真正变成容器可见目录,是 kubelet 与 CSI Node 插件配合完成的。
典型路径分两段:
NodeStageVolume:把设备映射到节点、格式化文件系统、做全局挂载
NodePublishVolume:把已经准备好的卷 bind mount 到某个 Pod 目录
这个两阶段设计的好处是:
- 一个卷可以先在节点级准备好
- 多个 Pod 使用时避免重复做重操作
- 节点重建挂载状态更清晰
3.6 第六步:容器启动,应用才真正开始读写
很多人误以为“Pod 已经 Running,卷一定没问题”。不完全对。
真实生产里要继续关注:
- 挂载后的文件系统权限
fsGroup 是否生效
- 容器用户与目录属主是否匹配
- 网络文件系统是否有长尾延迟
- 节点断链后 IO 是否卡死
Pod Running 只是说明“启动时挂载成功”,不等于“运行期间存储一直健康”。
4. 常见卷类型不是背清单,而是理解适用边界
4.1 emptyDir:最快上手,也最容易被误用
特征:
- Pod 创建时生成
- Pod 删除后数据消失
- 容器重启不丢,Pod 重建会丢
- 可落磁盘,也可落内存
medium: Memory
适合场景:
- 缓存
- 转码中间文件
- 临时排序和聚合结果
- Sidecar 间共享运行时数据
不适合场景:
- 任何需要跨 Pod 生命周期保留的数据
- 任务恢复依赖本地文件的批处理系统
4.2 hostPath:不是不能用,但只能谨慎用
特征:
- 直接挂宿主机路径
- 性能高,简单粗暴
- 完全绑定节点
- 安全风险大
适合场景:
- 采集宿主机日志
- 节点级代理读取容器运行时目录
- 实验环境或单节点调试
不适合场景:
- 生产数据库
- 可迁移的业务数据盘
- 多租户集群中的普通业务容器
4.3 local PV:本地盘的规范化版本
很多团队会把 local PV 和 hostPath 视为同义词,这是错误的。
local PV 的价值在于:
- 通过 PV/PVC 体系管理本地盘
- 可参与调度约束
- 生命周期更清晰
- 适合 Kafka、Elasticsearch、ClickHouse 这类自带副本机制的系统
它的代价也很明确:
- 节点坏了,卷不会自动迁移
- 数据恢复依赖应用自身复制能力
4.4 网络文件系统卷:RWX 不是万能药
典型代表:
- NFS
- CephFS
- EFS
- Azure Files
优势:
- 支持
ReadWriteMany
- 多 Pod 共享方便
- 运维心智负担低
问题:
- 元数据操作和小文件写入性能通常不如本地盘/块存储
- 网络抖动会直接反映到业务线程
- 文件锁、目录热点、单目录大文件数量都可能成为瓶颈
4.5 块存储卷:数据库的主战场
典型代表:
优势:
- 随机读写能力强
- 延迟更可控
- 更适合数据库、KV、状态机日志
限制:
- 大多数只支持
ReadWriteOnce
- 多副本共享写并不天然成立
5. 生产选型:不是“哪个最好”,而是“哪类业务配哪类卷”
下面给出一份更接近真实生产的存储分层设计。
5.1 推荐选型矩阵
| 业务类型 |
推荐存储 |
关键原因 |
明确边界 |
| Web 服务临时文件 |
emptyDir |
简单、低延迟、无需外部依赖 |
Pod 重建即丢 |
| 多 Pod 共享配置归档 |
CephFS/NFS/EFS |
支持 RWX |
高并发写入需评估 |
| MySQL/PostgreSQL |
RBD/云盘类块存储 |
RWO、性能稳、可扩容、可快照 |
不适合多实例共享写 |
| Kafka/Elasticsearch |
local PV + 本地 SSD |
顺序写强、吞吐高 |
节点故障靠应用副本恢复 |
| 宿主机日志采集 |
hostPath |
必须读取节点目录 |
只给 DaemonSet 或受控组件 |
5.2 不同团队的职责边界
把职责切清楚,比技术选型本身更重要。
- 应用团队负责:PVC 规格、目录使用方式、读写模式、数据恢复语义
- 平台团队负责:StorageClass、CSI 驱动、拓扑约束、配额、备份与监控
- DBA/中间件团队负责:数据库或消息系统自身副本、刷盘策略、恢复流程
如果团队把“存储可靠性”全部甩给 Kubernetes,本身就是错误预期。Kubernetes 负责调度和抽象,不负责替你的应用设计数据冗余模型。
6. 生产级 YAML:从临时缓存到 StatefulSet 持久化
这一节给几组可直接作为生产基线的配置。
6.1 订单服务:临时文件使用 emptyDir,避免误当持久卷
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
spec:
replicas: 6
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: app
image: registry.example.com/order-service:1.4.2
ports:
- containerPort: 8080
env:
- name: TMP_DIR
value: /data/tmp
volumeMounts:
- name: tmp-data
mountPath: /data/tmp
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "2Gi"
volumes:
- name: tmp-data
emptyDir:
sizeLimit: 8Gi
这里有两个生产细节常被漏掉:
- 给
emptyDir 设置 sizeLimit,避免把节点磁盘吃穿
- 让应用显式区分“临时目录”和“业务结果目录”,不要把临时数据和持久数据混写
6.2 共享归档目录:RWX 卷使用独立 StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: shared-nfs
provisioner: nfs.csi.k8s.io
parameters:
server: 10.10.20.15
share: /k8s-shared
reclaimPolicy: Retain
mountOptions:
- nfsvers=4.1
- hard
- timeo=600
- retrans=2
volumeBindingMode: Immediate
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: payment-archive-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 200Gi
storageClassName: shared-nfs
如果多个副本同时写共享目录,建议再加两层约束:
- 业务目录按实例分层,例如
/archive/${POD_NAME}/
- 归档写入异步化,不要让主请求线程直接阻塞在网络文件系统上
6.3 MySQL:使用 StatefulSet + volumeClaimTemplates + RWO 块存储
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rook-ceph-block
provisioner: rook-ceph.rbd.csi.ceph.com
parameters:
clusterID: rook-ceph
pool: mysql-replicapool
imageFormat: "2"
imageFeatures: layering
csi.storage.k8s.io/provisioner-secret-name: rook-csi-rbd-provisioner
csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
csi.storage.k8s.io/node-stage-secret-name: rook-csi-rbd-node
csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-rbd-provisioner
csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
labels:
app: mysql
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
securityContext:
fsGroup: 999
containers:
- name: mysql
image: mysql:8.4
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
volumeMounts:
- name: data
mountPath: /var/lib/mysql
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi"
readinessProbe:
exec:
command: ["sh", "-c", "mysqladmin ping -uroot -p$MYSQL_ROOT_PASSWORD"]
initialDelaySeconds: 20
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: rook-ceph-block
resources:
requests:
storage: 200Gi
这套配置背后的工程理由是:
- 用
StatefulSet 固定网络身份和卷身份
- 用
volumeClaimTemplates 保证 Pod 与 PVC 一一对应
- 用
Retain 防止误删 PVC 直接清空底层数据
- 用
WaitForFirstConsumer 避免拓扑错绑
6.4 Kafka:本地 SSD 更合适,但要接受节点绑定
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-ssd
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kafka
spec:
serviceName: kafka-headless
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.8
volumeMounts:
- name: data
mountPath: /bitnami/kafka
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-ssd
resources:
requests:
storage: 500Gi
这里最核心的不是 YAML,而是架构前提:
- Kafka 自己有副本和 ISR
- 本地盘坏了允许 Broker 失效后重建
- 你接受“数据可在集群层面恢复,但单卷不可迁移”
如果业务不能接受这个前提,就不要为了追求吞吐盲目选本地盘。
7. CSI 到底怎么工作:控制器、Sidecar 和 Node 插件各自做什么
很多文章一句“CSI 是存储接口标准”就带过去了,但真正落地时,运维和平台团队必须知道每个组件在做什么。
一个典型 CSI 驱动通常包含三部分。
7.1 Controller Service:负责卷的控制面动作
常见接口包括:
CreateVolume
DeleteVolume
ControllerPublishVolume
ControllerUnpublishVolume
ControllerExpandVolume
CreateSnapshot
这部分通常以 Deployment 跑在控制面可达区域,由多个 sidecar 协同。
7.2 Node Service:负责节点上的实际挂载
常见接口包括:
NodeStageVolume
NodePublishVolume
NodeUnpublishVolume
NodeUnstageVolume
NodeExpandVolume
它通常以 DaemonSet 跑在每个工作节点上,因为真正的设备映射和文件系统挂载必须在节点本地执行。
7.3 Identity Service:告诉外界“我是谁、我支持什么”
常见接口包括:
GetPluginInfo
GetPluginCapabilities
Probe
7.4 Sidecar 不是可有可无,而是 CSI 生态的一部分
常见 sidecar:
external-provisioner:动态供给
external-attacher:创建和维护 VolumeAttachment
external-resizer:卷扩容
external-snapshotter:卷快照
node-driver-registrar:向 kubelet 注册节点驱动
livenessprobe:健康检查
也就是说,CSI 驱动并不是“一个容器”这么简单,而是一组协作组件。
8. 一个更接近生产的 CSI 实现骨架:至少要做到幂等、可观测、可恢复
如果你的公司有自研存储系统,最终通常要自己实现 CSI 驱动。下面给一个比“伪代码”更接近生产要求的 CreateVolume 实现骨架,重点展示三个原则:
- 幂等:相同卷名重复请求不能重复创建
- 参数校验:不相信上游输入
- 可观测:日志和错误要能支撑排障
package controller
import (
"context"
"errors"
"fmt"
"strings"
"github.com/container-storage-interface/spec/lib/go/csi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"k8s.io/klog/v2"
)
type VolumeStore interface {
FindByName(ctx context.Context, name string) (*Volume, error)
Create(ctx context.Context, req CreateVolumeInput) (*Volume, error)
}
type Volume struct {
ID string
Name string
CapacityBytes int64
}
type CreateVolumeInput struct {
Name string
CapacityBytes int64
Parameters map[string]string
}
type ControllerServer struct {
csi.UnimplementedControllerServer
store VolumeStore
}
func (s *ControllerServer) CreateVolume(
ctx context.Context,
req *csi.CreateVolumeRequest,
) (*csi.CreateVolumeResponse, error) {
if req.GetName() == "" {
return nil, status.Error(codes.InvalidArgument, "volume name is required")
}
requiredBytes := req.GetCapacityRange().GetRequiredBytes()
if requiredBytes <= 0 {
return nil, status.Error(codes.InvalidArgument, "required bytes must be greater than zero")
}
volumeName := sanitizeVolumeName(req.GetName())
logger := klog.FromContext(ctx)
logger.Info("create volume requested", "name", volumeName, "requiredBytes", requiredBytes)
existing, err := s.store.FindByName(ctx, volumeName)
if err != nil {
logger.Error(err, "failed to query existing volume", "name", volumeName)
return nil, status.Error(codes.Internal, "query volume failed")
}
if existing != nil {
if existing.CapacityBytes < requiredBytes {
return nil, status.Error(codes.AlreadyExists, "existing volume capacity is smaller than requested")
}
logger.Info("volume already exists, return existing handle", "volumeID", existing.ID)
return buildCreateVolumeResponse(existing), nil
}
volume, err := s.store.Create(ctx, CreateVolumeInput{
Name: volumeName,
CapacityBytes: requiredBytes,
Parameters: req.GetParameters(),
})
if err != nil {
logger.Error(err, "failed to create volume", "name", volumeName)
if errors.Is(err, context.DeadlineExceeded) {
return nil, status.Error(codes.DeadlineExceeded, "backend create volume timeout")
}
return nil, status.Error(codes.Internal, "backend create volume failed")
}
logger.Info("volume created", "volumeID", volume.ID, "name", volume.Name)
return buildCreateVolumeResponse(volume), nil
}
func sanitizeVolumeName(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "-")
return fmt.Sprintf("k8s-%s", name)
}
func buildCreateVolumeResponse(v *Volume) *csi.CreateVolumeResponse {
return &csi.CreateVolumeResponse{
Volume: &csi.Volume{
VolumeId: v.ID,
CapacityBytes: v.CapacityBytes,
},
}
}
这段代码仍然只是核心骨架,但它已经体现了生产级最小要求:
- 同名卷重试返回已有卷,而不是重复创建
- 后端异常有明确错误分类
- 关键日志能串起请求与卷标识
真正上线时还要继续补齐:
ControllerPublishVolume
DeleteVolume
ControllerExpandVolume
- 节点侧
NodeStageVolume 与 NodePublishVolume
- 指标埋点和 trace 上下文
- 后端 API 的超时、重试、熔断
9. 高并发与可扩展:大促时真正会出问题的,不只是卷本身
用户特别提到要做工程化升级,这里必须强调一件事:
高并发场景下,Kubernetes 存储问题往往表现为“应用故障”,而不是“磁盘故障”。
9.1 共享卷抖动会放大成线程池阻塞
当应用把主请求链路直接绑定到共享文件系统写入时,存储抖动会传导为:
- Tomcat/Netty 工作线程阻塞
- 连接池占满
- 上游超时重试
- 下游雪崩
正确做法通常是:
- 把主请求和文件落盘解耦
- 先写数据库状态或消息队列
- 异步工作进程再执行文件归档
- 对共享存储写入设置超时与隔离线程池
9.2 存储扩容不是只改 PVC 容量
在线扩容真正涉及三层:
- Kubernetes 资源规格变更
- CSI 控制器扩容后端卷
- 节点文件系统扩容
所以生产变更前要确认:
- StorageClass 开启了
allowVolumeExpansion
- 驱动支持
ControllerExpandVolume 与 NodeExpandVolume
- 文件系统支持在线扩容
9.3 容量治理要前移,不要等磁盘打满才发现
建议平台默认建立三类监控:
- PVC 使用率
- inode 使用率
- 挂载错误和 IO 延迟
对 emptyDir 还应额外监控:
- 节点
ephemeral-storage
- 单 Pod 本地临时目录增长速度
9.4 调度层要和存储层一起设计
高并发状态系统不能只看存储,也要看调度策略:
- StatefulSet Pod 反亲和,避免副本落在同一节点
- 本地盘服务绑定
nodeAffinity
- 跨可用区部署时保证存储拓扑一致
- 使用
PodDisruptionBudget 降低维护期同时驱逐风险
10. 数据一致性与故障恢复:Kubernetes 不替应用兜底的部分
很多人误把“有 PV”理解成“数据就安全”。这在数据库、消息队列、搜索系统里尤其危险。
10.1 存储持久化不等于业务一致性
即使卷没丢,应用仍可能出现:
- 已写 binlog 但事务未对外可见
- Kafka 日志写入磁盘但副本未同步
- 文件写完但数据库状态未更新
所以要把“卷持久化”与“业务一致性”拆开看。
10.2 关键恢复流程建议
生产恢复建议按优先级拆成三层:
- 第一层:卷级恢复
例如重建挂载、从快照恢复 PVC、手动重绑 PV。
- 第二层:实例级恢复
例如 MySQL 从备份恢复、Kafka 重新拉起 Broker、ES 分片重分配。
- 第三层:业务级恢复
例如重新跑补偿任务、根据审计表重放归档、修复状态与文件不一致。
也就是说,Kubernetes 存储恢复只是整个恢复流程的中间一环,不是全部。
11. 可观测性与运维基线:想把状态服务跑稳,至少要盯住这些信号
11.1 Kubernetes 资源面
- PVC
Pending 持续时间
VolumeAttachment 失败事件
- Pod 挂载相关
Warning 事件
- 节点
DiskPressure
11.2 CSI 驱动面
external-provisioner 创建卷失败率
external-attacher 附加卷耗时
- Node 插件挂载失败次数
- CSI sidecar 资源使用率
11.3 存储后端面
- 卷延迟
- IOPS / 吞吐
- 后端容量池剩余量
- 网络文件系统连接数与元数据延迟
11.4 应用面
- 文件写入耗时分位值
- 归档队列积压量
- 数据库慢刷盘或 checkpoint 延迟
- 任务状态与文件状态不一致数量
如果监控只做到 PVC 容量告警,基本等于没有做到存储治理。
12. 最容易踩的坑,我按“真实伤害值”排个序
12.1 用 Delete 回收策略承载核心数据
这是最危险的默认配置之一。对于数据库、消息日志、关键业务文件,优先使用:
reclaimPolicy: Retain
- 快照
- 备份
否则删 PVC 就可能把底层卷也一并删掉。
12.2 忽略 WaitForFirstConsumer
在本地盘、多可用区云盘、带拓扑约束的后端里,不开延迟绑定,迟早遇到“卷在 A 区,Pod 想去 B 区”的问题。
12.3 把 subPath 当成目录隔离万能钥匙
subPath 很好用,但要知道两个边界:
- 用于 ConfigMap/Secret 时,热更新行为和直接挂载不同
- 目录不存在、权限不匹配时,排障成本高
12.4 把网络文件系统写入放在同步主链路
这类问题平时不出事,一出事就是整个服务 RT 飙升。
12.5 忽略 fsGroup、目录权限和容器用户
很多“挂载成功但应用起不来”的问题,本质不是卷坏了,而是目录属主不对。
13. 一条更合理的演进路线:别一上来就追求“最全能的存储平台”
不同阶段的团队,合理路径通常不同。
13.1 小规模阶段
- 无状态应用用
emptyDir
- 少量共享场景用托管 NFS/EFS
- 数据库先用托管数据库,而不是自己在集群里跑
13.2 中等规模阶段
- 开始把中间件迁入集群
- 建立标准化 StorageClass
- 引入 CSI 快照、扩容、监控
- 规范 PVC 配额与命名
13.3 平台化阶段
- 建立分层存储体系
- 建立存储准入规范
- 为不同应用模板预设存储默认值
- 将备份恢复、扩缩容、故障演练流程平台化
不是每个团队都必须上 Ceph,也不是每个状态应用都适合自建。真正专业的架构设计,不是堆能力,而是知道什么时候不该引入复杂度。
14. 总结:把 Pod 存储看成链路,而不是字段
如果要给这篇文章收一个结论,我会总结成四句话。
第一,Volume、PVC、PV、StorageClass、CSI 不是五个孤立概念,而是一条从“应用意图”到“后端实现”的完整链路。
第二,Kubernetes 解决的是存储抽象、调度协作和挂载编排,不替应用解决一致性、副本语义和业务恢复。
第三,生产选型的关键不在“有没有持久卷”,而在“业务是否匹配卷模型、拓扑模型和故障模型”。
第四,真正把状态服务跑稳,靠的不是一份 YAML,而是对象模型、CSI 原理、调度约束、恢复流程和运维治理一起闭环。
当你下次再看到一个 Pod 里写着:
volumeMounts:
- name: data
mountPath: /data
你看到的就不该只是一个挂载点,而应该是这条完整的数据生命线:
- 数据是否会丢
- 卷由谁创建
- 会不会跨区错绑
- 节点挂了能不能恢复
- 应用线程会不会被慢存储拖死
- 删除 PVC 会不会把底层数据一起删掉
这时你才算真正理解了 Kubernetes 存储。