K8s Lab 把当前仓库文档整理成一个可阅读的网页站点

Repository Reading Site

第八课:存储持久化、PV / PVC / StorageClass 与 NFS 原理

上一课我们讲清了: 但配置和凭据还不是“业务数据”。 你以后真正做系统架构时,必须把三类东西分清楚: 1. 配置 2. 凭据 3. 数据 这三者都能“进入容器”,但它们的工程语义完全不同。 通常应该: 通常应该: 通常需要考虑: 这就是为什么存储课非常关键。 如果你说不清: 那么你做项目架构时就很容易把“配置、缓存、状态、共享文件、数据库数据”混在一起。 -

Markdown08-第八课-存储持久化PV-PVC-StorageClass与NFS原理.md2026年4月9日 18:58

第八课:存储持久化、PV / PVC / StorageClass 与 NFS 原理

为什么这一课必须接在 ConfigMap / Secret 后面

上一课我们讲清了:

  • 配置如何进入 Pod
  • Secret 如何进入 Pod
  • 为什么有的注入方式会热更新,有的不会

但配置和凭据还不是“业务数据”。

你以后真正做系统架构时,必须把三类东西分清楚:

  1. 配置
  2. 凭据
  3. 数据

这三者都能“进入容器”,但它们的工程语义完全不同。

配置

通常应该:

  • 可版本化
  • 可替换
  • 通常体量小

凭据

通常应该:

  • 最小暴露
  • 可轮换
  • 严格控权

数据

通常需要考虑:

  • 重启后还在不在
  • 换 Pod 后还在不在
  • 换节点后还在不在
  • 多个 Pod 能不能同时读写
  • 删除资源时底层目录会不会一起删

这就是为什么存储课非常关键。

如果你说不清:

  • emptyDir
  • 容器可写层
  • PVC
  • PV
  • StorageClass
  • NFS / 本地盘 / 云盘

那么你做项目架构时就很容易把“配置、缓存、状态、共享文件、数据库数据”混在一起。


先建立一个最重要的认知:容器里的“文件”不止一种来源

初学者很容易把容器里的目录都看成同一类东西。

这会直接导致错误设计。

你必须把它们拆开。

1. 容器可写层

也就是镜像启动后,叠加在镜像只读层上面的那层可写文件系统。

特点:

  • 跟着容器生命周期走
  • 容器重建通常就没了
  • 不适合放真正有价值的数据

2. emptyDir

这是 Pod 级临时卷。

特点:

  • Pod 创建时出现
  • Pod 删除时消失
  • 同 Pod 内多个容器可共享
  • 适合缓存、临时文件、中间结果

3. 持久卷

也就是:

  • PV
  • PVC
  • StorageClass

这条体系的核心目标是:

把“数据生命周期”从“Pod 生命周期”里解耦出来。


Kubernetes 存储主链路,必须背下来

以后只要谈持久化,你脑子里就应该立刻出现这条链路:

应用声明需求(PVC)
        |
        v
StorageClass 选择存储类型
        |
        v
Provisioner 动态创建 PV
        |
        v
PVC 绑定到 PV
        |
        v
Pod 挂载 PVC 使用

这里每一层回答的问题不同。

PVC

回答的是:

我需要多大、什么访问模式、什么存储类型。

PV

回答的是:

真正提供给你的底层卷是什么。

StorageClass

回答的是:

用哪种后端、用什么 provisioner、回收策略是什么、绑定时机是什么。


你的真实集群存储现状,不是纸上谈兵

我先核对了这套集群当前的真实存储栈。

默认 StorageClass

当前默认存储类是:

  • nfs-dynamic

关键字段包括:

  • provisioner = cluster.local/nfs-provisioner-nfs-subdir-external-provisioner
  • reclaimPolicy = Delete
  • volumeBindingMode = Immediate
  • allowVolumeExpansion = true
  • parameters.archiveOnDelete = "true"

这些字段每一项都很重要。

它们分别意味着什么

