☰
K8S中NFS动态存储供给实战:从部署到调优
2026/10/21 15:56:27 网站建设 项目流程

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

先把场景说清楚。很多团队在K8S上跑有状态服务时,第一个绕不过去的问题就是存储。容器本身是无状态的,Pod一重建,里面的数据就没了。业务方跑个MySQL、跑个日志收集、跑个文件上传服务,数据必须落到外面。这时候摆在面前的路无非几条:云厂商的块存储、Ceph这类分布式存储、还有最朴素的NFS。

云厂商的块存储好用,但贵,而且跨云迁移时容易被绑定。Ceph功能强,但运维复杂度摆在那,小团队养不起。NFS就不一样了,几乎每家公司都有一台现成的文件服务器,运维成本低,兼容性好,Linux内核原生支持。所以哪怕到了云原生时代,NFS依然是很多中小规模集群里最务实的存储方案。

但问题来了。K8S原生的NFS使用方式是静态供给:你得先手动在NFS服务器上建好目录,然后写一个PV指向这个目录,再写一个PVC去绑定这个PV。业务方要一个10G的空间,你就得手动去NFS上mkdir一个目录,改/etc/exports,重启服务,再写YAML。来十个业务方,你就得重复十次。这活儿干多了谁受得了。

动态供给要解决的就是这个痛点。业务方只写一个PVC,声明我要多大空间、什么访问模式,剩下的建目录、配权限、创建PV、绑定PVC,全部由Provisioner自动完成。这背后靠的是StorageClass加外部Provisioner的组合。NFS本身没有原生的动态供给能力,所以需要借助社区维护的nfs-subdir-external-provisioner来补齐这块。

这篇文章我会把整套流程从零讲透:NFS服务端怎么配、Provisioner怎么部署、StorageClass怎么写、PVC怎么动态挂载、Pod里怎么用、出问题怎么查。每一步我都会说清楚为什么这么做,参数为什么这么填,以及我自己踩过的坑。适合正在给集群补存储能力的中级运维,也适合想搞明白动态供给底层逻辑的开发。

2. 整体方案设计与核心组件拆解

2.1 静态供给和动态供给的本质区别

先把这个概念掰开。静态供给的流程是:管理员预先创建PV,PV里写死了NFS服务器地址和路径,PVC提交后由K8S的绑定控制器去找一个匹配的PV绑上。整个过程PV是"现货",你得提前备好。

动态供给的流程是:管理员只创建StorageClass,声明用哪个Provisioner。业务方提交PVC时,K8S发现这个PVC引用的StorageClass带Provisioner字段,就把请求转给对应的Provisioner。Provisioner收到请求后,去NFS服务器上创建目录,然后反向创建一个PV,再把这个PV和PVC绑定。整个过程PV是"按需生产"。

这个区别带来的直接好处是:业务方自助。业务方提PVC,几秒钟后就能拿到可用的存储,不需要找运维开目录。运维的工作从"每次响应"变成"一次性搭好平台"。

2.2 为什么选nfs-subdir-external-provisioner

社区里做NFS动态供给的Provisioner不止一个,但nfs-subdir-external-provisioner是使用最广、维护最活跃的那个。它的工作模式很朴素:监听PVC事件,在NFS的共享根目录下创建一个以${namespace}-${pvcName}-${pvName}命名的子目录,然后把这个子目录作为PV暴露出去。

它有几个我很看重的特性。第一,支持archiveOnDelete,PVC删除时可以选择把目录归档而不是直接删掉,这对生产环境太重要了,误删PVC不至于把数据一起带走。第二,支持回收策略配置,Retain和Delete都能用。第三,它本身就是一个普通的Deployment,部署简单,不需要额外装CRD。

注意:这个Provisioner是"外部"的,意思是它不在K8S核心代码里,而是以Pod形式跑在集群里,通过ServiceAccount和RBAC跟APIServer交互。所以RBAC配置错了,它就没法创建PV。

2.3 整体架构和数据流

整个链路是这样的:NFS服务器暴露一个共享目录,比如/data/k8s。Provisioner以Deployment形式跑在集群里,通过一个指向NFS服务器的StorageClass对外提供服务。业务方提交PVC,Provisioner在/data/k8s下建子目录,创建PV,绑定PVC。Pod通过PVC挂载,最终落到NFS的具体子目录上。

