1. NFS动态供给的整体思路与应用场景
1.1 为什么需要PVC自动生成PV
用过Kubernetes的老哥都知道,写PV和PVC的YAML是最烦人的事情之一。尤其是集群里跑着几十个有状态应用,每个应用要独立的存储空间,PV得一个一个手写,PVC还得小心别写错storageClassName,一旦哪个字段没对上,PVC就永远Pending在那里,查半天才发现是存储类名字写错了一个字母。
动态供给(Dynamic Provisioning)解决的就是这个问题。它的核心逻辑是:你不再需要预先创建PV,只需要在PVC里指定一个StorageClass,集群里的Provisioner组件会自动根据PVC的请求(容量、访问模式)去存储后端创建存储卷,然后自动生成PV并绑定到PVC上。整个过程对用户完全透明,写PVC的人根本不用关心PV长什么样,就像你去食堂吃饭不用关心后厨是怎么备菜的一样,点单就完了。
NFS是很多中小团队的首选存储后端,原因很简单:便宜、好维护、不需要额外买商业存储设备,能凑合用。虽然性能跟Ceph这种分布式存储比有一定差距,但是对于开发环境、测试环境、日志收集这类对IO要求不苛刻的场景,NFS动态供给完全够用。
1.2 动态供给的工作链路解析
在动手配置之前,有必要把整个链路的运行原理梳理清楚。动态供给涉及几个核心组件:
- PVC(PersistentVolumeClaim):用户的存储请求,声明需要多大空间、什么访问模式。
- StorageClass:存储类,定义了使用哪种Provisioner、回收策略、挂载参数等。
- Provisioner:真正干活的组件,它监听集群中PVC的创建事件,然后调用存储后端的API去创建卷。
- NFS Server:实际存储数据的文件服务器。
整个流程是:用户创建PVC → Provisioner监听到新的PVC请求 → 根据StorageClass中的配置,在NFS服务器上创建一个子目录或者独立目录 → 把这个目录封装成一个PV对象 → 自动绑定PVC → Pod正常挂载使用。
我最早用NFS动态供给的时候,碰到的坑主要集中在外部Provisioner的部署方式上。早期版本的NFS Provisioner是以Pod方式跑的,需要一个Deployment加上RBAC授权,还要给Provisioner传NFS服务器的地址和导出路径,版本比较老,很多人卡的版本兼容问题。后来社区把NFS Provisioner整合到csi-driver-nfs中,用CSI插件的方式接入Kubernetes,配置逻辑更规范,容器镜像也不用自己费劲去搞了。
下面我梳理一下两种主流的方案,然后给出一种我个人推荐、实测稳定的落地操作流程。
2. 环境准备与存储服务端配置
2.1 基础环境要求和版本选型
先说环境要求。动态供给这套东西对Kubernetes集群版本没有特别苛刻的限制,但为了保证兼容性,还是建议集群版本不要太老。我自己在1.24到1.32的集群上都验证过,1.26以上用CSI驱动的方式基本没有任何兼容问题,如果还是1.23以下的集群,建议先升级集群再搞存储,别在老旧版本上硬凑。
NFS服务器端,建议用独立的服务器或者虚拟机,不要在Kubernetes集群节点上直接部署NFS服务。我是吃过亏的:把NFS放在其中一个节点上,结果这个节点故障导致整个集群的存储全挂,排查问题的时候定位到存储服务本身也在同一个故障域里,相当被动。
版本方面,NFS服务端用nfs-utils,内核版本要求不影响正常使用,只要内核支持NFSv4协议就没问题。客户端节点需要安装nfs-utils或nfs-common包,这个很重要,如果节点上没有装NFS客户端工具,后面Pod创建的时候会报mount.nfs: command not found,基本上一查一个准。
2.2 NFS服务端安装与目录规划
服务端的安装其实不难,我把常用的几个步骤写一下。
以Linux服务器为例,安装NFS服务:
# CentOS / Rocky Linux / 等红帽系 sudo yum install -y nfs-utils # Ubuntu / Debian sudo apt update && sudo apt install -y nfs-kernel-server安装完成后,需要规划一个父目录作为动态供给的根目录。这个目录规划很有讲究,千万不要直接用根分区或者系统盘。我建议单独挂载一块数据盘,无论是云盘还是物理盘都行,然后格式化挂载到/data目录下。接着在/data下创建一个nfs-share目录,专门用于Kubernetes动态供给创建的目录。
sudo mkdir -p /data/nfs-share sudo chmod -R 0770 /data/nfs-share sudo chown nobody:nogroup /data/nfs-share # 或者 nfsnobody,取决于系统配置/etc/exports导出这个目录,这里有一个细节:导出选项不能写死no_root_squash,除非你知道自己在做什么,否则会有权限放大风险。我一般是这么写的:
/data/nfs-share *(rw,sync,no_subtree_check,no_root_squash)等等,我刚才说不要写no_root_squash,但这里又写了。这里需要解释一下:no_root_squash在这种场景下确实能让NFS动态供给少出一些权限问题,但它的安全性确实存在争议。如果集群环境是内部受信网络、没有公网暴露风险,很多生产团队也用这种配置,属于“权衡之后的取舍”。如果你对安全性要求极高,可以改成默认的root_squash选项,但那样就需要额外考虑目录属主和权限映射,权限问题排查起来会麻烦不少。我的建议是:内部测试环境,用no_root_squash能降低很多麻烦;生产环境,如果安全规范很严,那就规规矩矩用root_squash,然后仔细设计目录权限。
配置完成后重启NFS服务并验证:
sudo systemctl restart nfs-server # 红帽系 sudo systemctl restart nfs-kernel-server # Debian系 sudo exportfs -rav showmount -e localhost2.3 集群节点安装NFS客户端
NFS服务端搞定了,别忘了客户端的依赖。Kubernetes的每个节点都需要安装nfs-utils或者nfs-common,因为最终挂载动作是在节点上完成的,节点上没有NFS工具,后面所有Pod的挂载都会失败。
在节点上批量执行:
# 红帽系 sudo yum install -y nfs-utils # Debian系 sudo apt update && sudo apt install -y nfs-common这个操作一定不要漏。我见过有人服务端配置了半天,PVC也创建好了,PV也有了,但Pod一直ContainerCreating,最后看事件才看到mount.nfs not found,原因就是客户端节点少装了工具。这一步很小,但很致命。
3. StorageClass配置与默认存储类设置
3.1 使用CSI驱动部署NFS Provisioner
现在社区主流的做法是使用csi-driver-nfs这个项目。它由Kubernetes SIG-Storage社区维护,通过CSI接口与Kubelet通信,负责处理卷的创建、挂载、卸载等操作。相比早期那种纯自定义的Provisioner Pod,CSI驱动在生命周期管理、异常处理、可观测性方面都更规范。
部署CSI驱动有两种方式,一种是用Helm,一种是从仓库直接部署YAML。我推荐用Helm,升级方便,配置也直观。先把仓库添加好:
helm repo add csi-driver-nfs https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts helm repo update查看一下可用版本,然后安装。安装的时候有几个关键参数需要关注:
helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs \ --namespace kube-system \ --set driver.name=nfs.csi.k8s.io \ --set controller.name=csi-nfs-controller \ --set controller.replicaCount=1控制器副本数默认1就够了,因为这个控制器主要处理创建、删除卷的操作,又不是什么高并发服务。当然你要想高可用,可以调成2,但要注意RBAC的leader-election支持,不过1已经能应付绝大多数场景。
部署完成之后,确认一下Pod状态:
kubectl get pods -n kube-system | grep csi-nfs正常情况下会看到类似csi-nfs-controller-xxxx的Pod处于Running,以及每个节点上都有一个csi-nfs-node-xxxx的DaemonSet Pod。
3.2 创建StorageClass
CSI驱动就绪之后,关键一步是创建StorageClass。这里要注意,StorageClass的provisioner字段必须和驱动部署时的driver.name保持一致,否则PVC创建的卷请求不会交给这个驱动处理。
我常用的StorageClass配置如下:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-csi provisioner: nfs.csi.k8s.io parameters: server: 192.168.1.100 share: /data/nfs-share mountPermissions: "0770" reclaimPolicy: Delete volumeBindingMode: Immediate mountOptions: - nfsvers=4逐项解释一下:
server:NFS服务器的IP地址,这里写的是内网IP。share:NFS导出的根目录路径,注意不要带斜杠结束。mountPermissions:这个参数会设置NFS目录的权限,如果省略,目录可能会保持默认权限,遇到挂载后无法写入的问题。reclaimPolicy: Delete:删除PVC时自动删除PV和NFS中的目录。如果你想保留数据,改成Retain。volumeBindingMode: Immediate:表示立即绑定,PVC创建后马上触发Provisioner创建卷。还有一种模式是WaitForFirstConsumer,延迟到第一个Pod调度后再创建卷,适合需要拓扑感知的场景,但NFS本身没有拓扑限制,用Immediate没问题。mountOptions: nfsvers=4:强制使用NFSv4协议,避免NFSv3和NFSv4在某些环境下的兼容性差异。
创建StorageClass:
kubectl apply -f storageclass-nfs-csi.yaml3.3 设置默认存储类
如果集群中已经有一个默认存储类,比如云服务商自带的,你可以把某个存储类设置为集群默认,这样用户创建PVC时不写storageClassName也能自动使用NFS动态供给。
设置默认存储类的操作:
kubectl patch storageclass nfs-csi -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'这里有一个坑:如果集群里已经存在默认StorageClass,你新设置的这个不会生效,因为系统发现有多个默认存储类时会拒绝自动填充。你需要先把原来的StorageClass注解去掉:
kubectl patch storageclass <旧的默认存储类名称> -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"null"}}}'注意这里的null是加引号的字符串,不是JSON里的null,删注解用这个方式有效。不过我的建议是,除非你确定要让NFS作为全集群默认存储,否则不要轻易设置默认存储类,因为原默认存储类可能是云盘的,如果原来那些工作负载打算继续使用云盘,你改成NFS后新创建的PVC会全跑到NFS上,性能表现差异会让你怀疑人生。
4. PVC创建与PV自动生成的实测验证
4.1 编写测试PVC并验证自动绑定
配置到这里,真正核心的验证环节就开始了。写一个最简单的PVC,只声明资源请求,不指定storageClassName,前提是你已经设置了默认存储类;或者显式指定storageClassName为nfs-csi。
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-claim spec: accessModes: - ReadWriteOnce storageClassName: nfs-csi resources: requests: storage: 5Gi应用这个PVC:
kubectl apply -f test-claim.yaml kubectl get pvc test-claim正常情况下几秒内PVC就从Pending变成Bound状态。接着看一下PV:
kubectl get pv | grep test-claim你会看到自动生成了一个PV,名字跟手写PV不一样,是一长串随机生成的标识。查看PV的详细定义:
kubectl get pv <pv-name> -o yaml能看到这个PV的spec里设置了persistentVolumeReclaimPolicy、storageClassName、csi节点的driver、volumeHandle等信息。最重要的是,NFS服务器上对应的目录已经被创建出来了。到NFS服务器上看一眼:
ls -la /data/nfs-share/会看到一个类似pvc-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx的目录,这个目录就是动态供给创建出来的。
4.2 挂载到Pod验证读写
PVC绑定成功只是第一步,真正能读写才说明全链路是通的。创建一个测试Pod,把PVC挂载进去:
apiVersion: v1 kind: Pod metadata: name: test-pod spec: containers: - name: app image: busybox command: ["/bin/sh"] args: ["-c", "echo 'dynamic provisioning works' > /data/test.txt && sleep 3600"] volumeMounts: - name: storage mountPath: /data volumes: - name: storage persistentVolumeClaim: claimName: test-claim应用后查看Pod状态:
kubectl apply -f test-pod.yaml kubectl get pod test-pod kubectl logs test-podPod应该很快进入Running状态,日志里不会有任何Error输出。如果一切正常,到NFS服务器上验证文件是否落地:
cat /data/nfs-share/pvc-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/test.txt能读到dynamic provisioning works,那整个动态供给链路就完全打通了。这里补充一个我实际遇到的问题:有的环境下,PVC创建后PV会自动生成,但Pod启动后一直报mount.nfs: Stale file handle,这种情况大概率是NFS服务端重启过、目录句柄端口冲突导致的,重启一下节点上的kubelet或者重新创建Pod通常能解决。
4.3 扩容与回收策略验证
创建完PVC之后,有一个重要验证不能跳过,就是存储扩容。CSI NFS驱动是支持在线扩容的,前提是StorageClass里没有禁止allowVolumeExpansion,并且你在创建时没有设置filesystem层限制。用同样的PVC,修改容量请求:
kubectl patch pvc test-claim -p '{"spec":{"resources":{"requests":{"storage":"10Gi"}}}}'然后查看PVC状态:
kubectl get pvc test-claim容量字段会变成10Gi并显示为FileSystemResizePending或直接扩容完成。注意,实际生效要等Pod重新挂载或者重启后,你说完全无视挂载状态直接扩容,有些环境会报错,需要把Pod删除后重建才能体面完成扩容。这不是驱动的问题,是CSI和Kubelet扩容流程的配合机制就是这样。
再验证回收策略:如果你用的是Delete策略,删除PVC后,PV和NFS目录应该自动被清理。这个能力对开发环境很友好,动态创建的垃圾卷不需要人工清理。但生产环境建议把reclaimPolicy改为Retain,否则一个误删PVC操作会把所有历史数据全部清掉,这个风险是真实存在的。我在某次演练中就误删了一个PVC,数据全没了,虽然是测试环境,但心有余悸。
5. 权限配置与安全防护的注意事项
5.1 目录权限和挂载权限参数的处理
NFS动态供给的权限问题是一个高频踩坑点。很多时候PVC绑定了,Pod也能起来,但应用写入文件时报Permission denied,查半天是目录属主和UID/GID不匹配。
CSI NFS驱动的mountPermissions参数可以给动态创建的目录设置权限,但要注意:它设置的是目录权限,不是所有者,也不是递归处理。如果你的应用容器以root身份运行,问题不大;如果以非root用户运行,可能需要额外处理。
举个例子,容器内应用UID是1000,而NFS目录的所有者是nobody,权限是0770,导致容器内非root用户无法写入。这时候有两个方向:一个是调整Pod的安全上下文,比如在securityContext里设置runAsUser和fsGroup:
securityContext: runAsUser: 1000 fsGroup: 2000这样Kubelet在挂载卷时会把NFS目录的属组调整到2000,并将卷的组权限设置为可读写。但实际表现取决于NFS服务端的配置,如果启用了root_squash,节点上以root身份执行chown可能被映射成nobody,那就无效了。
另一个方向是在NFS服务端对子目录做权限映射,不过动态供给创建的目录没法提前创建,所以这个方案基本不可行。综合来看,最实用的做法是:让应用容器的UID和NFS目录的所有者一致,或者直接用NFS root_squash配合fsGroup调通,这个需要结合你实际的应用镜像来配合。
5.2 针对NFS共享的安全配置
NFS本身是不加密的协议,传输层没有认证机制,如果网络不可信,数据包很容易被截获。这个问题在内部网络里往往被忽略,但当你把Kubernetes集群和NFS服务器放在不同机柜或者跨网段时,就要考虑是否有必要用Kubernetes网络策略限制到NFS的访问,或者考虑用带加密的替代方案。
还有一个小细节——/etc/exports的访问控制。不要把NFS共享暴露给整个网段,而是只放行Kubernetes节点的网段:
/data/nfs-share 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)如果节点跨多个网段,就用多个规则条目分别指定。否则NFS共享暴露在整个内网里,其他机器只要知道路径就能挂载,风险还是不小的。
另外,建议在NFS服务端配置只读或按需导出的选项,不要用星号通配所有来源。这种安全习惯能避免很多莫名其妙的泄露问题。
6. 常见问题排查与运维实录
6.1 PVC状态一直Pending的排查思路
这个现象非常常见,新手老手都会遇到。首先查看PVC的详细描述:
kubectl describe pvc test-claim重点看最后Events内容。我自己遇到的几种典型错误和处理思路:
storageclass.storage.k8s.io/nfs-csi not found:StorageClass没创建成功,或者名字写错了。回去检查StorageClass的metadata.name。provisioning failed: failed to create volume ... no such file or directory:NFS服务端共享目录不存在,或者NFS导出方式不对。到服务端用showmount查看导出路径,和StorageClass的share字段对比。invalid option "nfsvers=4":是mountOptions语法问题,有些系统要求写成vers=4而不是nfsvers=4,这个和NFS客户端版本有关,尽量用nfsvers=4不好用就换成vers=4试试。
6.2 Pod一直ContainerCreating的排查思路
PVC如果已经Bound但Pod起不来,问题和Provisioner没什么关系了,基本卡在节点层。查看Pod描述:
kubectl describe pod test-pod下面几种错误是我遇到过的:
failed to mount volume ... mount failed: exit status 32:节点上NFS客户端工具没装,或者NFS服务端防火墙没放行2049端口。先确认端口通不通:nc -vz <nfs-server-ip> 2049。mount.nfs: Connection timed out:网络不通,检查节点和NFS服务器之间的路由、安全组、防火墙。mount.nfs: access denied by server while mounting:exports配置里没有允许该节点的IP,或者节点IP不在放行网段内。修改exports后执行exportfs -ra生效。
排查这类问题的时候,最忌讳的就是在YAML里瞎改然后反复apply,更重要的是看事件、看日志、看系统挂在NFS服务端的实时连接。
6.3 性能与稳定性调优建议
NFS动态供给用起来之后,性能监控和调优是长期工作。几个参数值得关注:
NFS服务端调优:默认的NFS线程数适合小规模并发,如果负载高,可以调整NFS服务进程的数量,在/etc/sysconfig/nfs配置文件里修改RPCNFSDCOUNT参数。不过这个影响的是服务端并发处理能力,一般几十个Pod挂载的话,默认值也够用。
客户端挂载参数调优:StorageClass的mountOptions里可以加一些参数改善表现:
mountOptions: - nfsvers=4 - noatime - nodiratime - hard - intr其中hard和intr组合比较重要,前者表示NFS挂载遇到问题时不放弃重试,后者允许在等待期间用信号中断挂载请求,避免进程卡死。noatime能减少不必要的元数据写入,对读多写少的场景有收益。
监控和告警:NFS的动态供给本质上是把存储的生命周期管理自动化了,但你还是要监控NFS服务器的磁盘容量。因为Provisioner在创建目录的时候不会检查父目录剩余空间,写满后所有动态卷都会出问题。建议对/data/nfs-share做容量监控,设置阈值告警,比如用到80%就报警。
6.4 数据备份与灾备思路
动态供给会让人产生一个错觉:数据好像自动管理了就更安全了。其实恰恰相反,PVC一删,配合Delete策略,数据就没了。所以一定要有备份方案。
我在团队里推的做法是:对于重要的有状态应用(数据库这类),不用动态供给,而是用静态PV绑定到独立NFS目录,同时配置定期的文件级备份到异机。动态供给更多用于无状态化改造过程中的临时磁盘、日志存储、缓存目录这类对数据持久性要求不高的场景。
如果你确实要备份动态卷的数据,可以通过挂载PVC到备份Pod,然后用rsync同步到其他存储,或者给NFS服务器本身的目录做快照。这个要看你的NFS后端是什么类型,如果只是普通的Linux文件系统上的共享目录,那就用传统的文件拷贝方式。
7. 实操总结与个人体会
整套NFS动态供给搞下来,最大的感受就是:Kubernetes这套存储抽象确实解决了手工维护PV的痛点,但它的学习曲线是前置的,你得先理解StorageClass、Provisioner、PVC绑定之间的协作关系,不然出了错会很懵。
按照本文这套步骤操作,从零到一个可用的NFS动态供给环境,熟练的话大概半小时能搞定。不熟练的话也不用慌,把每个环节的验证步骤都走一遍,基本不会出大问题。我自己的经验是:先从最基础的配置跑通,再逐步增加安全加固和性能优化,不要一上来就把所有参数都堆上去,否则出了问题都不知道是哪个配置引起的。
最后再分享一个小技巧:如果你在创建PVC后发现PV没有自动生成,可以先查Provisioner的日志,用kubectl logs -n kube-system <csi-nfs-controller-pod> -c nfs-plugin,日志里会明确告诉你是哪一步出了问题,比你在那里瞎猜YAML快得多。动态供给本来就是为了省事,排查手段也得跟上节奏。