reclaimPolicy = Delete

PVC 删除后,PV 也会被回收删除。

但这里因为 provisioner 还启用了:

  • archiveOnDelete = true

所以底层目录并不是简单粗暴地立即物理抹掉,而是会被改名归档。

这个我们后面有真实证据。

volumeBindingMode = Immediate

表示 PVC 一创建,就会尽快触发绑定和供给。

它不需要等到有 Pod 真正消费时再决定。

这在:

  • NFS
  • 网络共享文件系统

这种“所有节点都能挂”的场景里通常没问题。

但在:

  • 本地盘
  • 带拓扑限制的云盘

场景里,ImmediateWaitForFirstConsumer 会对调度结果产生很大影响。

allowVolumeExpansion = true

表示这个类支持 PVC 扩容。

这不代表应用一定能无感知完成扩容,但至少存储侧允许你申请更大容量。


真实后端是什么:NFS 动态供给

我检查了 provisioner 的 Deployment,看到它真实配置是:

  • NFS_SERVER = 10.10.0.5
  • NFS_PATH = /srv/nfs/k8s

也就是说,集群里所有 nfs-dynamic 的 PVC,最终都落在:

  • wk-1
  • 10.10.0.5
  • /srv/nfs/k8s

这台机器上的 NFS 导出目录里。

我进一步 SSH 到这台节点,看到了:

  • exportfs -v
  • /srv/nfs/k8s 真实目录结构

真实导出配置里有:

  • rw
  • sync
  • no_subtree_check
  • no_root_squash

这说明它就是一个典型的实验/平台型 NFS 后端。


我们这次做了两组存储实验

实验文件在:

对象包括:

  • storage-lab namespace
  • emptydir-demo
  • shared-data PVC
  • pvc-writer
  • pvc-reader

这次实验的目标非常明确:

  1. emptyDir 证明“数据跟着 Pod 走”
  2. 用 PVC 证明“数据可以从 Pod 生命周期里独立出来”
  3. 用两个不同节点上的 Pod 证明 RWX + NFS 的共享语义
  4. 用真实 PV / NFS 目录证明“PVC 最终落到哪里”

实验一:emptyDir 不是持久化存储,它只是 Pod 级临时卷

我专门设计了一个:

  • emptydir-demo

它里面挂了两个卷:

  • /work
    • 普通 emptyDir
  • /memory
    • emptyDir.medium: Memory

为什么这样设计

因为这能同时讲清两件事:

  1. emptyDir 是临时卷
  2. emptyDir 还有“落盘型”和“内存型”两种常见表现

我在容器里看到的真实挂载

/proc/mounts 显示:

  • /workext4
  • /memorytmpfs

这意味着:

  • /work 走节点本地磁盘/根文件系统的临时空间
  • /memory 直接吃节点内存

这两个都安全吗

都不是“持久化数据”的正确归宿。

只是用途不同。

普通 emptyDir

适合:

  • 临时工作目录
  • 解压中间文件
  • 排序/转换缓存

medium: Memory

适合:

  • 极短生命周期热点缓存
  • 临时小文件
  • 对 I/O 延迟很敏感、但可丢失的数据

但你必须警惕:

  • 它吃的是内存
  • 大了会造成内存压力
  • Pod 删了数据必丢

我确实写了文件,然后删除 Pod 验证

我先在 emptydir-demo 中写入:

  • /work/data.txt
  • /memory/cache.txt

然后删除 Pod,再用同一份清单重建。

重建后的真实结果

新 Pod 里:

  • /work 是空目录
  • /memory 是空目录
  • 两个文件都不存在

这说明什么

这说明 emptyDir 的生命周期绑定的是:

  • Pod

不是:

  • 容器
  • Namespace
  • 节点

也不是:

  • “只要名字一样就还能找到旧目录”

所以你如果把数据库、上传文件、模型文件、用户数据放在 emptyDir 里,本质上就是把它们交给 Pod 生命周期赌博。


实验二:PVC 在没有消费者 Pod 时就已经 Bound 了

我创建了一个 PVC:

  • storage-lab/shared-data

规格是:

  • 1Gi
  • ReadWriteMany
  • storageClassName: nfs-dynamic

真实结果

在还没有创建 pvc-writer / pvc-reader 之前,它就已经是:

  • STATUS = Bound

为什么会这样

因为这套集群的 nfs-dynamic 使用:

  • volumeBindingMode = Immediate

也就是说,PVC 一出现,控制器就会立刻走动态供给流程。

这条链我拿到了完整证据

kubectl describe pvc shared-data 事件里真实出现了:

  • ExternalProvisioning
  • Provisioning
  • ProvisioningSucceeded

而 provisioner 日志里,也看到了对应记录:

  • provision "storage-lab/shared-data" class "nfs-dynamic": started
  • volume "pvc-b14e5bac-dd83-48be-8191-f89acccd2a20" provisioned
  • succeeded

这说明动态供给不是黑箱,而是有清晰控制链的。


PV 最终长什么样,我也给你看了

这个 PVC 自动生成的 PV 是:

  • pvc-b14e5bac-dd83-48be-8191-f89acccd2a20

真实 PV YAML 里最关键的字段是:

  • accessModes: [ReadWriteMany]
  • nfs.server: 10.10.0.5
  • nfs.path: /srv/nfs/k8s/storage-lab-shared-data-pvc-b14e5bac-dd83-48be-8191-f89acccd2a20
  • persistentVolumeReclaimPolicy: Delete

这一步非常重要,因为它把抽象的 PVC 申请,真正落到了:

  • 哪台机器
  • 哪个目录
  • 哪种协议

这才是真正的“知其所以然”。


实验三:两个不同节点上的 Pod,同挂一块 RWX PVC

为了把共享语义讲透,我没有让两个 Pod 都跑在同一台机器上。

而是刻意这样安排:

  • pvc-writer 固定在 us590068728056
  • pvc-reader 固定在 wk-1

为什么要这样做

因为我要验证的不是“同一节点进程共享目录”。

我要验证的是:

两台不同节点上的 Pod,是否能同时看到同一份数据。

这才是 RWX + NFS 真正有价值的地方。

实际结果

我在 pvc-writer 里写入:

  • /data/created-at.txt
  • /data/source.txt

然后立即去 pvc-reader 看。

最终在 pvc-reader 中也能看到同样两份文件和内容。

这说明:

  • 这不是 Pod 本地目录
  • 也不是节点本地目录
  • 而是共享网络文件系统

再补一条底层证据

我在 pvc-writer/proc/mounts 里看到了:

  • /data 挂的是 nfs4
  • 服务端是 10.10.0.5
  • 路径正是那条 storage-lab-shared-data-pvc-...

也就是说,从容器视角看,它确实就是一块 NFS v4.2 挂载的文件系统。


一个值得你作为专家记住的小现象

在第一次跨节点读取时,我短暂观察到一个现象:

  • pvc-reader 先看到了 created-at.txt
  • source.txt 没立刻出现
  • 随后几秒内再次读取时,两份文件都稳定可见

我把这件事记下来,是因为它很像网络文件系统里常见的:

  • 目录项可见性时机
  • 客户端缓存
  • 元数据同步节奏

这是一种基于观测的推断,不是我在这次实验中彻底证明的唯一原因。

但它足够提醒你一件事:

共享文件系统不是分布式事务总线,不要用“文件刚写完、另一边立刻 ls 一次”这种弱同步方式做严肃协调。

如果业务需要强同步、幂等、竞争控制,应该引入更明确的协调机制。


实验四:删除使用 PVC 的 Pod,数据仍然在

我删除了:

  • pvc-writer

但没有删除:

  • shared-data PVC

然后再重新创建 pvc-writer

重建后的真实结果

pvc-writer 挂上 /data 后,依然能看到:

  • created-at.txt
  • source.txt

并且 pvc-reader 一直也能看到同样内容。

这说明什么

这说明:

  • Pod 生命周期结束
  • 不等于数据生命周期结束

只要:

  • PVC 还在
  • PV 还在
  • 底层卷还在

新的 Pod 就可以重新挂载原来的数据。

这正是持久化存储最核心的价值。


我还直接去 NFS 服务器看了底层目录

为了把整条链打通,我直接 SSH 到:

  • wk-1
  • 10.10.0.5

看这条目录:

  • /srv/nfs/k8s/storage-lab-shared-data-pvc-b14e5bac-dd83-48be-8191-f89acccd2a20

真实结果里,我能直接看到:

  • created-at.txt
  • source.txt

这件事非常重要。

因为它证明:

  • PVC 不是“神秘云对象”
  • 它最终一定对应到某个真实存储后端
  • 真实后端里一定有真实目录、真实文件、真实容量、真实权限问题

专家和初学者的差别之一,就是会不会沿着这条链一直追到最底层。


Access Modes 到底是什么,很多人理解错了

当前集群里我看到了两类常见声明:

  • ReadWriteOnce
  • ReadWriteMany

比如:

  • ml-platform/ml-modelsRWX
  • aiforge/aiforge-modelsRWX
  • 很多数据库类 PVC 则是 RWO

正确认知是什么

Access Mode 表达的是:

这块卷在 Kubernetes 视角下被如何申请和使用。

它影响:

  • PVC 和 PV 的匹配
  • 调度/挂载语义
  • 控制面的允许模型

你不要把它误解成什么

不要把它简单等同为:

  • “底层物理上只能这样”
  • “这是一个强分布式锁”

尤其在 NFS 这种文件共享后端上,底层能力和你请求的模式不是同一层概念。

更稳妥的专家表述应该是:

Access Mode 是 Kubernetes 与卷之间的使用契约,不是应用级并发正确性的保证。

所以如果你需要:

  • 多副本共享读写

你应该明确声明:

  • RWX

而不是赌底层细节。


Reclaim Policy 和 archiveOnDelete,我也给你找到了真实证据

StorageClass 里

当前 nfs-dynamic 的配置是:

  • reclaimPolicy = Delete
  • archiveOnDelete = true

provisioner 日志里

我看到大量历史删除记录,例如:

  • delete "pvc-...": volume deleted
  • persistentvolume deleted

NFS 服务器目录里

我还直接看到了很多:

  • archived-gitea-*

目录。

这说明这套集群的删除行为不是:

  • 直接把底层目录立刻物理删除

而更像是:

  • PV / PVC 逻辑上删除
  • 底层目录做归档改名

这在实验环境和平台排障里很有价值,因为:

  • 你还能回头看旧目录
  • 能追数据来源
  • 能做人工救援

但也意味着:

  • 存储空间会持续被历史归档占用
  • 平台运维必须做归档清理策略

pvc-protection 是什么,为什么它存在

shared-data 的 PVC YAML 里,我也看到了:

  • finalizers: [kubernetes.io/pvc-protection]

这表示 Kubernetes 不希望你在卷仍可能被使用时,轻易把 PVC 直接从系统里“抹掉”。

你以后如果遇到:

  • PVC 删除卡住
  • 一直 Terminating

就应该想到:

  • 是不是还有 Pod 在使用
  • 是不是 finalizer 还在保护它

这不是故障,而是数据安全机制的一部分。


NFS 为什么常见,但为什么也不能乱用

NFS 是很多实验环境、内部平台、共享模型仓库里很常见的后端。

原因很现实:

  • 便宜
  • 简单
  • 易懂
  • 支持 RWX
  • 适合共享文件
  • 很容易做动态供给

适合什么场景

  • 共享模型文件
  • 上传文件
  • 报表输出
  • 中小规模共享目录
  • 开发/测试环境持久化

不适合什么场景

  • 高 IOPS、低延迟强依赖的数据库
  • 事务日志/WAL 极重的系统
  • 对元数据操作延迟很敏感的负载
  • 需要非常强一致语义的并发写入场景

为什么

因为 NFS 的典型问题包括:

  • 所有 I/O 走网络
  • 单个 NFS 服务器容易成为瓶颈或单点
  • 元数据操作和小文件性能往往一般
  • 并发写入的锁语义、缓存语义更复杂
  • 一旦网络抖动,应用侧会感知明显

所以一个成熟的 CTO 视角,不是“有没有持久化”这么简单,而是:

哪类数据适合哪类存储后端。


这节课最重要的调试命令

看 StorageClass

kubectl get storageclass
kubectl get sc nfs-dynamic -o yaml

看 PVC 生命周期和事件

kubectl -n storage-lab get pvc
kubectl -n storage-lab describe pvc shared-data

看 PV 最终指向哪里

kubectl get pv pvc-b14e5bac-dd83-48be-8191-f89acccd2a20 -o yaml

看 Pod 里卷实际是什么类型

kubectl -n storage-lab exec emptydir-demo -- cat /proc/mounts | grep '/work\|/memory'
kubectl -n storage-lab exec pvc-writer -- cat /proc/mounts | grep /data

看 Pod 重建后数据是否还在

kubectl -n storage-lab delete pod emptydir-demo --wait=true
kubectl -n storage-lab delete pod pvc-writer --wait=true
kubectl apply -f manifests/08-storage/10-emptydir-demo.yaml
kubectl apply -f manifests/08-storage/30-pvc-writer.yaml

看 NFS provisioner 日志

kubectl -n kube-system logs deploy/nfs-provisioner-nfs-subdir-external-provisioner

直接看后端目录

ssh root@154.219.104.66
exportfs -v
ls -lah /srv/nfs/k8s

这节课你必须真正记住的结论

结论 1

emptyDir 不是持久化卷,它只是 Pod 生命周期内的临时卷。

结论 2

emptyDir.medium: Memory 本质是 tmpfs,吃的是内存,不是“更高级的持久化”。

结论 3

PVC 的核心价值,是把数据生命周期从 Pod 生命周期里抽出来。

结论 4

StorageClass -> Provisioner -> PV -> PVC -> Pod 是一条真实可追踪的链,不是抽象图。

结论 5

volumeBindingMode = Immediate 会让 PVC 在没有消费者 Pod 时就先绑定。

结论 6

RWX 的价值在于共享挂载,但不要把 Access Mode 当成应用级锁语义。

结论 7

NFS 很适合共享文件和实验平台,但不应被想当然地当成所有状态型系统的最佳后端。

结论 8

删除 Pod 不等于删除数据;删除 PVC / PV 才会进入真正的卷回收链。


你现在应该能回答的高级问题

学完这一课,你至少应该能独立回答这些问题:

  1. 为什么把业务数据放在 emptyDir 是危险设计?
  2. PVC 为什么能在 Pod 删除后保住数据?
  3. ImmediateWaitForFirstConsumer 的区别为什么会影响调度?
  4. 为什么 RWX 常常意味着文件共享存储,而不是本地盘?
  5. 为什么很多数据库即使“能跑在 NFS 上”,也未必是好选择?
  6. 为什么删除 PVC 后,有时后端目录还可能以 archived-* 形式存在?
  7. 遇到“存储不见了”时,你应该顺着 Pod -> PVC -> PV -> StorageClass -> Provisioner -> 后端目录 这条链一路追下去。

如果这些问题你能讲清楚,那么你对 Kubernetes 存储的理解,就已经不再是“会写一个 PVC YAML”,而是开始具备真正的平台和架构视角。

下一轮最自然的衔接方向是:

  • Workload 进阶
  • StatefulSet 为什么要和存储一起学
  • Headless Service、稳定网络标识、稳定卷、滚动升级
  • 为什么数据库和有状态系统通常不只是“挂一个 PVC”这么简单