ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K8S中NFS动态存储供给实战:从部署到生产避坑

K8S中NFS动态存储供给实战:从部署到生产避坑

1. 为什么要在K8S里折腾NFS动态供给

很多团队在K8S里跑有状态服务时,第一个绕不开的问题就是存储。容器本身是无状态的,Pod一重启,写在容器里的数据就没了。早期大家最常用的办法是hostPath或者emptyDir,简单粗暴,但问题也很明显:Pod漂移到别的节点,数据就找不到了;多个副本想共享同一份数据,更是无从谈起。

再往后一点,有人开始用PV(PersistentVolume)加PVC(PersistentVolumeClaim)的静态绑定方式。管理员提前创建好一堆PV,用户写PVC去申请。这个方案在小规模场景下能用,但一旦集群里跑了几十个需要存储的服务,管理员就得手动创建几十个PV,还得盯着哪个被用了、哪个空着,维护成本高得离谱。更麻烦的是,用户提交PVC时如果容量、访问模式跟现有PV对不上,PVC就会一直卡在Pending状态,排查起来也费劲。

NFS动态供给就是为了解决这个痛点出现的。它的核心思路是:集群里部署一个Provisioner(供给器),用户只需要提交PVC,Provisioner会自动在NFS服务器上创建对应的目录,并生成匹配的PV,然后自动跟PVC绑定。整个过程不需要管理员介入,PVC提交后几秒钟就能变成Bound状态,Pod直接挂载使用。

这套方案特别适合以下几类场景:中小规模集群里跑有状态应用,比如数据库、日志收集、文件共享服务;团队内部测试环境,需要快速给不同项目分配独立存储空间;以及那些对存储性能要求不是极致、但需要数据持久化和多副本共享的场景。NFS本身是网络文件系统,天然支持多节点同时挂载,读写权限控制也相对成熟,配合K8S的动态供给机制,能省掉大量手工运维工作。

不过我得先说清楚,NFS动态供给不是银弹。它的性能受网络和NFS服务器磁盘IO限制,高并发写入场景下可能成为瓶颈;另外NFS本身是单点,服务器挂了所有挂载它的Pod都会受影响。所以生产环境用这套方案,NFS服务端的高可用和备份策略必须提前规划好。这篇文章我会从零开始,把NFS服务端配置、Provisioner部署、StorageClass定义、PVC动态挂载、常见坑排查整个链路讲透,代码和配置都可以直接抄作业。

2. NFS服务端的搭建与导出目录规划

2.1 选一台合适的机器做NFS Server

NFS服务端不一定非要独立物理机,用一台配置还行的虚拟机或者闲置服务器都行。我的经验是,磁盘IO尽量好一点,因为所有Pod的读写最终都落到这台机器的磁盘上。如果预算有限,至少保证数据盘是SSD,系统盘用机械盘无所谓。内存和CPU要求不高,2核4G就能跑得很稳,但如果挂载的Pod数量多、并发读写频繁,建议4核8G起步。

操作系统层面,主流的Linux发行版都自带NFS服务端软件包。我习惯用较新的稳定版系统,内核里NFS模块支持比较完善。安装之前先确认一下系统是否已经装了nfs-utils或者nfs-kernel-server,不同发行版包名不一样。用rpm -qa | grep nfs或者dpkg -l | grep nfs查一下,有的话就不用重复装。

网络方面,NFS服务端和K8S集群节点最好在同一个内网网段,延迟低、带宽足。如果跨网段,记得防火墙要放行NFS相关端口。NFS默认用2049端口做数据传输,但辅助服务mountd、rpcbind会随机用一些端口,所以生产环境建议在配置文件里把端口固定下来,方便防火墙规则管理。

2.2 安装NFS服务端并配置导出目录

以常见的Linux发行版为例,安装命令大概是这样的:

# Debian/Ubuntu系 apt-get update apt-get install -y nfs-kernel-server # RHEL/CentOS系 yum install -y nfs-utils

装完之后,创建专门用于K8S存储的目录。我一般会单独挂一块数据盘,挂载到/data/nfs这样的路径下,然后在这个目录里再分门别类。比如:

mkdir -p /data/nfs/k8s chmod 777 /data/nfs/k8s

这里权限给777是为了省事,让Provisioner能直接创建子目录。但生产环境更稳妥的做法是创建一个专用用户,把目录属主改掉,然后在NFS导出配置里做用户映射。不过K8S的NFS Provisioner默认以root身份操作,如果服务端做了root_squash,创建目录时可能会权限不足。所以测试环境我通常先关掉root_squash,生产环境再根据安全要求调整。

