
PVC 已经是 Bound，Pod 却一直停在 ContainerCreating。重新创建 PVC 之前，应该先确认：Bound 到底证明了哪一步完成，容器启动还在等什么？

本次在 `zj` 的 kind 集群中创建了一个真实的 CSI 卷。PVC 成功绑定后，应用仍因缺少另一个 Secret 卷而无法启动。补齐 Secret，原来的 PVC 没有任何变化，Pod 就恢复了。这个对照说明：PVC 绑定成功和 Pod 所有启动条件满足，是两件事。

<!--more-->

## 从申请到能读写，中间有哪些动作

PVC（PersistentVolumeClaim）表达应用对存储的申请；PV（PersistentVolume）记录可供消费的卷；StorageClass 指定动态供应所用的 provisioner 和策略。CSI（Container Storage Interface）则定义 Kubernetes 与存储驱动之间的调用接口。

对于支持相应能力的 CSI 驱动，一次使用过程可能包括下面这些阶段：

| 阶段 | 要完成的事 | 排查时对应的对象或日志 |
| --- | --- | --- |
| 供应 | 找到已有卷，或调用驱动创建卷 | PVC 事件、external-provisioner、`CreateVolume` |
| 绑定 | 把 PVC 与 PV 建立关联 | PVC 的 `spec.volumeName`、PV 的 `claimRef` |
| 附加 | 让目标节点能够使用卷 | VolumeAttachment、external-attacher、`ControllerPublishVolume` |
| 节点准备 | 在节点上准备卷 | kubelet、驱动的 `NodeStageVolume` |
| 发布到容器路径 | 把卷呈现到 Pod 使用的路径 | kubelet、`NodePublishVolume`、容器挂载 |

