K8S NFS动态供给实战:StorageClass与Provisioner部署指南
1. 为什么要在 K8S 里折腾 NFS 动态供给1.1 从一次存储告警说起事情的起因很简单某天凌晨收到一条告警某个有状态服务的 Pod 因为 PVC 一直处于 Pending 状态起不来业务直接中断。排查下来发现之前团队用的是静态 PV 的方式——每上一个新服务运维就得手动创建一个 PV、指定 NFS 路径、配置容量然后开发再写一个 PVC 去绑定。服务少的时候还能忍一旦微服务数量上到几十个这套流程就成了灾难PV 命名混乱、容量分配靠拍脑袋、NFS 目录权限经常对不上最要命的是每次扩容都要人工介入。后来我把整套存储方案换成了 NFS 动态供给Dynamic Provisioning配合 StorageClass 和 PVC 自动绑定新服务上线只需要写一个 PVC 声明底层 PV 和 NFS 目录自动创建容量、回收策略全部按预设规则走。这篇文章就把这套方案的完整落地过程拆开讲清楚包括 NFS 服务端怎么配、Provisioner 怎么选、StorageClass 参数怎么调、PVC 怎么动态挂载以及我在实际踩坑中总结出来的排查经验。这套内容适合谁看如果你正在用 K8S 跑有状态服务数据库、日志、文件服务、CI 缓存等并且希望存储这块能自动化、少人工干预那这篇基本可以直接抄作业。如果你只是刚接触 K8S 存储概念也能从里面搞明白 PV、PVC、StorageClass、Provisioner 这几个东西到底是怎么串起来的。1.2 静态供给和动态供给的本质区别先把概念捋清楚不然后面配置容易懵。静态供给Static Provisioning的逻辑是管理员先手动创建好 PVPV 里写死 NFS 服务器地址、共享路径、容量大小然后用户创建 PVCK8S 的绑定控制器去找一个满足条件的 PV 进行绑定。整个过程 PV 是预先存在的PVC 只是去认领。动态供给Dynamic Provisioning的逻辑是管理员只创建一个 StorageClass里面声明用哪个 Provisioner、用什么参数用户创建 PVC 时指定这个 StorageClassProvisioner 收到请求后自动在 NFS 服务端创建对应目录并生成一个 PV 与之绑定。PV 是按需生成的用户完全不用关心底层路径。打个生活化的比方静态供给就像你去酒店房间是提前开好的你只能挑一间现成的住动态供给则是你报个需求几人间、住几天前台当场给你开一间新房。前者房间数量固定、管理麻烦后者按需分配、弹性好。从运维角度看动态供给带来的直接收益有三个一是省人工不用再手动建 PV二是容量可控每个 PVC 申请多少就分配多少三是回收自动化PVC 删除后 PV 和 NFS 目录能按策略自动清理。1.3 整体架构长什么样在动手之前先把整个链路在脑子里过一遍这样配置的时候每一步在干什么就清楚了。整个架构分三层存储层一台或多台NFS 服务器提供共享目录所有 K8S 节点都能挂载。控制层运行在 K8S 集群里的 NFS Provisioner也叫 NFS Subdir External Provisioner它监听 PVC 的创建事件负责在 NFS 上建目录、建 PV。使用层业务 Pod 通过 PVC 声明存储需求PVC 通过 StorageClass 触发 Provisioner最终 Pod 挂载到具体的 NFS 子目录。数据流向是这样的用户创建 PVC → StorageClass 匹配 → Provisioner 在 NFS 根目录下创建${namespace}-${pvcName}-${pvName}这样的子目录 → 生成 PV 对象 → PVC 与 PV 绑定 → Pod 挂载 PVC → 容器内看到的就是那个 NFS 子目录。理解了这个链路后面遇到 PVC 一直 Pending 的时候你就知道该从哪一环开始查了。2. NFS 服务端与 Provisioner 的选型与准备2.1 NFS 服务端的基础配置NFS 服务端这块我假设你用的是一台独立的 Linux 主机CentOS 或 Ubuntu 都行IP 假设为192.168.1.100共享目录定为/data/nfs/k8s。以下命令在服务端执行。先装包。CentOS 系用yum install -y nfs-utils rpcbindUbuntu 系用apt install -y nfs-kernel-server。装完之后创建共享目录并设置权限mkdir -p /data/nfs/k8s chmod 777 /data/nfs/k8s这里权限给 777 是图省事生产环境建议根据实际 UID/GID 精细化控制后面注意事项里会细说。然后编辑/etc/exports写入共享规则/data/nfs/k8s 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)几个参数的含义必须搞清楚不然容易出权限问题rw读写权限只读的话用ro。sync同步写入数据先落盘再返回安全但慢async快但有丢数据风险生产建议sync。no_subtree_check关闭子树检查提升兼容性和性能现代 NFS 基本都建议关。no_root_squash允许客户端的 root 用户以 root 身份操作共享目录。这个参数很关键因为很多容器默认以 root 运行如果开了root_squashroot 会被映射成匿名用户写文件就会 Permission denied。配置完执行生效命令exportfs -r systemctl enable --now nfs-server rpcbind验证一下共享是否生效在服务端执行showmount -e localhost应该能看到/data/nfs/k8s这一行。然后在任意一个 K8S 节点上测试挂载mount -t nfs 192.168.1.100:/data/nfs/k8s /mnt能挂上并且能读写说明服务端没问题。这一步千万别跳过我见过太多人 Provisioner 配了半天最后发现是 NFS 服务端防火墙没放行 2049 端口。2.2 Provisioner 的选型对比NFS 动态供给的核心是 Provisioner市面上有几个选择我列个表对比一下方便你按场景选。方案维护状态是否需额外组件适用场景我的评价nfs-subdir-external-provisioner活跃否直接连现有 NFS已有 NFS想快速上动态供给首选轻量稳定nfs-client-provisioner旧版已归档否老集群兼容不推荐新项目用CSI NFS Driver活跃需部署 CSI追求标准化、多协议复杂但规范Rook CephFS活跃需整套 Ceph大规模、高性能重小集群杀鸡用牛刀对于绝大多数中小集群nfs-subdir-external-provisioner是最优解。它不需要你改造现有 NFS直接复用部署就是一个 Deployment维护成本极低。它会在 NFS 根目录下按namespace-pvcname-pvname的规则建子目录每个 PVC 一个独立目录隔离性够用。CSI NFS Driver 更正统支持快照、扩容等高级特性但部署复杂度高一个量级如果你没有这些高级需求没必要上。Rook CephFS 适合有专门存储团队的大规模场景个人和小团队玩不转。我选 nfs-subdir-external-provisioner 的核心理由是它把动态供给这件事做得足够简单而简单意味着故障点少。存储这东西稳定压倒一切。2.3 部署前的 RBAC 与命名空间准备Provisioner 要能监听 PVC、创建 PV就必须有对应的 RBAC 权限。这部分官方 Helm Chart 会自动创建但如果你像我一样喜欢手动部署、把每个对象都看清楚那就得自己写。先建命名空间kubectl create namespace nfs-provisioner然后是 ServiceAccount、ClusterRole、ClusterRoleBinding 三件套。ClusterRole 需要授予的权限包括对persistentvolumes的 get/list/watch/create/delete对persistentvolumeclaims的 get/list/watch/update对events的 create/update/patch以及对storageclasses的 get/list/watch。这里有个经验点权限宁可精确也不要图省事给 cluster-admin。我见过有人直接绑 cluster-admin 图快结果 Provisioner 出 bug 时误删了别的资源。存储组件权限收紧一点出问题影响面小。RBAC 配置好之后Provisioner 的 Deployment 里要指定serviceAccountName: nfs-provisioner否则它连 PVC 都读不到日志里会一直报 forbidden。3. StorageClass 与 Provisioner 的完整部署实操3.1 Provisioner Deployment 的关键参数Provisioner 的 Deployment 是整个方案的心脏我把关键配置逐项拆开讲。核心是它的启动参数和环境变量。启动参数里最重要的是--provisioner这个名字必须和后面 StorageClass 里的provisioner字段完全一致否则 StorageClass 找不到它。我一般命名为k8s-sigs.io/nfs-subdir-external-provisioner。环境变量有四个必填PROVISIONER_NAME和启动参数保持一致。NFS_SERVERNFS 服务端 IP比如192.168.1.100。NFS_PATHNFS 共享根路径比如/data/nfs/k8s。POD_NAMESPACEProvisioner 自己所在的命名空间用 fieldRef 自动注入。还有一个可选但强烈建议配的ENABLE_LEADER_ELECTION多副本部署时开启选主避免多个实例同时处理同一个 PVC 导致重复建目录。Deployment 的 volumes 部分要挂载 NFS这样 Provisioner 容器内才能操作 NFS 目录volumes: - name: nfs-client-root nfs: server: 192.168.1.100 path: /data/nfs/k8s容器内挂载点设为/persistentvolumesProvisioner 就是在这个目录下创建子目录的。这个路径和 StorageClass 里的archiveOnDelete等参数配合工作别改乱。3.2 StorageClass 参数逐项拆解StorageClass 是用户和 Provisioner 之间的契约用户只认它。下面是我生产在用的配置逐行解释。apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-dynamic annotations: storageclass.kubernetes.io/is-default-class: false provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: true pathPattern: ${.PVC.namespace}/${.PVC.name} reclaimPolicy: Delete volumeBindingMode: Immediate allowVolumeExpansion: trueprovisioner字段必须和 Provisioner 的PROVISIONER_NAME一致这是绑定的唯一凭据。archiveOnDelete: true是个非常实用的参数。默认情况下 PVC 删除后NFS 上的目录也会被删掉数据就没了。设为 true 后删除 PVC 时目录会被重命名为archived-xxx保留下来相当于给了一次后悔药。生产环境我强烈建议开这个误删 PVC 的事故我见过不止一次。pathPattern控制目录命名规则。默认是${namespace}-${pvcName}-${pvName}我改成${.PVC.namespace}/${.PVC.name}好处是目录结构按命名空间分层一眼就能看出哪个目录属于哪个服务排查问题时特别方便。reclaimPolicy: Delete表示 PVC 删除后 PV 也删除。配合archiveOnDelete使用实际效果是PV 删了但数据目录归档保留。如果你希望数据永久保留可以设为Retain但那样 PV 会一直占着需要手动清理。volumeBindingMode: Immediate表示 PVC 一创建就立即绑定。对于 NFS 这种网络存储Immediate 没问题。如果是本地盘那种有节点亲和性的存储才需要用WaitForFirstConsumer。allowVolumeExpansion: true允许后续扩容 PVC。注意 NFS 本身没有容量限制这个参数更多是让 K8S 层面允许修改 PVC 的 requests实际容量还是看 NFS 服务端磁盘。3.3 部署验证与状态检查所有 YAML 应用完之后按这个顺序验证一步都不能少。第一步看 Provisioner Pod 是否 Runningkubectl get pods -n nfs-provisioner如果 CrashLoopBackOff八成是 RBAC 或 NFS 挂载问题看日志kubectl logs -n nfs-provisioner deploy/nfs-client-provisioner第二步看 StorageClass 是否创建成功kubectl get sc确认nfs-dynamic在列表里PROVISIONER 列显示的名字和配置一致。第三步创建一个测试 PVC 验证动态供给apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc spec: storageClassName: nfs-dynamic accessModes: - ReadWriteMany resources: requests: storage: 1Gi应用后执行kubectl get pvc正常应该几秒内从 Pending 变成 Bound。同时kubectl get pv能看到自动生成的 PV。再去 NFS 服务端ls /data/nfs/k8s应该能看到default/test-pvc这样的目录。这三步全过说明动态供给链路完全打通。任何一步卡住对照第 5 章的排查表处理。4. PVC 动态挂载到 Pod 的实战4.1 一个完整的 Deployment 挂载示例PVC 建好之后怎么挂到 Pod 里是最后一步。我拿一个典型的 Web 服务举例它需要把上传的文件持久化到 NFS。先建 PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: web-uploads-pvc namespace: default spec: storageClassName: nfs-dynamic accessModes: - ReadWriteMany resources: requests: storage: 5Gi注意accessModes用的是ReadWriteMany这是 NFS 相比块存储的最大优势——多个 Pod 可以同时读写同一个卷。如果你的服务是多副本部署且共享文件必须用 RWX用 RWO 的话第二个 Pod 会挂载失败。然后是 DeploymentapiVersion: apps/v1 kind: Deployment metadata: name: web-app spec: replicas: 3 selector: matchLabels: app: web-app template: metadata: labels: app: web-app spec: containers: - name: web image: nginx:1.25 volumeMounts: - name: uploads mountPath: /usr/share/nginx/html/uploads volumes: - name: uploads persistentVolumeClaim: claimName: web-uploads-pvc三个副本共享同一个 PVC写入的文件所有副本都能看到。这就是 NFS 动态供给在无状态多副本场景下的典型用法。4.2 挂载权限与 UID 匹配的坑挂载本身不难难的是权限。这是 NFS 动态供给里最容易翻车的地方我单独拎出来讲。问题现象Pod 起来了但容器内往挂载目录写文件时报Permission denied。原因通常是容器内进程的 UID 和 NFS 目录的属主不匹配。NFS 的权限模型是基于 UID/GID 的。Provisioner 创建目录时默认属主是运行 Provisioner 的用户通常是 root。如果容器内进程以非 root 用户比如 UID 1000运行就会写不进去。解决办法有三个按推荐程度排序方案一在容器里指定 runAsUser 匹配目录属主。如果 NFS 目录属主是 root就让容器也以 root 跑securityContext.runAsUser: 0。简单直接但安全性差。方案二用 initContainer 改目录权限。启动一个 initContainer以 root 身份执行chown -R 1000:1000 /mnt/data把目录属主改成业务 UID。这是生产最常用的做法兼顾安全和可用。方案三NFS 服务端配置 all_squash 加 anonuid。在/etc/exports里加all_squash,anonuid1000,anongid1000把所有访问者都映射成 UID 1000。适合整个 NFS 只服务一类应用的场景。我一般用方案二灵活且不影响其他服务。initContainer 的配置大概是这样initContainers: - name: fix-permissions image: busybox command: [sh, -c, chown -R 1000:1000 /mnt/data] securityContext: runAsUser: 0 volumeMounts: - name: uploads mountPath: /mnt/data4.3 多副本共享读写的注意事项多副本共享 NFS 卷虽然方便但有几个坑必须提前知道。第一文件锁问题。NFS 的文件锁flock/fcntl在旧版本协议下支持不完善如果你的应用依赖文件锁做并发控制比如某些数据库、某些队列可能会出诡异问题。NFSv4 对锁的支持好很多建议服务端强制用 v4。第二性能瓶颈。所有副本的 IO 都打到同一台 NFS 服务器副本数一多NFS 的网卡和磁盘就是瓶颈。我实测过单台千兆网卡的 NFS跑 5 个以上高频写副本就开始有明显延迟。这种场景要么升级万兆网卡要么换分布式存储。第三缓存一致性。NFS 有客户端缓存多个节点同时读写同一文件时可能出现 A 节点写了、B 节点读到的还是旧内容。对一致性要求高的场景挂载时加noac参数关闭属性缓存但会牺牲性能。所以我的建议是NFS 动态供给适合共享读、少量写或各副本写各自文件的场景比如静态资源、上传目录、日志归档。如果是高频并发写同一文件老老实实上分布式存储。5. 常见问题排查与避坑经验5.1 PVC 一直 Pending 的排查路径PVC Pending 是最常见的问题排查要按链路顺序来别乱试。先看 PVC 的 Eventskubectl describe pvc pvc-nameEvents 里通常会直接告诉你原因。我整理了一张速查表报错信息根本原因解决方法no persistent volumes availableStorageClass 名字写错或不存在检查 PVC 的 storageClassName 拼写waiting for a volume to be createdProvisioner 没运行或没权限检查 Provisioner Pod 状态和日志failed to provision volumeNFS 挂载失败或目录创建失败检查 NFS 连通性和服务端权限storageclass not foundStorageClass 未创建kubectl get sc 确认forbiddenRBAC 权限不足检查 ClusterRole 绑定如果 Events 信息不够直接看 Provisioner 日志它会打印详细的失败原因kubectl logs -n nfs-provisioner deploy/nfs-client-provisioner --tail100日志里如果出现mount failed基本就是 NFS 服务端的问题出现create directory failed是 NFS 目录权限问题出现forbidden是 RBAC 问题。5.2 NFS 挂载超时与网络排查Provisioner 日志里报mount: connection timed out或no route to host说明 K8S 节点到 NFS 服务端的网络不通。按这个顺序查第一步在节点上直接 ping NFS 服务端 IP确认网络可达。第二步检查 NFS 端口是否放行。NFS 依赖 2049nfsd和 111rpcbind两个端口防火墙必须放行firewall-cmd --add-servicenfs --permanent firewall-cmd --add-servicemountd --permanent firewall-cmd --add-servicerpc-bind --permanent firewall-cmd --reload第三步在节点上手动挂载测试mount -t nfs 192.168.1.100:/data/nfs/k8s /mnt如果手动挂不上那就是 NFS 服务端或网络的问题跟 K8S 无关。如果手动能挂、Provisioner 挂不上那多半是 Provisioner 的 volumes 配置写错了 IP 或路径。这里有个隐蔽的坑NFS 服务端重启后客户端的挂载可能变成 stale失效状态表现为Stale file handle错误。这时候需要重新挂载K8S 里就是重启对应的 Pod。生产环境建议给 NFS 服务端做高可用避免单点重启。5.3 删除 PVC 后数据丢失的预防这是最痛的坑没有之一。默认配置下删除 PVC 会连带删除 NFS 上的数据目录如果业务方误删了 PVC数据就没了。预防措施有三层第一层StorageClass 开archiveOnDelete: true。这样删除 PVC 时目录会被重命名为archived-pvcname-pvname保留数据还在只是 PV 没了。需要恢复时手动把目录改回名字再建 PVC 绑定。第二层NFS 服务端做定期快照或备份。光靠 archive 不够万一有人手贱把 archived 目录也删了呢我一般用 rsync 每天增量备份 NFS 根目录到另一台机器保留 7 天。第三层生产环境用reclaimPolicy: Retain。这样 PV 删除后数据目录完全不动需要人工确认后再清理。代价是 PV 会一直占着需要定期巡检清理。适合核心数据。三层防护叠加基本能杜绝误删事故。我个人的配置是普通业务用 Delete archiveOnDelete核心数据用 Retain 备份。5.4 性能调优的几个实用参数NFS 默认配置在 K8S 场景下不一定最优几个可以调的参数挂载参数rsize和wsize控制单次读写的数据块大小默认 4K 或 64K调到 1Mrsize1048576,wsize1048576能显著提升大文件吞吐。但要注意这个参数在 K8S 的 PV 里配置比较麻烦因为动态供给生成的 PV 用的是 Provisioner 的默认挂载参数。要改的话得在 Provisioner 的 Deployment 里通过mountOptions传。nconnect参数NFSv4.1 以上支持多连接nconnect4能让单个挂载用 4 条 TCP 连接吞吐翻倍。这个参数对高并发场景提升明显但需要内核和 NFS 版本支持。服务端asyncvssync前面说过sync安全但慢。如果业务能容忍少量数据丢失风险比如缓存类可以改async换性能。但数据库这类绝对不能改。调优这事没有银弹得根据实际业务压测。我的建议是先用默认配置跑起来有性能问题再针对性调别一上来就堆参数那样出问题都不知道是哪个参数导致的。6. 从单机 NFS 到生产可用的演进思路6.1 单点 NFS 的风险与缓解前面整套方案跑下来你会发现有个绕不开的问题NFS 服务端是单点。它一挂所有挂载它的 Pod 全部 IO 阻塞业务雪崩。缓解单点风险有几个层次的方案最轻量的是做 NFS 服务端高可用用 keepalived drbd 或者 corosync pacemaker 做双机热备VIP 漂移。配置复杂但能扛住单机故障。中等方案是换分布式存储比如 Ceph、GlusterFS、Longhorn。这些方案本身有副本机制单节点故障不影响。代价是部署和运维复杂度上一个台阶需要专门的存储知识。最省事的是接受单点但做好监控和快速恢复。NFS 服务端加磁盘监控、进程监控出问题第一时间告警同时准备好备用机器故障时能快速切换。适合对可用性要求没那么极致的内部系统。我的实际选择是核心业务上分布式存储边缘业务用单机 NFS 监控。不是所有服务都值得为高可用付出那么大成本按业务重要性分级处理更务实。6.2 什么场景该换掉 NFSNFS 动态供给不是万能的以下几种情况建议尽早换方案需要高 IOPS 的数据库NFS 的网络延迟决定了它不适合跑 MySQL、PostgreSQL 这类对 IO 延迟敏感的服务。这类应该用本地 SSD 或高性能块存储。需要快照和克隆的场景NFS Provisioner 不支持快照如果你的 CI/CD 需要快速克隆环境得用支持 VolumeSnapshot 的 CSI 驱动。超大规模集群几千个 PVC 时单台 NFS 的目录数量和并发连接会成为瓶颈需要分布式存储。需要精细 QoS 的场景NFS 没法限制单个卷的 IOPS 和带宽多租户场景下容易互相影响。判断标准很简单如果你的存储需求已经超出了共享文件这个范畴就该考虑换方案了。NFS 的定位就是简单共享文件别让它干超出能力范围的活。6.3 我踩过的三个真实坑最后分享三个我在实际部署中踩过的坑都是文档里不会写的。坑一Provisioner 副本数设成 2 但没开选主。结果两个实例同时处理同一个 PVC在 NFS 上创建了两个目录PV 却只绑定了一个另一个成了孤儿目录。后来开了ENABLE_LEADER_ELECTION才解决。多副本部署 Provisioner 必须开选主这是硬性要求。坑二NFS 服务端 exports 用了通配符网段但集群节点跨网段。配置写的是192.168.1.0/24结果有个节点在192.168.2.0/24那个节点上的 Pod 全部挂载失败。排查了半天才发现是网段没覆盖全。exports 的网段一定要覆盖所有可能调度 Pod 的节点或者干脆用具体 IP 列表。坑三PVC 扩容后 Pod 没重启容量没生效。NFS 的扩容其实只是改了 K8S 层面的 requests底层 NFS 目录容量本来就是共享磁盘的不存在扩容一说。但如果你用的是支持扩容的 CSI 驱动扩容后需要重启 Pod 才能让文件系统感知新容量。这个坑让我一度以为扩容失败了。这三个坑的共同点是都跟想当然有关。存储这东西每个环节都得实际验证不能凭经验假设。6.4 一套可直接复用的部署清单把整套流程浓缩成一份清单照着做基本不会漏NFS 服务端装包、建目录、配 exports、放行防火墙、验证挂载。K8S 集群所有节点装 nfs-utils确保能挂 NFS。创建 nfs-provisioner 命名空间和 RBAC。部署 Provisioner Deployment配好 NFS_SERVER、NFS_PATH、PROVISIONER_NAME。创建 StorageClass配 archiveOnDelete、pathPattern、reclaimPolicy。建测试 PVC 验证 Bound 状态。建测试 Pod 验证挂载和读写。配置监控告警Provisioner 状态、NFS 服务端磁盘和进程。配置 NFS 数据定期备份。文档化 PVC 命名规范和容量申请流程。这套清单我在三个不同规模的集群上跑过基本一次成型。唯一需要根据环境调整的是网段、路径和权限配置其余都是通用的。存储这块我的核心体会是动态供给解决的是自动化问题但可靠性问题得靠架构和运维兜底。Provisioner 让 PVC 创建变得无感但 NFS 单点、数据备份、权限管理这些事一个都省不了。把自动化做好的同时别忘了给数据上保险。