线程泄漏拖垮节点:Pod 没有 pids 上限

节点突然 NotReady,根因是某 Pod 线程泄漏耗尽主机 PID 表,kubelet 无法创建新进程。为容器加 pids 上限、修复无界重试线程后恢复,附排查命令与监控项。

问题背景

公司有一套 5 节点的 Kubernetes 集群,跑着订单、库存、支付等十几个 Java 服务。资源限制按惯例只配了 CPU 和内存,没有人给容器设过进程数上限;kubelet 的 podPidsLimit 也保持默认的 -1,也就是不限制。大家默认“容器里能起多少进程是应用自己的事”。

订单服务有个重试逻辑:调用下游库存接口失败时,把请求交给一个新的 Thread 去重试,最多重试五次。平时下游很快,重试很少发生。上个月底大促,库存库出现慢查询,接口超时变多,重试线程开始堆积。

故障现象

  • 凌晨两点,监控告警:一台节点 NotReady,上面的 Pod 被批量驱逐,业务出现短暂 502。
  • 登录节点非常卡,普通命令还能执行,但 kubectl exec、systemctl restart 都报 resource temporarily unavailable。
  • kubelet 日志刷屏:failed to create new process: resource temporarily unavailable,伴随 runc 创建容器失败。
  • ps -eLf | wc -l 数出来三万多条,接近该节点 kernel.pid_max 的 32768。
  • 找到订单服务的一个 Pod,/sys/fs/cgroup/.../pids.current 显示三万左右,几乎全是线程。
  • 应用日志里有 java.lang.OutOfMemoryError: unable to create new native thread,但容器内存并没有到 limit。
  • 重启该 Pod 后节点恢复,NotReady 状态自动解除,其他服务陆续回到正常。

排查过程

第一步看节点状态。kubectl describe node 显示 NotReady,Conditions 里 PIDPressure 为 True。kubelet 日志的关键错误是 fork 失败:resource temporarily unavailable。这不是 CPU、内存或磁盘的问题,而是系统创建不出新进程了。

第二步确认 PID 总量。节点上 cat /proc/sys/kernel/pid_max 是 32768,ps -eLf | wc -l 已经三万多,/proc/loadavg 最后一位(最近创建的 PID)也接近上限。Linux 的 PID 表是节点级共享资源,线程和进程都占 PID;表满之后,任何进程再 fork 都会返回 EAGAIN,kubelet 无法调用 runc,节点自然就 NotReady 了。这里还要区分两个上限:pid_max 限制 PID 总量,threads-max 限制全系统线程总数,容器里的线程同时受两者约束,排查时两个都要看,不能只盯一个。

第三步找谁吃掉了 PID。遍历 /sys/fs/cgroup/kubepods/.../pids.current,绝大多数 Pod 只有几十到几百,只有一个订单服务的 Pod 到了三万。kubectl exec 已经进不去,只能通过节点上的 nsenter 进命名空间,执行 jstack 抓线程栈。

第四步看线程栈。jstack 输出里密密麻麻全是 retry-worker-* 线程,状态大多是 RUNNABLE 停在 SocketInputStream.socketRead0,少量在等数据库连接。对照代码,重试逻辑每次失败就 new Thread(...),没有线程池、没有上限、没有超时;下游一慢,线程只增不减,直到把节点 PID 表耗尽。

第五步确认为什么没有拦住。Deployment 里只有 resources.limits.cpu/memory,没有 pids 相关配置;kubelet 启动参数没有 --pod-max-pids,默认 -1;容器运行时也没有设 pids limit。也就是说,这个 Pod 可以无限创建线程,唯一的“上限”是整台节点的 PID 表——而它并不知道自己正在拖垮同节点的其他服务。

第六步验证。先在节点上杀掉该 Pod 的进程(kubelet 已经无法正常回收),节点 PID 数量回落,NotReady 解除;再把重试逻辑改成固定大小的线程池加超时,最后在测试集群用脚本模拟线程泄漏,确认 pids 上限会先于节点 PID 表触发,Pod 自己被限制而不是拖垮节点。

解决方案

  1. 立即恢复:节点 PID 耗尽时,kubelet 可能已经无法执行命令,优先通过带外或 SSH 杀掉异常进程,或直接重启节点;恢复后把问题 Pod 驱逐到其他节点,避免再次踩踏。
  2. 设置 pids 上限:kubelet 增加 --pod-max-pids=4096(对应 podPidsLimit),给所有 Pod 一个统一的进程数上限;对 Java、Go 这类线程多的服务,可按服务单独评估。容器运行时侧也可以配置 pids limit 作为第二道闸。
  3. 修复线程模型:重试不再 new Thread,改用固定大小的线程池 + 有界队列;设置连接超时、重试退避和熔断,下游持续慢时快速失败,而不是无限堆积线程。
  4. 补监控:采集每个 Pod 的 pids.current / container_processes,以及节点 PID 使用率;阈值设在 80%,并在 PIDPressure 变成 True 之前告警。同时监控 Java 线程数,线程数持续上涨就是泄漏信号。
  5. 压测与演练:在测试环境注入线程泄漏,验证 pids 上限能在 Pod 级别拦住,节点保持 Ready;把“PID 耗尽”纳入故障演练清单。
  6. 验证:生产观察一周,订单服务线程数稳定在池大小附近,节点 PID 使用率回落到 10% 以下,未再出现 NotReady。

根因分析

直接根因是 订单服务的无界重试逻辑在下游变慢时持续创建新线程,Pod 又没有 pids 上限,线程耗尽节点级 PID 表,kubelet 和 runc 无法创建新进程,节点进入 NotReady。

更深一层,是把资源限制理解成了只有 CPU 和内存。PID 是节点级的共享资源,容器默认可以无限使用;kubelet 的 podPidsLimit 默认 -1,等于把整台节点的 PID 表交给了每一个 Pod。只要有一个应用写出无界线程创建,它就能影响同节点的所有服务,故障半径从“一个 Pod”扩大到“一台节点”。

还有一层是重试逻辑的设计问题。重试本身没错,但“每次失败开一个新线程”把下游的慢放大成了本地的线程爆炸;没有超时、没有退避、没有熔断,重试就从保护机制变成了攻击自己的武器。

预防措施

  • 所有 Pod 默认配置 pids 上限,Java/Go 服务按线程模型单独评估;kubelet 的 podPidsLimit 不允许保持 -1。
  • 代码规范禁止无界创建线程:重试必须走有界线程池或异步框架,必须带超时、退避和熔断。
  • 监控三件套:Pod pids.current、节点 PID 使用率、应用线程数;任一持续上涨就告警,不等 PIDPressure。
  • 测试环境定期做线程泄漏注入,验证 pids 上限和 Pod 驱逐行为符合预期。
  • 容量评估时把“线程数”纳入服务画像,Java 服务尤其要关注连接池、线程池和重试策略的乘积效应。

总结

CPU 和内存限制保护的是资源配额,pids 限制保护的是节点本身。一个没有 pids 上限的 Pod,理论上可以创建到把整台机器的 PID 表吃光,让 kubelet 连一个容器都拉不起来;而应用侧只需要一段“失败就 new 一个线程重试”的代码,就能触发这条链路。把 PID 当成和 CPU、内存一样的资源来管,把重试关进有界线程池,节点才不会因为一个服务的坏习惯而整体倒下。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计