👋 欢迎来到吴老二技术博客,专注运维 / 数据库 / 云原生
首页/云原生/正文

一次 K8s 节点日志暴涨排查:node-exporter 的 hostPath 让 netns 变成了删不掉的僵尸

吴
吴老二
2026-10-29云原生8 次浏览0 条评论

测试集群里 k8s-node21 这根节点最近有点不对劲。108G 的系统盘用掉 75G,占用 69%,但节点上只跑了 18 个 pod。更怪的是 /var/log/messages 有 5.6G,而 logrotate 天天都在跑。

值班的同事转过来时说"资源好像不会自动回收"。我上去看了一眼,确实是。

日志里全是同一句话

先不管占用,看看这 5.6G 到底在写什么。

# tail -n 30 /var/log/messages
Oct 30 13:51:15 k8s-node21 containerd: level=error msg="PodSandboxStatus for \"f519fdb6...\" failed"
  error="failed to get sandbox ip: check network namespace closed: remove netns:
  unlinkat /var/run/netns/cni-f69c722c-...: device or resource busy"
Oct 30 13:51:15 k8s-node21 kubelet: E1030 13:51:15.936193 kuberuntime_manager.go:1456]
  "PodSandboxStatus of sandbox for pod" ... pod="ns-a/app-server-5d9f7c6b8-x2k9p"
Oct 30 13:51:15 k8s-node21 kubelet: E1030 13:51:15.936214 generic.go:453]
  "PLEG: Write status" err="rpc error: ... device or resource busy" pod="ns-a/app-server-5d9f7c6b8-x2k9p"

清一色的 unlinkat ... device or resource busy。head -1 /var/log/messages 显示文件第一行就是这个错误——10 月 25 号那次轮转之后写进来的 5.6G,全是这一种日志。

算了下速率:containerd 一小时 52992 条错误,差不多每秒 15 条。kubelet 的 PLEG 每秒同步一次 pod 状态,每个出问题的沙箱产生 3 行(containerd 一行、kubelet 两行),7 个沙箱就是 20 来行,对得上。

数字对不上

报错的 pod 名字看着眼熟,但和节点上实际在跑的对不上:

# crictl pods | wc -l
47
# kubectl get pods -A --field-selector spec.nodeName=k8s-node21 | wc -l
18

47 个沙箱,18 个 pod。多出来的 29 个沙箱对应的 pod 早就不在了,有些名字还是几个月前的 ReplicaSet hash。/var/run/netns 下躺着 40 个 cni-* 文件,/var/lib/kubelet/pods 下 40 个目录,其中 22 个也是孤儿。

现象到这里就串起来了:沙箱删不掉 → kubelet 每秒重试 → 日志爆炸 → 顺带容器、网络、镜像层全都不回收。crictl inspectp 对任何一个僵尸沙箱都直接抛同一个错误,连状态都查不出来。

关键是为什么删不掉。

找谁在占用 netns

/var/run/netns/cni-xxx 这种文件本质是把 /proc/<pid>/ns/net bind mount 过来。正常情况下 containerd 删除沙箱时会先 umount 再删文件。报 EBUSY 只有一种可能:这个 dentry 在内核看来还是挂载点。

在宿主上却查不到它:

# grep ' /run/netns/' /proc/self/mountinfo | wc -l
30          # 40 个文件里只有 30 个还挂着
# grep -c 'cni-f69c722c' /proc/self/mountinfo
0

宿主自己都认为没挂载了,umount 报 "not mounted",可 rm 还是 EBUSY。那挂载应该是跑到别的 mount namespace 里去了。

遍历所有进程的 mountinfo:

ns=mnt:[4026531840] pid=101  netns_mounts=31 prop=shared:6              # 宿主
ns=mnt:[4026532622] pid=971  netns_mounts=31 prop=shared:1747 master:6  # chronyd
ns=mnt:[4026532645] pid=1995 netns_mounts=31                            # node_exporter

三份。宿主那份是正常的,另外两份是副本。

node-exporter 那份最显眼:

# grep -m1 netns /proc/1995/mountinfo
... /host/root/run/netns/cni-b8de318f-... rw,... - proc proc rw

它的 DaemonSet 挂了宿主根目录:

volumeMounts:
- name: root
  mountPath: /host/root
  mountPropagation: HostToContainer
volumes:
- name: root
  hostPath:
    path: /

问题就出在这。/host/root 和宿主 / 是同一棵 dentry 树,/host/root/run/netns/cni-x 和 /run/netns/cni-x 在内核里是同一个对象。containerd 把 netns 挂上去之后,node-exporter 的 mount namespace 里这个 dentry 也成了挂载点,宿主 umount 掉之后容器里那份还在,d_mountpoint() 依然为真,unlink 就永远 EBUSY。

chronyd 那份是另一条路径:CentOS 7 的 systemd 219 给 PrivateTmp=yes 的服务建独立 mount namespace 时会整树 rbind,把 /run/netns 一起复制进去,显示成 master:6 的 slave。它是 slave,宿主 umount 会传播过去,危害小一些,但只要那一刻存在就参与阻塞。

顺带说一句,我一开始怀疑的是 kruise-daemon——它挂的是整个 /var/run。排查到后面发现它只是挂了目录,没拿到 netns 的 dentry 引用,不是主因。

不止一台

本以为是个别现象,扫了一圈不是。几十个工作节点里差不多三分之二在刷同样的日志,messages 加起来接近 80G,好几台根分区到 85%。只是 node21 的日志最大、僵尸最多,最先被发现。