这里有个关键点:Pod挂载NFS时,是kubelet直接挂的,不经过Provisioner。Provisioner只负责"建目录"和"建PV对象",真正的挂载动作由节点上的kubelet完成。所以每个节点都必须能访问NFS服务器,网络不通的话Pod会一直卡在ContainerCreating。

3. NFS服务端的准备与关键配置

3.1 安装NFS服务端软件包

假设你有一台独立的文件服务器,IP是192.168.1.100,系统是常见的Linux发行版。先装软件包。Debian系用apt install nfs-kernel-server,RHEL系用yum install nfs-utils。装完之后确认rpcbind服务是起来的,NFS依赖它做端口映射。

# Debian/Ubuntu apt update && apt install -y nfs-kernel-server # RHEL/CentOS yum install -y nfs-utils # 启动并设置开机自启 systemctl enable --now nfs-server systemctl enable --now rpcbind

装完之后用systemctl status nfs-server确认状态是active。如果起不来,八成是rpcbind没起来,先把它拉起来再试。

3.2 创建共享目录并配置exports

共享目录我习惯放在/data/k8s,单独挂一块盘或者单独一个分区,避免跟系统盘混在一起。建好目录后改权限,这里有个坑:NFS默认会把root用户映射成nobody,如果目录权限是root所有且755,Provisioner建子目录时可能没权限。所以要么把目录owner改成nobody,要么在exports里加no_root_squash。

mkdir -p /data/k8s chown -R nobody:nogroup /data/k8s chmod 777 /data/k8s

然后编辑/etc/exports,加上这一行:

/data/k8s 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)

参数逐个说清楚。rw是读写,sync是同步写入,数据先落盘再返回,安全但慢一点,生产环境建议用sync。no_subtree_check关掉子树检查,能提升兼容性,避免一些奇怪的权限报错。no_root_squash是关键,它让客户端的root用户在NFS上保持root身份,Provisioner建目录时才不会因为权限被拒。

提示:no_root_squash有安全考量,它意味着客户端root对共享目录有完全控制权。生产环境建议配合网络白名单使用,只允许集群节点网段访问。

改完exports后执行exportfs -arv让配置生效,然后用showmount -e localhost确认共享目录已经暴露出来。

3.3 防火墙和网络连通性检查

NFS不是单端口服务,它涉及2049端口加上rpcbind的111端口,还有一堆动态端口。最省事的做法是在集群节点和NFS服务器之间放行整个网段。如果非要精确控制,得固定NFS的端口范围,在/etc/nfs.conf里配mountd和statd的端口,然后在防火墙里放行。

# 简单做法,放行整个网段 firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" accept' firewall-cmd --reload

配完之后一定要在集群的每个节点上测一下showmount -e 192.168.1.100,能列出共享目录才算通。这一步别偷懒,我见过太多Pod卡在ContainerCreating最后发现是节点到NFS网络不通的。

4. 部署nfs-subdir-external-provisioner

4.1 用Helm快速部署

官方推荐用Helm装,最省事。先加repo,然后一条命令搞定。

helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/ helm repo update helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \ --namespace kube-system \ --set nfs.server=192.168.1.100 \ --set nfs.path=/data/k8s \ --set storageClass.name=nfs-client \ --set storageClass.defaultClass=true \ --set storageClass.reclaimPolicy=Retain

几个参数解释一下。nfs.server和nfs.path指向你的NFS服务器和共享目录。storageClass.name是生成的StorageClass名字,我习惯叫nfs-client。storageClass.defaultClass=true把它设为默认StorageClass,这样业务方提PVC不写storageClassName也能用。reclaimPolicy=Retain是回收策略,PVC删除后PV保留,数据不丢。

4.2 手动部署方式与RBAC配置

有些环境不让用Helm,那就手动部署。核心是四样东西:ServiceAccount、RBAC、Deployment、StorageClass。RBAC这块最容易出错,Provisioner需要创建、删除、监听PV和PVC的权限,还需要读StorageClass和Event。

apiVersion: v1 kind: ServiceAccount metadata: name: nfs-provisioner namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: nfs-provisioner-runner 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"]

