存储这块是 K8S 里最不“云原生”的部分——计算无状态、随时可抛,网络抽象得干干净净,唯独数据落地的那一刻,你还是得面对一块真实的盘、一个真实的挂载点、一次真实的 IO 错误。这些年我经手的集群里,出问题最多的从来不是 Service 不通,而是 Pod 卡在 ContainerCreating,kubectl describe一看,FailedMount。而 PV、PVC、StorageClass 这三个对象,恰恰是把“存储”这件脏活包装成声明式 API 的那层皮。这篇内容就是围绕 K8S 的 PV/PVC/StorageClass 展开,从概念分工、后端选型,到静态绑定、动态供给、扩容快照,再到底层排查,把这条链路完整走一遍。适合已经能跑 Pod、但对持久化存储还停留在“复制粘贴 YAML”阶段的同学,也适合被挂载问题折磨过的老手来对一遍排查清单。
1. 三个对象的分工:先把概念摆正
1.1 用“租房”把 PV、PVC、StorageClass 串起来
我习惯用一个租房的比喻讲给新人听,基本上一遍就懂。PV(PersistentVolume)是房东手里的房源,它代表一块已经存在、已经被划好的真实存储,可能是 NFS 上的一个导出目录,可能是块存储上的一个 LUN,也可能是某个节点本地的一块 SSD。PVC(PersistentVolumeClaim)是租客递上来的需求单,上面写着我需要多大面积、能不能多人合租、是只读还是能改。StorageClass 则是中介加装修标准,租客不用管房子在哪栋楼、怎么盖的,只要说“给我一套 50 平的”,中介按模板自动造一套出来。
整个流程的控制循环是这样的:你提交一个 PVC,K8S 的 PersistentVolumeController 就开始在集群里找符合条件的 PV;找到了就绑定,绑完之后 PVC 变成 Bound 状态;Pod 里引用这个 PVC,kubelet 在创建容器之前把对应的卷挂到宿主机再映射进容器。如果没有合适的 PV,而 PVC 又指定了一个带 provisioner 的 StorageClass,那就走动态供给,由一个外部的 CSI 驱动临时创建一块新存储。
这里有个特别容易搞混的点:PV 是集群级资源,PVC 是命名空间级资源。PV 的名字在整个集群里唯一,PVC 的名字只在它所在的 namespace 里唯一。所以你在 dev 命名空间里建一个叫data的 PVC,在 prod 里再建一个也叫data的,它们互不冲突,也不会绑到同一个 PV 上——除非你手贱写了同一个 PV 名字去 bind,那 K8S 会直接告诉你 Pre-Bound 冲突。
1.2 为什么非要中间加这一层抽象
很多从传统运维转过来的同学第一反应是:这不脱裤子放屁吗?我直接在 Pod 里写 NFS 的 server 和 path 不就完了?答案是能,但你会很快后悔。我在一个早期项目里就吃过这个亏:当时所有业务 Deployment 的 volumes 里直接写死了nfs: server: 10.x.x.x, path: /export/appdata,后来存储要迁到新的存储节点,我改了 200 多个 YAML,改到怀疑人生,而且中间漏了两个 StatefulSet 没改,导致那两天订单数据写到了老存储上,事后对账对了三天。
加了这层抽象之后,职责被彻底切开了。开发同学只关心“我要 20G、能读写”,写一个 PVC 就完事,他不需要知道底层是块存储还是文件存储,也不需要知道存储集群的地址。运维同学负责 PV 和 StorageClass,存储扩容、迁移、换后端,全在 PV 这一层解决,业务侧 YAML 一个字不用改。这就是为什么 K8S 坚持要把 PV 和 PVC 拆成两个对象、走一个绑定流程,而不是让 Pod 直接引用存储。
还有一层隐含价值是权限收敛。PV 是集群管理员级别的资源,普通业务团队没有权限创建 PV,只能创建 PVC。这样一来,就避免了某个团队随手挂一个宿主机根目录进容器这种灾难性操作。你可以把这理解成数据库里的“视图”概念:底层表怎么设计是 DBA 的事,业务只拿到一个受控的访问入口。
1.3 accessModes、reclaimPolicy、volumeBindingMode 三个字段到底在管什么
这三个字段是 PV/PVC 里最容易被无脑复制、然后在生产上炸掉的部分,值得单独拎出来讲透。
accessModes(访问模式),名字很有迷惑性,很多人以为是“权限”,其实它描述的是挂载的并发能力:
| 模式 | 缩写 | 准确含义 | 常见支持者 |
|---|---|---|---|
| ReadWriteOnce | RWO | 只能被单个节点以读写方式挂载 | 块存储类,绝大多数 |
| ReadOnlyMany | ROX | 可以被多个节点以只读方式挂载 | NFS、CephFS |
| ReadWriteMany | RWX | 可以被多个节点以读写方式挂载 | NFS、CephFS、对象存储网关 |
| ReadWriteOncePod | RWOP | 只能被单个 Pod以读写方式挂载 | 1.22 之后引入,需驱动支持 |
重点说说 RWO 那个坑。它说的是“单节点”,不是“单 Pod”。也就是说同一台宿主机上跑 5 个 Pod,挂同一个 RWO 的 PVC,是允许的。但如果你有两个副本被调度到了两台不同机器上,第二个 Pod 就会卡在Multi-Attach error for volume。这个报错我在无数个工单里见过,本质原因就是把 RWO 当成了多人共享盘来用。
reclaimPolicy(回收策略)决定 PVC 被删之后 PV 怎么办:
Retain:PV 保留,状态变成 Released,数据还在,但需要管理员手动清理后才能重新绑定。Delete:联动删除后端存储。动态供给出来的 PV 默认就是这个。Recycle:已经被废弃了,别用。
生产环境我基本上默认推荐Retain,理由很简单:误删一个 PVC 的成本,远大于手动清理一个 PV 的成本。至于为什么不干脆全局设成 Retain,因为动态供给场景下,如果测试环境频繁创建销毁命名空间,一堆 Released 的 PV 挂着没人管,存储配额很快就满了,所以还是得分环境配置。
volumeBindingMode只有两个值,但决定了你集群会不会出现诡异调度问题:
Immediate:PVC 一创建,立刻绑定 PV。适合网络存储,因为卷在哪都能挂。WaitForFirstConsumer:等到有 Pod 用了这个 PVC,才在 Pod 被调度到的节点上创建和绑定卷。本地盘、local PV、某些可用区级别的块存储必须用这个。
原因很直白:本地盘的 PV 只能挂在特定节点上。如果用 Immediate,PVC 一提交就绑了一块 node-01 上的盘,然后 Pod 因为资源不足被调度到 node-02,那就直接死锁——Pod 等着卷,卷等着 Pod 回到 node-01,永远等不到。
2. 存储后端怎么选:从本地盘到分布式存储
2.1 常见后端横向对比与选型逻辑
选型这件事没有标准答案,但有一套判断顺序。我的习惯是先问三个问题:要不要跨节点共享?要不要跨节点漂移?数据丢了能不能重建?三个问题答完,方案基本就定了。
| 后端类型 | 典型性能 | RWX | 跨节点漂移 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|
| local PV / 本地盘 | 极高 | 不支持 | 不支持 | 低 | 缓存、日志、可重建的中间件数据 |
| NFS | 中等 | 支持 | 支持 | 低 | 共享配置文件、小文件、内部工具 |
| 分布式块存储(如 Ceph RBD) | 高 | 不支持 | 支持 | 高 | 数据库、消息队列等有状态服务 |
| 分布式文件系统(如 CephFS) | 中高 | 支持 | 支持 | 高 | 需要多 Pod 共享读写的场景 |
| 轻量分布式块存储(如 Longhorn、OpenEBS) | 中 | 部分支持 | 支持 | 中 | 中小集群自建,追求部署简单 |
| 公有云块存储 CSI | 高 | 不支持 | 同可用区内支持 | 极低 | 云上集群,生产首选 |
| 对象存储 CSI | 低(延迟高) | 通过 FUSE 模拟 | 支持 | 低 | 大文件归档、模型权重、备份 |
这里我要重点说一个反直觉的结论:本地盘在 K8S 里并不是“不推荐”,恰恰相反,对于 Redis、Kafka 这类自己能做多副本的中间件,用 local PV 的性能收益非常明显,因为省掉了网络这一跳。代价就是你得接受“Pod 只能固定在那个节点”,节点挂了数据就在那躺着,等节点恢复。所以选型的关键不是“哪个最好”,而是“这个组件自己有没有冗余能力”。有冗余的,放心用本地盘;没冗余的(比如单机 MySQL),老老实实上网络存储。
还有一个经常被忽略的维度是延迟的稳定性。网络存储的 IOPS 数字看着漂亮,但那是平均值。我在一个日志采集组件上用过网络块存储,平时写得好好的,一到业务高峰期,采集延迟从 5ms 飙到 800ms,原因就是共享存储集群的带宽被别的租户抢了。后来换成 local PV,延迟曲线立刻变成一条直线。所以对延迟敏感的服务,本地盘永远是第一选择。
2.2 静态供给和动态供给,什么时候该用哪个
这个问题的判断标准其实特别简单:你需要为每一个 PVC 手工规划容量吗?如果需要,就是静态;不需要,就是动态。
静态供给的典型场景是“存量存储接入”。比如公司机房有一台跑了五年的 NFS,上面已经堆了 40T 的历史数据,你想让新集群的 Pod 也能读,那没别的办法,手工写一批 PV,每个指向 NFS 上的一个子目录。或者你从存储团队那边申请了 10 块已经划好的 LUN,每块 500G,那也只能静态建 10 个 PV。静态供给的好处是完全可控,哪块盘对应哪个业务,一清二楚;坏处是容量规划变成了人肉活,而且一旦 PV 用完,新的 PVC 就只能 Pending。
动态供给是现在的主流,靠 StorageClass 加 provisioner 实现。你提交 PVC,驱动自动去后端划一块新卷、创建一个 PV 对象、完成绑定,全程无人干预。它解决的核心问题是规模:一个几百个微服务的集群,如果每个服务都要人肉建 PV,那运维基本上不用干别的了。
我通常的做法是两者混用。核心的有状态服务(数据库、消息队列)用静态或者预创建的方式,因为它们的容量是精心规划过的,而且往往需要特殊的参数(比如高 IOPS 的卷类型、特定的可用区)。无状态服务里那些需要临时持久化的(比如 CI 的构建缓存、临时上传目录),全部走动态供给,用完就删,不占资源。这种混合模式在实操中特别省心,你不用为了一个 200M 的缓存目录去走一遍存储申请流程。
2.3 CSI 到底是什么,为什么现在绕不开它
CSI 全称 Container Storage Interface,是一个规范,不是某个具体产品。它定义了存储厂商需要实现哪些 gRPC 接口,才能让自己的存储在 K8S 里被使用。这个规范的出现,本质是为了解决一个历史包袱:早期的 K8S 把各种存储的驱动代码直接编译进了核心组件里,这叫 in-tree。结果就是 K8S 的代码库里塞满了各家存储的实现,改一个 bug 要发一个新版本 K8S,存储厂商想上新特性得等 K8S 半年一次的发布周期,双方都痛苦。
CSI 把驱动拆成了两个独立的组件,各自跑在 Pod 里:
- Controller 组件(通常是个 Deployment,1 到多副本):负责卷的生命周期管理,创建卷、删除卷、挂载到节点、从节点卸载、快照、扩容。它调用的是云厂商或者存储系统的管理 API,不需要跑在业务节点上。
- Node 组件(DaemonSet,每个节点一个):负责真正在节点上执行 mount、umount、格式化这些操作。它必须在业务 Pod 所在的节点上运行,因为它操作的是本机的文件系统和设备。
弄明白这个拆分,很多报错就瞬间读懂了。比如 Pod 卡在ContainerCreating,describe显示FailedMount,大概率是Node 组件出了问题,去kubectl logs -n kube-system <csi-node-pod>看。如果是 PVC 一直 Pending,describe pvc显示waiting for a volume to be created,那是Controller 组件的问题,去看 Controller 的日志。
现在新集群基本不用考虑 in-tree 了,那些老的kubernetes.io/aws-ebs、kubernetes.io/gce-pd之类的插件名早就标记废弃,新版本直接移除了。所以你在网上抄的很多老 YAML,storageClassName写着standard但集群里压根没有,PVC 就永远 Pending。这不是 YAML 写错了,是时代变了。
3. 实操一:静态 PV + PVC 走通一次完整挂载
3.1 搭一个最小可验证环境
理论讲完,动手才记得住。这一节我们用 local PV 搭一套最小可复现的环境,因为它不依赖任何外部存储服务,一台机器就能跑通。但 local PV 有几个硬性前提,少一个都跑不起来,我先把它们摆出来:
第一,目标目录必须预先在节点上存在,local PV 不会帮你创建目录。第二,PV 必须带nodeAffinity,明确告诉调度器这块盘在哪台机器上。第三,StorageClass(或者 PV 所在的绑定流程)必须用WaitForFirstConsumer,否则就会出现我前面说的绑定死锁。
准备动作,在 node-01 上执行:
sudo mkdir -p /data/k8s-volumes/vol01 sudo chmod 777 /data/k8s-volumes/vol01chmod 777是为了绕过后续的权限问题,生产上不要这么干,正确的做法是用 fsGroup,这个在第 6 节会详细说。测试环境图省事,先开权限。
节点打标签也是可选但推荐的一步,方便后面 nodeAffinity 引用:
kubectl label node node-01 storage=local-ssd用自定义标签比用kubernetes.io/hostname更灵活,因为后者是系统标签,节点改名就会失效,而自定义标签你可以自己控制语义。
3.2 PV 与 PVC 的 YAML 逐字段拆解
PV 的定义:
apiVersion: v1 kind: PersistentVolume metadata: name: pv-local-01 spec: capacity: storage: 10Gi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain storageClassName: manual local: path: /data/k8s-volumes/vol01 nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: storage operator: In values: - local-ssd逐字段解释几个关键项。capacity.storage: 10Gi是声明值,不是实际限制,local PV 不会真的给你限流到 10G,它只是个给调度器看的容量标签。所以如果你写 10Gi,实际目录所在磁盘只剩 5G,那 Pod 写爆了 kubelet 也不会拦。这一点在本地盘场景下特别危险,务必配合监控。
volumeMode有两个值:Filesystem(默认,会帮你格式化并 mount 成目录)和Block(直接把裸设备给容器,容器里看到的是/dev/sdb这种块设备,适合数据库自己做 IO 管理)。绝大多数场景用 Filesystem 就行,Block 模式要配合volumeDevices字段使用,容易写错。
storageClassName: manual这个值是我随便起的,重点在于PVC 那边必须写一模一样的。这里有个大坑:如果 PV 写了manual,PVC 忘了写storageClassName,那 PVC 会去找集群的默认 StorageClass,两边对不上,永远不绑。反过来,PV 没写storageClassName(即空字符串),PVC 也不写,那它们能绑上,但会跟所有没写 StorageClass 的 PVC 一起抢,非常混乱。
PVC 的定义:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pvc-local-01 namespace: demo spec: accessModes: - ReadWriteOnce storageClassName: manual resources: requests: storage: 8Gi命名空间记得先建:kubectl create ns demo。
这里有个特别值得说的细节:PVC 请求 8Gi,PV 是 10Gi,能绑上吗?能。容量匹配的规则是“PV 的容量大于等于 PVC 的请求”。反过来,PVC 请求 10Gi、PV 只有 8Gi,就绑不上,PVC 一直 Pending。
绑定算法本身也值得一提。当有多个 PV 都满足条件时,K8S 会选容量最接近的那个(best fit),避免出现“请求 1G 却绑走了 100G 的 PV”这种浪费。如果没有完全匹配的,就选最小的那个够用的。这个逻辑在静态供给场景下偶尔会坑到你:你以为某个 PVC 会绑到 A 盘,结果它绑到了 B 盘,因为 B 盘的容量更接近。所以静态场景下,PV 的 capacity 最好别乱写,尽量贴近真实。
验证绑定:
kubectl get pv pv-local-01 kubectl get pvc -n demo pvc-local-01正常的话 PV 是Bound,PVC 也是Bound,而且kubectl get pvc的 VOLUME 列会显示pv-local-01。如果 PVC 一直Pending,立刻kubectl describe pvc -n demo pvc-local-01,Events 里会写清楚原因,无非就三种:没有匹配的 PV、StorageClass 不存在、容量不够。
3.3 挂进 Pod 并验证真实的读写与回收行为
apiVersion: v1 kind: Pod metadata: name: demo-app namespace: demo spec: containers: - name: app image: busybox:1.36 command: - sh - -c - "while true; do date >> /data/out.log; sleep 5; done" volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: pvc-local-01注意 Pod 里引用 PVC 用的是persistentVolumeClaim.claimName,不是直接写 PV 名字。这个层级关系很多人第一次会写错。
起来之后验证:
kubectl exec -n demo demo-app -- cat /data/out.log kubectl exec -n demo demo-app -- df -h /datadf -h /data输出的容量,你会发现它是整个节点磁盘的容量,不是 10Gi。这就是我前面说的,local PV 的 capacity 只是个标签,不做限制。这也是本地盘容易出事故的根源之一。
再验证一次持久性:删掉 Pod,重建,看日志还在不在。
kubectl delete pod -n demo demo-app # 重新 apply 同一个 yaml kubectl exec -n demo demo-app -- cat /data/out.log日志还在,说明数据确实写在宿主机目录上,跟 Pod 生命周期解耦了。
最后验证回收策略。删 PVC:
kubectl delete pvc -n demo pvc-local-01 kubectl get pv pv-local-01因为设了Retain,PV 的状态会变成Released,但不会消失,数据也还在。这时候如果你想复用这个 PV,不能直接改 PVC 重新绑,得先手动清理:
kubectl patch pv pv-local-01 -p '{"spec":{"claimRef": null}}'把claimRef置空,PV 才会回到Available状态。这一步特别容易忘,很多人看到Released就以为坏了,其实是保护机制在生效。如果你改成Delete策略,删 PVC 的时候 PV 会被自动删掉——但注意,local PV 的Delete不会真删磁盘上的数据,只是把 PV 对象删了,目录里的文件依然在,下次同名目录再被挂载,数据就“诈尸”了。这个行为特别反直觉,我在一个测试环境里就因为这个,把上个项目的残留数据当成了新数据,查了半天为什么初始化脚本没生效。
4. 实操二:StorageClass 动态供给落地
4.1 装 provisioner 与 StorageClass 定义的正确姿势
动态供给的前提是集群里已经跑起来一个 provisioner。云上集群一般开箱即用,自建集群就得自己装。这一节我们不讲某一个具体驱动的安装步骤(各家差异太大),而是讲清楚 StorageClass 这个对象的定义逻辑,因为这部分是通用的。
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd annotations: storageclass.kubernetes.io/is-default-class: "true" provisioner: csi.example-storage.com parameters: type: ssd fsType: ext4 zone: cn-north-1a reclaimPolicy: Delete allowVolumeExpansion: true volumeBindingMode: WaitForFirstConsumer mountOptions: - noatimeprovisioner这一项是最容易出错的地方,必须和驱动注册的名字一字不差。驱动注册的名字通常是xxx.csi.k8s.io这种格式,你在驱动文档里能查到,或者直接看集群里已有的 StorageClass:
kubectl get storageclass写错了的表现是 PVC 创建后一直 Pending,describe显示storageclass.storage.k8s.io "xxx" not found,或者no provisioner with that name。
parameters是驱动自己定义的键值对,K8S 只负责透传,不会校验。不同驱动支持的参数天差地别,写错了驱动会直接报invalid parameter,卷创建失败。我的习惯是参数宁少勿多,只填必需的,剩下的用驱动默认值。因为驱动升级的时候,某些参数可能会被废弃,参数越多,升级踩坑的概率越大。
mountOptions这一项经常被忽略,但它对性能的影响可能比换个存储还大。比如noatime(不记录文件访问时间),在大量小文件读写的场景下能省掉不少元数据写入开销。再比如nfsvers=4.1或者rsize/wsize的调整,对 NFS 的吞吐影响是数量级的。不过要注意,mountOptions写错会导致挂载直接失败,而且报错信息往往很隐晦(mount failed: exit status 32),排查起来费劲。我的建议是改这一项之前,先在测试集群上单独验证一次。
4.2 默认 StorageClass 与那个“多个默认”的坑
storageclass.kubernetes.io/is-default-class: "true"这个注解把 StorageClass 设成默认。好处是业务侧写 PVC 的时候可以不写storageClassName,自动用默认的那个,省事。
但这里有个非常隐蔽的坑:如果一个集群里存在多个被标记为默认的 StorageClass,K8S 不会报错,它会选最近创建的那个。这个行为在官方文档里只是一句轻描淡写的描述,但实际后果是:你昨天装的监控组件顺带装了个默认 StorageClass,今天所有不写 storageClassName 的 PVC 就全部绑到了监控用的那个存储池上,性能完全不对。
所以我的做法是:部署任何会创建 StorageClass 的组件时,一定要检查它有没有偷偷设置默认注解,装完之后立刻kubectl get sc看一眼,多余的直接把注解去掉:
kubectl annotate sc <多余的那个> storageclass.kubernetes.io/is-default-class-注意末尾那个减号,是删除注解的语法。
另外一个常见问题是默认 StorageClass 和已有静态 PV 的冲突。如果集群里有默认 StorageClass,而业务 A 想用静态 PV(PV 上写了storageClassName: manual),那 PVC 必须显式写storageClassName: manual。如果它不写,就会走默认 StorageClass 动态创建一块新卷,你预期的“绑到老 PV”完全不会发生。
还有一种情况要特别小心:PVC 的storageClassName写成空字符串"",表示明确不使用任何 StorageClass,只和同样没有 StorageClass 的 PV 绑定。这个和“不写这个字段”是两个完全不同的语义——不写表示“用默认的”,写""表示“不用默认的”。我给新人讲的时候,一般会让他们记住一句话:这个字段的空缺和空值是两回事。
4.3 StatefulSet + volumeClaimTemplates 的标准用法
有状态服务用 PVC 的标准姿势不是手写 PVC,而是在 StatefulSet 里写volumeClaimTemplates,让控制器自动为每个副本生成独立的 PVC。
apiVersion: apps/v1 kind: StatefulSet metadata: name: db namespace: demo spec: serviceName: db-headless replicas: 3 selector: matchLabels: app: db template: metadata: labels: app: db spec: containers: - name: db image: some-db-image:1.0 volumeMounts: - name: data mountPath: /var/lib/data volumeClaimTemplates: - metadata: name: data spec: accessModes: - ReadWriteOnce storageClassName: fast-ssd resources: requests: storage: 50Gi这份 YAML 跑起来之后,会自动创建三个 PVC:>kubectl delete pvc -n demo -l app=db
或者更精确地按名字删。
第四,修改 volumeClaimTemplates 不生效。这个坑我必须重点强调。volumeClaimTemplates在 StatefulSet 创建之后就是不可变的,你改了里面的 storage 大小、storageClassName,kubectl apply会直接报错updates to statefulset spec for fields other than 'replicas', 'template', 'updateStrategy' are forbidden。想改只能删掉 StatefulSet 重建(PVC 会保留),或者手动去改每一个 PVC。这个限制到现在也没有放开,所以初始设计的时候容量一定要留足余量,别指望后面能轻松改。
我见过一个项目,初始给数据库配了 20Gi,半年后满了,想扩到 100Gi,结果因为 StatefulSet 模板不可变,只能先给 SC 开 allowVolumeExpansion,再逐个 PVC 手动 patch,然后再重启每个 Pod 让文件系统生效。整个过程折腾了一个通宵。
5. 扩容、快照与数据迁移:绕不开的日常运维
5.1 PVC 在线扩容的前置条件与三个坑
扩容这件事,很多教程就一句话“改 PVC 的 storage 字段”,但真正做完需要满足一堆前置条件,任何一条不满足都扩不了。
前置条件一:StorageClass 必须开allowVolumeExpansion: true。而且是在 PVC 创建之前就开好。有些老版本 K8S 存在一个问题:PVC 已经创建了,你再去把 SC 改成允许扩容,这个 PVC 依然扩不了,因为 PVC 上没有记录扩容能力这个信息。新版本已经修了,但如果你跑的是老版本集群,这个坑还是存在。
前置条件二:底层 CSI 驱动必须支持扩容。大部分块存储都支持,但一些基于对象存储或者特殊文件系统的驱动可能不支持。不支持的表现是 PVC 的status.conditions里出现resize not supported。
前置条件三:只能增加,不能减少。这个不用解释,K8S 层面就不允许,kubectl apply会直接拒绝。想缩容只能重建 PVC 加数据迁移。
满足条件之后,操作本身很简单:
kubectl patch pvc>kubectl get pvc>apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: db-snapshot-20240601 namespace: demo spec: volumeSnapshotClassName: csi-snapclass source: persistentVolumeClaimName:>apiVersion: v1 kind: PersistentVolumeClaim metadata: name:>apiVersion: v1 kind: Pod metadata: name: migrator namespace: demo spec: containers: - name: m image: alpine:3.19 command: ["sh", "-c", "apk add --no-cache rsync && sleep infinity"] volumeMounts: - name: old mountPath: /old - name: new mountPath: /new volumes: - name: old persistentVolumeClaim: claimName:>kubectl exec -n demo migrator -- rsync -aHAX --info=progress2 /old/ /new/第四步,停业务,做最后一次增量同步,切换引用,起业务验证。
第五步,观察一段时间再删老 PVC。
最后这一步就是血泪教训的现场。我见过不止一次,业务切过去跑了两天,运维觉得“应该没问题了”,顺手kubectl delete pvc清理一下,结果 StorageClass 的reclaimPolicy是Delete,后端存储直接被删了。老 PVC 是唯一的备份,新 PVC 用着没问题是因为数据已经同步过去了,但万一新卷出点什么事,连回滚的余地都没有了。
所以我的建议是:生产环境的 StorageClass,reclaimPolicy一律设成Retain,动态供给生成的 PV 也继承这个策略。真要清理,走人工审批,手动删 PV 再删后端存储。多出来的这点操作成本,换来的是“手滑也删不掉数据”的安全垫。如果非要用Delete,那至少给 PVC 加一个 finalizer 保护的流程,或者用 RBAC 限制只有特定角色能删 PVC,业务团队只能创建不能删除。
6. 常见问题与排查技巧实录
6.1 Pending、挂载失败、多挂载冲突的速查表
这一节是我这些年攒下来的排查清单,基本覆盖了 90% 的存储类故障。遇到问题先对照现象找,比盲目看日志快得多。
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
PVC 一直 Pending,Events 显示no persistent volumes available for this claim and no storage class is set | PVC 没写 storageClassName,集群也没有默认 SC | kubectl get sc确认有没有默认;给 PVC 补上 SC 名字 |
PVC Pending,Events 显示waiting for a volume to be created | provisioner 没跑、RBAC 权限不足、驱动报错 | 看 CSI Controller Pod 是否 Running,看它的日志 |
PVC Pending,Events 显示node(s) had volume node affinity conflict | local PV 的 nodeAffinity 与 Pod 调度需求冲突,一般是用了 Immediate 绑定 | 检查 SC 的 volumeBindingMode,改 WaitForFirstConsumer |
PVC 已 Bound,Pod 卡 ContainerCreating,describe 显示FailedMount | CSI Node 插件异常、mount 参数错、宿主机文件系统问题 | 看 CSI Node DaemonSet 日志,看节点dmesg |
Pod 报Multi-Attach error for volume | RWO 卷还挂在旧节点上,Pod 被调度到了新节点 | 等几分钟自动 detach;确认旧 Pod 真被删了;极端情况手动清 attachment |
容器内写文件报Permission denied | 卷属主属组不对,或 NFS 有 root squash | 检查 fsGroup、runAsUser、securityContext |
| Pod 启动特别慢(几分钟到十几分钟) | 大容量卷 + 默认fsGroupChangePolicy: Always,kubelet 在递归改权限 | 改成OnRootMismatch |
PVC 扩容卡在Resizing不动 | 驱动不支持、后端配额满、SC 未开 allowVolumeExpansion | 看 PVC 的 conditions,看驱动日志 |
关于Multi-Attach error再多说两句。这个报错的本质是卷没有被干净地卸载。正常情况下节点失联后,K8S 会等一段时间(默认 6 分钟)才强制把卷从旧节点 detach,然后允许新节点挂载。如果你着急,可以手动把卷的 attachment 对象删掉:
kubectl get volumeattachment | grep <pv-name> kubectl delete volumeattachment <name>但这么做有风险:如果旧节点其实还活着、还在往卷上写数据,那新节点同时挂载就会造成文件系统损坏。所以删 attachment 之前,务必确认旧节点是真的挂了,或者旧 Pod 是真的没了。我在生产环境上只在节点彻底失联、且确认不会恢复的情况下才用过这个操作。
6.2 权限与 fsGroup:一个能把启动时间拖到十分钟的坑
容器里跑非 root 用户,挂上来的卷权限是 root:root,写不进去,这是新手最常遇到的权限问题。标准解法是在 Pod 的securityContext里设fsGroup:
spec: securityContext: fsGroup: 2000 fsGroupChangePolicy: OnRootMismatchfsGroup的作用是让 kubelet 在挂载卷之后,把卷的属组改成这个 GID,并且给目录加上 setgid 位,这样子目录也会继承这个属组。注意它改的是属组不是属主,属主还是原来的,这跟很多人想的不一样。
fsGroupChangePolicy是 1.20 之后引入的,默认值是Always,意思是每次挂载都递归遍历整个卷改权限。这个行为对卷的大小极其敏感。我在一个日志采集服务上踩过:卷里堆了三百多万个小文件,Pod 每次重启,kubelet 都要把这三百多万个文件的权限过一遍,启动时间从 10 秒变成了 12 分钟,而且把节点的 IO 打满了,同节点其他 Pod 全部受影响。
改成OnRootMismatch之后,kubelet 会先检查卷的根目录权限对不对,对的话就跳过整个递归。这一个改动,启动时间直接回到 10 秒。我把这条写进了团队的 YAML 模板里,所有带 PVC 的 Pod 默认都带这个设置。
还有一个和权限相关的坑是NFS 的 root squash。NFS 服务端如果开了root_squash,容器里以 root 身份写入的文件,在 NFS 服务端会被映射成nobody,导致权限混乱。解决办法要么是关闭 root_squash(有安全风险),要么是让容器用非 root 用户跑,并配合fsGroup。这个坑的诡异之处在于,容器里ls -l看着权限是对的,但就是写不进去,因为服务端的映射发生在网络层,容器里看不到。
6.3 我自己的避坑清单
写到这里,把散落各处的经验收拢成一份清单,都是我实际踩过或亲眼见过的事故:
StorageClass 层面:新集群上线第一件事,kubectl get sc确认默认 SC 是哪个,把不是的那几个的默认注解去掉。生产 SC 的reclaimPolicy设Retain,volumeBindingMode一律WaitForFirstConsumer(不管是不是本地盘,这个设置对网络存储也没坏处,只是延迟了创建时机)。allowVolumeExpansion从第一天就开着,别等要扩容的时候才发现没开。
PVC 层面:storageClassName永远显式写,不要依赖默认值。原因是一旦集群默认 SC 变了,所有没写的 PVC 行为都会跟着变,这种“代码没动但行为变了”的问题最难排查。另外容量要留余量,按一年增长量预估,因为 StatefulSet 的volumeClaimTemplates改不了,PVC 缩容也做不到,只能往上加。
监控层面:一定要监控kubelet_volume_stats_used_bytes和kubelet_volume_stats_capacity_bytes这两个指标,算出每个 PVC 的使用率,超过 80% 就告警。local PV 尤其重要,因为它的 capacity 不做限制,真写满了会把整个节点拖垮,影响的是同节点所有 Pod。同时监控kubelet_volume_stats_inodes_free,小文件多的场景下经常是 inode 先耗尽,而不是容量。
备份层面:快照不能替代备份。快照和后端存储通常在同一个故障域,存储集群挂了快照也一起没。真正的备份一定要跨存储、跨集群,而且要定期做恢复演练。我见过太多团队配了快照就以为万事大吉,真出事的时候发现快照根本恢复不出来。
变更层面:动存储相关的配置,永远先在测试环境的同等规模数据量上验证。一个空卷上的扩容测试,和在 500G、两百万文件的卷上扩容,完全是两回事。
最后分享一个我自己养成的习惯:每次接手一个新集群,第一件事是写一个最简单的测试 Pod,用默认配置挂一块 PVC 进去,跑一遍“创建、写入、删除 Pod、重建、读取、扩容、删 PVC”这套动作。这套动作大概五分钟,能把这个集群存储链路上的绝大多数配置问题提前暴露出来,比等到业务上线才发现强太多。