☰
Helm部署NFS动态供给:K8s存储自动化实践
2026/10/18 4:30:22 网站建设 项目流程

刚看到这个标题的时候,我盯着“hlem”愣了两秒——这拼写,一看就是手快把Helm敲反了。但这反而挺真实,实际部署K8s存储时,类似的低级失误往往比想象中的多。今天要聊的这套东西,就是用Helm在K8s集群里安装NFS存储,核心组件是nfs-subdir-external-provisioner。它能解决的问题很直接:让开发者不用每次先手工建PV、再绑PVC,而是像用公有云存储一样,直接申请一块PVC就能拿到可用空间。这篇文章把选型思路、工作原理、安装参数和实际踩坑整条链路串起来讲,适合刚接手集群存储的运维,以及被NFS静态供给烦透的开发同学。

1. 为什么选nfs-subdir-external-provisioner:三种接入方式的取舍

1.1 静态PV、外部Provisioner和NFS CSI之间的区别

K8s要用上NFS,摆在面前的路有三条。最简单的做法是在集群里手工创建一个静态PV,把NFS服务器上的某个目录直接声明成PV,然后让PVC去绑定。这套做法适合目录数量固定、容量需求稳定的场景,比如日志归档目录、只读模板目录。缺点也很明显,每次新增应用需要新目录时,都要有人手工去NFS服务器上mkdir,再回集群里写PV和PVC的YAML。应用多了以后,这种重复劳动会占据大量维护时间。

第二条路就是本文的主角,通过Helm部署nfs-subdir-external-provisioner。它是一个外部存储供应器,启动后会一直监听集群里的PVC请求。一旦发现有PVC创建,并且这个PVC绑定到的StorageClass指向NFS供给器,它就会自动在NFS服务端的共享根目录下创建一个子目录,同时自动创建对应的PV并完成绑定。整个过程只需要用户提交一个PVC清单,剩下的目录创建、PV注册、绑定动作全部自动完成。

第三条路是用NFS CSI驱动,这是当前K8s社区推荐的NFS接入方式,功能也更丰富,比如支持卷快照、容量调整等。但CSI驱动对NFS服务端和客户端的版本要求更高,部署参数更细,在资源有限或者只想快速解决“开发环境动态存储”这类需求时,反而显得重。我在实际对比中得出的结论是:如果集群版本不是太老,且团队后续确实需要快照能力,直接上CSI是正路;如果眼下只是为了给应用提供一个能动态申请的NFS存储,nfs-subdir-external-provisioner足够用,而且维护成本极低。

1.2 动态供给带来的实际收益

动态供给最直观的好处,是把申请存储从“运维手工操作”变成了“开发自助提交”。在没有这个组件之前,开发提需求,运维要先去NFS服务器创建目录,再回来写PV清单,除了给目录取名字,还得保证NFS导出权限、目录属主、PV的访问模式都正确。只要漏掉一个环节,Pod就会一直处于ContainerCreating状态。

有了Provisioner之后,开发只需要提交一段标准的PVC声明,指定storageClassName为nfs-client,然后等几秒钟,PVC就会变成Bound状态。后续POD直接引用这个PVC即可。如果PVC使用完被删除,Provisioner根据配置决定是删除底层数据还是先归档,这个能力在测试环境非常实用——每次想重置环境,直接删PVC就能清掉应用数据,不用再登进NFS服务器手动清理目录。

1.3 与官方NFS插件等方案的关键差异

很多人在第一次接触NFS动态供给时,会搜到K8s仓库里那个已经停止维护的nfs-client-provisioner。它和nfs-subdir-external-provisioner在思路上是同一脉,但前者老旧,代码归档时间很久,用的依赖也偏旧。新集群里直接用它,有可能在K8s版本升级后出现权限或者RBAC兼容问题。nfs-subdir-external-provisioner是社区里在持续维护的替代品,Chart包更新及时,参数命名也清晰,还支持在values里配置archiveOnDelete这类保留策略。选型时我建议直接看后者,不要在停维护的项目上浪费时间。

2. 环境准备:集群、Helm、NFS服务端一个都不能少

2.1 集群端需要提前确认的几件事

安装Provisioner之前,先在集群侧做一轮检查,能省掉后面一大半排障时间。第一,确认集群版本。一般要求K8s 1.20以上,太老版本会导致部分StorageClass字段处理异常,比如allowVolumeExpansion。第二,确认集群所有Node节点都装了NFS客户端工具。因为Provisioner创建的PV挂载动作,最终发生在调度Pod的节点上,由kubelet执行mount命令。如果某个节点没装NFS客户端,Pod调度到该节点时就会挂载失败。

