上周三凌晨两点,值班电话响了。订单服务的 Pod 全部重启,界面上不是 503 就是一个诡异的 ContainerCreating,一直转。我第一反应是节点磁盘满了,登上跳板机跑 df -h,根分区还剩 41G,怎么都不像满盘。后来才搞明白,真正满的不是节点,是那块 PVC它停在 20Gi,而订单库早就写满了 20Gi,只是没人告诉它可以扩。
如果你也在做容器存储容量规划,或者需要一台云服务器先把测试环境的 PVC 跑通再动生产,可以先看看这两家的配置和技术说明:PQS彼得巧私有云(私有云与服务器主机,适合自建集群存储),另外Nube Cloud(全球云服务器,适合按量起一套演练环境) 也可以对照着看配置再决定。
扩容这件事本身不难,难的是默认状态下它根本不给你做。绝大多数 StorageClass 里 allowVolumeExpansion 这个字段是 false,也就是没写,K8s 的默认值就是 false。你直接去改 PVC 的 spec.resources.requests.storage,控制台立刻回一句:
persistentvolumeclaims "data-mysql-0" is forbidden: spec.resources.requests.storage: Forbidden: field can not be less than previous value,或者干脆 persistentvolumeclaims "data-mysql-0" is forbidden: only dynamically provisioned pvc can be resized and the storageclass that provisions the pvc must support resize。
解决办法是给 StorageClass 补上这个字段,注意改完不影响已有卷,只对之后新建的卷生效(老卷看的是创建它的那条记录):
kubectl patch storageclass standard-sc -p '{"allowVolumeExpansion": true}'
顺手说一句,这里有个容易混淆的地方:allowVolumeExpansion 开在 StorageClass 上,权限校验却发生在 PVC 上。字段没开的时候,你在 PVC 上写多大的新值都会被拒,看起来像是「值写错了」,其实是整条链路上的开关没打开。这也是我第一次折腾了两个小时才想明白的地方。
开关打开之后,还有第二道门。能不能真正在线扩容,取决于后端 CSI 驱动和云盘类型。有些块存储(常见的 CBS、ESS 这类云盘)是支持的,改完 PVC 的容量,等云盘扩好,K8s 里的 Pod 会自动挂上新容量的卷。但本地卷(Local PV)、某些 NFS 共享、还有一部分自建存储是不支持的,你把 PVC 的值一改,PVC 就直接进 Lost 或者一直 Pending,Pod 反而比之前更糟。
这类不支持在线扩容的场景,只有一条路:重建。流程大致是四步,我上次是这么走的。
第一步,先把数据保住。 如果 PVC 挂在 StatefulSet 上,先把副本缩到 0,让 Pod 正常卸载卷,别直接删 StatefulSet,否则 Pod 会带着挂载点一起被强杀。
第二步,把 PV 的回收策略改成 Retain。 kubectl patch pv <pv-name> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'。默认的 Delete 策略会在你删掉 PVC 的瞬间把底层云盘一起删掉,数据就没了,这一步千万别漏。
第三步,删 PVC、扩云盘、改回来。 删掉 PVC 之后(PV 因为 Retain 会变成 Released 状态而保留下来),去云控制台把那块云盘扩到目标容量,再把 PV 的 spec.capacity.storage 改成一样的数字,最后把 persistentVolumeReclaimPolicy 改回原值。
第四步,重建 PVC 并认领回 PV。 新建的 PVC 规格要和 PV 一致,PV 的 claimRef 如果还指向老的 UID,直接删 PVC 会卡在 Terminating,得先把它清掉:
kubectl patch pv <pv-name> -p '{"spec":{"claimRef":null}}'
这四步做完,PVC 会重新绑定这块 PV,Pod 起得来,数据还在。整个过程我的窗口期是二十多分钟,其中大部分时间花在等云盘扩容和等 Pod 重新拉镜像上。

还有一种更隐蔽的情况:kubectl get pvc 里 STATUS 的 CAPACITY 已经变成 50Gi 了,Pod 挂进去 df -h 一看还是 20Gi。这就是「卷扩了但文件系统没扩」,本质上是格式化这一步没人执行。
能不能自动扩,取决于 StorageClass 里的 fsTypePolicy:设成 File(或者 ExpandVolume)时,Kubelet 会在扩容完成后自动调用 resize2fs 或 xfs_growfs;保持默认的 Delete 行为时,不会自动做,你得手动进容器里补一条命令:
xfs_growfs /data(XFS)或者 resize2fs /dev/xxx(ext4)
我踩的第三次坑是 XFS 报的 xfs_growfs: cannot mount already mounted,其实是我在容器里又挂了一遍,属于操作姿势问题,不是存储本身的问题。但排查的时候很容易怀疑人生,建议扩完先去 df -h 里确认第二列数字,别凭感觉。
最后这个坑最气人。StatefulSet 扩容的标准写法是改 volumeClaimTemplates 里 resources.requests.storage,然后 kubectl apply。结果 kubectl 告诉你「没有变更」,PVC 的容量纹丝不动。
原因是 volumeClaimTemplates 只在创建新 PVC 时生效,改模板对已有的 PVC 不会有任何影响。正规做法是手动把每个 PVC 扩到新值:
kubectl edit pvc data-mysql-0,把 spec.resources.requests.storage 改成 50Gi,然后挨个改 data-mysql-1、data-mysql-2。
要是你的 StorageClass 压根不支持在线扩容,那更简单把 volumeClaimTemplates 里的数字直接改大,删掉 StatefulSet(--cascade=orphan 保留孤儿 Pod),让它按新模板重建 PVC。这条路的前提同样是 PV 回收策略得先改成 Retain,不然重建 PVC 的时候老数据直接被清空。

事后复盘,这四个坑里真正浪费时间的不是任何一个操作步骤,而是「没人知道那块卷还剩多少」。我们后来补了三件事。

第一,PVC 使用率进监控。 kubelet_volume_stats 这个指标能直接拿到每个 PVC 的已用容量和总容量,配一条 85% 的告警,比等应用写失败强太多。节点层面的 df -i(inode 使用率)也别忘了,小文件多的场景里 inode 常常比容量先撑爆。
第二,每个 StorageClass 的扩容能力登记成表。 哪个 SC 支持在线扩、哪个只能重建 PV、哪个的 fsTypePolicy 是自动 resize,三列写在 wiki 里,出事的时候不用再现场翻文档。
第三,给需要扩容的 PVC 打标签,纳入月度巡检。 我们现在每月会跑一遍,把使用率超过 70% 的 PVC 列出来提前扩,比半夜被叫起来从容多了。
说到底,K8s 存储扩容这件事,难点从来不在 kubectl 那几行命令,而在于「谁在什么条件下允许被扩容、扩容之后谁负责把文件系统撑开、底层存储到底支不支持」这三件事得先说清楚。把这三点写在同一个地方,下次再遇到 ContainerCreating 转圈,排查路径就是一条直线了。
