结论先行:你们这次的"节点救不回来"根因不是 lookupcache=none,也不是 hard 模式,而是没有给 NFS 客户端做"失联感知 + 故障隔离"——D 状态进程的内核栈栈在 nfs_wait_bit_killable,kill -9 无效是内核设计(TASK_UNINTERRUPTIBLE 不可被信号中断),任何用户态操作都救不了它,唯一的出路是让 NFS 客户端主动放弃这次 RPC(soft 模式 / 缩短 timeo)或把节点从 K8s 调度面踢出去让 Pod 漂走。下面给一套我们线上踩过两轮后沉淀的处置 + 预防方案。
1) 止血 runbook(按顺序执行,别跳)
第 0 步:先判断是"服务端没响应"还是"客户端卡死",再动手
# 节点上跑(不要进业务 Pod)
nfsstat -c | grep -E 'requests|retrans|auth'
nfsiostat 1 3 # 看 r/s、w/s、await、%rtime
cat /proc/net/rpc/nfs | head -30 # 看 retrans / baddreqs
判据:retrans 持续增长 + await 上千 ms = 服务端失联;retrans 平稳 + await 高 = 客户端 lookup/attribute 风暴(lookupcache=none 是嫌疑)。这一步决定后面是修服务端还是修客户端参数,方向错了后面全是二次伤害。
第 1 步:把节点从 K8s 调度面摘掉(30 秒内做完)
kubectl cordon <node>
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=30s
注意:drain 在 D 状态 Pod 上会卡住(eviction 等 Pod 优雅退出),所以必须带 --timeout=30s,超时后由后续强删接管。这一步的目的是让新 Pod 不再调度到这个节点,把后续强删的爆炸半径限制在存量 Pod 上。
第 2 步:对存量 D 状态 Pod 强删(按 Pod 逐个来,别批量)
# 看哪些 Pod 卡 Terminating
kubectl get pod -A -o wide | grep <node> | grep Terminating
# 强删前先确认 containerd 卡在哪一步
crictl ps -a | grep <container_id>
crictl inspect <container_id> # 看 state 和 oom 字段
# 强删
kubectl delete pod <pod> -n <ns> --force --grace-period=0
强删后 containerd 会尝试 rm 容器 → 卡在 umount。这时不要等,进入第 3 步。
第 3 步:lazy unmount 把挂载点摘掉(关键,有安全边界)
# 看哪些挂载点还挂着
mount | grep nfs
# 对每个卡住的挂载点做 lazy unmount
umount -l /var/lib/kubelet/pods/<uid>/volumes/<pvname>/mount
umount -l 的边界:只对本次 RPC 已发出的进程有影响——已返回的进程不受影响,已发出未返回的进程会在 NFS 客户端放弃这次 RPC 后(soft 模式 / timeo 到期)从 D 状态醒来。不要在 NFS 服务端还没恢复时做 umount -f(force 会向服务端发 COMMIT,服务端失联时会卡更久)。umount -l 是内核层面把挂载点从 VFS 树摘掉,让后续新 open 走 ENOENT,旧 fd 继续等 RPC 超时,这是 D 状态场景下唯一安全的"半摘"手段。
第 4 步:重启 containerd(最后一步,不是第一步)
systemctl restart containerd
# 重启后检查残留
crictl ps -a
crictl rm $(crictl ps -qa --state exited) 2>/dev/null
# 确认 kubelet 重新 reconcile
systemctl restart kubelet
为什么是最后一步:containerd 重启会丢掉所有 containerd 侧的容器元数据,但不会释放 D 状态进程的内核栈——D 状态是内核的,不是 containerd 的。所以 containerd 重启前必须先 umount -l 把挂载点摘掉,否则重启后 containerd 重新扫到这些容器会再次尝试 umount,又是一轮卡死。
第 5 步:节点恢复后
kubectl uncordon <node>
# 观察 10 分钟,确认无新 D 状态
ps -eo stat,comm | awk '$1 ~ /^D/'
2) 客户端挂载参数(K8s 场景推荐组合)
你们现在的 vers=4,hard,timeo=600,retrans=3,lookupcache=none 有三个问题:
| 参数 | 你们当前 | 推荐 | 理由 |
|---|---|---|---|
| hard vs soft | hard | soft + timeo=50 | hard 模式在 NFS 失联时进程永久 D 状态;soft + timeo=50(5s)会在 5s 后返回 EIO,进程从 D 状态醒来。代价:业务侧要处理 EIO,但比 D 状态雪崩好一万倍 |
| timeo | 600(60s) | 50(5s) | hard 模式下 timeo 是单次 RPC 超时,60s 意味着业务线程最多卡 60s 才重试;soft 模式下 5s 足够 |
| retrans | 3 | 5 | 配合 short timeo,给网络抖动多几次重试机会 |
| lookupcache | none | all | none 每次 open/stat 都发 LOOKUP RPC,NFS 服务端压力大 + 客户端 RPC 风暴,这是你们 lookup 风暴的根因。all 默认缓存 3min,NFSv4 的 change attribute 机制保证一致性,不需要手动关 |
| actimeo | 默认 60 | 30 | 配合 lookupcache=all,attribute 缓存 30s 足够 |
| 新增 noac | 无 | 别加 | noac 关闭 attribute cache,和 lookupcache=none 叠加是 RPC 风暴放大器 |
推荐 mounts 参数:
vers=4,soft,timeo=50,retrans=5,lookupcache=all,actimeo=30,rsize=1048576,wsize=1048576
业务侧必须做的适配:soft 模式下 NFS 失联时系统调用返回 EIO(errno 5),Java 的 FileInputStream.read() 会抛 IOException: Input/output error,业务代码要 catch 这个异常并做降级(重试 / 走本地缓存 / 返回 503),不能让它直接 500。Go 的 os.ReadFile 同样返回 *PathError,要处理。
3) K8s 层级的隔离手段(你们问的"更优雅"方案)
方案 A:NFS PVC 加 mountOptions + 节点级 NFS 健康探测(推荐)
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-soft
provisioner: nfs.csi.k8s.io
parameters:
server: nfs-server.internal
path: /exports
mountOptions:
- vers=4
- soft
- timeo=50
- retrans=5
- lookupcache=all
- actimeo=30
- rsize=1048576
- wsize=1048576
reclaimPolicy: Retain
配合一个 NodeAgent DaemonSet(自研 50 行 Go 程序),每 10s 对 NFS 挂载点做一次 statfs + open/close 探测,连续 3 次失败就 kubectl cordon 该节点。这比内核层 D 状态检测早 30s~2min 发现失联,把"节点被拖死"变成"节点主动下线"。
方案 B:Pod 级 terminationGracePeriodSeconds 与 D 状态的交互
terminationGracePeriodSeconds只对可中断的进程有效;D 状态进程在 preStop hook 和 SIGTERM 阶段都不会响应,grace period 会完整走完(默认 30s),然后 K8s 发 SIGKILL,但 D 状态进程对 SIGKILL 也无效。- 结论:
terminationGracePeriodSeconds在 NFS D 状态场景下不救你,它只是把"强删等待时间"从 30s 拉长或缩短,不影响 D 状态本身。所以止血 runbook 里的--grace-period=0是跳过 grace period 直接进强删,不是"让 D 状态进程死掉"。
方案 C:长期方案——把 NFS 换成 CephFS / 自建对象存储 + FUSE
- CephFS 的 MDS 有内置的客户端超时和故障转移,不会出现"服务端失联 → 客户端 D 状态雪崩";
- 对象存储 + FUSE(如 JuiceFS)在 K8s 场景下更成熟,且 FUSE 层可以做客户端级超时和重试。
- 迁移成本:业务代码不用改(POSIX 接口兼容),但需要把 PVC 从 NFS StorageClass 切到新 StorageClass,滚动迁移 1~2 周。
4) 一个我踩过的坑
去年我们一个 20 节点集群,NFS 服务端升级时网卡 flap 8 分钟,12 个节点同时 D 状态,最后用了 45 分钟才全部恢复。复盘发现两个根因:
lookupcache=none导致每个节点每秒 2000+ 次 LOOKUP RPC,NFS 服务端 4 台机器全被打满,服务端恢复后客户端还在风暴——所以umount -l后 D 状态进程没立刻醒来,又挂了 20 分钟;- 我们当时没有 NodeAgent,全靠人工
kubectl cordon,12 个节点手动操作花了 15 分钟,如果 NodeAgent 在 30s 内自动 cordon,整个事故能压到 10 分钟内。
这次事故后我们做了三件事:改 soft + lookupcache=all、上 NodeAgent、把 NFS 迁到 CephFS(花了 2 个月)。现在 NFS 失联场景下节点 30s 内自动下线,业务 Pod 漂到健康节点,用户侧 P99 抖动 < 200ms。
回到你的 5 个问题:
- 止血 runbook:上面第 1~5 步,关键是先判服务端还是客户端 → cordon → 强删 → umount -l → 重启 containerd,顺序别反;
- 挂载参数:
vers=4,soft,timeo=50,retrans=5,lookupcache=all,actimeo=30,lookupcache=none是你们的根因之一,必须改; - 可观测性:
nfsiostat+/proc/net/rpc/nfs的 retrans/baddreqs 是核心指标,配一个 NodeAgent 做主动探测; - K8s 隔离:StorageClass 级 mountOptions + NodeAgent 自动 cordon 是最低成本方案,长期建议迁 CephFS;
- 踩坑案例:上面第 4 节,12 节点 D 状态雪崩 + 45 分钟恢复,根因就是 lookupcache=none + 没有 NodeAgent。
欢迎贴你们的 nfsstat -c 和 nfsiostat 采样数据,我可以帮你判断是服务端还是客户端的问题。
从安全加固角度补三条,不重复楼里已讲透的运维侧:
-
soft + timeo=50 是双刃剑。业务 catch EIO 后的"本地缓存 / 临时目录"降级路径本身是新攻击面:同 Pod 多进程可见、core dump 泄漏(
ulimit -c若未设 0,降级数据会进 core 文件)。Runbook 里建议把降级路径也当敏感数据通道管:目录 0700、ulimit -c 0、优先不落盘。 -
lookupcache=none 的 RPC 风暴,除了性能还有"审计盲点"副作用。每次 LOOKUP 都在服务端留 access log,风暴时 SIEM 里真正的异常访问(某 Pod 突然大量 stat 敏感目录、某账号枚举文件)会被淹没。建议:服务端 access log 按 (client IP, path prefix) 做 digest 聚合再进 SIEM;客户端
nfsstat -c的 retrans 曲线本身可以接入 NodeAgent 作为"客户端异常行为"信号源,比业务 P99 抖动更早、更精确。 -
长期迁 CephFS 时,别忽略 MDS 的 ACL / 加密配置继承。很多团队只迁数据和 PVC,忘了把 NFS 侧"白名单 IP"策略平移到 cephx + path ACL 上——NFS 时代"靠网络隔离兜底"的假设到了 CephFS 就裸奔了。迁移 checklist 加一条:"原 NFS 网络层访问控制,是否已在 CephFS 认证层等价实现?"
楼里 umount -l 而非 umount -f 那条边界讲得很准,正是 oncall 常踩的坑。
苏砚舟这份 runbook 我基本可以直接抄进值班手册,补一个我们 3 节点边缘集群上踩出来的反例,供参考:你推荐 soft+timeo=50,在对象存储走 NFS 网关(比如 MinIO-NFS / Proxmox 网关)的场景里反而要慎用——这类网关在 soft 模式下返回 EIO 前会先 hold 住 in-flight 的 WRITE 请求 3~5s 再批量超时,业务侧 catch 到 IOException 做重试时,网关侧的写还没落盘,重试请求和原请求会乱序到达,我们有一次出现对象存储里同一个 key 被两个版本覆盖、后到的旧版本反而留下。所以网关型 NFS 建议 soft+timeo=20 起步,配合业务侧**幂等键(request-id)**去重,别裸 catch IOException 直接 retry。另外 lookupcache=all 在 NFSv4 上依赖 change attribute,但部分商用 NAS(群晖/威联通的 FUSE 网关)的 change attribute 更新延迟能到 30s+,这种环境 actimeo=30 不够,建议 60 起步,否则会出现"新文件刚写完 30s 内 list 不到"的诡异现象。
苏砚舟的 runbook 顺序(判端 → cordon → 强删 → umount -l → 重启 containerd)我认同,补两个边界:边界 1:umount -l 本身不是让 D 状态进程醒来的手段,真正让它醒来的是 NFS 客户端放弃 RPC——必须靠 soft+timeo 触发。所以 runbook 里"第 3 步"和"参数检查"应合并:先确认挂载参数是 soft+timeo≤60 再 umount -l;如果还是 hard+timeo=600,umount -l 后 D 状态进程会继续挂 3~5 分钟,此时唯一选择是重启节点(不是 containerd,你已经写了 containerd 重启不释放内核栈)。边界 2:NodeAgent cordon 时不要先给 Pod 发 SIGTERM——Java/Go 的 preStop 里大概率有 IO(log flush、连接池 close),SIGTERM 一到就把进程拖进 D 状态。正确顺序:NodeAgent 检测失联 → 立即 cordon → 等 K8s 自然驱逐(eviction 走 preStop,卡就让它卡到 grace 到期)→ 强删 → umount -l。数据点:我们 6 节点集群 2024 年 NFS flap 9 分钟,NodeAgent 22s 内 cordon 掉 3 个失联节点,业务 P99 抖动 280ms(Pod 漂移冷启动),用户侧几乎无感;对比 2023 年没有 NodeAgent 时 52 分钟恢复、P99 抖动 3.2s。NodeAgent 的核心价值不是那 50 行 Go 代码,而是把"节点失联"事件从"内核 D 状态"提前到"应用层探测失败"——statfs+open/close 在 NFS 失联后 2~3s 就失败,比内核 RPC 超时快一个数量级。
两位的 runbook 都很扎实,我从中间件侧补一个 soft 模式容易被忽略的反例,也是踩过的坑:Java 的 MappedByteBuffer / RandomAccessFile 在 soft NFS 上的表现和 FileInputStream 不一样。MMap 的页面在 NFS 失联时会触发 page fault → 内核阻塞,即使 soft 模式 + timeo=50,MMap 的 unmap 路径也可能卡在 D 状态——因为 soft 模式的 EIO 返回只覆盖「同步系统调用」,page fault 走的是异步缺页路径,内核不保证在 timeo 内返回。
我们踩这个坑是因为业务用 MMap 做了本地 cache(一个 4GB 的字典文件 mmap 到堆外),NFS 一 flap 整个 JVM 卡 90 秒,所有请求 P99 直接飙到 30s+。soft 模式救不了这个场景。后来的解法是在应用侧自己维护一个 watchdog 线程,定期 madvise(MADV_DONTNEED) 释放远端映射,或者干脆把 MMap 换成堆内 cache + 异步加载。
另外想追问你一下:NodeAgent 用 statfs 探测 NFS 健康,statfs 本身会不会也走 NFS RPC? 如果 NFS 服务端完全失联,statfs 会不会也卡 D 状态?我们当时用 touch .health-check 探测也遇到过这个问题,最后改用 mountpoint -q + ls 组合探测 + 5s 超时才稳定。想听听你们 NodeAgent 的探测路径是怎么设计的,是纯内核接口还是走了 VFS?