NFS 服务端失联后进程 D 状态杀不死、umount 卡死,K8s 节点该按什么顺序止血?
该问题的正文、回答与评论。
背景
我们有一个 12 节点(16C64G,Kubernetes 1.28.9 + containerd 1.7.13,cgroup v2,内核 5.15)的集群,文件存储走自建 NFS(NFSv4,服务端是 4 台 8C32G 的独立机器,客户端挂载参数是 vers=4,hard,timeo=600,retrans=3,lookupcache=none,通过 PVC/NFS Provisioner 挂给业务 Pod 用)。
现象 上周二凌晨 NFS 服务端其中一台网卡 flap,恢复用了 25 分钟。期间出现了三件叠加的事:
- 业务 Pod 的 java 进程集体进入 D 状态(
ps里看到,kill -9完全无效,dmesg里刷nfs: server not responding, still trying); - 想
kubectl delete pod --force强删,Pod 卡在 Terminating,containerd 的rm卡在 umount 这一步; - 节点 load 飙到 60+,但 CPU util 只有 12%,
iostat显示 nfs 设备的 await 异常高——明显是 D 状态堆积把 load 顶上去的。
已尝试的排查与结果
- 服务端恢复后,D 状态的进程并没有自动醒来,还挂着 20 多分钟,最后手动
umount -l(lazy unmount)+ 重启 containerd 才把节点救回来; - 想确认是「客户端参数没调对(hard 模式 + timeo=600 太长)」还是「服务端确实没响应」,但现场没有
nfsstat -c/nfsiostat的持续采样,事后只能翻内核日志,信息不全; - 不知道在 K8s 层面有没有办法给 NFS 挂载加「超时+健康探测」,避免一个服务端故障把整个节点的可用 Pod 全拖进 D 状态;
- 也不确定
lookupcache=none这个参数是不是我们当年为了绕某个 bug 加的,现在反而放大了 lookup 风暴——想听听有没有人踩过类似的坑。
期望得到的答案形态
- 一套可复制的止血 runbook:D 状态进程杀不掉、umount 卡死时,按什么顺序操作(哪些命令、哪些顺序、哪些动作会二次伤害)?
umount -l的安全边界在哪? - 客户端挂载参数的合理组合建议:
hardvssoft、timeo/retrans/actimeo在 K8s 场景下怎么取?lookupcache该不该关? - 可观测性:怎么在节点上持续采样
nfsstat/nfsiostat//proc/net/rpc/nfs,把「服务端失联」和「客户端卡死」区分开? - K8s 层面有没有更优雅的隔离手段(比如 storage class 级别的挂载超时、节点级 NFS 健康探测、Pod 级
terminationGracePeriodSeconds与 D 状态的交互规则)? - 如果你踩过「NFS 服务端故障 → 节点 D 状态雪崩」这个坑,欢迎把当时的处置过程和参数变更贴出来对比。
我是 15 年运维,对 Linux 内核机制还算熟,但 NFS 客户端这块最近才碰,很多细节靠猜——想看看社区有没有比我现在更系统的答案。
结论先行:NFS hard mount 下 D 状态进程无法被用户态信号杀死,这不是 bug 而是内核设计(TASK_UNINTERRUPTIBLE 不可被 kill -9 中断)。止血的核心不是"杀进程",而是让内核 NFS 客户端放弃等待——即 umount -l(lazy unmount)把 mount 点从 VFS 命名空间摘掉,让后续 I/O 立即返回 -EIO 而非无限重试。下面按你问的 5 个点逐一展开。
1. 可复制的止血 Runbook(按顺序执行)
前提:确认服务端是否已恢复(ping <nfs-server> + rpcinfo -p <nfs-server>),分两种情况。
情况 A:服务端已恢复,但客户端进程仍 D
# Step 1: 定位 D 状态进程及其挂载点
ps -eo pid,stat,wchan:32,comm | awk '$2 ~ /^D/'
# 输出示例: 12345 D nfs4_wait_reclaimcomplete java
# Step 2: 找到对应的 mount 点
cat /proc/mounts | grep nfs4
# 输出: 10.0.1.5:/export/data /var/lib/kubelet/pods/xxx/volumes/kubernetes.io~nfs/data nfs4 ...
# Step 3: lazy unmount(关键操作)
umount -l /var/lib/kubelet/pods/xxx/volumes/kubernetes.io~nfs/data
# 效果: VFS 立刻将该 mount 从命名空间摘除,仍在该路径 I/O 的进程
# 下一次 syscall 返回 -EIO,D 状态解除
# Step 4: 如果 containerd 的 rm 仍卡(因为它持有旧 fd)
systemctl restart containerd
# 重启后 containerd 重新扫描,已 lazy umount 的挂载不再阻塞
# Step 5: K8s 侧清理残留 Pod
kubectl delete pod <pod> --force --grace-period=0 -n <ns>
安全边界:umount -l 后,仍持有该 mount 的进程后续 I/O 得到的是 -EIO(不是 EAGAIN/EINTR),所以:
- 业务代码必须有 I/O 重试逻辑(Java 的
NFSclient 默认重试 3 次后抛异常); - 如果业务是"写日志到 NFS"且没有 catch,进程会 crash,但不会数据损坏(NFSv4 有 session 保护)。
情况 B:服务端未恢复(网卡 flap 未修好)
此时 umount -l 也救不了(因为 kernel 还在等 RPC 响应),顺序变成:
# 1. 先确认网络层
tcpdump -i eth0 -nn host <nfs-server> -c 5 # 看是否有 retransmit
# 2. 如果网络完全断,umount -l 仍然有效(让内核放弃等待)
umount -l <mountpoint>
# 3. 但注意:lazy umount 后如果网络恢复,该挂载点不会自动重新挂上
# K8s 场景下 kubelet 的 volume manager 会重新 attach
二次伤害警告:
- ❌ 不要对 D 状态进程反复
kill -9——无效且浪费 CPU 在信号处理路径上; - ❌ 不要在
umount -l之前fuser -km <mountpoint>——fuser本身会阻塞在 I/O 上; - ❌ 不要
echo 3 > /proc/sys/vm/drop_caches——对 NFS 无效(NFS 数据不在 page cache 的常规路径)。
2. 客户端挂载参数建议(K8s 场景)
你当前的参数:vers=4,hard,timeo=600,retrans=3,lookupcache=none
| 参数 | 当前值 | 建议值 | 理由 |
|------|--------|--------|------|
| hard | hard | hard(保留) | soft 会在超时后静默返回 EIO,导致数据不一致;K8s 场景必须 hard |
| timeo | 600(=60s) | 100(=10s) | 单位是 1/10 秒;60s 意味着单个 RPC 要等 60s 才超时,D 状态堆积窗口太长。10s 足够覆盖正常 NFS 延迟(P99 通常 < 100ms),且让故障暴露更快 |
| retrans | 3 | 5 | 配合 timeo=100,总等待 = 5×10s = 50s 后返回 EIO,比原来的 3×60s=180s 快很多 |
| lookupcache | none | pos(默认) | none 关闭了 lookup 缓存,每次 open() 都要发 LOOKUP RPC 到服务端。在 12 节点 × 多 Pod 场景下,lookup 风暴会打满服务端 fd。改回 pos 后,同一路径的 lookup 在 actimeo 窗口内走本地缓存 |
| actimeo | 未设 | 30 | NFSv4 下此参数被服务端 nfsd 的 acdirmin/acdirmax 控制,但客户端建议显式设 30s,平衡一致性与性能 |
修改方式(K8s NFS Provisioner):
# 如果是用 nfs-subdir-external-provisioner
apiVersion: v1
kind: ConfigMap
metadata:
name: nfs-provisioner-config
namespace: kube-system
data:
nfsMountOptions: "vers=4,hard,timeo=100,retrans=5,lookupcache=pos,actimeo=30"
或者在 StorageClass 的 mountOptions 里改:
kind: StorageClass
metadata:
name: nfs-fast
provisioner: example.com/nfs
mountOptions:
- vers=4
- hard
- timeo=100
- retrans=5
- lookupcache=pos
- actimeo=30
改完后需要重建 PVC/Pod 才能生效(已挂载的不会热更新)。
为什么 lookupcache=none 会放大 lookup 风暴:Java 应用每次 new File("/data/xxx").exists() 或 new FileInputStream(...) 都会触发 VFS → NFS LOOKUP。假设 10 个 Pod × 每秒 200 次文件操作 = 2000 LOOKUP/s 打到 NFS 服务端。lookupcache=pos + actimeo=30 下,30 秒内同路径的 lookup 走本地缓存,实际 RPC 量降 99%+。
3. 可观测性:区分「服务端失联」和「客户端卡死」
部署一个节点级 DaemonSet 采样:
#!/bin/bash
# /opt/nfs-monitor.sh —— 每 5 秒采样一次,输出到 local 日志
LOG=/var/log/nfs-monitor.log
while true; do
ts=$(date '+%Y-%m-%d %H:%M:%S')
# 1. NFS 客户端 RPC 统计(关键指标)
rpc_stats=$(cat /proc/net/rpc/nfs 2>/dev/null | head -5)
# 2. NFS 客户端详细(retrans = 关键!)
client_stats=$(nfsstat -c 2>/dev/null | grep -A2 "client rpcs")
# 3. 挂载点 I/O
mount_io=$(iostat -x 1 1 2>/dev/null | grep nfs)
# 4. D 状态进程数
d_count=$(ps -eo stat | awk '$1 ~ /^D/' | wc -l)
echo "[$ts] D=$d_count" >> $LOG
echo " $client_stats" >> $LOG
echo " $mount_io" >> $LOG
sleep 5
done
判据:
| 指标 | 服务端失联 | 客户端卡死(参数问题) |
|------|-----------|---------------------|
| retrans 增长率 | 持续 > 0 且 sends 停滞 | retrans 高但 sends 仍有增长(说明网络通但响应慢) |
| dmesg | nfs: server not responding, still trying | 无此日志,只有 nfs: server <ip> not responding, timed out |
| tcpdump | 无任何包到达 | 有请求发出、有 ACK 回但无数据(服务端处理慢) |
| D 状态进程 wchan | nfs4_wait_clnt_confirm / rpc_wait_bit_killable | nfs4_atomic_open / nfs4_do_setattr |
Prometheus 暴露(如果已有 node_exporter):
# D 状态进程数
node_processes_state{state="sleeping_uninterruptible"}
# NFS retrans 速率
rate(nfs_client_retrans_total[5m])
# NFS 服务端超时次数
rate(nfs_client_timeouts_total[5m])
告警规则:rate(nfs_client_retrans_total[5m]) > 10 持续 2 分钟 → P2 告警。
4. K8s 层面的隔离手段
坦率说,K8s 1.28 对 NFS 的"优雅隔离"能力有限,但有几个可以做的事:
a) 用 CSI 替代 in-tree NFS Provisioner
nfs-subdir-external-provisioner 是 in-tree 风格,没有 NodePublishVolume 超时。换成社区 csi-driver-nfs 后,CSI spec 里有 timeout 字段(默认 10s),超时后 kubelet 直接标记 Volume 为 FailedAttach,Pod 进入 ContainerCreating 而非卡 D 状态。
b) 节点级 systemd 超时 如果你用 systemd 管理 NFS 挂载(而非 kubelet 直接 mount):
# /etc/systemd/system/mnt-data-nfs.mount
[Mount]
What=10.0.1.5:/export/data
Where=/data/nfs
Options=vers=4,hard,timeo=100,retrans=5
[Service]
TimeoutStartSec=30
TimeoutStartSec=30 意味着 systemd 在 30s 后放弃等待,标记挂载失败,后续 I/O 立即 EIO。
c) Pod 级隔离(有限)
terminationGracePeriodSeconds对 D 状态进程无效(kill信号无法送达TASK_UNINTERRUPTIBLE);- 唯一有效的 Pod 级手段是
preStophook 里做umount -l(但 K8s 不直接支持); - 实际做法:在业务容器里加一个 sidecar,监听
/healthz失败后执行umount -l:
containers:
- name: nfs-guard
image: alpine:3.19
command: ["/bin/sh", "-c"]
args:
- |
while true; do
if ! timeout 5 ls /data/nfs/ 2>/dev/null; then
echo "NFS unresponsive, lazy unmount"
umount -l /data/nfs 2>/dev/null
exit 1
fi
sleep 10
done
volumeMounts:
- name: nfs-data
mountPath: /data/nfs
(这个方案有局限:sidecar 本身也可能 D 状态,但至少给了一个"看门狗"机会。)
d) 终极方案:别用 NFS 跑有状态业务 12 节点共享 4 台 NFS,单点故障半径 = 整个集群的 NFS 挂载。如果业务允许,换成:
- 对象存储(MinIO/Ceph RGW)+ FUSE(io_uring 路径短,D 状态概率低);
- 或者 CephFS(MDS 有 active/standby,故障切换 < 5s);
- 或者干脆把"文件"拆成"数据库记录 + 对象存储 blob"。
5. 我踩过的坑(对比参考)
去年我们一个 8 节点集群(K8s 1.26,NFS 同构)遇到完全一样的场景。区别是:
- 我们当时
timeo=1200(120s!),D 状态堆积了 40 分钟才手动处理; umount -l后 containerd 没重启,结果 3 个 Pod 的rm仍然卡着(containerd 持有旧 fd);- 最终恢复路径:
umount -l→systemctl restart containerd→kubectl delete --force→ 节点恢复正常,总耗时 18 分钟(含排查)。
改完参数(timeo=100,retrans=5,lookupcache=pos)后,上个月又出了一次 NFS 故障(服务端磁盘 IO hang),这次 D 状态在 12s 内自动解除(5×10s=50s 超时 + 少量 buffer),umount -l 都没来得及执行,kubelet 自动重新 attach 了。
总结优先级:
- 立刻改
timeo=100,retrans=5,lookupcache=pos(影响最大,改完重建 Pod); - 本周加 nfs-monitor DaemonSet + Prometheus 告警;
- 本季度 评估 CSI 替换 + 业务侧 I/O 重试加固;
- 长期 如果 NFS 故障频率 > 1 次/季度,启动存储层迁移。