16已经表明容器内同一路径可能属于可写层、bind、volume 或 tmpfs;Kubernetes 加上声明、绑定与节点挂载流程,才使 Pod 能看到一份外部存储。PV/PVC、StorageClass、CSI 的对象关系见存储体系;这里专看“这次写入究竟落到了哪一个挂载对象”。

把 kubectl get pvc 的 Bound 当作数据已经落盘,会混淆至少三个时间点:控制面决定使用哪个卷、节点让哪个挂载可见、进程成功写入并在下一次启动后重新读到同样的字节。本篇沿着一个固定 PVC 和同一份 probe-app,从声明到应用路径逐项取证;先不假定本机 kind 附带的默认 StorageClass 是 CSI,也不把单节点路径假装成跨节点持久存储。

声明成功不等于目录可写

PVC 指明容量/访问模式等需求,通过绑定/动态供给关联 PV;具体 CSI 驱动与节点组件按存储类型准备卷,把它挂载进 Pod;进程最终看到的是自己的 mount namespace 中某个路径。不同驱动对动态供给、重挂载、跨节点恢复、备份一致性的承诺不同。即使 Pod Running,卷的所有者、只读设置或应用 UID 不匹配仍可导致写入失败;不能仅凭 PVC Bound 说业务数据已可恢复。

从 PVC 的名字追到实际设备

Pod 写的是 volumes[].persistentVolumeClaim.claimName,这个名字只在 Pod 所在 namespace 内找 PVC。PVC .spec.volumeName 给出绑定的 PV;PV 可以是管理员预建对象,也可以由 StorageClass 动态供给。先记录 PVC/PV 的 UID、PV 的 spec.csi.driver 和 volumeHandle,再查 StorageClass provisioner、volumeBindingMode、reclaimPolicy,不能看到同名路径就假定是一块物理盘。PV 是集群对象,PVC 是有作用域的请求;同一个 claimName 出现在两个 namespace 并不代表同一个卷。

WaitForFirstConsumer 会推迟绑定/供给,等待有实际消费该 PVC 的 Pod 后再结合拓扑做决定。因此 Pending 的 PVC 不总是供给失败:同时保存 Pod 的调度事件、StorageClass 模式、PV 的 node affinity 和驱动状态。反过来,即使 PVC 已 Bound,在不可附加的节点上调度 Pod 仍可能卡在 attach/mount;此时应看 Pod Event 与节点日志,而不是反复删除 PVC 试运气。ReadWriteOnce 是约束“同一节点可读写挂载”的访问模式,不是“全世界只有一只 Pod 能写”的互斥锁;需要单 Pod 独占语义时查 ReadWriteOncePod 及 CSI 能力,但也不能把模式字段当成分布式锁或数据库一致性保证。

例如读者在第二台节点看到 Pod 长时间处于 ContainerCreating,首先应问 PVC 是否 Bound、PV 是否具有与此节点不相容的拓扑约束,再区分调度器没有选中节点、attach 阶段失败和节点 publish 失败。三种情况的事件对象与错误主体不同:调度失败未必走到节点驱动;attach 成功也不说明进程已经有挂载。仅凭 kubectl describe pvc 最后一行事件就宣布“CSI 挂载失败”,会把尚未发生的后续步骤提前写成事实。存储类采用立即绑定时尤其要保存它供给时选中的区域/节点条件,再比较新 Pod 的位置;不要仅通过反复重启应用掩盖拓扑不匹配。

volumeMode: Filesystem 与 Block 也不是同一实验:本篇探针按文件路径访问目录,所以要求文件系统卷,不能将块设备路径指给 /var/lib/probe 再期待 Python 自动识别文件系统。某些驱动会在 NodeStage/NodePublish 的不同阶段执行格式化、挂载或 bind,不应自行在宿主用 mkfs 补齐缺口。保留 PV/PVC 的 volumeMode 与真实挂载源,是将“目标目录存在但写不进去”分别归因到设备/文件系统、权限或只读配置的必要前提。目录本身存在并不意味着已经由 PV 覆盖;如果容器命令提前创建同名目录,应用仍可能向镜像可写层写入,必须对照挂载点和卷 ID 才能确认数据去向。