有意思的是,同样刷日志的节点里有些计数是 0——它们 /var/run/netns 下的文件数量和沙箱数量也对不上,但不报错。我原以为是运行时间长了才累积,对比 uptime 发现不是:有一台二月就开机、netns 40 个的节点,一次 EBUSY 都没有。所以是时序问题,某些节点上那个副本恰好没留下来。

两次没走通的改法

第一次想让 node-exporter 别碰宿主根。把挂载删掉,--path.rootfs 指向 /host/proc/1/root(它 hostPID: true,/proc/1 就是宿主 init)。改完容器内 netns 挂载数确实变成 0,但监控上的文件系统指标全废:

node_filesystem_device_error{mountpoint="/"} 1

两个原因。/proc/1/root 是个指向 / 的符号链接,容器里解析这个绝对路径会落到容器的根,不是宿主的;再加上容器没有 ptrace 权限,读 /proc/1/root 本身就不稳。这条路放弃,回滚。

第二次顺手想给 rsyslog 加限流,在 /etc/rsyslog.conf 顶上塞了两行 $SystemLogRateLimitInterval,结果 rsyslogd 启动直接 SEGV,配置校验却通过。日志服务停了半分钟,把备份 cp 回去拉起来。限流这一步后来干脆不做了,光靠 logrotate 的 size 限制就够防打满。

最后的改法

node-exporter 要的是"能对宿主文件系统做 statfs",不是"宿主根目录这个路径"。既然容器本身就跑在 overlay 上,底层就是那块盘,让它用容器视角读就行:

args:
- --path.procfs=/proc          # 原来 /host/proc
- --path.sysfs=/host/sys       # 不变
# --path.rootfs 直接去掉
- --collector.filesystem.mount-points-exclude=^/(.+)$
- --collector.filesystem.ignored-fs-types=^(autofs|...|tracefs)$
# volumes 里删掉 hostPath: /

^/(.+)$ 配合数字开头,只会剩下 / 一个挂载点——/ 本身不匹配,因为 .+ 至少要一个字符。再把 overlay 从 fs-type 忽略列表里拿掉,overlay 的 statfs 就会返回底层盘的数值。

改完验证:

# 容器内 netns 挂载数
0
# 指标
node_filesystem_size_bytes{device="/dev/vda1",fstype="xfs",mountpoint="/"}  1.15952562176e+11
node_filesystem_avail_bytes{...}                                            4.2362052608e+10
# 节点上 df -B1 /
/dev/vda1  115952562176  73590509568  42362052608  64% /

对得上。其他 collector(cpu、内存、disk、network)都正常,547 项指标都在。代价是面板上文件系统只剩 / 一个挂载点,原来那十几个 /run/.../shm 之类的小挂载点没了,磁盘使用率这个数字没变。

DaemonSet 用默认的 maxUnavailable: 1 滚动,50 台跑了二十来分钟。抽查十几台,netns_mounts 全是 0,指标也都对。

日志这边:truncate -s 0 /var/log/messages(不能 rm,rsyslog 持着 fd),journalctl --vacuum-size=500M,logrotate 改成 size 500M / rotate 5 / compress,journald 加 SystemMaxUse=500M。node21 从 70% 掉到 64%,单机释放 6G 左右。

存量清不掉

改完之后新的沙箱删除就正常了,但历史遗留的 28 个僵尸沙箱还是删不了。

验证过:把 node-exporter 容器 ns 里的 netns 副本 umount 掉,宿主挂载和业务 pod 都不受影响(那条路径只是引用,主挂载还在),容器内计数从 29 掉到 0。但从那之后 crictl rmp 依旧全部失败。

再扫一遍所有进程的 mountinfo,那 9 个 netns 文件已经没有任何 namespace 持有。stat 出来的 inode 是普通 tmpfs inode 而不是 nsfs 的(4026531xxx 那种),宿主 mountinfo 里没有,umount 报未挂载,rm 报 EBUSY。这是内核 dcache 上的残留状态,用户态没有口子能清。

只能重启节点。重启后 kubelet 会把僵尸沙箱全部回收,而且因为泄漏源已经断了,不会再长出来。

剩下那些有同样残留的节点,准备按业务窗口分批重启。

事后想想

hostPath: / 配 mountPropagation: HostToContainer 在监控组件里很常见。挂的是真个根目录,/run、/var/lib/kubelet 下面的所有子挂载都会被带进容器,而且容器里的路径和宿主是同一个 dentry。宿主 umount 之后副本还在,就形成跨 namespace 的挂载点,任何 unlink 都 EBUSY。选监控组件的挂载路径时,尽量挂具体子目录,别挂根。

containerd 1.6.21 的 PodSandboxStatus 里会顺手做 netns 清理,清理失败把整个状态查询打成 error。一个小错误被 kubelet 的 PLEG 放大成每秒 15 条日志,这个放大倍数挺吓人的。新版本在这块做了容错,值得排期升级。

排查时区分两种 inode 很有用:402653xxxx 是真 netns,普通 tmpfs inode 是空壳文件。空壳还删不掉,基本可以判定内核残留,别在用户态继续折腾。

最后,日志服务的配置改动别和别的变更混在一次里做。rsyslog 那次 SEGV 如果叠在 node-exporter 改造中,就分不清是谁的问题了。

评论 0

评论需 登录 后发表(注册并验证邮箱)。
还没有评论,来抢沙发~