容器里跑着跑着,Pod 被驱逐一次、节点宕机一次,或者只是简单地滚动更新了一版镜像,日志里就冒出一句no such file or directory——数据没了。这个场景在 K8S 持久化存储这个话题下几乎天天上演,也是我从单体应用迁到 K8S 之后踩得最深的一个坑。持久化存储(Persistent Storage)说白了就干一件事:把数据从"跟着容器活着"变成"活得比容器久"。它解决的是 Pod 生命周期与数据生命周期不对等的问题,牵扯到 Volume、PV、PVC、StorageClass、CSI 驱动、访问模式、回收策略、扩容与快照一整套东西。这篇内容适合两类人:一类是刚把业务搬上 K8S、还在一股脑用 emptyDir 的开发者;另一类是已经踩过几次 Pending 和 Multi-Attach,想把这套机制彻底理顺的运维。下面我按"为什么要它、它由谁组成、怎么落地、哪里会翻车"的顺序,把这几年的实践整理一遍。
1. 数据为什么会说没就没:先看清容器的存储边界
1.1 一次教科书级的数据丢失现场
某团队把一个文件上传服务迁到了 K8S,镜像里什么都没配,代码里写文件路径是/app/upload,容器跑得好好的,压测也过了。上线第三天,节点磁盘告警触发驱逐,Pod 被调度到另一台机器,用户上传的附件全没了。复盘的时候发现两个问题叠在一起:第一,/app/upload落在容器的可写层上,这一层随容器生灭;第二,就算换成hostPath挂到节点目录,Pod 换了节点之后照样找不到原来的文件。
这个案例里最容易被忽略的是"可写层"这个概念。容器镜像本身是只读的分层结构,运行时在最上面加一层可写层(overlay 的上层),所有写操作都落在这里。容器退出,可写层被丢弃;Pod 被删除,容器跟着没了;即使 Pod 只是被重建(比如 Deployment 滚动更新),新容器也是从镜像重新拉起一层全新的可写层。所以在这个层面上,容器的"临时性"不是缺陷,而是设计——它换来的是秒级启动、不可变交付、随意替换。想让数据活下来,就必须把写操作引导到一个生命周期独立于容器的存储介质上,这就是 Volume 存在的全部理由。
再往下推一层,K8S 里的"存储"其实分了三档,理解这三档边界,后面选型就不会乱:临时存储(emptyDir,跟 Pod 同生共死,Pod 删除即清空,适合缓存、临时交换目录、Sidecar 与主容器共享文件);节点存储(hostPath、local PV,数据在物理节点上,跨节点不可见,适合本地缓存、高性能但可重建的数据,以及配合亲和性使用的有状态场景);网络存储(通过 NFS、块设备、对象存储网关等方式挂载,跨节点可访问,是有状态服务的正解,代价是多了一跳网络和一套额外运维)。选型的第一步永远是先把数据分类:这份数据丢了会怎样?能不能重建?需不需要跨节点访问?三个问题答完,三档里面基本只剩一个选项。
1.2 从 Volume 到 PV:一次抽象带来的解耦
最早的 K8S 允许直接在 Pod 里写volumes: - nfs: {server: ..., path: ...},能用,但问题很大。存储的连接信息(服务地址、路径、账号、密钥)全散落在业务 YAML 里,业务方要改一行存储配置,得把整个 Deployment 的模板翻出来改,还得重新发布;运维想换一套后端存储,得挨个通知应用团队改配置。这种耦合在几十个微服务的规模下就是灾难。
PV/PVC 这一层抽象解决的就是这件事。PV(PersistentVolume)是集群管理员视角的存储资源,描述"我这里有一块 100Gi 的、支持 ReadWriteOnce 的、回收策略是 Retain 的存储",它是一份供给声明;PVC(PersistentVolumeClaim)是应用视角的存储需求,描述"我需要 50Gi、能单节点读写、最好别删数据",它是一份需求单。两者通过绑定(Binding)配对,配对成功后 PVC 与 PV 是一对一独占关系,其他 PVC 抢不走,直到绑定的 PV 被释放。应用侧只认 PVC 名字,存储后端换成什么都跟它无关,这才是这套设计的价值所在。
绑定的匹配规则值得记牢,因为它直接决定了 PVC 卡在 Pending 时你该往哪儿查。必须同时满足:storageClassName一致(空字符串表示"只绑不带 StorageClass 的 PV",不写则走默认 StorageClass 逻辑,这两者差别很关键);accessModes包含 PVC 要求的全部模式;PV 容量不小于 PVC 的请求量;如果 PVC 写了selector,PV 的标签还得匹配上。任何一条不满足,PVC 就停在 Pending,kubectl describe pvc的 Events 里通常只有一句干巴巴的 "no persistent volumes available for this claim",剩下的得自己推。
1.3 手工建 PV 的极限在哪里
手工写 PV 不是不能用,小集群、十几块盘、变化不频繁的场景,一年也就动几次,静态供给完全够。但它有三个绕不过去的天花板。第一是容量浪费:你建了 100Gi 的 PV,业务只申请 10Gi,剩下的 90Gi 就锁死在这块盘上,因为绑定是独占的,不会分给第二个 PVC。第二是扩容困难:PV 容量是声明出来的静态属性,业务涨了要扩容,要么重建 PV(数据迁移),要么依赖底层存储自己的扩容能力再同步到 PV 对象上,两头都得手动对。第三是响应速度:业务方提一个工单要 200Gi 存储,运维建盘、建 PV、交付,中间隔半天,云原生环境下这个节奏撑不住。
所以生产上除非有特殊理由(本地盘、已有存量存储、合规要求单独管理),基本都会走动态供给。动态供给的核心是 StorageClass:PVC 里只写storageClassName: xxx和需要的容量,集群里的供给器(Provisioner)收到请求后现场创建一块底层存储,再自动生成对应的 PV 对象并完成绑定。整个过程对业务方是秒级的,容量也按需分配,不再有"建大了浪费、建小了不够"的问题。
2. 四个核心对象的关系网:Volume、PV、PVC、StorageClass
2.1 Volume:Pod 级别的挂载声明
Volume 是 Pod spec 里的一个字段,本质是"给容器挂一个目录"的声明。它的作用域是 Pod,Pod 内所有容器默认都能访问同一个 Volume(这是 Sidecar 模式共享文件的基石,比如日志收集容器读主容器写的目录、配置热更新容器往共享目录里写渲染结果)。Volume 的类型决定了它的生命周期和后端介质,常见的有这么几类:
| Volume 类型 | 生命周期 | 跨节点 | 典型用途 |
|---|---|---|---|
| emptyDir | 与 Pod 同生共死 | 否 | 临时缓存、容器间共享文件 |
| hostPath | 与节点绑定 | 否 | 读取节点信息、本地缓存(高危,慎用) |
| configMap / secret | 跟随对象 | 是(只读注入) | 配置文件、证书注入 |
| nfs | 独立于集群 | 是 | 共享文件、多读场景 |
| persistentVolumeClaim | 跟随 PV | 取决于后端 | 有状态业务的标准姿势 |
emptyDir有个容易被低估的参数medium: Memory,配上sizeLimit之后就是一个 tmpfs 目录,走内存不落盘,读写快到飞起,适合做本地缓存层或者大数据量排序的临时空间。反过来,它吃的是节点内存,不加sizeLimit的话一次写爆能把节点打挂,这个坑我在早期项目里真实吃过一次,之后再也不敢不写限制。
hostPath是另一个高危项。它把节点的目录直接映射进容器,Pod 一旦被调度到别的节点,数据就"消失"了;更麻烦的是安全边界问题,容器内进程对宿主机目录的读写权限完全取决于宿主机文件的权限位,写错一个路径就可能把节点上的东西改坏。真需要访问节点本地数据,优先考虑用local类型的 PV 配合 nodeAffinity,至少调度层面是受控的。
2.2 PVC 的申请语义与常见误解
PVC 的 spec 里有几个字段,看起来简单,理解偏一点就要出问题。resources.requests.storage是最小容量要求,不是上限:PV 有 200Gi,PVC 申请 100Gi,绑定是可以成功的,但你只能用 100Gi 这个配额(从 K8S 的视角,容量是逻辑配额,具体能用多少由后端文件系统决定)。accessModes是挂载方式的声明,而不是权限控制;volumeMode默认是Filesystem,可以设成Block直接给 Pod 一块裸设备,数据库直挂裸盘做自管理文件系统的场景会用到。
还有一个特别常见的误解:以为删掉 PVC 数据就没了。这取决于 PV 的persistentVolumeReclaimPolicy。Delete表示删 PVC 时连带删除后端存储(动态供给的默认值,方便但也危险);Retain表示只解绑 PV、保留数据,需要人工处理;Recycle是早期的清空重用法,现在基本废弃了。生产环境里,凡是存业务数据的 PVC,我一般要求在 StorageClass 层面把回收策略设成Retain,宁可多一步手工清理,也别让一次误删的kubectl delete pvc把生产数据带走。
注意:把回收策略设成 Retain 之后,PVC 删除后 PV 会进入
Released状态,这个状态的 PV 不能被新的 PVC 绑定。想复用必须手工把spec.claimRef清掉(或者直接重建 PV 对象),否则你会发现明明有可用 PV,新 PVC 还是 Pending。
2.3 StorageClass 与 CSI:把手工活交给控制器
StorageClass 描述的是"用哪套供给器、带什么参数去创建存储"。几个关键字段的作用必须分清:
provisioner:供给器名字,动态供给的核心,形如xxx.example.com/nfs这样的域名式标识。parameters:透传给供给器的参数,比如后端存储池名、副本数、类型(SSD/HDD)、NFS 服务端地址和路径。reclaimPolicy:这个 SC 创建的 PV 默认回收策略,不写则默认Delete。volumeBindingMode:Immediate表示 PVC 一创建就绑定,WaitForFirstConsumer表示等第一个使用该 PVC 的 Pod 调度成功后再绑定。后者是解决"拓扑约束"问题的关键,本地盘、可用区限定、节点亲和这几类场景必须用它,否则可能出现 PV 被创建在 A 可用区、Pod 却被调度到 B 可用区导致挂载失败。allowVolumeExpansion:是否允许在线扩容,必须显式设为true才能改 PVC 的容量。
再往下就是 CSI(Container Storage Interface)。它是一套标准接口,让存储厂商用统一的插件形态接入 K8S,不用改 K8S 主干代码。一个 CSI 驱动通常分两部分跑:Controller 组件(以 Deployment 形式跑,负责创建、删除、挂载、扩容、快照这些控制面操作)和Node 组件(以 DaemonSet 形式跑在每个节点上,负责把设备挂到宿主机、格式化文件系统、bind mount 到容器的目标路径)。围绕它还常驻着一组 sidecar:external-provisioner 负责监听 PVC 事件并调用驱动的 CreateVolume,external-attacher 管挂载编排,external-resizer 管扩容,external-snapshotter 管快照,node-driver-registrar 负责把驱动注册到 kubelet。搞不清"PVC Pending 到底该看谁的日志"的时候,记住一条线:PVC 事件 → provisioner 日志 → 驱动 controller 日志 → 后端存储 API,按这个顺序查,一般三层之内就能定位。
3. 从零跑通一套网络存储动态供给
3.1 服务端准备:先把共享目录支起来
为了让流程可复现,这里用最通用的网络文件系统方案举例,生产上换成本地盘、分布式块存储或对象存储网关,思路是一致的。假设有两台机器:一台做存储服务端(192.168.10.20),其余节点做客户端。
服务端先装包并建目录:
# 服务端 mkdir -p /data/k8s-volumes chown -R nobody:nogroup /data/k8s-volumes chmod 0777 /data/k8s-volumes # 导出配置 cat >> /etc/exports <<EOF /data/k8s-volumes 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash) EOF exportfs -rav systemctl enable --now nfs-server exportfs -v # 确认导出生效几个参数值得说清楚。no_root_squash是为了让容器里以 root 身份运行的进程在共享目录上也有写权限,代价是放开了一部分安全边界,生产上如果容器都以非 root 运行,最好用all_squash配合固定的anonuid/anuid,把权限收敛到一个普通用户上。sync保证写入落盘后才返回,比async慢但不会因为断电丢数据。no_subtree_check在较新版本里是默认行为,显式写上避免版本差异带来的行为不一致。
客户端节点需要能挂载,装好客户端工具即可,容器镜像里如果要用到挂载能力,基础镜像也得带对应工具,或者直接用 CSI 驱动托管挂载,不用在业务镜像里装东西。验证一下:
# 客户端 mkdir -p /mnt/test && mount -t nfs 192.168.10.20:/data/k8s-volumes /mnt/test touch /mnt/test/hello && ls /mnt/test umount /mnt/test这条命令能过,服务端就没问题了。别跳过这一步,我遇到过好几次"PVC 一直 Pending"最后发现是安全组没放行、或者服务端 exports 里网段写错的情况,先在节点上用 mount 命令验证一遍,比在 K8S 里猜半天高效得多。
3.2 部署供给器与 StorageClass
动态供给需要一个常驻的供给器组件。它通常由三部分组成:ServiceAccount + RBAC(拿到监听 PVC 事件、创建 PV 对象的权限)、Deployment(跑供给器逻辑)、StorageClass(声明入口)。核心 YAML 结构大致如下:
apiVersion: v1 kind: ServiceAccount metadata: name: nfs-provisioner namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nfs-provisioner-runner rules: - apiGroups: [""] resources: ["persistentvolumes"] verbs: ["get","list","watch","create","delete"] - apiGroups: [""] resources: ["persistentvolumeclaims"] verbs: ["get","list","watch","update"] - apiGroups: ["storage.k8s.io"] resources: ["storageclasses"] verbs: ["get","list","watch"] - apiGroups: [""] resources: ["events"] verbs: ["create","update","patch"] --- # 省略 RoleBinding / ClusterRoleBinding,与上面 ServiceAccount 对应 apiVersion: apps/v1 kind: Deployment metadata: name: nfs-provisioner namespace: kube-system spec: replicas: 1 selector: matchLabels: { app: nfs-provisioner } template: metadata: labels: { app: nfs-provisioner } spec: serviceAccountName: nfs-provisioner containers: - name: provisioner image: registry.example.com/sig-storage/nfs-subdir-external-provisioner:v4.0.2 env: - name: PROVISIONER_NAME value: example.com/nfs - name: NFS_SERVER value: 192.168.10.20 - name: NFS_PATH value: /data/k8s-volumes volumeMounts: - name: nfs-root mountPath: /persistentvolumes volumes: - name: nfs-root nfs: server: 192.168.10.20 path: /data/k8s-volumes然后是 StorageClass:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-standard annotations: storageclass.kubernetes.io/is-default-class: "true" provisioner: example.com/nfs parameters: archiveOnDelete: "true" reclaimPolicy: Retain volumeBindingMode: Immediate allowVolumeExpansion: trueprovisioner的值必须和 Deployment 里PROVISIONER_NAME环境变量一字不差,这是最常见的配置错误之一。archiveOnDelete: "true"是这类子目录供给器的特性参数,删 PVC 时不真删数据,而是把目录重命名成archived-xxx,多一层保险。is-default-class的注解让它成为默认 StorageClass,之后 PVC 不写storageClassName就会走它——这个便利性也是隐患,多 StorageClass 的集群里我一般不允许标默认,强制业务显式声明,避免"新 PVC 莫名绑到了低性能存储池"。
部署完检查一下组件状态:
kubectl -n kube-system get deploy nfs-provisioner kubectl -n kube-system logs -l app=nfs-provisioner --tail=50 kubectl get sc日志里出现Starting provisioner之类的内容,并且没有持续报错,就可以往下走了。
3.3 用一个 PVC 跑通完整链路
写一个业务侧最常见的声明:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: app-data namespace: demo spec: accessModes: ["ReadWriteMany"] storageClassName: nfs-standard resources: requests: storage: 10Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: upload-api namespace: demo spec: replicas: 2 selector: matchLabels: { app: upload-api } template: metadata: labels: { app: upload-api } spec: containers: - name: api image: registry.example.com/demo/upload-api:1.4.0 volumeMounts: - name: data mountPath: /app/upload volumes: - name: data persistentVolumeClaim: claimName: app-data应用之后按顺序验证:
kubectl -n demo get pvc app-data -w # 等状态变成 Bound kubectl -n demo get pv | grep app-data # 看生成的 PV 名字与容量 kubectl -n demo exec deploy/upload-api -- sh -c 'echo hi > /app/upload/probe.txt' kubectl -n demo delete pod -l app=upload-api kubectl -n demo exec deploy/upload-api -- cat /app/upload/probe.txt最后一条能读到hi,说明持久化生效了。这一步验证的意义在于:它验证的不是"挂载成功",而是"Pod 重建后数据还在",这才是持久化真正要保证的语义。很多团队只做到 PVC Bound 就认为完事,直到第一次滚动更新才发现路径写错、挂载点被镜像内容覆盖之类的问题。
顺手再验证一下回收行为。把 Deployment 删掉,再删 PVC,然后去服务端看目录:
kubectl -n demo delete deploy upload-api kubectl -n demo delete pvc app-data kubectl -n demo get pv # 状态应为 Released ls /data/k8s-volumes/demo-app-data-*因为配了Retain和archiveOnDelete,数据和 PV 对象都会保留下来,PV 进入Released。这时候如果业务要重建同名 PVC,会拿到一块全新的存储,老的 PV 需要手工清理或重建后才能复用。这个行为一定要提前跟业务方对齐,不然"为什么我重建了 PVC 数据就没了"会成为反复出现的疑问。
4. 参数细节:访问模式、扩容与回收策略的坑
4.1 accessModes 到底约束了什么
先看一张表,把四种模式的真实含义摊开:
| 模式 | 缩写 | 字面意思 | 真实约束 |
|---|---|---|---|
| ReadWriteOnce | RWO | 单节点读写 | 同一时刻只能被一个节点挂载读写,同节点上多个 Pod 可以共享 |
| ReadOnlyMany | ROX | 多节点只读 | 可被多个节点同时只读挂载 |
| ReadWriteMany | RWX | 多节点读写 | 可被多个节点同时读写,需要共享文件系统支持 |
| ReadWriteOncePod | RWOP | 单 Pod 读写 | 整个集群内只允许一个 Pod 挂载,语义最严格 |
RWO 是最容易被误解的一个。它不是"只能一个 Pod 用",而是"只能一个节点挂载"。同一个节点上跑三个 Pod 共用同一个 RWO 的 PVC,技术上是可以的;但一旦这三个 Pod 被调度到两个不同节点,就会出现那个经典的Multi-Attach error for volume ... volume is already exclusively attached to one node。这个报错在 Deployment + RWO + 多副本的组合下几乎必然出现,因为 Deployment 的副本默认是分散调度的。解决办法有三条:改成 StatefulSet 让每个副本有自己的 PVC;用支持 RWX 的后端;或者用亲和性把副本强行圈在一个节点上(不推荐,可用性差)。
RWX 能不能用,完全取决于后端存储的能力。块存储一般只支持 RWO/ROX,文件系统类存储才支持 RWX。选型时要先确认这一点,别等到上线才发现后端压根不支持多节点写挂载。还有一点:即使后端支持 RWX,应用自身的并发写逻辑也得扛得住,两个进程同时往一个文件里追加,靠挂载模式是解决不了的。
4.2 扩容:从改数字到文件系统真正变大
在线扩容的链路比想象中长:先改 PVC 的spec.resources.requests.storage,供给器调用后端把设备扩容,然后 kubelet 在节点上对文件系统做 resize。任何一环卡住,PVC 都不会真的变大。前置条件有三个:StorageClass 的allowVolumeExpansion: true、驱动支持扩容、后端存储池有余量。
kubectl -n demo patch pvc app-data -p \ '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}' kubectl -n demo describe pvc app-data | grep -A5 Conditions如果 Conditions 里出现FileSystemResizePending,说明块设备已经扩了但文件系统还没扩,此时重启挂载该 PVC 的 Pod,kubelet 会在重新挂载时完成文件系统扩容。有些环境下不需要重启,取决于文件系统类型和驱动实现(xfs 支持在线扩,ext4 一般也可以,但前提是驱动把 resize 操作打通了)。
有两个坑记住:缩容是不可能的,PVC 的容量只能增不能减,写小数字会直接被 API 校验拒绝,需要缩容只能新建再迁移数据;扩容失败会一直重试,如果后端容量不足,PVC 会长期停在扩容中的状态,I/O 可能受影响,所以扩之前先确认存储池水位。
4.3 回收策略与数据安全边界
回收策略那一小节已经提过,这里补充一个真实的工作流建议。生产环境我习惯分两档:普通业务的 SC 用Retain,但配套一个定时清理任务,扫描Released状态超过 N 天的 PV,通知负责人确认后统一走删除流程;核心数据的 SC 除了Retain,还要求周期快照,用快照做恢复的兜底。
快照这块依赖 CSI 的 Snapshot 能力,需要额外部署 snapshot-controller 和 CRD(VolumeSnapshotClass、VolumeSnapshot、VolumeSnapshotContent)。用起来大概是这样:
apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: app-data-20240612 namespace: demo spec: volumeSnapshotClassName: nfs-snapclass source: persistentVolumeClaimName: app-data命令式一点的话:kubectl -n demo get volumesnapshot,看READYTOUSE是否为 true。要注意快照不是备份,它依赖后端存储,如果后端整个挂了快照也一起没;而且快照恢复出来的是 PVC,要找回单个文件还得再挂载一次。真要合规级的数据保护,还是得把快照定期导出到独立的备份存储里。
5. StatefulSet 场景:有状态服务的存储编排
5.1 volumeClaimTemplates 的工作机制
Deployment 加 PVC 的组合只适合"所有副本共享一份数据",数据库、消息队列这类每个实例要有独立存储的服务,得用 StatefulSet 的volumeClaimTemplates。它的语义是:按模板为每个副本生成一个 PVC,命名规则是<模板名>-<StatefulSet名>-<序号>,比如>apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql namespace: demo spec: serviceName: mysql-headless replicas: 3 selector: matchLabels: { app: mysql } template: metadata: labels: { app: mysql } spec: containers: - name: mysql image: registry.example.com/library/mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: ssd-standard resources: requests: storage: 100Gi
这里有个必须配合的东西:headless Service(clusterIP: None)。StatefulSet 依赖它给每个 Pod 提供稳定的 DNS 记录(mysql-0.mysql-headless.demo.svc.cluster.local),主从复制、集群成员发现全靠这个。存储稳定了但网络标识不稳定,有状态集群照样起不来,这两件事要一起做。
5.2 缩容残留:最容易被忽视的存储泄漏
StatefulSet 扩缩容的行为很反直觉:缩容不会删除 PVC。副本从 3 缩到 2,mysql-2这个 Pod 没了,但>apiVersion: v1 kind: PersistentVolume metadata: name: local-pv-node-a spec: capacity: storage: 500Gi accessModes: ["ReadWriteOnce"] persistentVolumeReclaimPolicy: Retain storageClassName: local-ssd local: path: /data/ssd nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: ["node-a"]
代价有三点必须接受:数据与节点强绑定,节点挂了 Pod 就起不来,除非节点恢复或者手工迁移数据;没有动态供给,盘要提前规划、手工建 PV,容量管理靠人;可用性靠应用层解决,数据库的多副本同步、跨节点复制得由业务自己保证。所以本地 PV 适合"性能优先、有副本冗余、可接受短时不可用"的场景,不适合当成默认方案到处用。
6. 排查实录:那些年卡住过的 Pending 与挂载失败
6.1 PVC Pending 的五条排查主线
Pending是最高频的问题,但原因就那么几类,按顺序查效率最高:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| Events 提示 no persistent volumes available | 静态供给下没有匹配的 PV | kubectl get pv看容量、accessModes、storageClassName 是否匹配 |
| Events 提示 storageclass not found | SC 名字拼错或未创建 | kubectl get sc核对名字 |
| 有 SC 但迟迟不 Bound | 供给器异常 | 看 provisioner Pod 日志与状态,确认 RBAC 权限 |
| Events 提示 waiting for first consumer | WaitForFirstConsumer模式,还没有 Pod 使用 | 创建使用该 PVC 的 Pod,属正常现象 |
| 供给器日志报后端错误 | 存储池满、网络不通、认证失败 | 从 provisioner 日志往驱动、后端逐层排查 |
经验上,60% 的 Pending 是storageClassName拼写问题或默认 SC 误判,20% 是静态 PV 的容量/模式不匹配,剩下才是真正的组件故障。所以第一步永远是kubectl describe pvc看 Events,别急着翻日志。
6.2 挂载失败与权限问题
FailedMount的常见类型有这么几种。第一种是Multi-Attach error,前面说过,本质是 RWO 的卷被两个节点同时要,解决方案是改拓扑或者改工作负载类型。第二种是mount: wrong fs type, bad option, bad superblock,多半是服务端导出配置有问题、网络不通、或者挂载参数不兼容(比如服务端只支持 v4.1,客户端默认走 v3)。第三种是权限类,容器里写文件报Permission denied,这时候要看两件事:宿主机目录的属主权限,以及 Pod 的securityContext配置。
spec: securityContext: fsGroup: 1000 fsGroupChangePolicy: "OnRootMismatch" containers: - name: api securityContext: runAsUser: 1000 runAsGroup: 1000fsGroup会让 kubelet 在挂载后把卷的属组改成指定 GID 并给组加读写权限,是非 root 容器写持久卷的标准做法。fsGroupChangePolicy: OnRootMismatch只在根目录属组不匹配时才递归改权限,大目录下能明显缩短挂载时间——不加这个参数,一个几百万文件的卷每次挂载都要递归 chmod,Pod 启动能卡几分钟。
6.3 性能与一致性:上线之后才会暴露的问题
存储跑通之后,真正的麻烦才刚开始。网络文件系统在小文件密集写入场景下性能衰减明显,我的做法是先压测再定参数:mountOptions里显式指定协议版本(nfsvers=4.1),调整rsize/wsize到 1MB,hard挂载配合合理的timeo,避免服务端抖动导致客户端直接报错。数据库类的负载则要实测 fsync 延迟,网络存储的同步写延迟往往是本地盘的数倍,这个差距没法靠调参抹平,只能靠选型解决。
一致性方面,RWX 卷上的并发写要用文件锁或者应用层队列来保证,别指望文件系统。另外,容器内看到的文件属主可能与预期不符(用户命名空间映射导致),排查时以ls -n看数字 UID/GID 为准,别被名字骗了。
最后分享一个我一直在用的检查习惯:任何存储相关的变更(换 SC、改回收策略、扩 PV、迁移后端),上线前先在一个隔离命名空间里跑一遍"写数据 → 删 Pod → 重建 → 读数据 → 删 PVC → 查后端残留"这五步。这五步花不了十分钟,但能挡掉绝大部分会在半夜把你叫起来的问题。