第三,确认节点上的mount命令支持NFSv4或至少NFSv3。大多数现代Linux发行版都自带NFS内核模块,但有个别精简版系统会缺失。第四,确认集群的kube-apiserver能访问NFS服务器。有些集群有网络策略,比如控制平面和业务网络隔离,如果Provisioner运行在业务节点,它访问NFS走的是业务网络,问题不大;但如果有NetworkPolicy把命名空间隔离了,需要记得放行到NFS服务器的2049端口。

2.2 Helm的安装与常用仓库管理

集群装Helm这一步比想象中简单。在管理节点上下载Helm二进制,放到/usr/local/bin目录,执行helm version确认版本。实际部署时,建议使用3.x版本,因为K8s 1.16之后的API迁移,让Helm 2的Tiller架构变得过时。Helm 3直接没了服务端组件,客户端对接kubeconfig,权限完全由本地kubeconfig控制,逻辑更简单。

安装前,检查一下本地有没有已经配置好的Helm仓库,用helm repo list查看。如果仓库列表为空,执行帮助安装的操作即可。要注意,helm repo add只是把仓库地址写进本地配置,并没有实际下载Chart。后续调helm install时,Helm才去仓库拉取索引并获取Chart包。所以执行完add操作后,最好立刻执行helm repo update,确保拿到最新索引。

2.3 NFS服务端的目录准备与导出配置

NFS服务端是整个链条的地基。我习惯单独拿一台机器做NFS Server,避免在已运行其他业务的服务器上切存储目录,因为权限和安全策略容易被既有配置干扰。假设NFS服务端地址是192.168.1.100,共享根目录是/data/nfs-k8s。先创建目录并设置权限,这里有个细节:目录属主和权限,直接影响Provisioner是否能写入子目录。

如果NFS服务端开启了root_squash,K8s节点上的root用户会被映射成nobody,如果/data/nfs-k8s的属主是root且权限是755,那么nobody用户只能在根目录里看,没权限创建子目录。所以在NFS服务端执行chmod 1777 /data/nfs-k8s,或者把属主改成nfsnobody。实际操作里,用chmod 1777最省事,让任何通过NFS访问的客户端都能在共享目录下创建子目录。然后把导出配置写进/etc/exports:/data/nfs-k8s 192.168.0.0/16(rw,sync,no_subtree_check,no_root_squash)

这里no_root_squash是我个人的偏好,因为Provisioner在子目录里会执行chown和chmod操作,如果root被压缩成nobody,很多权限调整动作会失效。当然,no_root_squash会带来安全风险,只适合在内网环境使用。如果网络环境要求安全隔离,可以把网段限制得更精确,或者不要使用no_root_squash,而是提前建好Provisioner运行时的用户。导出配置写完,执行exportfs -rava生效,再用showmount -e 192.168.1.100验证。

3. 核心原理:子目录动态供给是怎么自动完成的

3.1 PVC、StorageClass和Provisioner之间的协作流程

动态供给的全流程,简单说可以拆成四步。第一步,用户提交PVC清单,清单里指定storageClassName。第二步,名为nfs-provisioner的控制器Pod一直在watch PVC对象,它发现自己负责这个StorageClass出现了一条新的未绑定PVC。第三步,Provisioner调用NFS服务端接口,在共享根目录下创建形如namespace-pvcname-pvname的子目录,并在这个子目录里准备文件系统。第四步,Provisioner自动创建一个PV对象,设置PV的NFS卷路径为该子目录,并把PV与PVC绑定,PVC立刻进入Bound状态。

这套流程的关键在于Provisioner拥有创建、删除、修改PV和StorageClass的权限。安装Chart时,RBAC会自动创建ServiceAccount、ClusterRole和ClusterRoleBinding,把相应权限都绑定好。如果手工部署,很容易漏掉某个权限项,这也是我推荐直接用Helm部署的原因之一,RBAC不会缺项,权限模型已经经过大量环境验证。

3.2 子目录命名规则:如何避免不同命名空间覆盖数据

Provisioner创建子目录时,默认命名格式包含命名空间名、PVC名和PV名。形如${namespace}-${pvcName}-${pvName}。这个命名设计是有意图的。如果不带命名空间,两个不同Namespace下恰好有同名PVC,就可能把子目录撞到一起,导致数据互相覆盖。带上PV名,是为了保证即使同一PVC被删除后重建,新旧PV对应的子目录也不会混淆。

