Skip to content

NFS 对接 K8s

NFS 是理解 K8s 存储链路最直接的后端。服务端暴露一个目录,所有 K8s 节点装上客户端能挂载,PV 里填 NFS 地址和路径,Pod 挂载 PVC 后写入文件,到 NFS 服务端就能直接看到真实文件变化。整条链路没有抽象层遮挡,PV/PVC/StorageClass/后端存储的关系一次就能看清楚。

NFS 的局限也摆在明处:单点、性能一般、权限模型和容器 UID 的配合容易出错。它不适合承载数据库和核心高 IO 服务,但非常适合做存储实验和共享文件类的轻量场景。

环境

示例用了四台机器:

主机IP作用
nfs01192.168.10.20NFS 服务端
k8s-master01192.168.10.11控制平面
k8s-node01192.168.10.12工作节点
k8s-node02192.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.1

exports 里四个参数的含义:

参数作用为什么这样写
rw读写权限Pod 需要写入数据,不只是读
sync同步写入NFS 服务端确认数据落盘后才回复客户端,数据安全性更好;代价是写入延迟比 async 高
no_subtree_check不检查子目录减少父目录权限变化导致的导出问题,NFS 官方实际上也推荐这个选项
no_root_squash客户端 root 保持 root实验环境省事,生产里如果容器以 root 运行就会直接拿到 NFS 服务端 root 权限,风险很高

生产环境把 0777no_root_squash 同时打开不是好做法。这里只是为了把 PV/PVC 链路跑通,真正交付业务时,目录权限要和容器的 runAsUserfsGroup 对齐,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.20

Ubuntu 节点:

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 demo

PV 定义:

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-demo

PVC 定义:

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 pv

Pod 写入验证

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  # 引用前面创建的 PVC
bash
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 PendingPV 的 storageClassName、accessModes、容量不匹配;动态供给时 provisioner 异常kubectl describe pvc
Pod 卡在 ContainerCreating节点缺 NFS 客户端、NFS 服务端不可达、exports 未授权该节点 IPkubectl describe pod、kubelet 日志
写入 Permission deniedNFS 目录权限不包含容器运行 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、云盘)或专门数据库方案。