ClusterRole建好之后用ClusterRoleBinding绑到ServiceAccount上。然后Deployment里指定serviceAccountName: nfs-provisioner,镜像用registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2。环境变量里传NFS_SERVER和NFS_PATH,卷挂载把/persistentvolumes挂进去。

注意:Provisioner的Deployment必须能访问NFS服务器。如果集群有网络策略限制,记得放行kube-system命名空间到NFS的流量。

4.3 StorageClass参数详解

StorageClass是动态供给的核心,参数填错了PVC就绑不上。除了Helm自动生成的,我建议手动写一份,把参数都显式声明出来。

apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-client annotations: storageclass.kubernetes.io/is-default-class: "true" provisioner: k8s-sigs.io/nfs-subdir-external-provisioner parameters: archiveOnDelete: "true" pathPattern: "${.PVC.namespace}/${.PVC.name}" reclaimPolicy: Retain volumeBindingMode: Immediate

archiveOnDelete: "true"是PVC删除时把目录重命名为archived-前缀,而不是直接删。pathPattern控制子目录命名规则,默认是${namespace}-${pvcName}-${pvName},我改成命名空间加PVC名的层级结构,方便在NFS服务器上按业务找目录。volumeBindingMode: Immediate表示PVC一创建就绑定,NFS场景下用Immediate没问题,不像本地盘需要WaitForFirstConsumer。

5. PVC动态挂载与Pod实战

5.1 提交PVC并观察动态供给过程

StorageClass就绪后,业务方只需要写一个PVC。比如要一个5G的存储:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: app-data namespace: default spec: accessModes: - ReadWriteMany storageClassName: nfs-client resources: requests: storage: 5Gi

提交后执行kubectl get pvc -w观察。正常流程是PVC先处于Pending,几秒后变成Bound。这时候去看NFS服务器,/data/k8s/default/app-data目录已经建好了。再看PV,kubectl get pv能看到一个自动创建的PV,名字是一串UUID。

这里有个细节:NFS的容量是"软限制"。你声明5Gi,Provisioner不会真的去限制目录大小,它只是把这个数字写进PV的capacity字段。NFS本身没有配额能力,实际能用多少取决于NFS服务器那块盘的总容量。所以别指望靠PVC的size做隔离,要真限制得在NFS服务端上quota。

5.2 Pod挂载PVC的完整示例

PVC绑好之后,Pod里直接引用就行。下面是一个Nginx挂载存储的例子:

apiVersion: apps/v1 kind: Deployment metadata: name: web-app namespace: default spec: replicas: 2 selector: matchLabels: app: web-app template: metadata: labels: app: web-app spec: containers: - name: nginx image: nginx:1.25 volumeMounts: - name: data mountPath: /usr/share/nginx/html volumes: - name: data persistentVolumeClaim: claimName: app-data

因为NFS支持ReadWriteMany,两个副本可以同时挂同一个PVC,数据共享。这是NFS相比块存储的一大优势,块存储通常只能ReadWriteOnce,多副本挂载会冲突。

5.3 验证数据持久化和多副本共享

挂载完成后,进Pod写个文件,然后删掉Pod,等新Pod起来再看文件还在不在。

kubectl exec -it web-app-xxx -- sh -c "echo hello > /usr/share/nginx/html/index.html" kubectl delete pod web-app-xxx # 等新Pod起来 kubectl exec -it web-app-yyy -- cat /usr/share/nginx/html/index.html

文件还在,说明数据落到了NFS上,跟Pod生命周期解耦了。再验证多副本共享:在副本A里写文件,去副本B里读,能读到就说明RWX生效了。

6. 常见问题排查与避坑经验

6.1 PVC一直Pending的排查思路

这是最常见的问题。排查顺序是:先看PVC的Events,kubectl describe pvc app-data,Events里会写清楚卡在哪。如果提示no persistent volumes available for this claim and no storage class is set,说明PVC没指定StorageClass且没有默认StorageClass。如果提示waiting for a volume to be created,说明Provisioner没响应。

Provisioner没响应的原因通常有三个:Provisioner Pod没起来、RBAC权限不够、Provisioner连不上NFS。依次检查kubectl get pod -n kube-system | grep nfs、kubectl logs看报错、在Provisioner Pod里showmount -e测连通性。

6.2 Pod卡在ContainerCreating的典型原因