本篇演示清单故意使用不可直接应用的 REPLACE_WITH_CSI_CLASS:操作员应先选择确认为 CSI 且支持动态供给及节点挂载的 StorageClass,把驱动名称、实现版本、卷拓扑与节点权限的取证留存。kind 常见本地路径 provisioner 只说明在该节点可以挂目录,不符合本篇的 CSI 源码对照;不能为了跑通 YAML 就把它冒充 CSI。实验范围也仅是自有 namespace、固定合成字节和可丢弃的卷,不使用共享生产 PVC。

从节点 Publish 到进程的 mountinfo

在 Kubernetes v1.33.4 的 pkg/volume/csi/csi_mounter.go 中,节点端的 SetUpAt 准备挂载目录,收集只读标志、文件系统组等参数并调用 CSI NodePublishVolume。这份固定版本源码只是调用路径的阅读定点;不能因此断定某一驱动已经提供 NodeStageVolume、支持某个访问模式,或在当前 VM 上发生过 publish。CSI driver 的 Node 服务怎样把后端设备/共享目录带到目标路径,还要查实际驱动版本及节点原始调用日志。不同驱动可能需要先 attach 或 stage,也可能省略;不要把抽象步骤硬套成每次都发生的四次系统调用。

真正进程读到的路径取决于 volumeMounts[].mountPath 在容器 mount namespace 中的挂载。先记录 kubelet 所在节点、实际容器 ID、应用返回的 mount namespace inode 和 cat /proc/self/mountinfo,再对照 Pod UID、PVC UID 与 CSI 日志的 volume_id 和 target path。应用 /identity 中的 PID 与 mount namespace 可用于关联请求处理进程;PID 为 1 不代表它在宿主直接持有后端块设备。mountinfo 里容器内的路径也不等于存储服务中可直接读取的路径,必须从节点的 publish 目标再反查卷身份。不可从别的 Pod 的 /proc 或共享节点猜测本篇进程的实际挂载。

/proc/self/mountinfo 以读取进程的 mount namespace 为准;在宿主进程读取 /proc/self/mountinfo 并搜索相同字符串,既可能找不到容器看到的路径,也可能命中别的 Pod 的挂载。同一台机器上的两个容器可以有不同的 mount namespace inode,即使它们把同一卷映射到相同的文本路径。排查时先用应用返回的 namespace inode 固定处理这次 HTTP 的进程,再在同容器里采样挂载表;如果用 kubectl exec 启动新进程,要确认它与服务进程处于同一 mount namespace,而不是把 exec 的 PID 当成原服务 PID。CSI target path 往往是 kubelet 侧的路径,容器里看到的路径由运行时映射,双方的文本不必一致,需靠卷身份与节点记录桥接。

只保存成功请求的 body 还少了一环:先记录写前的 /state 返回,再写不超过 4096 字节的合成标记,随后立即读取并核对完整字节;标记的随机性或时间戳应存放在独立的实验原始记录里。读取成功若来自同一进程的应用缓存,只能证明这一进程能读到内容,因此本篇的探针从文件读取,每次调用 read_text();接着仍要在进程/Pod 重建后再次读取,逐步排除进程内短暂状态。不同版本的探针可能改变写入语义,运行前需要校验本篇下载的共用包与构建制品所用源码,并记录实际镜像 digest;不能只对 YAML 中的占位符拍照。

mountPath 覆盖镜像中同名目录的原始内容;应用若原先把状态写在镜像可写层,改成 PVC 之后也要确认 --state-dir 真正指向挂载路径。只看到 /state 返回 200,并不能区分写入 PVC、容器可写层还是 tmpfs。本篇 Pod 清单覆盖 image 的命令,让同一个 probe_app.py 的 --state-dir 明确指向 /var/lib/probe,并保留应用端返回的内容与 Pod mountinfo。少了任一项,就无法从同一次 HTTP 返回建立到挂载对象的证据链。

