做运维这些年,被问到最多的问题之一就是"容器删了数据怎么办"。K8s 里所有工作负载实际上都暗含一个前提:应用可以随时重建,日志、配置、缓存状态都可以丢,唯独数据不行。从 Volume 到 PersistentVolume,再到 NFS 共享存储,这一整套链路几乎每个生产集群都躲不开。这一篇我打算把 K8s 存储管理讲透,包括最基础的 Volume 类型、PV/PVC 的设计思路,以及最常用的 NFS 共享存储完整实战——从服务端搭建、PV/PVC 绑定、业务读写验证到问题排查,全部走一遍,顺便把文档里不写的坑也一并交底。
适合谁看?刚把集群装起来还不清楚存储怎么接的同学,在 emptyDir、hostPath、PV/PVC 之间犹豫不决的开发者,以及想给开发测试环境配一个统一共享存储目录的运维,这篇文章都能直接当手册用。不需要你有很深的存储基础,只要会敲 kubectl 命令,跟着往下走就能跑通。
1. 先搞清楚:K8s 里为什么需要 Volume
1.1 容器文件系统的天然缺陷
开始动手之前,得先把根本问题讲清楚:为什么 K8s 需要一套这么复杂的存储方案?答案不在 K8s,而在容器的生命周期。Pod 里的容器运行在镜像产生的可写层上,这个层跟着容器走,容器一删,写入的数据基本就没有了。更要命的是,K8s 的调度逻辑决定了 Pod 可能在任何节点上重建——今天跑在 node1,明天可能就被调度到 node3。如果数据只存在某个节点本地,重建之后连文件在哪台机器上都不确定。
这里有一个很经典的教训:很多人第一次部署有状态应用时,随手就把数据写到容器目录里,比如 MySQL 的 /var/lib/mysql,看起来一切正常。某天发布新版本触发滚动更新,Pod 被重建,数据库瞬间变成空库。这个坑几乎每个人都踩过,而且踩完才真正理解"容器不负责持久化"这句话的分量。
Volume 就是解决这个问题的。它的本质很简单:把 Pod 里的某个目录和某种存储后端建立关联,这个存储后端可以是一台宿主机上的目录、一块云盘、一个 NFS 共享目录,也可以是分布式存储集群。数据写到 Volume 里,Pod 删除重建之后,卷还在,数据就还在。理解这一点,后面所有的概念都是在这个基础上做扩展。
1.2 常用 Volume 类型与适用场景
K8s 的 Volume 类型很多,但日常真正用得上的就几类,先把它们的定性和边界说清楚,免得用错场景。
emptyDir 是最简单的卷类型,Pod 创建时分配一个空目录,Pod 存活期间一直存在。它有两个典型用途:一个是同一个 Pod 里多个容器共享文件,比如日志采集 Sidecar 容器读主容器写入的日志,靠 emptyDir 做中转;另一个是放缓存和临时计算数据。注意 emptyDir 的生命周期跟 Pod 绑定,Pod 被删除目录就清空,所以它只适合放"丢了也无所谓"的数据。
hostPath 直接把宿主机上的目录挂进容器。单机测试或者需要访问宿主机文件的场景(比如采集系统日志)很顺手,但多节点集群里用 hostPath 就要格外小心:Pod 被调度到哪台节点,访问的就是哪台节点的目录。同一个配置在 node1 上有文件,调度到 node2 上可能什么都没有。它适合做"节点级"的临时存储,不适合做跨节点的共享存储,更不适合存数据库文件。
configMap 和 secret 也可以作为 Volume 挂载。这种方式把配置以文件形式暴露给容器,很多应用不用重新打镜像就能改配置,靠的就是挂载文件后监听变更并重载。另外像云厂商的云盘卷(AWS EBS、阿里云云盘等)依赖各自 CSI 插件,用法大同小异,但涉及云账号权限,是另一个话题。
1.3 emptyDir 和 hostPath 快速实操
空讲不好理解,动手验证一遍。先看 emptyDir 的 yaml——下面的例子声明了一个 Pod,里面跑两个容器,共用同一个 emptyDir 卷。writer 容器往里写文件,reader 容器在同一 Pod 里直接读,两个容器之间没有任何网络调用:
apiVersion: v1 kind: Pod metadata: name: emptydir-demo spec: containers: - name: writer image: busybox command: ["sh", "-c", "echo 'hello storage' > /data/test.txt && sleep 3600"] volumeMounts: - name: shared-data mountPath: /data - name: reader image: busybox command: ["sh", "-c", "sleep 3600"] volumeMounts: - name: shared-data mountPath: /read volumes: - name: shared-data emptyDir: {}writer 容器把文件写进 /data,reader 容器从 /read 里直接能读到,这就是 emptyDir 最常见的用法。如果没有卷机制,同 Pod 的容器之间想共享文件只能走网络协议,复杂度高得多。接下来把这个 Pod 删掉再重新 apply,进入 reader 查看 /read/test.txt,大概率已经不存在了——emptyDir 的宿命就是这样,Pod 不在,数据就没了。
hostPath 的 yaml 长这样,这里我特意加了 nodeName 把 Pod 钉死在 node01 上,避免调度漂移带来的混乱:
apiVersion: v1 kind: Pod metadata: name: hostpath-demo spec: nodeName: node01 containers: - name: main image: nginx volumeMounts: - name: host-log mountPath: /usr/share/nginx/html volumes: - name: host-log hostPath: path: /opt/static type: DirectoryOrCreate挂上之后,往宿主机 /opt/static 目录里丢文件,nginx 直接就能访问。我在生产环境里见过用 hostPath 做服务目录的,全都得加上节点亲和性限制,否则 Pod 一漂移,目录对不上,故障就来了。所以我的建议是:hostPath 用在单机验证和特殊场景,能不用尽量不用。
1.4 用 Volume 时必须避开的坑
说完用法,补充几个血泪经验。
第一个坑是权限。emptyDir 和 hostPath 目录的属主、属组、权限位,直接决定了容器内用户能不能读写。容器里跑的是非 root 用户,而宿主机目录所有者是 root,那基本上就是 Permission denied。解决办法是提前规划 uid/gid,或者设置好目录权限位,不要指望 K8s 帮你自动 chmod,它不会。
第二个坑是容量。Volume 级别默认没有配额概念,emptyDir 默认不限制大小。一个失控的应用写满宿主机磁盘,整个节点都可能被拖挂。K8s 从 1.20 开始 emptyDir 支持 sizeLimit,建议声明时顺手加上:emptyDir: { sizeLimit: 1Gi },成本几乎为零,能挡掉一大半磁盘打满的事故。
第三个坑是 hostPath 的 type 字段。如果不写 type,K8s 默认按 DirectoryOrCreate 处理,目录不存在会自动创建。这个行为在单机环境很友好,但在多节点环境下每台节点都会悄悄"自动创建"目录,很容易掩盖配置错误。明确场景时建议指定 Directory 或 File,让 K8s 提前校验路径是否真实存在,而不是打太极。
2. 从 Volume 到 PV/PVC:K8s 存储抽象是怎么一步步设计的
2.1 为什么不能直接把存储路径写死在 Pod 里
如果存储只是"挂个目录"这么简单,Volume 就够用了。但实际用起来很快会发现,应用开发者根本不应该关心存储底层长什么样。开发说"我要 10G 存储",运维如果直接告诉他"这是 NFS 路径 /data/xxx,那是 Ceph 的 pool 名",两边都痛苦,耦合也太重。
PV(PersistentVolume)和 PVC(PersistentVolumeClaim)就是为了解开这层耦合。一句话概括:PV 是集群里的存储资源,由管理员创建,相当于"仓库里备好的货";PVC 是应用提出的存储需求,相当于"采购单"。开发者只需要写清楚需要多大容量、什么访问模式,K8s 负责把合适的 PV 和 PVC 绑到一起。存储底层的差异被完全隐藏,这就是 K8s 存储抽象的核心价值。
打个比方:PV 是出租房源,PVC 是你的租房申请。你只需要告诉中介"我要两居室、朝南",中介自动帮你匹配房源,你不需要直接联系房东,也不需要关心房子是砖混结构还是钢结构。这就是声明式思维和命令式操作的本质区别。
2.2 PV、PVC 和 StorageClass 的三方关系
三者关系里有几个关键点必须说透。
StorageClass 是"动态供给"的入口。没有 StorageClass 时,PV 必须事先手工建好,PVC 再去匹配,这叫静态供给。有了 StorageClass,PVC 可以直接声明 storageClassName,K8s 会调用对应的 Provisioner 自动创建 PV,这叫动态供给。内部机制是:PVC 提交后,Provisioner 收到请求,去后端(云盘、NFS、Ceph 等)创建一个真正的存储卷,包装成 PV 完成绑定,全程不需要人工干预。
StorageClass 里有两个字段值得特别留意:reclaimPolicy(回收策略)和 allowVolumeExpansion(是否允许扩容)。nfs-subdir-external-provisioner 这类社区方案会在集群里生成默认 StorageClass,很多新手发现 PVC 只写了两三行 yaml 就能自动绑上 PV,其实就是动态供给在背后工作,而不是什么魔法。
2.3 访问模式与回收策略
PV/PVC 绑定之前,K8s 需要判断二者是否匹配,匹配的核心条件就两个:容量足够,访问模式一致。
访问模式决定一个卷能被多少个节点以什么方式挂载。ReadWriteOnce(RWO)表示卷只能被一个节点读写,同一个节点上的多个 Pod 可以共享,跨节点不行,云盘基本都是这种。ReadOnlyMany(ROX)表示卷可以被多个节点同时只读挂载,适合共享配置和公共数据。ReadWriteMany(RWX)表示卷可以被多个节点同时读写,NFS、CephFS 这类共享文件系统才支持。多个应用实例需要写同一个目录时,只能选 RWX。
回收策略决定 PV 释放后的命运。Retain 是保留数据等人来处理,Delete 是自动删除后端存储,Recycle 已经被弃用。生产环境里重要的 PV 我建议一律用 Retain,后端数据删了就再也找不回来了,宁可留在那里让你手动确认,也不能让程序自动销毁。
这里有个常见的困惑:PVC 删除后,PV 并不会自动删除。除非 StorageClass 配置了 Delete 策略且 PV 是动态创建的,否则 PV 会继续存在,只是状态变成 Released。很多人以为删了 PVC 数据就清空了,这是大错特错。
2.4 PV 的生命周期:从 Available 到 Released
PV 的状态流转看起来抽象,实际就是四态:Available(空闲等待 PVC)、Bound(已绑定)、Released(PVC 删除后释放但仍保留数据)、Failed(回收失败)。把整个生命周期走一遍就完全懂了。
管理员创建 PV,状态是 Available。开发者创建 PVC,匹配成功后状态变 Bound。Pod 通过 PVC 使用存储。之后如果 PVC 被删除,PV 并不会凭空消失,而是进入 Released 状态,数据还在,但不能再被新的 PVC 绑定,因为 PV 的 claimRef 还指向旧 PVC 的命名空间和名称。要让这个 PV 重新可用,需要人工清理 claimRef,或者干脆走动态供给创建全新的 PV。
我见过不少人在"PV 卡在 Released"的问题上耗了大半天,本质就是没理解这条状态链路。后面的实战部分我会演示怎么处理这种"僵尸 PV",先把这个状态模型记住,排障时思路就顺了。
3. 环境准备:手把手搭一个 NFS 共享存储
3.1 为什么实战首选 NFS
先回答一个问题:存储方案这么多,为什么实战教程首选 NFS?
原因其实很务实。第一,NFS 部署简单,一条命令装包、几行配置就能跑起来,不依赖额外的控制组件,也没有复杂的运维门槛;第二,它天然支持 RWX 访问模式,多个节点可以同时挂载读写,对理解"共享存储"这个概念非常直观;第三,学习成本低,底层就是 Linux 网络文件系统,遇到问题搜"nfs 怎么开启""nfs 挂载失败",参考资料一抓一大把。
当然,NFS 的定位是"够用而不是最强"。单点是它最大的问题——服务端挂了,所有挂载它的 Pod 都会受影响;性能上走网络协议,跟本地盘没法比;高级能力如快照、克隆也基本没有。所以我的建议很明确:开发测试环境、中小规模集群、需要共享读写的场景,NFS 完全够用;生产核心数据库这类对性能和可靠性要求极高的业务,再考虑 Ceph RBD、云盘 CSI 等方案。这篇先把 NFS 玩明白,原理通了,换其他存储后端技术方案也只是套模板的事。
3.2 服务端安装与目录规划
下面在一台 NFS 服务端(假设 IP 是 192.168.10.10)和 K8s 节点之间完成搭建。NFS 服务端也可以是 K8s 节点本身,这完全没问题,很多实验环境就是这么干的。一个前提条件必须满足:K8s 集群本身要健康,api-server、kubelet 都正常,否则后面 PVC 卡 Pending 时你会分不清是存储问题还是集群问题。
服务端如果是 Ubuntu/Debian,安装命令是:
apt update && apt install -y nfs-kernel-serverCentOS/Rocky 系则是:
yum install -y nfs-utils安装完成后创建共享目录。规矩是必须为共享数据单独划分目录,不要随手把根目录下某个零散目录共享出去,否则后续做权限控制和容量管理都很被动:
mkdir -p /data/k8s-storage chmod -R 777 /data/k8s-storage这里 chmod 777 是为了测试方便,生产环境不建议这样用,更合理的做法是设置专用 uid/gid,然后让容器通过安全上下文去匹配。权限的问题后面专门说,先让环境跑通。
3.3 exports 配置与参数说明
共享配置的核心文件是 /etc/exports,在文件里追加一行:
/data/k8s-storage 192.168.10.0/24(rw,sync,no_root_squash,no_subtree_check)逐个说明参数的含义。rw 允许读写挂载;sync 表示数据同步写入磁盘后再响应客户端,防止数据丢失,默认建议保留;no_root_squash 是关键——不加这个参数,客户端以 root 写入的文件会被映射成匿名用户 nobody,容器里以 root 写入的数据在服务端一看属主全变了,后面会遇到各种诡异的权限问题。测试环境建议加,生产环境要慎重,因为它意味着客户端 root 能直接以 root 身份操作共享目录。
配置完成后,需要执行 exportfs -r 让新配置立即生效,同时重启服务确保下次开机也能正常加载。然后用 showmount 查看当前导出的共享列表,重点确认共享路径和允许网段是否和我们预期一致:
exportfs -r systemctl restart nfs-kernel-server showmount -e localhost正常情况下服务端会返回类似下面的内容,路径和网段都对得上,说明这一步完成:
Export list for localhost: /data/k8s-storage 192.168.10.0/24如果实际输出和预期不符,优先检查 /etc/exports 的语法和网段写法。网段写错一位,或者漏了括号里的参数,都会导致客户端挂载时找不到导出项。确认服务端没问题后,再进入下一步。
3.4 客户端验证挂载
K8s 节点侧需要安装 NFS 客户端工具。Debian 系装 nfs-common,RedHat 系装 nfs-utils:
apt install -y nfs-common # debian/ubuntu yum install -y nfs-utils # centos/rocky装完之后先手工挂载一次,验证网络和权限都通,避免一上来就进 K8s 排查,叠加太多变量不好定位:
mkdir -p /mnt/nfs-test mount -t nfs 192.168.10.10:/data/k8s-storage /mnt/nfs-test touch /mnt/nfs-test/hello.txt umount /mnt/nfs-test echo "nfs check ok"能看到 nfs check ok 的输出,说明协议、端口、权限全部正常。NFS 使用的主要端口是 2049,但还会涉及 mountd、rpcbind 等动态端口。如果客户端怎么都挂不上,优先检查防火墙是否放行了 nfs、rpc-bind、mountd 这几个服务,或者直接把相关端口固定下来再统一放行。这是新手特别容易忽略的点,我见过太多"装好了但挂不上"的案例,最后都是防火墙拦截。
4. 核心实战:创建 NFS 类型 PV、PVC 并跑通业务
4.1 编写 PV 声明
NFS 服务端就绪后,回到 K8s 里干活。第一步是编写 PV。这一步建议单独用文件保存,不要图省事直接复制粘贴到命令行里,yaml 文件后续要反复查看和审计,留档很重要。先看 PV 的完整声明:
apiVersion: v1 kind: PersistentVolume metadata: name: nfs-pv-001 labels: app: k8s-storage type: nfs spec: capacity: storage: 5Gi accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain nfs: server: 192.168.10.10 path: /data/k8s-storage创建完成后,第一时间查看 PV 状态,确认它进入 Available(可用)而不是其它异常状态:
kubectl apply -f nfs-pv.yaml kubectl get pv预期输出是 PV 显示 Available:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE nfs-pv-001 5Gi RWX Retain Available这里有几个关键点必须说明。capacity 里的 5Gi 对 NFS 来说只是一个逻辑容量,NFS 本身不会做配额限制,这个值只给调度器做绑定判断,真正能写多少取决于服务端磁盘大小。accessModes 写 ReadWriteMany,因为 NFS 支持多节点同时读写。reclaimPolicy 选 Retain,原因前面说过——NFS 后端没有自动删除的能力,策略写 Delete 反而会造成误导,仿佛删了 PVC 数据就没了,实际上并没有。yaml 里的 labels 不是必须的,但配合下面的选择器绑定会很灵活。
4.2 创建 PVC 并观察绑定过程
PVC 声明非常简单,应用开发者只需要关心容量和访问模式,完全不涉及存储后端的细节,这正是抽象的价值所在:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: nfs-pvc-001 spec: accessModes: - ReadWriteMany resources: requests: storage: 5Giapply 之后,先看 PVC 状态,再看 PV 状态变化,两条命令配合起来才能看出完整链路:
kubectl apply -f nfs-pvc.yaml kubectl get pvc kubectl get pv正常情况下,PVC 从 Pending 变为 Bound,PV 从 Available 变为 Bound。绑定过程发生在瞬间,直接看结果即可。匹配依据前面说过:容量够,访问模式一致,二选一不满足都不可能绑定。
这里有一个非常重要的经验:如果 PVC 没有显式指定 storageClassName,它会尝试使用集群的默认 StorageClass。在新版 K8s 或一些发行版里,默认 StorageClass 是存在的(比如云厂商的云盘 CSI、Rancher 的 local-path)。如果集群里存在默认动态供给,PVC 很可能不会绑定我的 NFS PV,而是直接动态创建一个新 PV,造成"抢单"现象。排查时先看这两条:
kubectl get storageclass kubectl get pvc nfs-pvc-001 -o yaml如果只想要静态绑定,就在 PVC 里加一行storageClassName: "",显式表示不使用默认 StorageClass,强制匹配手工创建的 PV。这个细节很多人不知道,我在这上面实实在在吃过亏,写出来提醒一下。
4.3 部署测试 Pod 验证数据读写
PV/PVC 绑定只是准备工作,真正要验证的是业务能不能读写。先用一个 busybox Pod 做冒烟测试,往 PVC 对应的目录里写一个文件:
apiVersion: v1 kind: Pod metadata: name: nfs-writer spec: containers: - name: writer image: busybox command: ["sh", "-c", "echo 'hello k8s nfs' > /mnt/data/test.txt && sleep 3600"] volumeMounts: - name: share mountPath: /mnt/data volumes: - name: share persistentVolumeClaim: claimName: nfs-pvc-001执行下面的命令创建 Pod,然后到 NFS 服务端看文件是否真的出现:
kubectl apply -f nfs-writer.yaml kubectl get pod -o wide # 等 Pod Running 后,到 NFS 服务端执行 ls -l /data/k8s-storage/正常的话,服务端目录下能看到 test.txt,内容正确。到这里已经能证明一件事:Pod 里写的文件,最终落在 NFS 服务端的磁盘上。但这还不够,真正的核心价值在于"应用重建后数据不丢"。接下来把 writer Pod 删除,换一个全新的 reader Pod 挂同一个 PVC,读刚才的文件:
apiVersion: v1 kind: Pod metadata: name: nfs-reader spec: containers: - name: reader image: busybox command: ["sh", "-c", "cat /mnt/data/test.txt && sleep 3600"] volumeMounts: - name: share mountPath: /mnt/data volumes: - name: share persistentVolumeClaim: claimName: nfs-pvc-001创建后执行:
kubectl apply -f nfs-reader.yaml kubectl logs nfs-reader能看到 hello k8s nfs 的输出,整套链路就算真正打通了。这个过程模拟了生产环境里最常见的场景:应用发布、Pod 重建、数据完好无损。
4.4 多 Pod 共享与 StatefulSet 场景验证
RWX 的核心价值是多个 Pod 同时读写同一个目录。为了眼见为实,可以用 Deployment 部署两个副本,让每个副本启动时把自己的主机名追加到共享文件里:
apiVersion: apps/v1 kind: Deployment metadata: name: nfs-share-demo spec: replicas: 2 selector: matchLabels: app: nfs-share-demo template: metadata: labels: app: nfs-share-demo spec: containers: - name: appender image: busybox command: ["sh", "-c", "echo $(hostname) >> /mnt/data/pods.txt && sleep 3600"] volumeMounts: - name: share mountPath: /mnt/data volumes: - name: share persistentVolumeClaim: claimName: nfs-pvc-001两个副本运行后,去 NFS 服务端查看 pods.txt,里面应该有两行不同的主机名。这证明两个跨节点的 Pod 确实在共享存储上协作,而不是各自写各自的本地盘。这个特性对有状态应用非常重要:日志收集场景中多个服务实例把日志写到同一共享目录,由采集组件统一处理;文件上传服务中多个实例共享同一个上传目录,用户请求落到任意节点都能看到同一个文件。
不过要提醒一句:RWX 只解决"能不能同时读写",不解决"并发写同一文件时的冲突问题"。两个应用同时写同一个文件的同一个位置,仍然会数据错乱,这是应用层的并发控制问题,K8s 不背这个锅。数据库这类随机读写频繁的应用,也不太适合直接跑在 NFS 上;NFS 更适合日志、静态文件、备份、上传目录这类顺序读写的场景。
5. 踩坑实录:存储相关常见问题与排查思路
任何一个存储方案,跑通只是开始,坑都在后面。下面把我在实际环境里遇到频率最高的几个问题整理成清单,每个都附上排查思路。
5.1 PVC 一直 Pending
PVC 卡在 Pending,九成是匹配不上 PV,或者动态供给没跑通。排查的第一个动作永远是看事件:
kubectl describe pvc nfs-pvc-001describe 输出里的 Events 字段会直接告诉你原因,常见的就几类:
- no persistent volumes available for this claim and no storage class is set:集群里没有可匹配的静态 PV,同时也没有默认 StorageClass。检查 PV 是否创建成功,容量和访问模式是否匹配。
- waiting for a volume to be created, either by external provisioner or by manually creating a PV:PVC 指定了 storageClassName,但 Provisioner 没有正常工作。常见原因是外部 Provisioner 的 Pod 没就绪,或者 NFS 服务端网络不通。
- storageclass not found:PVC 引用的 StorageClass 名称不存在,检查拼写。
经验总结:Pending 问题八成因 yaml 细节导致,比如访问模式写成了 ReadWriteOnce,而 PV 是 RWX,永远匹配不上。所以第一个动作永远是 describe,而不是凭感觉去猜。
5.2 Pod 挂载失败与权限 denied
Pod 创建后一直停在 ContainerCreating,看事件一般是两类问题。
第一类是网络和协议问题,报错类似 mount failed: 输入输出错误或者 no route to host。先确认 K8s 节点到 NFS 服务端的网络是否通,用 ping 和 showmount -e 验证。再看防火墙。还有一种隐蔽原因:NFS 服务端重启后,客户端旧的挂载点状态异常,此时重启 kubelet 或者重启节点通常能解决。
第二类是权限问题,报错就是 Permission denied。这基本是 root_squash 和目录属主的问题。测试环境在 exports 里加 no_root_squash 可以规避大部分场景,但生产环境如果不加,就需要在 Pod 的 securityContext 里指定 fsGroup。K8s 会把挂载目录的属组纠正为对应 gid,容器内进程才能正常写入。另外如果报错是 wrong fs type, bad option, bad superblock,说明节点上根本没装 nfs-common/nfs-utils,kubelet 执行 mount 时找不到客户端工具,装上并重启 kubelet 即可。
这里有个细节值得记住:Pod 失败时不要只盯 describe,还要看 kubelet 日志。很多 RPC 错误、字段错误在事件里被截断,但 kubelet 日志里有完整输出。日志位置一般在 /var/log/messages 或 journalctl -u kubelet -f。
5.3 PV 卡在 Released 的处理方法
这是最典型的"僵尸 PV"问题。PVC 删除后,PV 状态变 Released,数据还在但无法被新 PVC 绑定。在 Retain 策略下这是正常行为,目的是保护数据。如果确认数据确实不需要了,想把这个 PV 释放出来重新用,需要手动清理 claimRef:
kubectl patch pv nfs-pv-001 -p '{"spec":{"claimRef":null}}'执行后 PV 状态会从 Released 变回 Available,可以再次被新 PVC 绑定。如果还不行,用 kubectl get pv nfs-pv-001 -o yaml 检查 claimRef 里是不是残留了 uid 字段,一并清掉即可。
这里想强调一个理念:Retain 策略下,删 PVC 不等于删数据。有人误以为删掉 PVC 存储就清空了,实际上数据盘照旧,只是少了一层引用。要彻底清理存储,必须去 NFS 后端手动删除对应目录,这个动作一定要在确认数据不再需要之后再做。
5.4 动态供给:nfs-subdir-external-provisioner 一劳永逸
手工 PV 适合演示和小规模场景,但集群里 PVC 一多,每个都要手工建 PV 就太痛苦了。社区里最成熟的 NFS 动态供给方案是 nfs-subdir-external-provisioner。它的原理很简单:监听 PVC 创建事件,自动在 NFS 后端按命名空间和 PVC 名称创建子目录,并生成对应的 PV。这样开发者只要提交 PVC,存储立即就绪,运维不需要再手工创建任何 PV。
部署方式一般用 Helm,核心参数是 NFS 服务端地址和共享路径:
helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ --set nfs.server=192.168.10.10 \ --set nfs.path=/data/k8s-storage \ --set storageClass.name=nfs-client装完集群里会多出一个名为 nfs-client 的 StorageClass。PVC 声明里加 storageClassName: nfs-client,就能自动创建子目录并完成绑定。这个方案有两个注意点:一是 StorageClass 的 reclaimPolicy 要根据需求设置,一般配合 Delete,这样 PVC 删除时对应子目录会被清理;二是动态创建出来的目录属主经常是 nobody,需要在 values 里配置合适的参数,或者预先把共享目录 chown 成指定 uid。这个坑我踩过好几次,每次都是目录权限不对导致 Pod 里写不进去。
5.5 生产环境存储选型建议
NFS 很好用,但生产环境选型还是要回到业务本身。把踩完这些坑之后的选型思路也分享一下,不同规模、不同业务对存储的要求差别很大,我自己在项目里习惯用下面这个框架做初步判断:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 测试环境、共享日志、静态文件 | NFS / nfs-client 动态供给 | 部署简单,原生支持 RWX |
| 生产数据库、核心有状态服务 | 云盘 CSI / Ceph RBD | 性能和可靠性更高,支持快照 |
| 大规模文件共享、多读多写 | CephFS / 对象存储 | 水平扩展能力强,容量弹性大 |
| 临时数据、单节点需求 | emptyDir + sizeLimit | 零成本,生命周期清晰 |
存储选型没有银弹,关键是把访问模式、性能、可靠性、运维成本放在一起权衡。NFS 的上限很清晰,但它作为入门首选和中小规模集群的主力方案,价值无可替代。至少对我来说,NFS 是理解 K8s 存储抽象的最佳教材,没有之一。
最后说点实际体会。K8s 存储这套东西,第一次接触时容易觉得概念又多又绕,但把 Volume 和 PV/PVC 的本质想明白之后,后面接任何存储后端都是同一个套路:后端准备好,创建 PV(或走动态供给),声明 PVC,Pod 挂载。我在实际排查中反复验证过一个原则:存储问题最怕层层叠叠的猜测,用 describe 看事件、用 kubelet 日志看细节,一步一个脚印,绝大多数问题都能在十分钟内定位。NFS 这个方案,我建议每个玩 K8s 的人都亲手搭一遍,踩过权限和挂载的坑,比看十篇教程都管用。下一篇如果有机会,我打算讲讲 StatefulSet 和有状态应用编排,存储和它结合的那些事更有意思,咱们到时候接着聊。