Appearance
NFS 对接 K8s
NFS 是理解 K8s 存储链路最直接的后端。服务端暴露一个目录,所有 K8s 节点装上客户端能挂载,PV 里填 NFS 地址和路径,Pod 挂载 PVC 后写入文件,到 NFS 服务端就能直接看到真实文件变化。整条链路没有抽象层遮挡,PV/PVC/StorageClass/后端存储的关系一次就能看清楚。
NFS 的局限也摆在明处:单点、性能一般、权限模型和容器 UID 的配合容易出错。它不适合承载数据库和核心高 IO 服务,但非常适合做存储实验和共享文件类的轻量场景。
环境
示例用了四台机器:
| 主机 | IP | 作用 |
|---|---|---|
nfs01 | 192.168.10.20 | NFS 服务端 |
k8s-master01 | 192.168.10.11 | 控制平面 |
k8s-node01 | 192.168.10.12 | 工作节点 |
k8s-node02 | 192.168.10.13 | 工作节点 |
NFS 共享目录统一放在 /data/nfs/k8s-demo。
部署 NFS 服务端
RHEL/Rocky/CentOS 上先装 NFS 包,建共享目录,写 exports,启动服务:
bash
yum install -y nfs-utils
mkdir -p /data/nfs/k8s-demo
# 实验环境用宽松权限先把链路跑通,生产要按应用运行 UID/GID 收口
chmod 0777 /data/nfs/k8s-demo
cat > /etc/exports <<'EOF'
/data/nfs/k8s-demo 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash)
EOF
systemctl enable --now nfs-server
exportfs -rav
showmount -e 127.0.0.1exports 里四个参数的含义:
| 参数 | 作用 | 为什么这样写 |
|---|---|---|
rw | 读写权限 | Pod 需要写入数据,不只是读 |
sync | 同步写入 | NFS 服务端确认数据落盘后才回复客户端,数据安全性更好;代价是写入延迟比 async 高 |
no_subtree_check | 不检查子目录 | 减少父目录权限变化导致的导出问题,NFS 官方实际上也推荐这个选项 |
no_root_squash | 客户端 root 保持 root | 实验环境省事,生产里如果容器以 root 运行就会直接拿到 NFS 服务端 root 权限,风险很高 |
生产环境把 0777 和 no_root_squash 同时打开不是好做法。这里只是为了把 PV/PVC 链路跑通,真正交付业务时,目录权限要和容器的 runAsUser、fsGroup 对齐,root_squash 通常也应该保持默认开启。
所有 K8s 节点安装 NFS 客户端
K8s 里 Pod 的卷挂载实际发生在 Pod 被调度到的节点上。kubelet 要根据 PV 里的 NFS 信息去挂载远程目录,这个动作依赖节点本机的 NFS 客户端。节点没装客户端时,Pod 会卡在 ContainerCreating,事件里能看到 mount 失败的报错。
RHEL/Rocky/CentOS 节点:
bash
yum install -y nfs-utils
# 这一步验证节点能访问 NFS 服务端的 RPC 服务,不通的话后面的 PV 挂载都做不到
showmount -e 192.168.10.20Ubuntu 节点:
bash
apt-get update
apt-get install -y nfs-common
showmount -e 192.168.10.20静态 PV/PVC
静态供给就是管理员先把 PV 建好,用户再建 PVC 来绑定。储存在 NFS 路径上的 PV 本身不需要"创建磁盘"的动作——PV 只是一个指向 NFS 目录的 K8s 资源对象。
先建 namespace:
bash
kubectl create namespace demoPV 定义:
yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-nfs-demo
spec:
capacity:
storage: 10Gi # PV 声明的容量,PVC 请求的容量必须 ≤ 这个值
accessModes:
- ReadWriteMany # NFS 支持多节点同时读写
persistentVolumeReclaimPolicy: Retain # PVC 删除后 PV 和后端数据保留
storageClassName: nfs-static
mountOptions: # mountOptions 会透传给节点上的 mount 命令
- hard # NFS 挂载用 hard 模式,IO 操作在服务端无响应时会一直重试而非直接报错
- nfsvers=4.1 # 指定 NFS 协议版本,避免协商到旧版本
nfs:
server: 192.168.10.20
path: /data/nfs/k8s-demoPVC 定义:
yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-nfs-demo
namespace: demo
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs-static # 必须和 PV 的 storageClassName 一致才能绑定
resources:
requests:
storage: 5Gi # 请求 5Gi,PV 有 10Gi,满足条件则绑定PVC 创建后,K8s 会找一个 accessModes 匹配、容量足够、storageClassName 相同的 PV 来绑定。绑定成功后 PVC 状态变成 Bound:
bash
kubectl apply -f pv.yaml
kubectl apply -f pvc.yaml
kubectl -n demo get pvc
kubectl get pvPod 写入验证
Pod 通过 PVC 挂载 NFS,写入文件后直接去 NFS 服务端看文件是否出现——这是验证整条存储链路是否真正工作的关键一步:
yaml
apiVersion: v1
kind: Pod
metadata:
name: nfs-test
namespace: demo
spec:
containers:
- name: busybox
image: busybox:1.36
command: ["sh", "-c", "while true; do date >> /data/time.log; sleep 10; done"]
volumeMounts:
- name: data
mountPath: /data # 容器内挂载点
volumes:
- name: data
persistentVolumeClaim:
claimName: pvc-nfs-demo # 引用前面创建的 PVCbash
kubectl apply -f pod.yaml
kubectl -n demo get pod nfs-test -o wide
kubectl -n demo exec nfs-test -- tail /data/time.log到 NFS 服务端确认:
bash
tail /data/nfs/k8s-demo/time.log看到 NFS 服务端文件里出现同样的时间戳,链路就完整了。PV 不是磁盘本身,它只是指向 NFS 路径的 K8s 对象;PVC 绑定 PV 后,Pod 通过 kubelet 将远程 NFS 目录挂载到容器里——这和容器直接 mount NFS 在原理上是同一件事。
动态 provisioner
静态供给每次都要管理员手工建 PV,PVC 多了以后操作量会变大。NFS 动态供给常用的方案是 nfs-subdir-external-provisioner,它在 NFS 共享目录下为每个 PVC 自动创建子目录,PV 也自动生成。
bash
helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm repo update
helm upgrade --install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
-n nfs-provisioner \
--create-namespace \
--set nfs.server=192.168.10.20 \
--set nfs.path=/data/nfs/k8s-demo \
--set storageClass.name=nfs-client \
--set storageClass.defaultClass=false # 不设为默认 StorageClass,避免误用现在 PVC 只需要写 StorageClass,不需要提前建 PV:
yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-dynamic-nfs
namespace: demo
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs-client # provisioner 会为这个 StorageClass 自动创建 PV 和子目录
resources:
requests:
storage: 1Gi创建后观察自动生成的 PV 和 NFS 上的子目录:
bash
kubectl -n demo get pvc pvc-dynamic-nfs
kubectl get pv | grep pvc-dynamic-nfs
# NFS 服务端上会看到 provisioner 自动创建的 PVC 子目录
find /data/nfs/k8s-demo -maxdepth 2 -type d动态供给的好处是业务方只写 PVC,管理员不需要预先建 PV。但要注意两点:回收策略默认通常是 Delete(PVC 删除后子目录也被删),以及 provisioner 本身变成了存储链路里多出来的一个故障点。
常见故障
| 现象 | 常见原因 | 查看 |
|---|---|---|
| PVC Pending | PV 的 storageClassName、accessModes、容量不匹配;动态供给时 provisioner 异常 | kubectl describe pvc |
| Pod 卡在 ContainerCreating | 节点缺 NFS 客户端、NFS 服务端不可达、exports 未授权该节点 IP | kubectl describe pod、kubelet 日志 |
| 写入 Permission denied | NFS 目录权限不包含容器运行 UID、root_squash 把 root 压成了 nobody | 服务端目录 ls -la、容器内 id |
| 删除 PVC 后数据消失 | 回收策略是 Delete,provisioner 删除了对应子目录 | 查看 PV 的 persistentVolumeReclaimPolicy |
| NFS 服务端宕机 | 所有挂载该 NFS 的 Pod IO 阻塞 | 节点上 mount | grep nfs 看挂载状态、dmesg 看 nfs 相关错误 |
NFS 挂载用 hard 模式时,服务端宕机会导致 Pod 内 IO 操作阻塞(不会直接报错,而是等待服务端恢复)。这是 NFS 协议的固有行为,不是 K8s 的问题。soft 模式会在超时后返回错误,应用需要能处理 IO 错误。
NFS 的单点风险决定了它不适合核心数据库。把它放在共享配置文件、日志归档、临时实验数据这类场景里最合适。高 IO、低延迟、强一致性的场景更适合块存储(Ceph RBD、云盘)或专门数据库方案。