kubelet PLEG is not healthy 排查实录:3 个节点 NotReady,真凶其实不在控制面

🔑 关键词:kubelet PLEG is not healthy, Kubernetes 节点 NotReady, nodefs imagefs 区别, containerd 磁盘IO, k8s 节点驱逐

📖 摘要:一次 12 节点集群的真实故障复盘:3 台 worker 陆续 NotReady,日志刷满 PLEG is not healthy。控制面指标全部正常,最后定位到 nodefs 和 imagefs 共用同一块系统盘。文中给出完整排查命令、阈值参数、三种修法的成本对比,以及一个被很多人忽略的判断方法。

先摆结论,省得你翻到最后

图片

集群规模不大:12 个 worker,K8s v1.26.8,容器运行时 containerd 1.6.21,节点是 8C32G 的云主机,系统盘 ESSD PL1 100G,没挂数据盘。某天下午 15:40 起,node-07、node-09、node-11 三台陆续 NotReady,kubectl describe node 里都是同一行:

PLEG is not healthy: pleg was last seen active 3m27s ago; threshold is 3m0s

看到这句,绝大多数人的第一反应是控制面扛不住了。我也一样。先去翻 apiserver 和 etcd 的指标:apiserver_request_duration_seconds 的 P99 是 62ms,etcd_disk_wal_fsync_duration_seconds 的 P99 是 8ms,etcd_server_leader_changes_seen_total 是 0。控制面干净得让人失望,问题根本不在那儿。

(顺便提一句,如果你也撞上这行报错,别急着扩容 apiserver。先跑 kubectl get --raw /metrics | grep apiserver_request_duration_seconds,P99 没超 100ms,控制面就是无辜的,方向要立刻往节点上挪。)

把方向从控制面挪到节点

登上 node-07,journalctl -u kubelet --since "15:30" -f,PLEG 的报错刷得停不下来。接着我做了三个动作。

第一,数容器。crictl ps -a | wc -l 出来是 214,其中 Running 状态 187 个。8C32G 的机器上跑 187 个容器,密度不算离谱,但也不算低。

图片

第二,看磁盘。iostat -x 1 5 的输出是这样的:

Device    r/s      w/s      rkB/s     wkB/s     await   %util
nvme0n1   1247.6   3862.1   8421.3    96120.4   342.7   99.87

%util 99.87,await 342.7ms。作为参照,一块状态正常的 ESSD PL1 在 4K 随机写下 await 大概在 1ms 上下,340ms 意味着 IO 队列已经彻底堵死了,不是「有点忙」,是「已经动不了」。

第三,看挂载点。df -h | grep -E "kubelet|containerd"

/dev/nvme0n1p1   99G   91G   8.1G   92%  /var/lib/kubelet
/dev/nvme0n1p1   99G   91G   8.1G   92%  /var/lib/containerd

同一个挂载点。到这里其实就破案了。

图片

nodefs 和 imagefs 是同一块盘,事情就大了

kubelet 内部把节点的文件系统分成两类:nodefs(/var/lib/kubelet,放 emptyDir、Pod 目录、容器日志)和 imagefs(/var/lib/containerd,放镜像层和容器可写层)。它分别统计这两者的容量,也分别用它们来做驱逐判断。

但当它们物理上就是一块盘的时候,kubelet 拿到的「两个文件系统」其实是同一套数字。nodefs.available<10%imagefs.available<15% 这两条阈值,等于是在拿同一个分母做两次判断——100G 的盘被镜像层和容器可写层吃掉 91G,可用只剩 8.1G,两条阈值同时触发。kubelet 一边疯狂做 image GC(默认 imageGCHighThresholdPercent=85imageGCLowThresholdPercent=80),一边在驱逐 Pod。GC 本身要读磁盘,驱逐重建又要拉镜像,IO 被推得更高,磁盘更堵,循环就这么转起来了。

PLEG 就是被这个循环拖垮的。PLEG(Pod Lifecycle Event Generator)默认每 1 秒 relist 一次所有容器的状态,这个动作本身不重,但它要去读 cgroup 和容器元数据,这些全在磁盘上。当磁盘 await 到 300ms 量级,一次 relist 就可能要好几秒。连续 3 分钟没能成功完成,kubelet 就报 PLEG is not healthy,然后停止上报节点状态——40 秒后(--node-monitor-grace-period 默认 40s)节点被标记 NotReady,再等 300 秒(taint-based eviction 的默认容忍时间),上面的 Pod 开始被驱逐、重建到别的节点,别的节点开始拉镜像……雪崩就是这么起来的。