我在测试环境里做过一次验证:创建两个同名的PVC,分别放在test-a和test-b命名空间,最终NFS服务端生成了两个不同的目录,前缀一个test-a,一个test-b。如果当时用的是不带命名空间的命名规则,第二个PVC就会把第一个目录里的数据冲掉。所以除非有专门需求,不要轻易改动这个命名模板。

3.3 回收策略:Delete和Retain到底怎么配合archiveOnDelete

安装Chart时,StorageClass里有两个参数很容易被忽略,一个是reclaimPolicy,另一个是archiveOnDelete。回收策略reclaimPolicy有两个选项。Retain表示PVC删除后,PV对象保留在集群里,底层NFS目录数据不动,需要运维手工介入回收PV。Delete表示PVC删除时,关联的PV对象会被删除,Provisioner会响应删除事件,清理底层NFS子目录。

但archiveOnDelete这个参数可以改变Delete的最终行为。如果reclaimPolicy为Delete,archiveOnDelete为true,那么PVC删除后,Provisioner不会直接清掉NFS子目录,而是把目录重命名为archived-开头的目录,相当于做了一次软删除。这个设计非常有意思,它既给了Delete策略自动化清理的能力,又避免了误删数据。我在测试环境就靠这个参数救回过一份数据库备份。

反过来说,如果reclaimPolicy是Retain,archiveOnDelete不生效,因为Retain本身不会触发Provisioner做任何清理,PV被删后底层数据会一直留着,等运维决定如何处理。理解了这三者关系,后面设置StorageClass时就不会踩到“删PVC数据没了”这种大坑。

4. Helm安装实操:values参数逐个拆解

4.1 添加Chart仓库与命名空间规划

安装用的Chart仓库地址,指向项目维护方发布的Helm仓库。命令如下: bash helm repo add nfs-subdir-external-provisioner https://nfs-subdir-external-provisioner.github.io/charts helm repo update

这段命令没有复杂逻辑,但要注意仓库地址拼写。实际遇到最多的报错是连接超时、仓库地址过期之类,先在网络层面排掉,再执行update。命名空间规划方面,我建议把Provisioner单独放到一个专门的命名空间,比如nfs-provisioner,不要把它混进业务命名空间。这样后续看日志、改配置都方便,即使某个业务命名空间被删除,存储供给器也不会受影响。

4.2 一份可用的values.yaml长什么样

安装时,直接使用官方默认参数可以跑通,但很多环境不具备默认参数要求的条件,比如NFS服务器地址。所以我习惯先写一份values.yaml,把关键参数显式声明出来。以下是一份最小可用配置: yaml nfs: server: 192.168.1.100 path: /data/nfs-k8s mountOptions: - hard - nfsvers=4.2

storageClass: name: nfs-client defaultClass: false reclaimPolicy: Delete archiveOnDelete: true allowVolumeExpansion: true

nodeSelector: kubernetes.io/os: linux

这里的mountOptions,我特意加了nfsvers=4.2,用来避免客户端默认NFS协议版本过低导致的挂载兼容问题。仲裁路径下的灯不亮,先亮,逐项检查,基本能定位到问题。storageClass.name字段决定后续PVC里写的storageClassName,建议统一成有辨识度的名字,比如nfs-client,别写nfs这种太泛的值。

4.3 关键参数对照表

这部分参数在实际部署时用的频率最高,我整理成一张表,方便对着配置改。

参数路径 | 示例值 | 作用与建议 nfs.server | 192.168.1.100 | NFS服务端地址,内网IP nfs.path | /data/nfs-k8s | NFS共享根目录,Provisioner会在其下创建子目录 storageClass.name | nfs-client | StorageClass名称,PVC里必须引用这个值 storageClass.defaultClass | false | 是否将存储类设为集群默认,测试环境可设为true storageClass.reclaimPolicy | Delete | PVC删除后的策略,选Delete并配合archiveOnDelete storageClass.archiveOnDelete | true | 删除PVC时归档子目录而不是直接清空 storageClass.allowVolumeExpansion | true | 允许PVC扩容,需底层NFS支持 storageClass.mountOptions | ["hard","nfsvers=4.2"] | 挂载参数,生产环境建议增加timeo和retrans

实际执行安装的命令: bash helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner -n nfs-provisioner --create-namespace -f values.yaml

安装完成后,用kubectl get pods -n nfs-provisioner确认Pod处于Running状态。如果一直Pending,先检查是否因为nodeSelector没有匹配节点,这个问题在混合架构集群里非常常见。