这里刻意不使用 subPath:子路径挂载要先由 kubelet定位卷内目录,容易把卷根目录与最终目标目录混淆,且不同更新机制不能直接套用根目录挂载的观察。也不把 ConfigMap、Secret 或 projected volume 当成可写 PVC 的对照组:它们更新与挂载语义不同,probe-app 的 POST 无法验证“持久卷仍在”。如果业务真正依赖文件原子替换、锁或目录 fsync,要专门为这些行为制定一致性实验;本篇的单次 write_bytes() 只在固定进程和存储路径下提供基础写读证据,不证明断电后落盘。把这些不同种类的挂载都叫“容器挂了一个 volume”会抹掉最关键的失效和恢复条件。

所有权和只读比 Bound 更靠近失败点

共用 Dockerfile.single 以非 root 的 UID/GID 65532 运行。新 PVC 根目录的 UID/GID 和模式可能让该进程无权写入;本篇通过 Pod 的 fsGroup: 65532 向支持此能力的 CSI 驱动表达组权限需求,而不是在宿主上 chmod -R 777。是否由 kubelet调整所有权还是由驱动处理 VOLUME_MOUNT_GROUP,应按该驱动的 CSI 能力与节点日志核对。不能只看 Pod YAML 中存在 fsGroup 就宣称写入会成功;若驱动不支持、目录不允许写或安全策略另有拒绝,应用仍可能失败。

出现权限错误时先保留文件系统实际 UID/GID、目录权限位、容器的有效 UID/GID、Pod SecurityContext 以及挂载是否只读,再从驱动的报告区分 fsGroup 生效失败和原始卷目录本就不可写。以 root 用户重跑一次若成功,只能说明权限路径改变,不证明原来的非 root 方案正确;在共享节点递归修改宿主权限更会污染后续实验。SELinux/AppArmor 的拒绝与文件权限拒绝同样可能在应用表现为无法写入,但诊断证据在不同层;先看节点安全审计、挂载标签以及驱动处理方式,不把应用日志里的 Permission denied 自动翻译成某一层规则。即使写成功,也要记下这次用到的权限设置,迁移到另一种 CSI 驱动不能沿用默认假设。

同一 PVC 用 readOnly: true 挂入另一个非并发的测试 Pod,是与正常写路径相对照的拒绝案例。先删除原写入 Pod,防止把 RWO 挂载冲突当成只读权限拒绝;只读 Pod 仍可 GET 已写入的文本,但对同一路径 POST 应保存 HTTP 客户端原始异常、容器日志、mountinfo 和节点事件。当前 probe-app 对写文件的 PermissionError 没有专门转换成 403,可能直接中断连接或在日志里报错;不能事先填一个“预期 HTTP 403”。假如只读 Pod 根本无法挂载,记录 attach/mount 失败即可,不算已经验证应用层只读拒绝。

三种重建操作要分开取证

第一步,容器重启而 Pod UID 不变:应用进程 PID、容器 ID 会换,PVC 及挂载路径仍随 Pod 使用同一 claim;但是否读到先前内容需 GET 核对,不能只凭新进程的 Running。手动向进程发 TERM 的动作也可能让探针返回一次错误,记录实际 restartCount、请求开始/结束时间与重新就绪,而不是假定所有在途请求被自动续上。

第二步,删除自有 Pod、以同名 PVC创建新 Pod:保存前后 Pod UID 和卷 handle,同样的数据如果仍可读,证明在这次节点/驱动/回收条件下跨 Pod 保留。应用路径、PVC volumeName 和 PV UID 若发生变化,就不能把结果解释成“同一卷重挂成功”。只删除 Pod 而不删 PVC;删除 PVC 后再建同名对象会分配新 UID,动态供给可能得到完全不同的卷,无法充当复用实验。

两次重建间还需要比对容器所见的 mountinfo、应用 GET 的原始响应与挂载卷的 PV UID。若新 Pod 返回旧数据但 PV UID 已变,不能排除底层共享数据路径或客户端缓存造成的假阳性;若 PV UID 未变而数据为空,也不能立刻说存储丢数据:先看 --state-dir 是否仍指向同一挂载、是否误以新名字引用了别的 PVC、是否在重建过程中修改了应用镜像。保持写入字节与声明不变,只改变一个重建变量,才能把读写结果归因给被测的生命周期变化。相同名称的 Pod 并不意味着相同 Pod UID,尤其不能将旧 Pod 的事件归给新 Pod。

