Kubernetes PVC 扩容:ControllerExpand、NodeExpand 与文件系统完成条件
PVC 的 spec.resources.requests.storage 改大后,PV 容量可能已经变成新值,PVC 状态却还显示旧 capacity,并出现 FileSystemResizePending。这时继续调用 resizer 没有用,真正等待的是节点在挂载时完成文件系统扩容。
本文在 Kubernetes v1.35.1 上验证扩容的控制面、节点侧和完成条件,实验环境是 zj 的三节点 kind v0.33.0 集群 article-next(提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500)。CSI 驱动取自 csi-driver-host-path v1.19.0 仓库,实际运行的镜像是 hostpathplugin:v1.18.0,配套 csi-resizer:v2.3.0。hostpath 是测试驱动,部分操作只模拟 CSI 调用,不会改变容器里的实际文件系统容量;实验特意保留这个限制,不把 PVC status 的变化写成生产存储一定在线扩容成功。
三个容量值分别表示什么
PVC 扩容至少涉及三个不同的值:
pvc.spec.resources.requests.storage:用户希望申请的容量。pv.spec.capacity.storage:控制面记录的卷容量。pvc.status.capacity.storage:当前可供消费者使用的容量。
修改请求后,controller resizer 先调用存储驱动扩容后端卷,再更新 PV。节点侧完成文件系统调整后,kubelet 才会把 PVC status capacity 推进到新值。
sequenceDiagram
participant U as 用户
participant A as API Server
participant C as external-resizer
participant D as CSI Controller
participant K as kubelet
participant N as CSI Node
U->>A: 修改 PVC request 为 128Mi
A-->>C: watch PVC 更新
C->>D: ControllerExpandVolume(128Mi)
D-->>C: capacity=128Mi,nodeExpansionRequired=true
C->>A: 更新 PV capacity
C->>A: PVC condition FileSystemResizePending
Note over K,A: 节点侧还要在卷挂载时完成文件系统扩容
K->>N: NodeExpandVolume
N-->>K: 扩容完成
K->>A: 更新 PVC status capacity
图中的 resizer 和 kubelet 是两个独立的推进者。控制面更新 PV 成功,不等于节点上的文件系统已经变大。
未允许扩容时在准入阶段就被拒绝
实验先创建两个 StorageClass:一个 allowVolumeExpansion: true,另一个为 false。对后者绑定的 PVC 直接修改请求容量,API Server 返回:
| |
这个失败发生在准入阶段,由 PersistentVolumeClaimResize 准入插件给出(v1.35.1 的 admission.go 在请求容量变大、PVC 已绑定但 allowResize 返回 false 时构造这个 Forbidden),尚未创建 resizer 任务,也没有调用 CSI。把 StorageClass 的 allowVolumeExpansion 当成在线扩容能力证明也不对;它只是允许 API 接受扩容请求,驱动是否支持 EXPAND_VOLUME、是否需要节点扩容仍要单独确认。
实验也验证了把请求改小到当前容量以下会被拒绝:
| |
这里的 status.capacity 是当前已提供容量。PVC 缩容不是把值改回去,生产上需要复制数据、创建新卷或走后端特有的离线流程。
这条校验的边界比“不能改小”更细。RecoverVolumeExpansionFailure 功能门开启时(v1.35 已 GA 并 LockToDefault),改小请求不再一律拒绝,只要求不低于 status.capacity。注释说明了意图:卷扩容失败后,允许你把请求从 128MiB 调回 64MiB,让 PVC 摆脱失败的扩容状态。这不是缩容支持——后端卷仍是原来的大小,Kubernetes 不会缩小它(v1.35.1 的 validation.go:2586-2597)。
实验这条报错就走在这个分支里:PVC 从 64MiB 扩到 128MiB 后停在待节点扩容状态,status.capacity 仍是 64MiB;脚本把请求改回 64MiB,正好等于该值,于是被“不高于当前容量”这条挡住。旧路径的错误文本是 field can not be less than previous value,新路径是 can not be less than status.capacity;看到哪一条,就能判断集群用的是哪种校验。
控制面扩容先更新 PV 容量
在允许扩容的 StorageClass 上,PVC 从 64 MiB 改到 128 MiB。实验记录的控制器阶段为:
| |
external-resizer 日志中出现了 ControllerExpandVolume,驱动返回:
| |
external-resizer v2.3.0(独立于 Kubernetes 发版,随 CSI hostpath 一起部署)的 pvNeedResize 会区分两种状态:PV 容量已不小于请求时,如果 PVC 处于 PersistentVolumeClaimFileSystemResizePending,就返回 false 让控制器不再重复扩容,等 kubelet 在挂载路径上完成节点阶段;否则继续处理。PV 容量仍小于请求时,说明控制面扩容还没完成。
实验记录里的 allocatedResourceStatuses.storage: NodeResizePending 就是这条分界线的名字。这个字段的取值是有限枚举,v1.35.1 的 types.go:738-766 列了五个:ControllerResizeInProgress、ControllerResizeFailed、NodeResizePending、NodeResizeInProgress、NodeResizeFailed。字段为空表示当前没有进行中的扩容。所以排障时不必只看 conditions 的布尔值,这个字段直接告诉你卡在哪个阶段:NodeResizePending 表示控制面已完成、等节点;NodeResizeFailed 表示节点侧遇到终态错误,瞬时错误不会写这个值。
对应的 allocatedResources 也参与配额计算。注释写明存储配额取 allocatedResources 与 spec.resources 中较大的那个;请求容量被调低时,只有在没有进行中的扩容操作、且实际容量不高于请求值的情况下,allocatedResources 才会跟着降(同文件 types.go:718-729)。这解释了为什么扩容期间把请求改小,配额不会立刻回落。
这说明 PVC Bound 和 PVC 扩容完成 不能划等号。Bound 只记录声明与卷的绑定关系,扩容还包含控制面和节点两个阶段。
节点扩容发生在卷挂载路径
实验中的消费 Pod 先保持运行,扩容后 PVC 停在等待状态。删除并重建消费者后,kubelet 在挂载卷时调用 NodeExpandVolume,日志中带的参数包括:
| |
随后事件出现 FileSystemResizeSuccessful,PVC status capacity 变为 128 MiB,而且原文件仍可读取:
| |
本次 hostpath 驱动只记录卷大小并接受 NodeExpand 调用,容器内 df 显示的仍是节点整块 /dev/sda2,不是 128 MiB 的配额。实验证明了 Kubernetes 和 CSI 调用链,以及 PVC 状态在消费者重建后达到驱动报告的目标容量;它不证明生产存储后端已经完成真实文件系统扩容。
节点的完成条件见官方文档 Expanding persistent volume claims:文件系统卷通常在 Pod 启动挂载时完成扩容,只有底层文件系统支持在线扩容时才能在运行中完成。实验中 hostpath 场景通过重建消费者触发,不能推广成所有存储都必须重建 Pod。不同 CSI 驱动是否支持在线扩容、是否支持块设备扩容,需要查对应驱动的能力。NodeExpansionRequired 是控制面响应的一部分,检查时应同时看 driver capabilities、PVC conditions 和实际设备/文件系统容量。
flowchart TB
A[StorageClass allowVolumeExpansion] --> B{API 接受更大的 request?}
B -->|否| C[Forbidden,未进入 resizer]
B -->|是| D[ControllerExpandVolume]
D --> E{驱动返回成功?}
E -->|否| F[保持 Resizing 或写失败状态]
E -->|是| G[更新 PV capacity]
G --> H{需要节点扩容?}
H -->|否| I[完成]
H -->|是| J[FileSystemResizePending]
J --> K[消费者挂载或重建]
K --> L[NodeExpandVolume]
L -->|成功| M[更新 PVC capacity]
L -->|失败| N[继续重试并保留挂载失败证据]
节点扩容失败时,Pod 不应该被当成已经拿到可用的大卷启动。v1.35.1 的 operation_generator.go 在挂载阶段调用 expandVolumeDuringMount,失败时调用 MarkVolumeMountAsUncertain,注释说明了原因:挂载其实已经成功,但卷没有扩到目标大小,所以不标记为 mounted,让 reconciler 在 Pod 删除时触发卸载,同时阻止 Pod 开始使用这个卷。修复驱动或后端后,下一轮 reconcile 再尝试。这一条来自源码阅读,本次实验没有制造 NodeExpand 失败。
排障时按容量和条件分开看
- PVC 尚未变化:先确认 StorageClass 是否允许扩容,API 是否拒绝了请求。
- 只有请求变大:看 external-resizer 和 CSI Controller 日志,确认是否调用了
ControllerExpandVolume。 - PV 已变大、PVC capacity 仍旧:检查
FileSystemResizePending、消费者所在节点和 kubelet 日志。 - NodeExpand 失败:查看具体挂载路径、文件系统类型、卷是否只读以及驱动返回的错误。
- 所有状态都显示成功:仍在容器或块设备上读取实际容量,不要只看 PVC YAML。
这套流程的上一步是卷的供应、绑定和挂载,见 PVC 生命周期。扩容本身不迁移数据;容器内 df 和 PVC YAML 不一致时,以设备或文件系统的实际大小为准。
实验目录、CSI 版本和运行步骤见 k8sdev/article-pvc-expansion。本次验证了拒绝未允许扩容、ControllerExpand、消费者重建后的 NodeExpand、数据保留和缩容拒绝;没有验证真实云盘在线扩容、块设备扩容、跨节点拓扑变化或后端容量耗尽。