4.4 参数调整与Helm升级需要注意的点

很多人在安装完Provisioner之后,过段时间想改StorageClass的reclaimPolicy或archiveOnDelete参数。此时直接编辑StorageClass对象的字段是不建议的,因为Helm管理的资源在下次升级时会被覆盖回去,导致配置“回跳”。正确做法是修改values.yaml,然后执行helm upgrade。helm upgrade会更新StorageClass等资源,已经存在并绑定的PVC和PV不会受到直接影响,只会影响后续新建的PVC。

还要特别留意provisionerName参数。这个参数决定了存储供给器对外声称的身份标识。如果多个集群或多次安装用同一个名字,PV对象里记录的provisioner名称会冲突,导致PVC创建时,多个Provisioner同时争抢同一个PVC。在参数表中没写这个值是为了保持简单,实际生产环境建议显式指定一个唯一名字,比如nfs-subdir-external-provisioner集群A。否则默认会用Chart全名和命名空间生成一个名字,也能用,但多环境时就容易混。

5. 从StorageClass到PVC再到Pod:全链路验证方法

5.1 部署第一个测试PVC

安装完Provisioner只是第一步,真正要确定这套存储链路没问题,还得完整走一遍PVC申请和Pod读写。先创建一个测试命名空间: bash kubectl create ns nfs-test

然后写一个PVC声明,文件名为test-pvc.yaml: yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc namespace: nfs-test spec: accessModes: - ReadWriteMany storageClassName: nfs-client resources: requests: storage: 1Gi

这里storageClassName必须和values.yaml里配置的storageClass.name值完全一致。如果填错,PVC会一直Pending。提交清单后,执行kubectl get pvc -n nfs-test,正常情况下几秒钟后STATUS会从Pending变成Bound。

如果一直Pending,别急着删,先用kubectl describe pvc test-pvc -n nfs-test查看事件。常见事件会有两种,一种是provisioning阶段找不到StorageClass,另一种是Provisioner报日志无法连接NFS服务端。无论哪一种,都需要回过去检查对应配置。

5.2 观察自动生成的PV和NFS目录

PVC变成Bound之后,紧接着去看PV对象: bash kubectl get pv | grep nfs-test

输出里应该有一个名字由K8s自动生成的PV,容量是1Gi,StorageClass是nfs-client,Reclaim Policy是Delete。用kubectl get pv -o yaml查看明细,重点看nfs字段,nfs.server和nfs.path应该指向NFS服务端路径下的自动子目录。

为了直观,可以登录NFS服务端,ls /data/nfs-k8s,会看到类似nfs-test-test-pvc-pvc-xxxxx的子目录。目录名里既有命名空间nfs-test,也有PVC名test-pvc,后面一长串是PV的UID。这就是3.2节提到的命名规则在真实环境的验证。如果这一步看到目录生成,说明Provisioner自身的NFS连接、权限、RBAC都已正常。

5.3 用BusyBox Pod写读文件验证持久化

PVC绑定正常不代表Pod读写没问题,因为最终挂载动作发生在kubelet所在节点。先部署一个测试Pod: yaml apiVersion: v1 kind: Pod metadata: name: nfs-write-pod namespace: nfs-test spec: containers:

  • name: busybox image: busybox:1.36 command: ["/bin/sh", "-c", "sleep 3600"] volumeMounts:
    • name: data mountPath: /data volumes:
  • name: data persistentVolumeClaim: claimName: test-pvc

Pod进入Running后,执行kubectl exec -it nfs-write-pod -n nfs-test -- sh,在容器里写一个文件: echo "hello k8s nfs" > /data/hello.txt cat /data/hello.txt

文件能正常读写,说明NFS挂载链路已通。此时回到NFS服务端,可以确认hello.txt已经写进对应的子目录。这一步能验证很多东西,节点上的NFS客户端工具、NFS协议协商、防火墙规则等等。如果这里卡住,问题通常不在Provisioner,而在节点与NFS服务端的网络或防火墙。

5.4 删除Pod后重建,验证数据不丢

最后做一次持久化验证。删除刚才的Pod: bash kubectl delete pod nfs-write-pod -n nfs-test

然后重新创建一个同样的Pod,或者直接用完全相同名字重新apply一次。Pod再次Running后,进入容器执行cat /data/hello.txt,如果内容还在,说明NFS数据在Pod生命周期之外得到了保留。这个验证做完,整个动态存储链路才算真正闭环。