容器重启的证据也有时序陷阱:kubectl wait --for=condition=Ready 如果在旧实例尚未退出时执行,可能立刻返回成功,却完全没有观察到重启。要分别记录旧/新 containerID、restartCount 与 lastState.terminated,并采样至少一次 UID 不变而容器 ID 改变的事实;发 TERM 命令失败只证明信号注入失败。真实请求应在确认新进程处理时重新发送,保存响应时间与服务返回标记。不把容器退出瞬间 HTTP 连接错误写成“卷故障”,也不把 Kubernetes 重新创建进程等同于业务写入已经恢复。

第三步,换节点:先确认两个节点均具备该 CSI Node 插件、卷拓扑允许、网络可达,且已选的存储类支持该形态。单节点 kind 或 local PV 不满足条件时记 NOT_RUN,不通过假标记 Pod 的 nodeName 或强制删除 VolumeAttachment 制造伪成功。可移动的真实卷还要观察旧节点 unpublish/detach 与新节点 stage/publish、重新调度时间以及应用完整内容;RWO 通常不允许让两个节点同时写,不应用“同时起两只 Pod”去验证跨节点恢复。换节点之后 GET 相同文本只证明应用能读到这段测试数据,仍不等于备份已建立或所有写入具备断电持久性。

所谓“换节点”必须先标明这是另一台独立机器、kind 中另一只 node 容器,还是同一宿主上的两个目录。它们的故障边界不同:仅切换容器名称不足以触发卷 detach/attach,甚至可能始终处于相同的底层文件系统。若驱动不支持多节点迁移,就在本篇标记具体失败条件与不适用,不用测试节点停机制造无法回收的卷挂载。节点被隔离时 Kubernetes API 的 Pod 状态也可能比真实存储故障滞后;应同时保留节点可达性、旧挂载回收事件、卷的 attachment 对象和新 Pod 的首次成功请求时间。只有一条“后来又返回 200”的请求不提供中途错误次数,也不说明旧节点有没有继续写同一份数据。

对于应用是否真正持久化,probe_app.py 当前写路径是 Path.write_bytes(),没有提交数据库事务、没有 fsync、也没有在退出前等待存储控制器报告镜像快照完成。进程重建后读到同样的字节,可以排除“只在原 Python 对象里”的假象,不能区分页缓存、存储设备已落盘和异地副本已同步。若生产系统需要这些承诺,另建有界断电/故障注入、数据库一致性或 CSI VolumeSnapshot 实验,定义安全边界与可失数据窗口;本篇禁止在共享节点断电,也不凭一个回环 HTTP 读取宣布“备份可恢复”。归纳结论时注明这是同一 PVC 的一次文件级写读,别把它推广为所有存储后端的恢复 SLA。

清理时数据可能不会跟着 PVC 消失

PV 的 reclaim policy 可为 Retain 或 Delete;动态卷的默认策略以实际 StorageClass/PV 为准。Retain 意味着管理员要按卷身份处理残留数据和回收,不是“删除 PVC 后自动清空”;Delete 也要检查底层存储确认控制器完成回收。使用同名新 PVC 并不能保证绑定回旧 PV。实验先只删本篇 Pod,再在确认没有后续取证需求、合成数据已备份后删本篇 PVC,最后检查 PV 与驱动侧 volumeHandle 的状态;遇到回收卡住时保留证据并请求存储管理员处理,绝不移除共享集群的 finalizer 或手工格式化未知设备。

清理也要区分“API 对象删除”与“数据实际清除”:删除测试 namespace 之后,若 PVC 的终结器或 CSI 删除任务仍在执行,卷内容可能暂时保留;若 reclaim policy 是 Retain,卷继续存在本来就是预期。为避免误伤其他实验,清理脚本只删除本篇 c30-writer、c30-readonly、c30-state 和专用 namespace,清理前先保存 PV UID、handle 和策略。如果卷是实验室管理员提供的预建共享 PV,不能按照动态卷的删除流程处理。后续再次运行必须创建新的独占 namespace,确认旧卷 handle 的归属与回收结果;否则同名资源可能指向上次运行留下的真实数据。