接下来编辑/etc/exports文件,配置导出规则:

/data/nfs/k8s *(rw,sync,no_subtree_check,no_root_squash)

这行配置的意思是:把/data/nfs/k8s目录导出给所有网段,读写权限,同步写入,不检查子目录,并且不压缩root用户权限。sync选项保证数据写入磁盘后才返回成功,虽然性能比async差一点,但数据安全性高很多。no_root_squash让客户端的root用户在服务端也保持root权限,这样Provisioner创建目录时不会因为权限问题失败。

配置改完后,让导出规则生效:

exportfs -arv

然后重启NFS服务:

systemctl restart nfs-kernel-server systemctl enable nfs-kernel-server

在K8S节点上验证一下能不能挂载:

showmount -e <NFS_SERVER_IP> mount -t nfs <NFS_SERVER_IP>:/data/nfs/k8s /mnt

能挂上并且能读写文件,说明服务端配置没问题。这一步很关键,很多后续问题都是因为服务端导出配置不对导致的,所以务必先手工验证通过再往下走。

2.3 固定NFS辅助服务端口

前面提到NFS除了2049端口,还会用到mountd和rpcbind的随机端口。如果K8S节点和NFS服务端之间有防火墙,随机端口会被拦掉,导致挂载超时。解决办法是在NFS服务端配置文件里把端口固定。

编辑/etc/nfs.conf或者/etc/sysconfig/nfs(不同发行版路径不同),设置:

[lockd] port=32803 udp-port=32769 [mountd] port=20048 [statd] port=32765 outgoing-port=32766

改完后重启服务,然后用rpcinfo -p确认端口都固定了。防火墙放行2049、20048、32803、32765这些端口。这一步做完,跨网段挂载基本就不会因为端口问题卡住了。

3. 部署NFS Subdir External Provisioner

3.1 Provisioner的工作原理与选型

K8S官方并没有内置NFS动态供给器,社区里用得最多的是nfs-subdir-external-provisioner这个项目。它的工作逻辑很清晰:监听集群里新创建的PVC,一旦发现PVC的StorageClass匹配自己,就在NFS服务端的导出目录下创建一个以${namespace}-${pvcName}-${pvName}命名的子目录,然后生成对应的PV对象,最后把PV和PVC绑定。

这个Provisioner以Deployment形式跑在集群里,通常单副本就够了。它内部用了一个小的控制器循环,通过K8S的API监听PVC事件。因为NFS本身不支持配额管理,所以PVC里申请的容量大小实际上只是个标签,并不会真正限制目录使用空间。这一点要特别注意,别以为申请了10G就只能用10G,实际能写多少取决于NFS服务端磁盘剩余空间。

选型方面,社区还有基于CSI的NFS驱动,功能更丰富,支持快照、扩容等高级特性。但如果只是基础的文件共享和持久化需求,nfs-subdir-external-provisioner足够用,部署简单,维护成本低。我下面就以这个方案为例来展开。

3.2 用Helm快速部署Provisioner

手工写一堆YAML比较繁琐,推荐用Helm Chart来部署。先添加仓库:

helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm repo update

然后创建一个values文件,把NFS服务端地址和导出路径填进去:

nfs: server: <NFS_SERVER_IP> path: /data/nfs/k8s storageClass: name: nfs-client defaultClass: false reclaimPolicy: Delete archiveOnDelete: true

这里几个参数值得说明一下。reclaimPolicy: Delete表示PVC删除后,对应的PV和NFS目录也会被清理掉。archiveOnDelete: true是个很实用的选项,它不会直接删目录,而是把目录重命名成archived-xxx,相当于留了个后悔药。万一误删了PVC,数据还在NFS服务端能找到。生产环境我强烈建议开启这个选项。

执行安装:

helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ -f values.yaml \ -n kube-system

装完后检查Pod状态:

kubectl get pods -n kube-system | grep nfs

看到Running就说明Provisioner起来了。再看一下StorageClass:

kubectl get sc

应该能看到名为nfs-client的StorageClass。如果没看到,多半是RBAC权限或者镜像拉取有问题,用kubectl logs看Provisioner的日志排查。

3.3 手工部署方式与关键配置解读

有些环境不方便用Helm,那就手工部署。核心资源包括:ServiceAccount、ClusterRole、ClusterRoleBinding、Deployment、StorageClass。我重点说Deployment里的几个关键配置。