实际环境中,我还会多做一步:删除PVC,再看看NFS服务端目录是否被归档。因为values里configure了archiveOnDelete: true,所以删除PVC后,NFS服务端应该看到目录被改名为archived-nfs-test-test-pvc-pvc-xxxxx。这个过程能帮新人直观理解回收策略的实际效果。

6. 实际操作中最容易踩的坑:权限、路径与回收策略

6.1 NFS权限不足导致Provisioner无法创建子目录

这是我在不同环境里遇到最多的问题,症状是Provisioner Pod状态Running,但PVC一直Pending,日志反复报mkdir permission denied或者input/output error。原因通常有两个,一个是NFS导出目录属主不是nobody,且权限是755,另一个是NFS服务端开了root_squash,而Provisioner以root身份去NFS上创建目录,被强制映射成了nobody,没有写权限。

解决思路分两步。先在NFS服务端确认共享根目录的权限,执行ls -ld /data/nfs-k8s,如果属主是root且权限不是1777,就执行chmod 1777。再检查/etc/exports,把no_root_squash加上,并执行exportfs -rava让配置生效。如果环境不允许no_root_squash,那就需要在NFS服务端创建一个和Provisioner Pod中UID一致的属主,并把这个目录的属主设成它。很多人在第三步挂载时才排查权限,实际在Provisioner创建目录时就该处理。

6.2 多节点集群部分节点挂载失败

另一种常见故障:PVC是Bound,Pod调度到A节点能正常写,但滚动更新后Pod被调度到B节点,就卡在ContainerCreating,describe显示FailedMount,错误是mount: unknown filesystem type nfs。这种问题集中出现在节点底噪不全的集群。大概率是某个新加入集群的节点没装NFS客户端工具。

在Ubuntu系节点执行apt install -y nfs-common,在CentOS系节点执行yum install -y nfs-utils,然后重启节点上的kubelet或者直接让Pod重新调度一次。为了从根本上避免,我在初始化节点时就会把nfs-common这类工具通过起应用模板固化下来,之后新增节点都会自带,不会再出现这种“各节点能力不一致”的问题。

6.3 回收策略与归档参数配合不当,PVC重建后数据冲突

StorageClass的reclaimPolicy为Delete,archiveOnDelete为false时,用户删掉一个PVC后,Provisioner会把底层NFS目录整个删掉。这在测试环境可能省事,但也容易造成事故。比如开发误删了PVC,数据立刻就没有了,没有任何恢复路径。

我的建议是,除非确实需要删除数据,否则在values里把archiveOnDelete设为true。这样即使删除PVC,数据还会以归档目录的形式保留在NFS服务端。在归档目录里看到archived-前缀时,可以手动恢复数据,而不是彻底绝望。恢复的方式也比较简单:把归档目录改回普通目录,再建立一个PVC,通过静态PV去指向这个目录即可。

6.4 换了StorageClass名称导致历史PVC无法匹配

有些人装完Provisioner后,觉得storageClass.name不够响亮,想改成nfs-ssd之类。改完后创建新的PVC没问题,但历史PVC和已经绑定的PV不受影响,仍然指向旧的StorageClass。这时候如果团队里有同学想复用旧PVC,他会发现Pod启动不了,因为PV的StorageClass名称和PVC里的storageClassName不一致。

这个问题不是无法挽回,但需要额外做PVC重建和PV迁移,相当折腾。所以,storageClass.name一旦确定,最好不要随意改动。实在要改,就把它当成一次存储类迁移来做,先在低峰期创建新PVC,把数据拷贝过去,再切换应用,最后回收旧PVC。切忌上线后用着用着突然改名,那会让所有人一起“欣赏”Pending的PVC。

6.5 升级Provisioner时的网络与仓库索引问题

Helm升级Provisioner时,如果helm repo index没更新,会提示找不到指定版本的Chart。执行helm repo update后再upgrade即可。还有就是Offline环境,如果集群无法访问外部Chart仓库,需要提前在有网络的机器上把Chart包拉下来,用helm pull命令保存tgz,再传到离线环境执行helm install -f values.yaml ./chart.tgz。这个方式可以完全绕开仓库访问问题,前提是提前准备好所需版本。

升级完成后,务必检查Provisioner新版本Pod是否正常运行。我遇到过某次升级后,新版本的镜像tag变了,但镜像拉取策略是IfNotPresent,集群里没有新镜像导致ImagePullBackOff。解决办法是手动docker pull或者修改values里的imagePullPolicy为Always,然后再次upgrade。这类零散问题很多,但只要沿着日志和事件一路查,通常半小时内都能定位。

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

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

立即咨询