PVC是Bound的,但Pod起不来,一直ContainerCreating。kubectl describe pod看Events,如果提示mount failed: exit status 32,基本是节点挂载NFS失败。原因可能是节点没装nfs-utils,或者节点到NFS网络不通,或者exports里没放行这个节点网段。

我遇到过一次,集群新加了一个节点,网段跟老节点不一样,exports里只写了老网段,新节点上的Pod全卡住。后来把exports改成整个大网段就好了。所以exports的网段一定要覆盖所有可能的节点。

6.3 权限问题的几种表现和解决

权限问题很隐蔽。表现是Pod能起来,但写文件报Permission denied。根因是NFS的root squash。默认情况下客户端root被映射成nobody,如果NFS目录owner是root且权限755,nobody就没写权限。

解决办法有两个:一是在exports里加no_root_squash,二是把NFS目录owner改成nobody并给777。我倾向后者,安全一点。另外Pod里如果指定了securityContext.runAsUser,那个UID在NFS上也得有对应权限,否则一样写不进去。

6.4 常见问题速查表

现象可能原因排查命令解决方式
PVC Pending无默认StorageClasskubectl get sc给SC加default注解
PVC PendingProvisioner未运行kubectl get pod -n kube-system检查Provisioner日志
Pod ContainerCreating节点挂载NFS失败kubectl describe pod装nfs-utils、查网络
写文件Permission deniedroot squashshowmount -e加no_root_squash或改owner
PV删除后数据丢失reclaimPolicy=Deletekubectl get pv改成Retain
多副本挂载冲突accessMode=RWOkubectl get pvc改成ReadWriteMany

6.5 几个我踩过的坑

第一个坑是archiveOnDelete和reclaimPolicy的配合。如果reclaimPolicy是Delete,archiveOnDelete是true,PVC删除时PV会被删,但NFS目录会被归档保留。如果reclaimPolicy是Retain,PV保留,目录也不动。我建议生产环境用Retain加archiveOnDelete,双保险。

第二个坑是pathPattern改了之后,老PVC的目录结构不会变,只有新建的PVC才按新规则走。所以改pathPattern要谨慎,最好一开始就定好。

第三个坑是NFS服务器的inode数量。NFS上如果跑大量小文件业务,inode容易耗尽,表现是建目录失败但磁盘还有空间。部署前用df -i看一眼inode余量。

第四个坑是Provisioner的副本数。默认是1副本,如果这个Pod挂了,新的PVC就创建不了。可以调到2副本,但要注意它用的是leader election,多副本只有一个在工作,另一个待命。这个配置在Deployment的--leader-elect参数里控制。

7. 性能调优与生产环境建议

7.1 NFS挂载参数调优

默认挂载参数在大文件场景下够用,但小文件多的时候性能会掉。可以在StorageClass的mountOptions里调。常用的有nfsvers=4.1指定协议版本,rsize=1048576和wsize=1048576调大读写块,hard和timeo=600控制超时重试行为。

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

hard表示NFS服务器无响应时无限重试,保证数据一致性,但可能导致Pod卡住。soft会返回错误,可能丢数据。生产环境建议用hard。

7.2 生产环境的几个硬性建议

NFS服务器一定要做高可用,单点挂了整个集群的存储都受影响。可以用DRBD加keepalived做双机热备,或者直接用商业NAS。共享目录单独挂盘,别跟系统盘混。定期备份,NFS本身不是备份方案。

StorageClass的reclaimPolicy统一用Retain,PVC删除前先确认数据不需要了。给Provisioner配资源限制,避免它异常时吃满节点资源。监控NFS服务器的磁盘使用率、inode使用率、网络流量,这些指标提前告警。

7.3 什么场景不适合用NFS

NFS适合中小规模、对性能要求不极端的场景。如果是高并发数据库、需要低延迟的场景,NFS的网络开销会成为瓶颈,这时候该上本地SSD或者分布式块存储。另外NFS对文件锁的支持在不同版本间有差异,跑依赖文件锁的应用要提前测试。

我个人在实际操作中的体会是,NFS动态供给最大的价值不是性能,而是把运维从重复劳动里解放出来。搭好一次,业务方自助,这才是云原生该有的样子。至于性能,够用就行,真不够用再换方案,别一上来就过度设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询