
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](https://github.com/kubernetes-csi/csi-driver-host-path) v1.19.0 仓库，实际运行的镜像是 `hostpathplugin:v1.18.0`，配套 `csi-resizer:v2.3.0`。hostpath 是测试驱动，部分操作只模拟 CSI 调用，不会改变容器里的实际文件系统容量；实验特意保留这个限制，不把 PVC status 的变化写成生产存储一定在线扩容成功。

<!--more-->

## 三个容量值分别表示什么

PVC 扩容至少涉及三个不同的值：

- `pvc.spec.resources.requests.storage`：用户希望申请的容量。
- `pv.spec.capacity.storage`：控制面记录的卷容量。
- `pvc.status.capacity.storage`：当前可供消费者使用的容量。

修改请求后，controller resizer 先调用存储驱动扩容后端卷，再更新 PV。节点侧完成文件系统调整后，kubelet 才会把 PVC status capacity 推进到新值。

```mermaid
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 返回：

```text
persistentvolumeclaims "no-expand" is forbidden:
only dynamically provisioned pvc can be resized
and the storageclass that provisions the pvc must support resize
```

这个失败发生在准入阶段，由 `PersistentVolumeClaimResize` 准入插件给出（v1.35.1 的 [`admission.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/admission/storage/persistentvolume/resize/admission.go#L99-L112) 在请求容量变大、PVC 已绑定但 `allowResize` 返回 false 时构造这个 Forbidden），尚未创建 resizer 任务，也没有调用 CSI。把 StorageClass 的 `allowVolumeExpansion` 当成在线扩容能力证明也不对；它只是允许 API 接受扩容请求，驱动是否支持 `EXPAND_VOLUME`、是否需要节点扩容仍要单独确认。

实验也验证了把请求改小到当前容量以下会被拒绝：

```text
spec.resources.requests.storage:
Forbidden: field can not be less than status.capacity
```

这里的 `status.capacity` 是当前已提供容量。PVC 缩容不是把值改回去，生产上需要复制数据、创建新卷或走后端特有的离线流程。

这条校验的边界比“不能改小”更细。`RecoverVolumeExpansionFailure` 功能门开启时（v1.35 已 GA 并 `LockToDefault`），改小请求不再一律拒绝，只要求不低于 `status.capacity`。注释说明了意图：卷扩容失败后，允许你把请求从 128MiB 调回 64MiB，让 PVC 摆脱失败的扩容状态。这不是缩容支持——后端卷仍是原来的大小，Kubernetes 不会缩小它（v1.35.1 的 [`validation.go:2586-2597`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/apis/core/validation/validation.go#L2586-L2597)）。

实验这条报错就走在这个分支里：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。实验记录的控制器阶段为：

```text
PVC request: 128Mi
PV capacity: 128Mi
PVC status capacity: 64Mi
PVC condition: FileSystemResizePending=True
allocatedResourceStatuses.storage: NodeResizePending
```

external-resizer 日志中出现了 `ControllerExpandVolume`，驱动返回：

```text
capacity_bytes=134217728
node_expansion_required=true
```

external-resizer v2.3.0（独立于 Kubernetes 发版，随 CSI hostpath 一起部署）的 [`pvNeedResize`](https://github.com/kubernetes-csi/external-resizer/blob/v2.3.0/pkg/controller/controller.go#L398-L429) 会区分两种状态：PV 容量已不小于请求时，如果 PVC 处于 `PersistentVolumeClaimFileSystemResizePending`，就返回 false 让控制器不再重复扩容，等 kubelet 在挂载路径上完成节点阶段；否则继续处理。PV 容量仍小于请求时，说明控制面扩容还没完成。

实验记录里的 `allocatedResourceStatuses.storage: NodeResizePending` 就是这条分界线的名字。这个字段的取值是有限枚举，v1.35.1 的 [`types.go:738-766`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/apis/core/types.go#L738-L766) 列了五个：`ControllerResizeInProgress`、`ControllerResizeFailed`、`NodeResizePending`、`NodeResizeInProgress`、`NodeResizeFailed`。字段为空表示当前没有进行中的扩容。所以排障时不必只看 conditions 的布尔值，这个字段直接告诉你卡在哪个阶段：`NodeResizePending` 表示控制面已完成、等节点；`NodeResizeFailed` 表示节点侧遇到终态错误，瞬时错误不会写这个值。

对应的 `allocatedResources` 也参与配额计算。注释写明存储配额取 `allocatedResources` 与 `spec.resources` 中较大的那个；请求容量被调低时，只有在没有进行中的扩容操作、且实际容量不高于请求值的情况下，`allocatedResources` 才会跟着降（同文件 [`types.go:718-729`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/apis/core/types.go#L718-L729)）。这解释了为什么扩容期间把请求改小，配额不会立刻回落。

这说明 `PVC Bound` 和 `PVC 扩容完成` 不能划等号。Bound 只记录声明与卷的绑定关系，扩容还包含控制面和节点两个阶段。

## 节点扩容发生在卷挂载路径

实验中的消费 Pod 先保持运行，扩容后 PVC 停在等待状态。删除并重建消费者后，kubelet 在挂载卷时调用 `NodeExpandVolume`，日志中带的参数包括：

```text
volume_id: ...
mount_path: /var/lib/kubelet/pods/.../mount
required_bytes: 134217728
```

随后事件出现 `FileSystemResizeSuccessful`，PVC status capacity 变为 128 MiB，而且原文件仍可读取：

```text
request: 128Mi
PV capacity: 128Mi
PVC capacity: 128Mi
data: persistent
```

本次 hostpath 驱动只记录卷大小并接受 NodeExpand 调用，容器内 `df` 显示的仍是节点整块 `/dev/sda2`，不是 128 MiB 的配额。实验证明了 Kubernetes 和 CSI 调用链，以及 PVC 状态在消费者重建后达到驱动报告的目标容量；它不证明生产存储后端已经完成真实文件系统扩容。

节点的完成条件见官方文档 [Expanding persistent volume claims](https://v1-35.docs.kubernetes.io/docs/concepts/storage/persistent-volumes/#expanding-persistent-volume-claims)：文件系统卷通常在 Pod 启动挂载时完成扩容，只有底层文件系统支持在线扩容时才能在运行中完成。实验中 hostpath 场景通过重建消费者触发，不能推广成所有存储都必须重建 Pod。不同 CSI 驱动是否支持在线扩容、是否支持块设备扩容，需要查对应驱动的能力。`NodeExpansionRequired` 是控制面响应的一部分，检查时应同时看 driver capabilities、PVC conditions 和实际设备/文件系统容量。

```mermaid
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`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/volume/util/operationexecutor/operation_generator.go#L617-L629) 在挂载阶段调用 `expandVolumeDuringMount`，失败时调用 `MarkVolumeMountAsUncertain`，注释说明了原因：挂载其实已经成功，但卷没有扩到目标大小，所以不标记为 mounted，让 reconciler 在 Pod 删除时触发卸载，同时阻止 Pod 开始使用这个卷。修复驱动或后端后，下一轮 reconcile 再尝试。这一条来自源码阅读，本次实验没有制造 NodeExpand 失败。

## 排障时按容量和条件分开看

1. PVC 尚未变化：先确认 StorageClass 是否允许扩容，API 是否拒绝了请求。
2. 只有请求变大：看 external-resizer 和 CSI Controller 日志，确认是否调用了 `ControllerExpandVolume`。
3. PV 已变大、PVC capacity 仍旧：检查 `FileSystemResizePending`、消费者所在节点和 kubelet 日志。
4. NodeExpand 失败：查看具体挂载路径、文件系统类型、卷是否只读以及驱动返回的错误。
5. 所有状态都显示成功：仍在容器或块设备上读取实际容量，不要只看 PVC YAML。

这套流程的上一步是卷的供应、绑定和挂载，见 [PVC 生命周期](/posts/kubernetes-pvc-lifecycle/)。扩容本身不迁移数据；容器内 `df` 和 PVC YAML 不一致时，以设备或文件系统的实际大小为准。

实验目录、CSI 版本和运行步骤见 [k8sdev/article-pvc-expansion](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-pvc-expansion)。本次验证了拒绝未允许扩容、ControllerExpand、消费者重建后的 NodeExpand、数据保留和缩容拒绝；没有验证真实云盘在线扩容、块设备扩容、跨节点拓扑变化或后端容量耗尽。