三种修法,按「见效速度」排个序

方案 C(先止血):把高频写入挪出 nodefs

先找出谁是写入大户。crictl inspect <container-id> | grep -i log 拿到日志路径,再配合 cat /proc/<pid>/io 看 write_bytes 的增长速度。

图片

我们那次抓到的是两个应用:一个 Go 写的网关服务在打访问日志,每秒大概 3000 行;另一个是 Java 服务开着 DEBUG 级别日志没关。最快的处置办法是直接用内存盘接住它们——给容器挂 medium: Memory 的 emptyDir,日志写进去,采集器(我们用的是 Fluent Bit)从内存卷读走再转发:

volumeMounts:
  - name: app-logs
    mountPath: /var/log/app
volumes:
  - name: app-logs
    emptyDir:
      medium: Memory
      sizeLimit: 256Mi

两个坑要提前说:一是 sizeLimit 必须设,medium: Memory 的 emptyDir 走的是内存而不是磁盘,不设上限能把节点内存吃干净;二是 Pod 一重启日志就没了,如果你的业务需要事后查日志,采集链路必须先落远端。

改完之后,那台节点的 nodefs 日增长从 40G 掉到 6G 左右。这一步花的成本是零,见效是分钟级的。

方案 A(顺手做):调 kubelet 参数

把驱逐阈值收紧,给系统留出反应时间。我们最后落的是:

--eviction-hard=nodefs.available<15%,nodefs.inodesFree<10%,imagefs.available<20%,memory.available<500Mi
--image-gc-high-threshold=70
--image-gc-low-threshold=60
--serialize-image-pulls=false

图片

--serialize-image-pulls 默认是 true,也就是同一时间只拉一个镜像。改成 false 之后并发拉取会抬高瞬时 IO,但能缩短批量调度时的等待窗口,要不要动看你磁盘的余量。

坦白讲,参数调优只是把问题往后推。阈值从 10% 提到 15%,无非是提前二十分钟开始驱逐,磁盘 IO 该堵还是堵。它该做,但别指望它救命。

方案 B(治本):给 containerd 单独挂一块盘

申请一块 ESSD 挂到 /data,然后改 containerd 的 /etc/containerd/config.toml

root = "/data/containerd"
state = "/run/containerd"

kubelet 的 --root-dir 保持默认的 /var/lib/kubelet 不动。这样 nodefs 和 imagefs 在物理上就分家了,kubelet 的两条阈值判断才是各自独立、准确的。

图片

代价也很实在:改 containerd 的 root 要重启 containerd 和 kubelet,节点上所有容器会重建,必须先 drain;已有的镜像要重新拉一遍(100G 的镜像库走内网大概 20 分钟);再加上多一块盘的钱。所以我把这一步排在最后,但如果你问我「长期怎么做」,答案就是这个。

我真正想说的那件事

kubelet 的文档把 nodefs 和 imagefs 写成了两个并列的概念,绝大多数教程也照着这么说,但它们到底是不是物理分离的,其实取决于你的磁盘拓扑,而文档里几乎不强调这一点。结果就是大量中小集群在「两个 FS 共享一块盘」的状态下运行,所有基于阈值的驱逐逻辑在这种架构下都是失准的——你以为你在给镜像层设上限,其实你同时在给 Pod 日志设上限,反之亦然。

判断方法就一行,现在就可以在你自己的机器上跑:

df -h /var/lib/kubelet /var/lib/containerd | awk 'NR>1 {print $1, $6}'

如果两行输出的设备名是一样的,那你就该考虑拆盘了。

最后说个反直觉的点:节点上容器越多,PLEG 越容易出问题,但扩容并不解决这件事。因为 PLEG 的开销正比于容器数量,磁盘 IO 的争抢也是——加一个节点,等于给每个节点分摊了更多容器,单节点的 IO 压力反而更大。真正能走通的只有两条路:降低单节点的 IO 密度(拆盘),或者降低单个容器的 IO 需求(内存卷、限流、把狂写日志的应用修掉)。前者要花钱,后者要改代码,都不轻松,但比加监控告警有用得多。

🏷️ 标签: