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

Repository Reading Site

本轮操作记录:存储持久化、PV / PVC / StorageClass 与 NFS 实验

这一轮的目标不是只做一个 PVC 示例。 我要把下面几件事做成可观察事实: 1. `emptyDir` 为什么只是 Pod 级临时卷 2. PVC 为什么能把数据从 Pod 生命周期里独立出来 3. 动态供给到底是谁在干活 4. NFS 共享卷为什么能跨节点共享 5. 存储删除和回收在这套集群里到底表现成什么样 --- 因为仓库里已经有: 的基础说明。 我这

Markdown08-操作记录-存储持久化与NFS实验.md2026年4月9日 18:58

本轮操作记录:存储持久化、PV / PVC / StorageClass 与 NFS 实验

本轮目标

这一轮的目标不是只做一个 PVC 示例。

我要把下面几件事做成可观察事实:

  1. emptyDir 为什么只是 Pod 级临时卷
  2. PVC 为什么能把数据从 Pod 生命周期里独立出来
  3. 动态供给到底是谁在干活
  4. NFS 共享卷为什么能跨节点共享
  5. 存储删除和回收在这套集群里到底表现成什么样

Step 1:先读仓库里的已有存储资料

实际命令

sed -n '1,280p' phase-2/02-storage-nfs.md

为什么先读已有资料

因为仓库里已经有:

  • PV / PVC / StorageClass
  • NFS 动态供给

的基础说明。

我这轮不是从零重复,而是要升级成:

  • 真实集群取证
  • 真实动态供给链路
  • 真实 Pod 生命周期对比

我得到的结论

现有文档能帮助入门,但还不够解释:

  • Immediate 绑定时机
  • archiveOnDelete
  • Pod 重建与数据保留
  • emptyDir.medium: Memory
  • 真实 NFS 后端路径与控制器日志

所以我决定把这些都补成实验课。


Step 2:先看集群当前存储全景

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl get storageclass,pv,pvc -A | sed -n '1,260p'
KUBECONFIG=~/.kube/config-k8s-lab kubectl get sc nfs-dynamic -o yaml | sed -n '1,220p'
KUBECONFIG=~/.kube/config-k8s-lab kubectl get pvc -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"  "}{.spec.accessModes[*]}{"  "}{.spec.resources.requests.storage}{"  "}{.spec.storageClassName}{"\n"}{end}' | sed -n '1,200p'

为什么先看全局

因为我不想把 lesson 建在“假设存储栈存在”上。

我需要先确认:

  • 默认 StorageClass 是什么
  • 集群里真实用的是哪种后端
  • 现有 PVC 大多是 RWO 还是 RWX

我看到的关键结果

当前默认 StorageClass 是:

  • nfs-dynamic

关键字段包括:

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

已有 PVC 中:

  • 很多数据库/组件请求的是 RWO
  • ml-platform/ml-modelsaiforge/aiforge-models 这类共享目录请求的是 RWX

原理解释

这一步先把真实场景建立起来:

  • 这不是空集群
  • 不是没有状态型系统
  • 你的平台已经真实依赖这套 NFS 动态供给机制

Step 3:检查 NFS provisioner 和后端服务器

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n kube-system get deploy,pod | rg 'nfs|provisioner'

KUBECONFIG=~/.kube/config-k8s-lab \
kubectl -n kube-system get deploy nfs-provisioner-nfs-subdir-external-provisioner -o yaml | \
  sed -n '1,260p'

ssh root@154.219.104.66 \
  'hostname && sudo exportfs -v && sudo ls -lah /srv/nfs/k8s | sed -n "1,120p"'

为什么必须走到这一步

因为很多人学存储时只停在:

  • kubectl get pvc

但如果你想成为能排障的人,就必须知道:

  • 是哪个 provisioner 在干活
  • 它挂的是哪台 NFS 服务器
  • 后端真实目录长什么样

我看到的结果

Provisioner Deployment 明确写着:

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

NFS 服务器上真实导出的是:

  • /srv/nfs/k8s

导出参数里有:

  • rw
  • sync
  • no_subtree_check
  • no_root_squash

原理解释

这说明:

  • 集群里的 PVC 最终都会落到一台真实服务器和真实目录上
  • 动态供给不是魔法,它依赖一个常驻 controller 和一个真实 NFS export

Step 4:设计本轮实验对象

我创建了哪些文件

路径:

对象包括:

  • Namespace/storage-lab
  • Pod/emptydir-demo
  • PVC/shared-data
  • Pod/pvc-writer
  • Pod/pvc-reader

为什么这样设计

这轮实验需要同时回答两类问题:

  1. 临时卷和持久卷的生命周期区别
  2. 共享存储的跨节点语义

所以我做了两条链:

链一:emptyDir

用来证明:

  • Pod 删了,数据就没了

链二:RWX PVC + NFS

用来证明:

  • 两个不同节点上的 Pod 可以同时看到同一份数据
  • 删 Pod 不删 PVC,数据还在

Step 5:先只创建 namespace、emptyDir Pod 和 PVC

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl apply \
  -f manifests/08-storage/00-namespace-storage-lab.yaml \
  -f manifests/08-storage/10-emptydir-demo.yaml \
  -f manifests/08-storage/20-shared-data-pvc.yaml

为什么先不创建 writer / reader

因为我想先单独观察:

  • PVC 在没有消费者 Pod 时会不会先绑定

这就是验证 Immediate 的最好办法。

实际结果

成功创建:

  • storage-lab
  • emptydir-demo
  • shared-data

Step 6:验证 PVC 在无消费者时就先 Bound

实际命令

kubectl -n storage-lab wait --for=condition=Ready pod/emptydir-demo --timeout=180s
kubectl -n storage-lab wait --for=jsonpath='{.status.phase}'=Bound pvc/shared-data --timeout=180s
kubectl -n storage-lab get pvc shared-data -o wide
kubectl -n storage-lab describe pvc shared-data

我看到的结果

即使这时还没有:

  • pvc-writer
  • pvc-reader

shared-data 已经:

  • Bound

describe pvc 事件里还出现了完整链路:

  • ExternalProvisioning
  • Provisioning
  • ProvisioningSucceeded

原理解释

这一步直接证明了:

  • 这套 StorageClass 是 Immediate

PVC 不是“等 Pod 真来用时才绑定”,而是创建后立刻触发供给。


Step 7:把 PVC 对应到真实 PV

实际命令

KUBECONFIG=~/.kube/config-k8s-lab \
kubectl get pv pvc-b14e5bac-dd83-48be-8191-f89acccd2a20 -o yaml | sed -n '1,220p'

为什么要看 PV YAML

因为 PVC 只是“需求”,PV 才是“真正供给出来的卷”。

我看到的关键信息

PV 里明确有:

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

原理解释

这一步把“抽象申请”变成了“真实目录和真实协议”。


Step 8:把 writer / reader Pod 创建出来,并固定到不同节点

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl apply \
  -f manifests/08-storage/30-pvc-writer.yaml \
  -f manifests/08-storage/31-pvc-reader.yaml

kubectl -n storage-lab wait --for=condition=Ready pod/pvc-writer --timeout=180s
kubectl -n storage-lab wait --for=condition=Ready pod/pvc-reader --timeout=180s
kubectl -n storage-lab get pod -o wide

为什么固定到不同节点

因为我要验证的是:

  • 跨节点共享

而不是:

  • 同一节点本地共享

所以我在清单里明确写了:

  • pvc-writer -> us590068728056
  • pvc-reader -> wk-1

我确认到的结果

两个 Pod 都成功启动在不同节点。


Step 9:writer 写数据,reader 读同一份数据

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab exec pvc-writer -- \
  sh -c 'echo writer-host=$(hostname); date > /data/created-at.txt; echo writer-node=us590068728056 >> /data/created-at.txt; echo storage-lab-demo > /data/source.txt; ls -l /data; echo; cat /data/created-at.txt'

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab exec pvc-reader -- \
  sh -c 'echo reader-host=$(hostname); ls -l /data; echo; cat /data/created-at.txt; echo; cat /data/source.txt'

我第一次看到的一个小现象

第一次 reader 读取时:

  • 先看到了 created-at.txt
  • 还没看到 source.txt

随后几秒再次读取时,两份文件都稳定可见。

我如何处理这个现象

我没有急着下结论,而是继续做了三步验证:

  1. 再次在 reader 中循环读取
  2. writer 中再次确认文件都在
  3. 直接到 NFS 服务器目录里看

最终确认到的结果

  • reader 能稳定看到两份文件
  • writer 也能看到两份文件
  • NFS 服务器目录里也确实存在两份文件

原理解释

这说明共享链路是通的。

至于第一次读时那一点点差异,我在课程文档里明确标成:

  • 基于观测的推断

很可能和:

  • NFS 客户端缓存
  • 元数据可见性时机

有关。

这就是实战态度:

  • 看到异常现象,不武断
  • 继续收证据
  • 再做结论

Step 10:看 Pod 里真实挂载类型

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab exec emptydir-demo -- \
  sh -c 'echo "[mounts]"; cat /proc/mounts | grep "/work\|/memory"; echo; echo first-write > /work/data.txt; echo hot-cache > /memory/cache.txt; ls -l /work /memory; echo; cat /work/data.txt; echo; cat /memory/cache.txt'

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab exec pvc-writer -- \
  sh -c 'cat /proc/mounts | grep "/data"'

我看到的结果

emptydir-demo 中:

  • /workext4
  • /memorytmpfs

pvc-writer 中:

  • /datanfs4
  • 服务端是 10.10.0.5
  • 路径就是那条 storage-lab-shared-data-pvc-...

原理解释

这一步非常有价值,因为它把三种存储介质在容器里的实际表现彻底分开了:

  • 本地磁盘型临时卷
  • 内存型临时卷
  • 网络文件系统持久卷

Step 11:删除 emptyDir Pod,验证数据丢失

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab delete pod emptydir-demo --wait=true

然后我做了什么

重新 apply:

KUBECONFIG=~/.kube/config-k8s-lab kubectl apply -f manifests/08-storage/10-emptydir-demo.yaml
kubectl -n storage-lab wait --for=condition=Ready pod/emptydir-demo --timeout=180s

再进入容器里检查:

kubectl -n storage-lab exec emptydir-demo -- \
  sh -c 'echo "[work]"; ls -l /work; echo; echo "[memory]"; ls -l /memory; echo; [ -f /work/data.txt ] && cat /work/data.txt || echo "/work/data.txt missing"; [ -f /memory/cache.txt ] && cat /memory/cache.txt || echo "/memory/cache.txt missing"'

我看到的结果

重建后的新 Pod 里:

  • /work 空了
  • /memory 空了
  • 两个文件都没了

原理解释

这一步用真实现象证明了:

  • emptyDir 的数据跟 Pod 走
  • Pod 删了,临时卷也就跟着没了

Step 12:删除使用 PVC 的 Pod,验证数据还在

实际命令

KUBECONFIG=~/.kube/config-k8s-lab kubectl -n storage-lab delete pod pvc-writer --wait=true

我先踩到的一个坑

第一次我在删除还没完全完成时,就立刻重新 apply

结果 Kubernetes 提示:

  • resource is currently being deleted

这个坑为什么值得记录

因为它说明:

对象生命周期是异步的。

你在自动化脚本里如果不等删除真正完成,就很容易拿到“看起来重建了、其实没重建干净”的混乱状态。

修正后的动作

我先确认:

  • Pod 真正不存在了

然后再重新 apply:

KUBECONFIG=~/.kube/config-k8s-lab kubectl apply -f manifests/08-storage/30-pvc-writer.yaml
kubectl -n storage-lab wait --for=condition=Ready pod/pvc-writer --timeout=180s

再检查:

kubectl -n storage-lab exec pvc-writer -- sh -c 'ls -l /data; echo; cat /data/created-at.txt; echo; cat /data/source.txt'
kubectl -n storage-lab exec pvc-reader -- sh -c 'ls -l /data; echo; cat /data/created-at.txt; echo; cat /data/source.txt'

我看到的结果

pvc-writer 和一直没删的 pvc-reader 都还能看到:

  • created-at.txt
  • source.txt

原理解释

这一步把“Pod 生命周期”和“数据生命周期”真正拆开了。


Step 13:直接到 NFS 服务器上确认后端目录

实际命令

ssh root@154.219.104.66 \
  'sudo ls -l /srv/nfs/k8s/storage-lab-shared-data-pvc-b14e5bac-dd83-48be-8191-f89acccd2a20 && echo && sudo sed -n "1,20p" /srv/nfs/k8s/storage-lab-shared-data-pvc-b14e5bac-dd83-48be-8191-f89acccd2a20/created-at.txt && echo && sudo sed -n "1,20p" /srv/nfs/k8s/storage-lab-shared-data-pvc-b14e5bac-dd83-48be-8191-f89acccd2a20/source.txt'

为什么必须做这一步

因为我要把这一课从“kubectl 世界”拉回“真实存储世界”。

我看到的结果

底层目录里真实有:

  • created-at.txt
  • source.txt

原理解释

这一步说明 PVC 背后的数据绝不是抽象概念,它就真实躺在 NFS 服务器的某个目录里。


Step 14:看 provisioner 日志与 archive 目录

实际命令

KUBECONFIG=~/.kube/config-k8s-lab \
kubectl -n kube-system logs deploy/nfs-provisioner-nfs-subdir-external-provisioner | \
  rg 'storage-lab/shared-data|pvc-b14e5bac-dd83-48be-8191-f89acccd2a20'

ssh root@154.219.104.66 \
  'sudo ls -d /srv/nfs/k8s/archived-* | sed -n "1,20p"'

我看到的结果

日志里明确有:

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

NFS 目录里还看到了大量:

  • archived-gitea-*

原理解释

这两条证据组合起来,就把你这套集群的回收和归档模式讲清了:

  • PVC / PV 删除链路确实会走 provisioner
  • 后端目录会按 archiveOnDelete 的逻辑保留归档痕迹

本轮最重要的结论

这轮实验最核心的,不是“创建了一个 PVC”,而是把以下规律做成了事实:

  1. emptyDir 数据跟 Pod 生命周期绑定,Pod 重建后数据消失。
  2. emptyDir.medium: Memory 在容器里表现为 tmpfs,吃的是内存。
  3. PVC 一旦绑定,数据生命周期可以独立于 Pod 生命周期。
  4. 你的默认存储类 nfs-dynamic 采用 Immediate 绑定模式,所以 PVC 会先绑定。
  5. NFS provisioner 会把 PVC 动态映射为 NFS 服务器上的真实子目录。
  6. RWX 可以让不同节点上的 Pod 共享同一卷。
  7. Access Mode 是 K8s 存储语义契约,不应被误当成应用级锁。
  8. 这套集群的存储删除链还有 archiveOnDelete,所以历史目录会以 archived-* 形式保留。

本轮新增交付物

下一轮最自然的衔接是:

  • StatefulSet
  • Headless Service
  • 稳定网络身份
  • 稳定卷
  • 有状态系统为什么不只是“挂一个 PVC”