Provisioner容器启动参数里,--provisioner指定供给器名称,必须和StorageClass里的provisioner字段一致,否则PVC不会触发动态供给。--nfs-server和--nfs-path指定NFS服务端信息。环境变量POD_NAMESPACE用fieldRef注入,让Provisioner知道自己跑在哪个命名空间。

Deployment的volume部分要挂载一个NFS卷,指向服务端的导出目录。这样Provisioner容器内部才能在该目录下创建子目录。挂载配置大概长这样:

volumes: - name: nfs-client-root nfs: server: <NFS_SERVER_IP> path: /data/nfs/k8s

容器里的挂载点通常是/persistentvolumes,Provisioner就在这个路径下创建子目录。StorageClass的YAML里,provisioner字段填k8s-sigs.io/nfs-subdir-external-provisioner,reclaimPolicy按需选Delete或Retain,volumeBindingMode用Immediate就行,NFS不涉及拓扑感知调度。

手工部署的好处是每个字段都看得见,出问题容易定位。Helm部署省事,但排查时得先helm get manifest把渲染后的YAML导出来看。两种方式我都用过,建议新手先手工部署一遍,理解每个组件的作用,之后再上Helm提效。

4. PVC动态挂载的完整实操链路

4.1 创建一个测试PVC并观察绑定过程

StorageClass就绪后,创建一个PVC试试:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc namespace: default spec: accessModes: - ReadWriteMany storageClassName: nfs-client resources: requests: storage: 1Gi

注意accessModes这里写的是ReadWriteMany,因为NFS天然支持多节点读写。如果写ReadWriteOnce,虽然也能绑定,但语义上不太匹配NFS的能力。storageClassName必须和前面创建的StorageClass名字一致。

提交之后,用kubectl get pvc -w盯着看。正常流程是:PVC先变成Pending,Provisioner监听到事件,在NFS目录下创建子目录,生成PV,然后PVC变成Bound。整个过程通常几秒钟。如果超过一分钟还是Pending,就要查Provisioner日志了。

绑定成功后,kubectl get pv能看到自动生成的PV,名字一般是pvc-<uuid>格式。PV的claimRef指向刚才的PVC,nfs字段里的path就是Provisioner创建的子目录路径。到NFS服务端上ls /data/nfs/k8s,应该能看到一个类似default-test-pvc-pvc-xxxx的目录。

4.2 用Deployment挂载PVC验证读写

光绑定成功还不够,得实际挂载进Pod验证读写。写一个简单的Deployment:

apiVersion: apps/v1 kind: Deployment metadata: name: nfs-test-app spec: replicas: 2 selector: matchLabels: app: nfs-test template: metadata: labels: app: nfs-test spec: containers: - name: app image: busybox command: ["/bin/sh", "-c", "while true; do echo $(date) >> /data/out.txt; sleep 5; done"] volumeMounts: - name: nfs-vol mountPath: /data volumes: - name: nfs-vol persistentVolumeClaim: claimName: test-pvc

这个Deployment跑两个副本,每个副本每5秒往/data/out.txt追加一行时间戳。因为两个副本挂的是同一个NFS目录,所以它们会往同一个文件里写。等一会儿进Pod里看:

kubectl exec -it <pod-name> -- cat /data/out.txt

应该能看到时间戳在不断增加,而且两个副本写入的内容混在一起。这就验证了NFS的ReadWriteMany能力。如果只有一个副本能写、另一个报权限错误,那多半是NFS服务端导出配置里root_squash没关,或者目录权限不对。

4.3 删除PVC时数据到底去哪了

这是很多人容易踩坑的地方。前面StorageClass里配了reclaimPolicy: Delete和archiveOnDelete: true。当你删除PVC时,PV会被删除,但NFS目录不会被真删,而是被重命名为archived-<原目录名>。你可以到NFS服务端确认:

ls /data/nfs/k8s/ # 会看到 archived-default-test-pvc-pvc-xxxx 这样的目录

如果archiveOnDelete设为false,那目录就直接被删了,数据找不回来。所以生产环境务必开启归档。另外,如果reclaimPolicy设为Retain,PV会变成Released状态,目录保留原名,但PV不能被新PVC复用,需要管理员手工清理。三种组合的行为差异挺大,建议在测试环境都试一遍,心里有数。

还有一个细节:删除PVC时如果还有Pod在挂载使用,PVC会卡在Terminating状态,直到Pod被删掉。这是K8S的保护机制,防止数据正在写入时被强制卸载。所以下线服务时,先删Pod再删PVC,顺序别搞反。