把结论从这个 CSI 驱动迁移到另一个驱动,先核对 access mode、拓扑、fsGroup 能力、是否支持重建后重挂和删除策略,再复跑同一 /state 写读与挂载身份链。无这些前提时,“PVC Bound”和“业务已持久化”之间缺了应用写入、同卷重挂以及恢复验证三个独立证据,不可能用一个 kubectl 表格补上。

排障时按“对象绑定 → 调度与 attach → 节点 publish → 进程 mount namespace → 应用读写 → 重建后读取”的顺序寻找第一个证据缺口,比逐个重启 Pod 更有效。若 PVC Pending,先不要讨论应用权限;若 PV 已 Bound 而 Pod 报 FailedMount,先不要声称文件已写入卷;若 Pod Ready 但 POST 断连,则优先看挂载只读、目录 UID/GID 和应用 stderr。每次只修复当前缺口,保留修复前后的对象 UID 与同一合成请求,防止重新供给新卷之后把“写入成功”错归给原始故障已恢复。对象存在与进程实际访问之间必须通过同一时间窗和同一卷 handle 连接。

本篇故意让 writer 和 readonly 使用不同 Pod 名称、相同 PVC;这样才能在只读拒绝时排除第二个 Pod 错把另一个卷当成测试对象。相反,如果把 PVC 删掉、重建同名 claim 后再发写入请求,就失去对同一底层卷的控制,不能与原始正例做 A/B 对比。将该方法迁移到 StatefulSet 时,要先理解 volumeClaimTemplate 为每个副本创建独立 PVC 的命名规则,以及 StatefulSet 缩容时的保留策略,不能从本篇单 claim 推断所有副本共享一个持久卷。迁移到数据库工作负载时,还需另验写入顺序、恢复一致性与数据备份;文件能被第二次 HTTP GET 读到,只覆盖这里明确声明的最小问题。

只读负例尤其要标记失败发生在哪一层:PVC 未绑定、节点无法挂载、容器进程无法启动、进程已启动而 POST 写入失败,各自对应不同证据和修复动作。观察到 kubectl wait 超时应收集事件,不能补一个想象中的 HTTP 错误;只有实际请求到达应用并保留客户端输出与服务日志,才允许分析只读挂载如何阻止写入。这也决定能否把负例拿来解释生产应用的权限问题。

正例在专用 kind VM 且有明确 CSI 驱动能力时,用同一 probe-app 的 /state 写一段合成文本,记录 PVC UID、PV 名称、Pod UID、容器 ID、mountinfo、应用响应;分别重建容器、重建 Pod、换节点,区分每一步数据是否留在原卷。反例将自有卷作为只读挂入,在同一服务端点发写入请求,保存准确错误与事件;不得修改共享 PVC 或改权限绕过安全机制。逐步取证与清理入口、PVC 与写入 Pod 清单、只读 Pod 清单、同篇清单校验和探针包均可独立取得。

本机无 kind/CSI/外部共享存储能力;即使旧文提到 CSI,也不能据此写入本篇的宿主 mountinfo 或持久化结果,完整实验 NOT_RUN。任何换节点案例先核查驱动是否支持所需访问模式,禁止把单节点路径模拟成跨节点存储。

现象 证据 不能推出
PVC Bound PV/驱动/节点 mount 应用有写权限
重建 Pod 数据仍在 相同卷 ID、文件内容 换节点也必然可用
写入成功 真实持久化/备份检查 fsync/备份一定完成

练习

  1. 先预测删除容器、重建同一 Pod 对卷内容和容器可写层各有什么影响;在专用 CSI 环境分别核对挂载 ID 与数据。
  2. 预测把原来可写的挂载改为只读后 /state 的 HTTP 行为,保存应用异常与节点事件,再还原自有卷配置。

两题均须先写下预计保留的 PVC/PV UID 与应用返回值,再执行并保存实际退出码;未具备明确 CSI 驱动的 VM,只能完成文档预测,不能把预测填入实验记录。

上一篇:29:Service;下一篇:31:指标如何归属,按同一 Pod UID 关联应用延迟、cgroup 与请求日志。

系列总目录:从进程隔离到运行时与编排。

参考资料