不是每一种存储驱动都会执行所有可选阶段，例如有的驱动不要求 Controller Attach。也不能把“attach”一律理解成某一种云盘挂载命令。CSI 规定接口，具体操作由驱动实现。接口定义见 [CSI 规范](https://github.com/container-storage-interface/spec/blob/v1.11.0/spec.md)。

动态供应时，external-provisioner 观察 PVC，调用 CSI 驱动的 `CreateVolume`，再把返回的卷信息写成 PV；PV 控制器协调 PVC 与 PV 的绑定。对需要附加的驱动，控制面创建 VolumeAttachment，external-attacher 据此调用 `ControllerPublishVolume`。节点上的 kubelet 则通过 CSI node 服务准备和发布卷。

这些组件分别观察 API 状态并推进自己的工作，PVC 的 Bound 只确认声明与卷建立了绑定关系。它没有记录每个节点上的挂载是否完成，也不检查 Pod 依赖的其他 Secret 或 ConfigMap。组件分工见 [CSI 架构](https://kubernetes-csi.github.io/docs/)与 [PV 生命周期](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#lifecycle-of-a-volume-and-claim)。

## 实验环境和存储实现

实验使用 kind v0.33.0、Kubernetes v1.35.1，通过 SSH 在 Linux 主机 `zj` 执行。CSI 部署来自 `csi-driver-host-path` 仓库 v1.17.0 的 `kubernetes-1.30` 清单；这个清单实际运行的主驱动镜像是 `hostpathplugin:v1.16.1`。仓库版本、清单目录名、镜像版本和集群版本是不同对象，不能混写。

hostpath 驱动是 CSI 的测试实现。实验把消费 Pod 调度到驱动所在节点，使用它观察供应、附加和挂载行为，不把它当作跨节点共享存储或云盘性能模型。

实验创建两种 StorageClass，provisioner 都是 `hostpath.csi.k8s.io`，分别使用 `Immediate` 和 `WaitForFirstConsumer`。

## Bound 后仍然失败：看具体卡住的卷

第一组先创建一个 64 MiB、RWO 的 PVC `disk`，等待它变成 Bound。消费 Pod 同时挂载两个卷：`disk` 和名为 `missing` 的 Secret。此时 Secret 故意不存在。

观察结果是：PVC 保持 Bound，Pod 已经选定节点，但事件出现 FailedMount。创建缺失的 Secret 后，Pod 变成 Ready，应用可以向 `/data/proof` 写入 `persistent`。

这个实验中的挂载故障根本不在 PVC 上。如果只看到 ContainerCreating 就删除重建 PVC，不仅没有解决缺少 Secret 的问题，还可能引入数据生命周期风险。

```sh
kubectl --context kind-deep-dive -n dd-storage get pvc disk -o yaml
kubectl --context kind-deep-dive -n dd-storage describe pod first
kubectl --context kind-deep-dive get volumeattachment -o yaml
```

读 FailedMount 时应核对报错的 volume 名称，再映射到 Pod 的 `spec.volumes`。只有确认故障落在 CSI 卷上，才继续沿 PV、VolumeAttachment、驱动日志向下查。

## RWO 允许同一节点上的两个 Pod 使用吗

实验随后创建第二个消费 Pod，把它放在同一个节点并挂载同一 PVC。两个 Pod 都 Ready，第二个 Pod 能读取第一个 Pod 写下的 `persistent`。删除第一个 Pod 后，第二个 Pod 仍能读到相同内容。

因此，在本次 CSI 实现中，RWO 没有把卷限制为“只能被一个 Pod 使用”。它描述的是单节点读写访问模式。若需要把读写使用限制为一个 Pod，应区分 ReadWriteOncePod。访问模式定义见 [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes)。

接着创建 RWOP PVC，让 `exclusive-one` 先使用它。再创建同样指向该 PVC 的 `exclusive-two`，第二个 Pod 无法完成调度，证据中保存了 `PodScheduled=False` 和对应的 FailedScheduling 事件。

| 配置 | 本次观察 |
| --- | --- |
| RWO，同一节点，两个 Pod | 两个 Pod 都可运行并读取同一份文件 |
| RWOP，已有一个 Pod 使用 | 第二个 Pod 被阻止调度 |

允许两个进程访问同一个文件系统，也不表示应用允许两个实例同时写同一份数据。文件锁、事务和应用一致性协议仍由应用与存储系统承担。

## WaitForFirstConsumer 为什么让 PVC 保持 Pending

对于 `dd-delayed` StorageClass，脚本先只创建 PVC `late`。它保持 Pending，事件提示等待第一个消费者。创建使用它的 Pod 后，PVC 完成绑定，Pod Ready。

这个 Pending 来自 `WaitForFirstConsumer` 的协调方式。scheduler 的 VolumeBinding 插件把消费者的节点约束与卷拓扑一起检查；需要动态供应时，选中节点的信息通过 PVC 的 `volume.kubernetes.io/selected-node` 注解交给 provisioner。卷供应和绑定完成后，Pod 才能继续绑定到节点。若没有消费者，就还没有足够的调度信息决定应在哪里供应卷。实现可对照 [VolumeBinding 插件](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/volumebinding/volume_binding.go)。

```sh
kubectl --context kind-deep-dive -n dd-storage describe pvc late
kubectl --context kind-deep-dive get storageclass dd-delayed -o yaml
```

这也解释了为什么排查调度时不能只看 CPU 和内存。节点本身有空闲资源，但卷的可访问拓扑与 Pod 的调度条件不相容，Pod 仍然无法运行。

## 延迟绑定怎样避免先选错存储位置

`WaitForFirstConsumer` 把节点选择引入卷决策：scheduler 同时考虑 Pod 的节点约束和卷的拓扑条件，选出节点后才推进绑定。否则先在某个可用区供应磁盘，随后才发现 Pod 只能运行在另一个可用区，就可能得到一个 Bound 却无法消费的卷。

在 v1.35.1 的 [VolumeBinding 实现](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/scheduler/framework/plugins/volumebinding/binder.go#L401)中，`AssumePodVolumes` 先更新调度器本地的 PV/PVC 假设缓存；`BindPodVolumes` 才提交 API 更新并等待绑定结果。对于需要动态供应的延迟绑定 PVC，选中节点信息通过 PVC 注解传给供应组件。把“暂定方案”和“提交并等待”分开，既让后续调度知道这份卷已经被计划使用，也避免慢速供应阻塞其他 Pod 选择节点。

```mermaid
sequenceDiagram
    participant S as scheduler
    participant A as API Server
    participant V as 存储控制器
    S->>S: 结合 Pod 和卷约束选择节点
    S->>S: 暂存 PV/PVC 方案
    S->>A: 提交绑定或选中节点信息
    A-->>V: 观察到 PVC 更新
    V->>A: 推进供应和绑定状态
    A-->>S: 观察到卷绑定完成
    S->>A: 提交 Pod 到节点的绑定
```

这里的“存储控制器”合并表示 provisioner 与 PV 控制器的协作；节点侧挂载还在后面。调度失败时，`RevertAssumedPodVolumes` 撤销的是本地假设缓存，不能据此推断已经创建的外部卷也被事务性回滚。

节点侧也要避免重复操作。卷操作使用 [`nestedPendingOperations`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/volume/util/nestedpendingoperations/nestedpendingoperations.go#L142)，按卷、Pod 和节点组合检查冲突：已有相关操作执行时不再启动冲突操作；同名操作失败后按退避条件重试。因此连续看到 FailedMount，不代表 kubelet 每个同步周期都重新并发挂载一次。检查原始错误和后续重试，比反复重建 PVC 更有用。

## 怎样把故障定位到具体阶段

PVC Pending 时，先看 StorageClass、访问模式、容量、供应事件和是否等待消费者。PVC Bound 后，再看 Pod 是否完成调度；已调度但未启动，则读事件中具体的镜像、配置或挂载错误。

对于需要附加的 CSI 卷，VolumeAttachment 可以帮助判断控制面的附加是否完成；节点侧挂载则要结合 kubelet 和 CSI node 服务日志。出现 Multi-Attach 类错误时，需要核对旧节点的使用状态和驱动的附加记录，不能只靠删掉 API 对象推断旧节点已经停止访问。

本次测试驱动验证了 CSI 调用与访问模式，没有模拟云盘控制面的网络故障，也没有做跨主机断电测试。它提供的是排查阶段的划分，实际存储错误需要回到所用驱动和后端确认。

实验代码与使用说明见 [k8sdev/deep-dive-storage](https://github.com/jimyag/k8sdev/tree/8f9bfbbd44ac061702310f332ee949fcdf0a213d/deep-dive-storage)。

