
在Kubernetes里跑有状态应用存储这一关谁都躲不掉。很多朋友一开始都是手动创建 PV指向同一个 NFS 路径需求一变就抓瞎PVC 要 5GiPV 却建了 8Gi两个环境共用一个目录互相写乱了忘了删 PV结果 NFS 目录越堆越多。后来我把这套流程换成了 nfs-subdir-external-provisioner 4.0.18PV 不用再手动造PVC 一提交对应的 NFS 子目录和 PV 就自动生成删除时还能按策略清理或归档。这个工具就成了我这边 Kubernetes 存储方案里的标准件。这篇文章就围绕它展开从工作原理到部署实操再到我踩过的坑一次性说完。适合正在给 K8s 集群做存储规划的运维、开发和刚接触容器存储的同学。1. Kubernetes存储为什么需要动态供给1.1 静态PV运维的痛点手动在集群里预建 PV大概是很多K8s管理员最早接触存储的方式。写一个 pv.yaml里面填上 capacity、accessModes、nfs server、nfs path再用kubectl create -f pv.yaml扔进集群。单个 PV 这么搞问题不大一旦业务多起来痛点就很明显了。首先是路径和容量很难匹配。PVC 申请的容量是“需求”PV 被绑定前不会自动跟 PVC 匹配大小管理员要提前估量各种需求并建好不同规格的PV。业务方改一个容量底层 PV 可能就匹配不上了得重新人工规划。其次是命名和路径容易乱我给 A 项目建了/srv/nfs/k8s/a给 B 项目建了/srv/nfs/k8s/b看起来有隔离但时间一长某个目录里到底跑的是哪个应用谁都不敢确认。最头疼的是回收Delete 策略的 PV 删除时并不会自动清理 NFS 目录需要人登到 NFS 服务器上手动删这个操作又危险又容易漏。我亲身遇到过一个问题两个环境共用一个 NFS 根目录因为 path 写错A 环境的 Pod 直接挂到了 B 环境的目录上两边数据交叉写在了一起。排查了很久才发现是 PV 配置里的 path 少了一层子目录。从那以后我就明白了存储分配这件事不能靠手工必须让系统按规则自动来。1.2 动态供给的核心思路Kubernetes 其实早就给出了解决方案StorageClass Provisioner。StorageClass 可以理解成一份“存储套餐模板”里面声明了用哪个 provisioner、回收策略是什么、挂载参数有哪些。用户创建 PVC 时只需要写容量和访问模式比如“我要 5GiReadWriteOnce”后台的 provisioner 就会根据 StorageClass 自动完成存储分配和 PV 创建整个过程中不需要任何人去碰服务器。这个机制很像点外卖用户只说自己要什么菜至于后厨怎么备菜、怎么炒、怎么打包都是系统内部的事。Provisioner 就是那个“后厨”它监听 PVC 的创建和删除事件动态地在底层存储上创建出对应的卷再生成 PV 对象让 Kubernetes 完成绑定。用户侧看到的效果就是PVC 创建成功PV 紧随其后自动出现数据卷直接可用了。这样的设计带来了几个看得见的好处存储分配变成自动化服务命名规则可以统一控制容量申请按需精准匹配回收策略可以量化多环境之间也能通过不同的 StorageClass 做隔离。接下来要聊的 nfs-subdir-external-provisioner 4.0.18就是这套机制里最常用的 NFS 实现之一。2. nfs-subdir-external-provisioner 的工作原理与版本选择2.1 工具到底做了什么先把这个工具的定位说清楚。它本质上是一个跑在 Kubernetes 集群里的控制器通过 ServiceAccount 拿到 API 权限监听 PVC 对象的创建、删除事件。当一个新的 PVC 出现时它会做四件事读取 PVC 的命名空间、名称、容量请求等元数据。在 NFS 根目录下创建一个新的子目录子目录名默认是namespace-pvcName-pvName也可以通过 pathPattern 自定义结构。构造一个 PV 对象设置好 NFS server、NFS path、容量、访问模式、回收策略。把这个 PV 提交给 Kubernetes API让调度器完成 PV 与 PVC 的绑定。删除 PVC 时逻辑反过来当 PVC 被删除它收到事件后查看关联 PV 的回收策略。如果策略是 Delete它会删除 NFS 里的对应子目录如果设置了 archiveOnDelete则不会删除目录而是把目录重命名成带-archived-后缀的名字相当于先归档防止误删数据。这个工具最大的价值在于把“分配存储”和“回收存储”这两件人工操作变成了纯自动化流程。业务方申请存储不再需要提工单让运维去建目录建 PV只要在 YAML 里声明 PVC 就行剩下的交给 provisioner。2.2 为什么是 subdir 模式工具名里有个很关键的字眼subdir。它不是把整个 NFS 路径当作一个卷给到 Pod而是在 NFS 共享根目录下按 PVC 动态生成独立子目录每个 PV 对应一个子目录。这样的好处非常明显。第一是隔离性。每个应用的数据在自己的子目录里A 应用写满了目录不会撑爆 B 应用误删清理也只影响单个目录不会把整个 NFS 共享里的数据搞乱。第二是管理的直观性。查看 NFS 服务器上的目录结构就能一眼看出哪些 PVC 还在使用哪些已经归档不需要对比 PV 列表和实际路径。第三是安全性。回收策略 Delete 时删除动作被限定在子目录范围不会误删其他数据。我在生产环境里给多个业务团队共用一台 NFS 服务器用的就是 subdir 模式。团队之间互不可见目录内容每个团队看到的存储跟自己独占一样实际底层共享着 NFS 磁盘池。这种“逻辑隔离、物理共享”的方式对中小规模集群来说性价比非常高。2.3 版本选择与 4.0.18 的关键点nfs-subdir-external-provisioner 由 Kubernetes sig-storage 社区维护镜像地址是registry.k8s.io/sig-storage/nfs-subdir-external-provisioner。我这里用 4.0.18镜像 tag 直接写v4.0.18。选择这个版本的原因很朴素它在我的环境里跑了很久没有出过问题事件上报、重试逻辑、删除卷的处理都正常RBAC 也好配。4.x 版本看起来简单但有几个点要注意。首先它的默认支持 Kubernetes 1.20如果你还在用很老的集群可能要选 3.x 分支。其次是镜像所在仓库早期一些教程用的是quay.io/external_storage/nfs-client-provisioner那是旧版项目仓库已经进入冻结维护新环境不建议再用了。我推荐直接用现在的 sig-storage 官方镜像名字长一点但可靠。最后是 Provisioner 名称需要自己定义并且在 Deployment 的环境变量和 StorageClass 的 provisioner 字段里保持一致这个名称本身只是标识可以随便起但一旦定了就尽量不要改否则会导致旧 PV 无法绑定新 PVC。3. 从零到一的部署实操3.1 NFS 服务端准备与目录导出先准备一台 NFS 服务器。我这边常用 Ubuntu安装 NFS 服务本身就是一条命令的事apt update apt install -y nfs-kernel-server创建一个专门给 Kubernetes 用的共享目录比如/srv/nfs/k8smkdir -p /srv/nfs/k8s chown nobody:nogroup /srv/nfs/k8s chmod 755 /srv/nfs/k8s然后编辑/etc/exports把这个目录共享出去/srv/nfs/k8s *(rw,sync,no_root_squash,no_subtree_check)这里no_root_squash是我特别要提醒的。默认 NFS 导出是 root_squash也就是客户端上的 root 用户会被映射成 nobody 或 anonymous权限会被压得很低。而 Kubernetes Pod 里的进程经常是以 root 运行或者以任意 UID 运行如果服务端默认开启 root_squashPod 在挂载目录里创建文件就会报 Permission denied这是新手最常踩的坑。配置改完后重载导出exportfs -ra在客户端上先验证一下 NFS 能不能挂、能不能写确认没问题再进 Kubernetes 环节。测试办法是随便找一台机器执行mount -t nfs 192.168.1.10:/srv/nfs/k8s /mnt/test touch /mnt/test/hello如果能创建文件说明 NFS 服务端配置正常。3.2 在集群里部署 ProvisionerProvisioner 需要一组 Kubernetes 资源ServiceAccount、ClusterRole、ClusterRoleBinding、Deployment。我把它们放在同一个 YAML 文件里方便部署。apiVersion: v1 kind: ServiceAccount metadata: name: nfs-provisioner namespace: nfs-provisioner --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nfs-provisioner rules: - apiGroups: [] resources: [persistentvolumes] verbs: [get, list, watch, create, delete] - apiGroups: [] resources: [persistentvolumeclaims] verbs: [get, list, watch, update] - apiGroups: [storage.k8s.io] resources: [storageclasses] verbs: [get, list, watch] - apiGroups: [] resources: [events] verbs: [create, update, patch] - apiGroups: [] resources: [endpoints] verbs: [get, list, watch, create, update, delete] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: nfs-provisioner subjects: - kind: ServiceAccount name: nfs-provisioner namespace: nfs-provisioner roleRef: kind: ClusterRole name: nfs-provisioner apiGroup: rbac.authorization.k8s.io --- apiVersion: apps/v1 kind: Deployment metadata: name: nfs-provisioner namespace: nfs-provisioner spec: replicas: 1 selector: matchLabels: app: nfs-provisioner template: metadata: labels: app: nfs-provisioner spec: serviceAccountName: nfs-provisioner containers: - name: nfs-provisioner image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.18 volumeMounts: - name: nfs-root mountPath: /persistentvolumes env: - name: PROVISIONER_NAME value: k8s-sigs.io/nfs-subdir-external-provisioner - name: NFS_SERVER value: 192.168.1.10 - name: NFS_PATH value: /srv/nfs/k8s volumes: - name: nfs-root nfs: server: 192.168.1.10 path: /srv/nfs/k8s部署前先创建命名空间kubectl create namespace nfs-provisioner kubectl apply -f nfs-provisioner.yaml这个 Deployment 里最核心的两个环境变量是PROVISIONER_NAME和NFS_PATH。PROVISIONER_NAME必须是你在 StorageClass 里写的 provisioner 名NFS_PATH必须和 NFS 导出的路径一致。容器把 NFS 根目录挂载到/persistentvolumes所以它在这个挂载点下创建的所有子目录最终都会落到 NFS 服务器的实际目录里。检查部署是否正常kubectl get pods -n nfs-provisioner如果 Pod 一直是 Running日志里没有任何报错就可以创建 StorageClass 了。3.3 创建 StorageClass 并配置关键参数StorageClass 是 PVC 和 Provisioner 之间的桥梁。下面是我常用的配置apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: true pathPattern: ${.PVC.namespace}/${.PVC.name} reclaimPolicy: Delete volumeBindingMode: Immediate mountOptions: - vers4.2 - noatimeprovisioner 字段必须和 Deployment 里的PROVISIONER_NAME完全一致。pathPattern表示每个 PVC 对应的子目录结构我写的是${.PVC.namespace}/${.PVC.name}也就是在 NFS 根目录下按“命名空间/PVC名称”建目录。这样目录结构一目了然比如 nginx 应用在 default 命名空间里申请了一个叫 data 的 PVC那么实际目录就是/srv/nfs/k8s/default/data。archiveOnDelete: true的含义是当 PVC 被删除时NFS 里的子目录不直接删掉而是重命名成archived-前缀目录。这个配置对生产环境非常重要因为误删 PVC 后数据还有机会找回。reclaimPolicy: Delete则是告诉 KubernetesPV 也随之删除。这两者配合起来逻辑是PVC 删了PV 删了但 NFS 目录保留并归档。mountOptions里的vers4.2指定了 NFS 协议版本noatime可以减少写盘时的访问时间更新。这些参数看实际 NFS 环境而定后面会有专门一节展开讲。3.4 验证自动创建持久化卷的完整链路StorageClass 创建好了直接测试整个链路。先申请一个 PVCapiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc namespace: default spec: accessModes: - ReadWriteOnce resources: requests: storage: 2Gi storageClassName: nfs-client执行创建kubectl apply -f test-pvc.yaml然后观察 PVC 和 PV 状态kubectl get pvc test-pvc kubectl get pv | grep test-pvc正常的话PVC 会从 Pending 变成 BoundPV 会自动创建出来。到 NFS 服务器上看一眼目录ls -l /srv/nfs/k8s/default/test-pvc你会发现目录已经建好了并且里面的属主、权限和 NFS 导出配置一致。这就说明 Provisioner 已经完成了自动创建卷的工作。为了真正验证我习惯再部署一个 nginx 测试应用把 PVC 挂进容器里写一个文件apiVersion: apps/v1 kind: Deployment metadata: name: nginx-test spec: replicas: 1 selector: matchLabels: app: nginx-test template: metadata: labels: app: nginx-test spec: containers: - name: nginx image: nginx:latest volumeMounts: - name: data mountPath: /usr/share/nginx/html ports: - containerPort: 80 volumes: - name: data persistentVolumeClaim: claimName: test-pvc进入容器写入文件kubectl exec -it nginx-test-xxx -- bash echo hello nfs /usr/share/nginx/html/index.html再回到 NFS 服务器上查看文件确实已经写到对应的子目录里了。到这里整套动态供给链路跑通了PVC 创建PV 自动生成NFS 子目录自动创建Pod 可以正常读写。4. 参数调优与最佳实践4.1 archiveOnDelete 与 reclaimPolicy 的配合关系这两个参数经常让人搞混。reclaimPolicy控制 PV 对象的生命周期archiveOnDelete控制 NFS 子目录的数据是否保留。它们不是一回事但互相影响。我整理了一张配合表方便你根据场景选择reclaimPolicyarchiveOnDeletePVC删除后 PV 状态NFS 目录的行为DeletefalsePV 被删除子目录同时删除数据不可恢复DeletetruePV 被删除子目录重命名为 archived- 前缀数据保留RetainfalsePV 变成 Released子目录保留需要人工处理数据和 PVRetaintruePV 变成 Released子目录保留但不会自动加 archived- 前缀需要人工介入我自己的习惯是生产环境尽量用reclaimPolicy: DeletearchiveOnDelete: true。这样 PV 不会被残留对象阻塞NFS 目录又不会因为误删直接丢数据。归档目录只需要定期用 crontab 清理比如保留 7 天超过就删除。这样比 Retain 模式省心因为 Retain 模式下 PV 会一直处于 Released 状态Kubernetes 不会自动复用容易累积一堆僵尸 PV还得手动去删 PV 对象。如果你在测试环境想省磁盘空间那就设archiveOnDelete: false删除 PVC 后连目录一起清掉干净利落。4.2 pathPattern 的目录规划技巧pathPattern 支持几种占位符最常见的是${.PVC.namespace}和${.PVC.name}也可以使用${.PV.name}。通过不同组合可以定制不同的目录结构。单团队项目可以用${.PVC.namespace}作为顶层目录下面再由 Provisioner 自动拼接名字。多租户环境我用的是${.PVC.namespace}/${.PVC.name}这样每个 PVC 的目录都有完整上下文先按命名空间分租户再按 PVC 名分应用。还有一种场景同一个命名空间里多个应用共用存储不分目录容易乱那可以再加一层固定前缀比如app/${.PVC.namespace}/${.PVC.name}。这里有个细节需要注意pathPattern 里的占位符不要带空格YAML 里最好用双引号包起来否则某些情况下会被解析成字符串里的空格。例如${.PVC.namespace}/${.PVC.name}是安全的但如果写成${.PVC.namespace} / ${.PVC.name}生成的目录名里就会带空格后面挂在 Pod 上很容易出诡异问题。4.3 mountOptions 与 NFS 挂载性能StorageClass 里的 mountOptions 会直接传给节点上的挂载命令。这块调优空间不小但前提是你得清楚自己 NFS 服务端的版本和能力。我常用的 options 是mountOptions: - vers4.2 - rsize1048576 - wsize1048576 - hard - noatimevers4.2需要 NFS 服务端支持如果老版本内核只支持 NFSv3强行指定会挂载失败。rsize/wsize设置读写块大小NFSv4.2 通常支持 1MiB 的块能明显减少小文件读写时的 RPC 次数吞吐提升比较可观。hard是硬挂载网络抖动时进程会卡住而不是返回 I/O error对数据库类应用更友好但要注意如果 NFS 长期不可达Pod 进程会一直阻塞这时得靠上层监控报出来。noatime可以降低元数据写回适合大量读的场景。还有actimeo可以调目录缓存时间但修改后可能带来数据一致性延迟不建议默认开。我通常先按上面的配置跑再用 fio 或 dd 实测一下读写带宽根据业务特性再微调。如果你的 Pod 对 NFS 的延迟很敏感建议在 StorageClass 里禁止使用 soft 挂载因为 soft 模式在网络故障时会返回错误给应用很多应用处理不了这种异常 I/O反而比卡住更危险。4.4 NFS 权限问题的本质与解决思路NFS 权限问题几乎是每个用这个工具的人都会遇到的坎。表面现象是 Pod 能挂载但一写文件就Permission denied或者挂载时直接失败。根本原因有两层。第一层是 NFS 导出配置里的 root_squash。服务端默认把客户端的 root 映射为 nobody即使 Pod 内进程是 root写目录时也没有权限。解决办法是在/etc/exports里给共享目录加no_root_squash让客户端 root 保留 root 权限反过来如果你出于安全考虑不想开 no_root_squash那就得保证 Pod 内进程使用的 UID 在 NFS 目录的属主范围之内。第二层是目录属主和权限。Provisioner 创建的子目录可能属主是 rootPod 里的进程如果以 uid 1000 运行同样没权限写。你可以提前在 NFS 服务器上把/srv/nfs/k8s的属主改成 1000或者用chmod 777临时验证但不建议长期用 777。更稳妥的做法是统一约定所有使用这个 StorageClass 的 Pod 都以某个固定 UID 运行然后让 NFS 根目录属主指向这个 UID。这样 Provisioner 创建的子目录继承了根目录的属主Pod 内进程就能正常读写。5. 常见问题与排查技巧5.1 PVC 一直 Pending事件里报 provisioning failed这种情况八成是 Provisioner 没有正常处理 PVC。先看 PVC 的事件kubectl describe pvc test-pvc常见报错有几个方向。一是unexpected error getting claim ref这通常是 Provisioner 的作者版本和 Kubernetes API 版本不匹配优先检查 Deployment 日志确认是不是 RBAC 权限缺失。二是failed to provision volume with StorageClass nfs-client: ... connection refused这说明 Provisioner Pod 到 NFS 服务器的网络不通先 telnet 一下 NFS 的 2049 端口。三是 NFS 路径在服务器上不存在路径写错再明显不过服务端执行ls /srv/nfs/k8s确认。还有一个隐蔽问题是 PROVISIONER_NAME 不一致。StorageClass 的 provisioner 字段和 Deployment 环境变量只要有一个字符不同PVC 就永远等不到 Provisioner 响应。排查时先把这两个值打出来对比这是我最先查的地方。5.2 删除 PVC 后 PV 处于 Released 状态删掉 PVCPV 没有跟着消失而是停在 Released通常是因为 StorageClass 的 reclaimPolicy 设成了 Retain。这是预期行为但很多人会误以为没回收干净。如果确实要自动回收把 StorageClass 改成 Delete并且确认 Provisioner 在正常监听事件。还有一种情况是 Delete 策略下 PV 被删了但 NFS 目录还在这大概率是你开了 archiveOnDelete。目录名字会变成archived-default-test-pvc-xxx。只需要定期清理归档目录就行。5.3 Pod 挂载目录后权限不足或只读权限问题要从挂载选项和 NFS 导出配置两头查。先用kubectl describe pod看有没有 MountVolume 失败事件再用mount | grep nfs在节点上看实际挂载参数。如果节点上报no such file or directory多半是 path 不对如果报Permission denied优先查/etc/exports里的 squash 配置。我的建议是先在 NFS 服务器上手动模拟一次用相同的路径挂载用非 root 身份写文件。如果手动能写Pod 里不能写那就是 NFS 导出或 UID 映射问题如果手动也不能写那就是 NFS 服务端权限的问题跟 Kubernetes 无关。5.4 NFS 根目录容量爆掉怎么办自动创建卷用起来很爽但容量规划容易失控。NFS 是一个共享目录池底层就是一块大磁盘某个应用写满磁盘所有 PVC 都会受影响。我在生产里遇到过 1TB NFS 磁盘被日志应用写爆结果整个集群所有 NFS PV 进入只读状态。解决办法只能在规划层面做。一是给不同业务建不同的 StorageClass指向不同的 NFS 导出目录或不同磁盘二是在 NFS 服务器上用配额工具限制单个目录大小不过很多文件系统默认不支持得手动配置三是加监控把 NFS 服务器磁盘使用率接入 Prometheus超过阈值就告警。这个工具的日志也值得盯Provisioner 会打印每个卷的创建和删除记录对应 NFS 目录的增长趋势配合看react就能提前发现异常。6. 进阶扩展与日常维护建议6.1 多 StorageClass 隔离不同业务如果你的集群里同时跑着生产、测试、大数据等不同类型的业务就不应该只用一个 nfs-client StorageClass。我的做法是为每个环境或业务线创建一个独立的 StorageClass分别指向不同的 NFS 导出目录甚至不同的 NFS 服务器。比如生产环境用 storageclassnfs-prodNFS 路径是/srv/nfs/prod测试环境用nfs-testNFS 路径是/srv/nfs/test。这样即使某个环境的 Provisioner 配置错误也不会影响另一个环境的数据。底层还是同一套 Provisioner Deployment只不过你可以多部署几个每个使用不同的 PROVISIONER_NAME 和 NFS_PATH。多 StorageClass 的另一个好处是可以调整回收策略。测试环境可以archiveOnDelete: false省磁盘生产环境archiveOnDelete: true保数据。业务方自己选择适合自己的存储套餐运维端不用反复改配置。6.2 日常巡检应该盯哪几个指标工具本身不复杂但涉及 NFS 和网络有些点还是值得每天盯一眼。首先看 Provisioner Pod 是否稳定运行日志里有没有持续的 retry 或认证错误。其次看 PV 数量增长如果一天内新增上百个 PV说明有人在批量申请存储容量趋势要提前关注。最后看 NFS 服务器的负载和磁盘iostat能看出读写是否异常df -h能看到剩余空间。我还会用 cron 定期清理 archived 目录脚本非常简单find /srv/nfs/k8s -maxdepth 1 -name archived-* -mtime 7 -exec rm -rf {} \;注意maxdepth要按实际目录层级设置避免误删到业务数据。6.3 最后分享一点个人体会实际操作中我最大的感受是nfs-subdir-external-provisioner 把 Kubernetes 存储方案的入门门槛降低了一大截但别把“自动创建”等同于“自动运维”。NFS 底层磁盘是共享的没有任何工具能帮你解决容量隔离问题存档策略、权限模型、目录规划这些事还是得在上线之前想清楚。先把一个 StorageClass 跑稳再慢慢扩展多个存储池这才是稳妥的推进方式。