5. 那些让我熬夜排查的坑与应对方案

5.1 PVC一直Pending的几种典型原因

PVC卡在Pending是最常见的故障。排查思路要按顺序来:先看StorageClass名字对不对,kubectl get pvc <name> -o yaml看storageClassName字段,如果为空或者拼错,Provisioner根本不会理它。再看Provisioner Pod是否正常运行,kubectl logs看有没有报错。常见错误包括:连不上NFS服务端、创建目录权限不足、RBAC权限不够。

连不上NFS服务端的情况,日志里会显示mount failed或者connection timed out。这时候先在Provisioner Pod里手动挂载测试:kubectl exec进去,mount -t nfs <server>:/path /mnt,看报什么错。如果是端口不通,检查防火墙和端口固定配置;如果是权限拒绝,检查/etc/exports里的客户端网段限制。

权限不足的报错通常是mkdir permission denied。这是因为NFS服务端做了root_squash,而Provisioner以root身份创建目录被映射成了nobody。解决办法是在exports里加no_root_squash,或者把导出目录权限放开。我一般测试环境直接chmod 777加no_root_squash,生产环境再收紧。

5.2 挂载成功但写入报Read-only file system

这个坑我踩过好几次。Pod挂载PVC成功,但一写文件就报只读。原因通常是NFS服务端导出目录的权限问题,或者exports配置里写成了ro。检查/etc/exports,确认是rw不是ro。另外,如果NFS服务端磁盘满了或者文件系统变成只读(比如磁盘故障),也会导致客户端写入失败。用df -h和mount命令确认服务端磁盘状态。

还有一种情况是SELinux在捣乱。某些发行版默认开启SELinux,会阻止NFS客户端写入。临时用setenforce 0测试,如果好了,就说明是SELinux策略问题,需要配置相应的布尔值或者策略规则。生产环境不建议直接关SELinux,而是加规则放行。

5.3 Provisioner Pod反复重启的排查

Provisioner Pod如果一直CrashLoopBackOff,先看日志。常见原因是镜像拉取失败,国内环境拉registry.k8s.io的镜像可能超时,需要提前把镜像同步到私有仓库或者用镜像加速。另一个原因是RBAC配置不全,Provisioner没有权限监听PVC和创建PV,日志里会报forbidden。检查ClusterRole里是否包含了persistentvolumes的create、delete、list、watch权限,以及persistentvolumeclaims的get、list、watch、update权限。

还有一个隐蔽的问题:Provisioner的Deployment里挂载NFS卷时,如果NFS服务端地址写错或者网络不通,Pod会一直卡在ContainerCreating。用kubectl describe pod看Events,会显示FailedMount。这时候回到NFS服务端确认服务是否正常,网络是否可达。

5.4 多副本并发写入的数据一致性问题

NFS本身提供的是最终一致性,不是强一致性。多个Pod同时往同一个文件追加内容,在低并发下一般没问题,但高并发时可能出现内容交错或者部分丢失。如果业务对数据一致性要求高,建议每个Pod写自己的独立文件,而不是共享同一个文件。比如用Pod名字或者IP作为文件名后缀,避免写冲突。

另外,NFS的锁机制在某些场景下不太可靠,尤其是跨多个NFS客户端做文件锁时。如果应用依赖文件锁做互斥,最好换成基于数据库或者Redis的分布式锁,别依赖NFS锁。这个坑在跑一些老式应用时特别容易遇到,表现是偶尔出现两个进程同时写同一份数据,导致文件损坏。

6. 生产环境落地时的容量与性能考量

6.1 NFS服务端磁盘容量规划与监控

因为NFS动态供给不限制单个PVC的实际使用量,所以服务端磁盘很容易被某个失控的应用写满。一旦磁盘满,所有挂载该NFS的Pod都会写入失败,影响面很大。我的做法是给NFS数据盘单独分区,并且设置监控告警,使用率超过80%就通知。监控可以用Node Exporter采集磁盘指标,配合Prometheus和Alertmanager做告警规则。

容量规划上,先估算所有有状态服务的总存储需求,然后留至少30%的余量。比如预计总共需要500G,那就准备至少700G的磁盘。如果增长快,可以考虑用LVM做动态扩容,或者直接上分布式存储方案替代NFS。但分布式存储运维复杂度高,小团队用NFS加监控就够了。

6.2 提升NFS读写性能的几个实用参数

NFS挂载参数对性能影响很大。K8S的NFS Volume默认挂载参数比较保守,可以在StorageClass的mountOptions里调整:

mountOptions: - nfsvers=4.1 - rsize=1048576 - wsize=1048576 - hard - timeo=600 - retrans=2 - noresvport

rsize和wsize设成1M可以提升大文件传输吞吐。nfsvers=4.1比3版本在并发和安全性上更好。hard表示挂载操作会一直重试而不是报错返回,避免网络抖动导致应用看到IO错误。noresvport让客户端使用非保留端口,某些NFS服务端配置下必须加这个才能挂载成功。

不过这些参数不是万能的,得根据实际网络和负载调。我一般先用默认参数跑一轮基准测试,记录IOPS和吞吐,然后逐项调整对比。用fio或者dd做简单测试就行,别搞太复杂。

6.3 NFS服务端高可用与数据备份思路

NFS服务端是单点,挂了整个存储层就不可用。提升可用性有两个方向:一是用DRBD加Keepalived做双机热备,主备切换时VIP漂移,客户端重新挂载;二是直接用云厂商的NAS服务,底层做了高可用,省心但成本高。小团队如果预算有限,双机热备方案比较现实,但切换过程中会有短暂中断,应用要能容忍。

数据备份方面,NFS服务端上的数据目录要定期备份到另一台机器或者对象存储。可以用rsync做增量同步,或者用restic、borg这类工具做去重加密备份。备份策略根据数据重要程度定,核心数据每天全备加每小时增量,非核心数据每天一次就行。备份完一定要做恢复演练,不然真出事的时候发现备份不能用就尴尬了。

7. 从静态PV到动态供给的迁移经验

7.1 存量静态PV如何平滑过渡

如果集群里已经有一批静态PV在用,想迁移到动态供给,不能直接删了重建,得考虑数据迁移。我的做法是:先部署好Provisioner和StorageClass,新业务一律用动态PVC。存量静态PV继续保留,等对应服务下次发版或者维护窗口时,再逐步迁移。

迁移单个服务时,先把老PV里的数据用rsync或者cp拷到新创建的动态PVC挂载目录里,然后改Deployment的PVC引用,重启Pod验证数据完整。确认没问题后,再删掉老PVC和PV。整个过程可以在线做,但最好选业务低峰期,避免数据拷贝影响性能。

7.2 多命名空间下的存储隔离

动态供给默认所有命名空间共用一个StorageClass,PVC名字在不同命名空间可以重复,Provisioner创建的目录会带上命名空间前缀,所以不会冲突。但如果想给不同团队分配不同的NFS导出目录,可以创建多个StorageClass,每个指向不同的NFS路径。比如给开发团队用/data/nfs/dev,给测试团队用/data/nfs/test,通过StorageClass名字区分。

RBAC层面也可以做限制,让某个命名空间只能使用特定的StorageClass。不过这需要额外配置,K8S原生不直接支持StorageClass级别的命名空间隔离,得靠准入控制器或者策略引擎来实现。小规模场景下,靠命名规范和人工约定就够了,不必过度设计。

7.3 日常运维中值得固化的几个检查项

跑了一段时间后,我总结了几条日常检查清单,每周花几分钟过一遍,能避免大部分突发故障。第一,确认Provisioner Pod状态正常,没有重启记录。第二,检查NFS服务端磁盘使用率,超过阈值就清理归档目录。第三,看有没有长期处于Pending或者Terminating状态的PVC,及时处理。第四,验证备份任务是否按时完成,备份文件大小是否正常。

这些检查项可以写成脚本定时跑,输出结果发到运维群或者邮件。自动化程度不用太高,关键是形成习惯。存储这东西,平时不出事,一出事就是大事,提前花点时间巡检,比事后救火划算得多。

8. 写在最后的一点个人体会

这套NFS动态供给方案我在好几个集群里用过,从测试环境到中小规模生产环境都跑得挺稳。它最大的价值是把存储分配这件事从手工操作变成了自助服务,开发同学提交PVC就能用,运维不用再当人肉PV工厂。但前提是NFS服务端要配好、监控要到位、备份要可靠,这三样缺一个都可能埋雷。

另外提醒一句,NFS终究是网络存储,延迟比本地盘高一个数量级。对IO延迟敏感的应用,比如高频交易或者实时数据库,还是老老实实用本地SSD或者专业块存储。NFS适合的是那些对延迟不敏感、但需要共享和持久化的场景。选型的时候想清楚业务需求,别为了图省事把不合适的负载硬塞进来,后面性能问题会让你更头疼。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进