不少人应该遇到过这种场景:部署一个有状态应用,需要创建一个PVC,结果PVC卡在Pending状态半天没有反应。真正原因通常是集群里没有配置可用的StorageClass,或者预置的PV已经被占满。今天我想把这套NFS动态供给的完整实操记录整理出来,让PVC一旦创建就能自动生成对应的PV。简单说,就是在Kubernetes里搭好NFS动态供给,让存储的申请和分配自动化,省掉重复的人工预置工作量。
NFS动态供给在中小型集群里非常实用。只要有一台NFS服务器,部署一个供给组件,再定义一个StorageClass,之后创建的PVC都会自动在NFS服务端划出目录,并以PV的形式绑定出来。整个过程不需要手动维护PV清单,扩容和回收也灵活很多。适合刚接触Kubernetes存储没多久、想从手工创建PV中解脱出来的开发者,也适合测试环境或内部应用需要快速给Pod挂存储的场景。
1. 为什么PVC能自动生成PV:NFS动态供给到底做了什么
1.1 传统静态PV的痛点
先回忆一下最常见的“静态供给”做法。运维提前在集群里创建好一批PV,每个PV对应后端存储的一块空间,比如NFS里的某个子目录、一个本地磁盘路径,或者云盘快照。创建PV的时候要指定容量、访问模式、回收策略,还要手动调整和PVC的绑定关系。
这种模式在集群规模小、存储类型单一的时候还能凑合。但如果业务方经常需要不同大小的存储,比如一个应用要50Gi,另一个要100Gi,运维就得提前准备各种规格的PV,非常死板。更难受的是,如果PV创建太多,容量超过实际物理存储,集群里就会漂着一堆“僵尸PV”,谁也看不到它们对应哪块真实空间。PVC和PV的绑定也经常踩坑,比如明明有个空闲PV,因为label或accessMode对不上,就是不绑定。
1.2 动态供给的工作原理
动态供给的核心是“StorageClass + 供给控制器”。可以这样理解:StorageClass是一张价格和规格清单,PVC是用户下的订单,供给控制器是仓库管理员,NFS服务器是货仓。
用户创建PVC时,如果指定了StorageClass,Kubernetes就会把需求发给对应的动态供给组件。供给组件监听到PVC事件后,会去NFS服务器上创建一个子目录,比如/data/nfs/default-test-pvc-pvc-xxxx,然后把它包装成一个PV对象写入集群。这个PV会自动关联到刚才的PVC上,状态变成Bound,整个过程不需要人工介入。
这里面有几个关键点:PV的容量取自PVC的requests,StorageClass决定了provisioner类型和回收策略,供给组件通过storage.k8s.io的接口和API Server交互。组件甚至在Pod里以普通工作负载的方式运行,只需要能访问NFS服务器和集群API,权限通过RBAC控制。
1.3 动态供给给集群带来的变化
最大的变化是存储资源的使用从“预分配”变成了“按需分配”。PVC想要多少容量,供给组件就去NFS上创建对应大小的目录,PV和PVC可以一一对应,不会出现闲置的孤PV。
其次是回收逻辑变简单了。PV的回收策略可以直接写在StorageClass里,常见的是Delete和Retain。Delete模式下,用户删掉PVC后,对应的PV也会被删除,后端目录虽不一定被物理删除,但至少不会继续占用集群对象资源;Retain模式则保留PV,方便手动处理数据。动态供给让这个决定在StorageClass维度统一管理,而不是每个PV各自设置。
但要注意,动态供给不是银弹。NFS本身没有强大的容量配额能力,所谓“自动生成1Gi目录”更多是逻辑层面的记录,底层实际用的是NFS共享目录。所以做动态供给之前,务必要先把NFS服务器的磁盘余量考虑清楚。
2. 动手前的环境准备与组件选型
2.1 环境清单与版本要求
我这里用的是Kubernetes 1.20以上版本,所有节点都装了nfs-common,NFS服务器是独立的Linux主机。如果你用的发行版不同,命令可能稍有差别,但整体架构不变。
| 角色 | 要求 | 说明 |
|---|---|---|
| Kubernetes集群 | 1.20+ | 需要支持StorageClass和RBAC |
| NFS服务器 | Linux主机,NFSv4 | 对外提供共享目录 |
| 集群节点 | 安装nfs-common/nfs-utils | 让Pod所在节点可以挂载NFS |
| 网络 | 放行NFS相关端口 | 2049/tcp,rpcbind等 |
版本太老的集群建议先升级,因为动态供给依赖external-provisioner的字段,早期版本对volumeBindingMode和reclaimPolicy的支持不够完善。
2.2 选型:为什么选择NFS而不是其他方案
存储方案非常多,Ceph、Longhorn、Local PV都能做动态供给。但NFS优势在简单,架构干净。Ceph功能强大,但要额外维护MON、OSD组件,故障排查门槛高;Local PV受限于节点,Pod调度要先考虑节点亲和性,对无状态应用不友好;Longhorn虽然体验不错,但需要为每个节点安装驱动和额外存储空间。
NFS比较适合我这种“一个人管集群”的场景。供给组件以Deployment形式跑在集群里,NFS服务器配置也很少,运维成本低。而且很多现有的业务负载和中间件都支持NFS挂载,不需要改造存储接口。
当然,NFS的短板也很明显:有单点风险,性能天花板取决于NFS服务器的磁盘和网络,不建议放高并发数据库,也不建议做跨地域容灾。如果只是做文件存储、日志收集、附件上传,它绰绰有余。
2.3 动态供给组件与StorageClass的对应关系
我们需要部署一个运行在集群内的供给组件,它负责和NFS服务器通信。不同版本的组件可能对外叫“nfs-client-provisioner”或“nfs-subdir-external-provisioner”,本质都是在PVC创建时去NFS服务端做目录操作。我这里统一用nfs-provisioner作为资源名,避免混淆。
StorageClass里的provisioner字段必须和供给组件启动参数PROVISIONER_NAME保持一致,否则API Server会把PVC需求发给不存在的组件,PVC就一直Pending。
另外还要确认volumeBindingMode: Immediate。如果是WaitForFirstConsumer,PVC不会立即绑定,得等到有Pod使用它时才会触发绑定,初次使用可能觉得状态怪怪的。
3. 核心实操:从部署NFS到PVC自动生成PV全流程
3.1 在服务器上准备NFS共享目录
我用的测试NFS服务器IP是192.0.2.10(保留地址段,读者替换成自己环境)。先装软件包,我用的是Debian系:
apt update apt install -y nfs-kernel-server然后建共享目录:
mkdir -p /data/nfs chown nobody:nogroup /data/nfs chmod 777 /data/nfs编辑/etc/exports:
/data/nfs *(rw,sync,no_subtree_check,no_root_squash)这里特别说明一下no_root_squash。这个选项允许Pod里以root身份写入文件时不映射为nobody,否则很多容器镜像里的进程会碰到“Operation not permitted”。但是生产环境要小心,NO_ROOT_SQUASH提升了风险,建议仅在可信内网或测试环境使用。如果不想开,也可以用root_squash配合Pod的securityContext.fsGroup解决部分权限问题。
重启服务并确认共享已导出:
systemctl restart nfs-kernel-server exportfs -rav确保每个Kubernetes节点都装了NFS客户端:
apt install -y nfs-common装完后手动挂载测试一次:
mount -t nfs 192.0.2.10:/data/nfs /mnt ls /mnt umount /mnt能正常ls就说明网络和NFS服务都没问题。我遇到过几次节点上没装nfs-common,结果Pod调度过去后一直ContainerCreating,事件里报“mount: unknown filesystem type 'nfs'”,属于典型的低级坑。
3.2 部署动态供给组件与StorageClass
先建一个命名空间,我习惯放到kube-system,因为它是集群存储基础组件:
kubectl create namespace kube-system # 通常已存在,可跳过创建ServiceAccount和RBAC权限,供给组件需要读取PVC、PV,也要能创建PV和StorageClass。我这里给一份精简的RBAC示例:
apiVersion: v1 kind: ServiceAccount metadata: name: nfs-provisioner namespace: kube-system --- 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: ["list", "watch", "create", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: nfs-provisioner roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: nfs-provisioner subjects: - kind: ServiceAccount name: nfs-provisioner namespace: kube-systemDeployment里最关键的是要挂载NFS服务端的共享目录到容器内/persistentvolumes,同时通过环境变量告诉组件NFS的地址和路径。配置文件如下:
apiVersion: apps/v1 kind: Deployment metadata: name: nfs-provisioner namespace: kube-system spec: replicas: 1 selector: matchLabels: app: nfs-provisioner template: metadata: labels: app: nfs-provisioner spec: serviceAccountName: nfs-provisioner containers: - name: nfs-provisioner image: registry.example.com/nfs-provisioner:v1.0 env: - name: PROVISIONER_NAME value: nfs-provisioner - name: NFS_SERVER value: 192.0.2.10 - name: NFS_PATH value: /data/nfs volumeMounts: - name: nfs-volume mountPath: /persistentvolumes volumes: - name: nfs-volume nfs: server: 192.0.2.10 path: /data/nfs这里的镜像地址需要替换成实际可拉取的地址,不同发型版的镜像有版本差异。如果集群没有外网,就把镜像上传到内网仓库。
创建好资源后,检查Pod是不是Running:
kubectl get pod -n kube-system | grep nfs-provisioner kubectl logs -n kube-system <pod-name>接下来定义StorageClass:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-storage provisioner: nfs-provisioner reclaimPolicy: Delete volumeBindingMode: Immediateprovisioner必须和Deployment里的PROVISIONER_NAME一致。reclaimPolicy: Delete让删除PVC时自动删除对应PV。volumeBindingMode: Immediate会促使PVC创建后立即触发供给,符合多数场景。
如果想保留数据,把reclaimPolicy改成Retain。不过要注意,Retain模式下删除PVC后PV会变成Released,PV对象不会被删除,但对后端NFS目录的控制也会减弱,得自己手动清理。
3.3 创建PVC并验证PV自动生成
准备工作做完后,创建一个测试PVC:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc namespace: default spec: accessModes: - ReadWriteOnce storageClassName: nfs-storage resources: requests: storage: 1Gi执行创建后,先看PVC状态:
kubectl get pvc test-pvc正常情况下几秒内会看到Status: Bound。再检查PV:
kubectl get pv终端会多出一个PV,名称类似pvc-xxxxxxx,StorageClass列是nfs-storage,对应的来源是NFS子目录。我用实际测试输出给你参考:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE pvc-5f4a3c2d-8e7b-4a1f-b9c3-1234567890 1Gi RWO Delete Bound default/test-pvc nfs-storage 5s到现在,PVC自动生成PV的流程已经通了。但想确认真的能用,还得让Pod挂载这个PVC,专门测试读写。
apiVersion: v1 kind: Pod metadata: name: nfs-test-pod spec: containers: - name: test image: busybox command: ["sh", "-c", "echo 'hello nfs' > /data/test.txt && sleep 3600"] volumeMounts: - name: test-storage mountPath: /data volumes: - name: test-storage persistentVolumeClaim: claimName: test-pvcPod正常Running后,进入容器查看文件:
kubectl exec nfs-test-pod -- cat /data/test.txt能输出hello nfs就说明整个链路都对了。如果Pod一直ContainerCreating,大概率是节点缺nfs-common,或者防火墙挡住了NFS端口,可以用kubectl describe pod看事件信息。
3.4 设置默认StorageClass(可选)
如果希望集群里不指定StorageClass的PVC也能自动使用NFS动态供给,可以把nfs-storage标记为默认。给StorageClass加一个annotation:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-storage annotations: storageclass.kubernetes.io/is-default-class: "true" provisioner: nfs-provisioner reclaimPolicy: Delete volumeBindingMode: Immediate也可以在已经存在的StorageClass上patch:
kubectl patch storageclass nfs-storage -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'设置默认后,PVC不写storageClassName也会自动绑定。但这个操作要谨慎,如果集群里已经有多套存储,默认箭头最好指向最可靠的那个。我最初把NFS设成默认后,有状态应用都很随意地往NFS上跑,后面排查了一圈才发现存储性能跟不上。
4. 常见问题与排查技巧实录
4.1 PVC一直Pending的排查思路
PVC卡在Pending,是这个问题下面出现频率最高的情况。按这套步骤来:
第一,确认PVC里的storageClassName打对了。可以执行下面命令查看事件:
kubectl describe pvc test-pvc事件里经常出现“storageclass.storage.k8s.io "nfs-storage" not found”或者“provisioning failed for PVC ...”。前者是StorageClass名写错或没创建,后者通常要看供给Pod日志。
第二,检查供给组件Pod日志:
kubectl logs -n kube-system <provisioner-pod>常见的报错信息有wrong disk、no such file or directory、connection refused。如果看到connection refused,就去NFS服务器上确认服务是否监听:
systemctl status nfs-server ss -lntp | grep 2049第三,确认NFS路径和权限。我踩过的坑是共享目录被写成了/data/nfs/,但容器挂载路径指向/data/nfs,目录权限不一致时会报“permission denied”。
第四,如果RBAC没配对,供给组件没有权限创建PV,也会一直重试。这时Pod日志里通常有Forbidden字样。
4.2 挂载后没有读写权限或Permission denied
有几种典型情况。一种是NFS服务端/etc/exports里没开no_root_squash,而容器里进程以root写入,会被映射成nobody,容易报Operation not permitted。另一种是PV的目录权限不对,供给组件在NFS共享根目录下创建的子目录权限默认继承,但不同组件的行为不同。
建议在NFS共享根目录设置chmod 777或者chown nobody:nogroup,确保所有子目录可写:
chown nobody:nogroup /data/nfs chmod 777 /data/nfs如果应用容器运行用户不是root,可以给Pod加securityContext:
spec: securityContext: fsGroup: 1000再把子目录权限调整到1000。但注意这种调整只对后续挂载生效,如果PV已经生成,可能需要删除PVC和PV重新触发一次供给。
4.3 数据删除但PV没有释放
很多人误以为动态供给“回收”会把后端数据一起删掉。实际上NFS动态供给的Delete回收,只是删除PV对象并尝试删除NFS上的对应子目录,不同实现可能保留一份归档目录,名字带archived-前缀。这其实是保护机制,避免误删用户数据。
如果你发现删了PVC,PV还停留在Released状态,说明StorageClass的reclaimPolicy是Retain。Retain模式下PV不会自动清理,也是为了安全。确认是否需要手动删除PV:
kubectl delete pv pvc-xxxxxx再到NFS服务器上手动删除对应目录。整个过程并不可怕,就是要记得亲手处理,别指望自动化帮全忙。
4.4 容量配额与性能陷阱
NFS动态供给生成的PV,在集群里写了容量,比如1Gi,但这个容量基本是“纸面容量”。NFS本身不会限制某个子目录最多只写1Gi,应用写爆磁盘也照样能操作,直到整块磁盘满了。所以对容量敏感的场景,一定要在应用层做限制,或者使用真正支持Quota的存储后端。
性能方面,NFS适合IO并发要求不高的业务。如果有Pod大量读小文件,可能会让NFS服务器的负载飙升。我的经验是:日志型、归档型、共享型应用放NFS没问题,数据库这种持续读写的负载建议用块存储或本地盘。
另外,如果部署了多个副本的供给组件,需要注意并发写NFS时的状态一致性问题。多数精简实现建议只跑一个副本,避免同时创建同名目录冲突。这也解释了为什么动态供给Deployment里replicas通常写1就够了。
5. 一些后续扩展想法
NFS动态供给上线后,很多日常操作都能拿到新体验。想给应用扩容时,编辑PVC的容量字段,如果底层后端支持扩容就能触发PV扩容,NFS场景下因为PV容量只是记录,扩容实际意义没那么大。想切换存储时,只需要换一套StorageClass,业务方不用关心后端细节。
我在实际使用中还是尽量保持一条原则:核心数据库绝不放在NFS动态供给上,文件存储和中间件目录可以随意用。原因是NFS天然面向共享文件,而不是随机IO。这个取舍想清楚后,整个存储体系就顺了。
如果后续想再优化,可以考虑给NFS服务器做冗余,比如双机热备或负载均衡;也可以在NFS上开配额功能。不过别为了“高可用”硬堆复杂度,一个NFS服务跑得好好的,贸然引入分布式架构反而可能成为新的故障源。
最后再分享一个小技巧:动态供给组件和StorageClass最好用代码管理,放到Git仓库里。每次集群迁移或者重建,直接apply目录下的yaml,几秒钟就能把存储基础恢复。我经历过一次测试集群被清理后重新搭建,所有存储配置都能秒级还原,但没纳入代码管理的NFS服务器密码和目录结构反而花了不少时间回忆。希望这些踩坑总结能帮你少绕几步路。