NPD 容器持续 OOM,Pod 重建时引发节点高负载根因分析
kubernetes containerd k8s runc oom cgroup摘要
K8S 生产集群的 worker 节点在 NPD(Node Problem Detector)Pod 被重建时,出现 CPU sys% 飙升、load 暴涨、同节点其他 Pod 延迟明显变大的现象。经过完整排查,根因定位为:NPD 的 memory limit 配置过小(200Mi),导致容器频繁触发 memcg OOM;OOM 后 containerd 通过 cleanupAfterDeadShim 路径调用 runc delete 清理容器,而 runc 1.1.12 中存在两处错误丢弃缺陷以及 RemovePaths 重试窗口过短的问题,致使每次容器退出后其 cgroup 目录均未被正确清理;经过约一个月的累积,单个 NPD Pod 下残留了 8147 个子 cgroup 目录;当该 Pod 被删除重建时,kubelet 的 PodContainerManager.Destroy 对全部残留子 cgroup 进行递归遍历和 rmdir 操作,在 cgroup v1 的全局锁 cgroup_mutex 上形成严重争抢,最终导致节点 sys% 飙高并波及同节点所有 Pod。本文完整记录从现象到根因的排查过程,并结合源码分析给出修复方案。
1 故障现象
1.1 排查起点:节点 CPU 高负载
本次排查的直接触发点是:节点在 NPD Pod 被重建时出现瞬时高负载,具体表现为:
top观察到 sys%(内核态 CPU 占用)飙升至 30% 以上,而 us%(用户态)相对正常;vmstat 1观察到cs(上下文切换次数)和in(中断次数)同时大幅上升;- 同节点上其他正在运行的 Pod 出现明显的调度延迟和 I/O 抖动。
进一步向下定位,发现高负载的源头是 NPD Pod 对应的 cgroup 目录下累积了大量残留的容器子 cgroup 目录。继续深入排查,才追溯到 NPD 反复 OOM 与 runc 清理时序缺陷之间的连锁反应。
1.2 现象特征
现场观察到的关键特征如下:
- NPD Pod
node-problem-detector-z6bzz处于 CrashLoopBackOff 状态,restartCount=9115,对应 Pod 自 3 月 24 日创建以来约一个月时间; - 每次容器退出后,
/sys/fs/cgroup/<subsystem>/kubepods/burstable/pod442613a5-.../<container-id>/路径下全部 12 个 subsystem 的目录均未被删除; - 累积到 8147 个残留目录,所有目录的
cgroup.procs均为空,手动执行rmdir可以瞬间成功; - containerd 和 kubelet 均未自动清理这些残留目录,且日志中没有任何相关错误记录。
1.3 典型日志序列
以下是一条典型容器生命周期中的日志序列(容器 ID:092a70936d92...,Attempt=8886):
Apr 25 12:19:07 CreateContainer ... Attempt:8886
Apr 25 12:19:07 StartContainer
Apr 25 12:19:08 StartContainer returns successfully
Apr 25 12:19:10 – 12:19:11 TaskOOM × 10
Apr 25 12:19:12 shim disconnected id=092a70936d92...
Apr 25 12:19:12 cleaning up after shim disconnected
在该容器 ID 对应的所有日志中,自 shim disconnected 之后便再无任何记录。containerd 从未报告容器清理失败,看似一切都很正常,但实际却有 cgroup 目录残留了下来。
2 环境信息
| 组件 | 版本 / 配置值 |
|---|---|
| OS 内核 | 5.4.241-19-0017.1_plus |
| cgroup 版本 | v1 |
| containerd | v1.6.9 |
| runc | 1.1.12 |
| OCI runtime wrapper | nvidia-container-runtime 1.17.4 |
| cgroup driver | cgroupfs(SystemdCgroup=false) |
| NPD memory limit | 200Mi(严重偏小) |
| NPD 加载的 monitor plugin 数量 | 23 个以上(包含一些自定义的 plugin) |
需要说明的是,本环境中 containerd 配置的 OCI runtime 名称为 nvidia-container-runtime,该二进制作为 wrapper 在收到 delete 请求后会注入 GPU 相关 hooks,再转调底层 runc。在本文描述的故障路径中,wrapper 本身并未引入额外缺陷,根因仍在 runc 内部。
3 各组件在容器生命周期中的职责
在展开根因分析之前,有必要先建立对容器创建/销毁链路上各组件职责的全景认知。容器生命周期涉及 kubelet → containerd → shim → runtime wrapper → runc → kernel 共六层协作,任何一层都可能存在缺陷。
┌─────────────────────────────────────────────────────────────────────────┐
│ kubelet │
│ ├── PodContainerManager : 管理 pod 级 cgroup(创建/删除 pod 目录) │
│ ├── cleanupOrphanedPodCgroups : 兜底清理孤儿 pod(仅当 pod 被删时) │
│ └── cAdvisor : 周期扫描 cgroup 获取 stats │
└────────────┬────────────────────────────────────────────────────────────┘
│ CRI gRPC
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ containerd │
│ ├── cri plugin : 生成 CgroupsPath 写入 OCI spec │
│ ├── runtime/v2/manager : 管理 shim 进程生命周期 │
│ └── cleanupAfterDeadShim : shim 异常退出 → fork 新 shim 执行 delete │
└────────────┬────────────────────────────────────────────────────────────┘
│ ttrpc
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ containerd-shim-runc-v2(常驻进程,1 容器 1 个 shim) │
│ ├── task/service.go : 处理 Task.Create/Start/Delete 等 RPC │
│ ├── processExits : 监听容器 init 退出(SIGCHLD reaper) │
│ ├── OOM watcher : 监听 memory.oom_control eventfd │
│ └── manager.Stop : 收到 delete 命令时,拉起 runc delete │
└────────────┬────────────────────────────────────────────────────────────┘
│ exec
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ nvidia-container-runtime(wrapper,本案例中配置的 BinaryName) │
│ └── 注入 GPU 相关 hooks → 转调底层 runc │
└────────────┬────────────────────────────────────────────────────────────┘
│ exec
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ runc(1.1.12) │
│ ├── delete.go : delete 子命令入口,status 决策分支 │
│ ├── libcontainer.destroy : signalAllProcesses + cgroupManager.Destroy │
│ └── cgroups/fs.Destroy : RemovePaths (5 次重试 = 310ms 窗口) │
└────────────┬────────────────────────────────────────────────────────────┘
│ rmdir syscall
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Linux kernel (5.4.241) │
│ ├── cgroup v1 fs : rmdir 需持 cgroup_mutex(全局锁) │
│ └── memcg OOM killer : 异步逐个 SIGKILL,清完 ~19 个进程需 ~2 秒 │
└─────────────────────────────────────────────────────────────────────────┘
本案例中出问题的是链路的后四层,各层的具体问题将在后续章节逐一分析。
4 根因分析
4.1 容器退出清理的两条路径
理解整个 bug 的前提是看清 containerd 对容器退出的两种清理路径。NPD 的 cgroup 残留发生在 Path B。
Path A:正常路径(kubelet 主动删除运行中的容器)
kubelet containerd shim runc
────── ────────── ──── ────
StopContainer ────> task.Delete ──ttrpc───> Init.delete
│ waitTimeout(2s)
│ runtime.Delete ──> runc delete
│ │ status=Running
│ │ killContainer
│ │ (100×100ms 轮询)
│ │ 进程退出
│ │ destroy(c)
│ │ cgroupManager.Destroy ✓
│ │ 清理 runc state
│ ▼ exit 0
│ UnmountAll
▼
b.bundle.Delete()
结果:cgroup 正常删除,bundle 删除,runc state 删除
Path B:异常路径(shim 先于 containerd 发现 init 死亡,ttrpc 断开)
kubelet containerd shim runc
────── ────────── ──── ────
[ttrpc OnClose 回调] ╳ shim 挂了
cleanupAfterDeadShim
│ binaryCall.Delete ──fork──> 新 shim binary(delete 子命令)
│ │ manager.Stop
│ │ r.Delete(Force:true) ──> runc delete --force
│ │ │ status=Stopped(init 已死)
│ │ │ destroy(container) ◄── 问题点
│ │ │ signalAllProcesses
│ │ │ (p.Wait → ECHILD, 不等待)
│ │ │ cgroupManager.Destroy
│ │ │ RemovePaths: 5×retry = 310ms
│ │ │ ✗ EBUSY(cgroup 里还有进程)
│ │ │ 错误 → log.json(即将被删)
│ │ ▼ exit 0(bug #1: destroy 无返回值)
│ │ UnmountAll
│ ▼ bundle.Delete()(连带 log.json 一起删)
▼ Warn 也不打(Force-Delete 看 exit 0 = 成功)
结果:cgroup 残留,bundle 删除,runc state 删除,containerd 日志无任何错误
NPD 必然走 Path B 的原因:NPD 稳态下有 19 个进程,memcg OOM 时 kernel 并发发送 SIGKILL,init 进程通常最先死亡;shim 感知到 init 退出,在完成 TaskExit 事件上报前 ttrpc 连接已经断开;containerd 观察到的是 shim disconnected → 进入 cleanupAfterDeadShim。
4.2 runc delete 的决策树与三个缺陷
runc delete 根据 container.Status() 的返回值进入不同分支,不同分支中"是否等待进程退出"的行为差异极大。NPD 命中的正是没有等待的那条分支。
runc delete --force <id>
│
▼
s = container.Status()
│
┌───────────────────────┼───────────────────────────┐
▼ ▼ ▼
Stopped (init 已死) Created Running / Paused (+ force)
│ │ │
│ (NPD 走此分支) │ │
│ │ │
│ killContainer() killContainer()
│ │ │
│ ┌──────────────┴──────────────┐ │
│ │ send SIGKILL to init │◄───────────┘
│ │ for i in 0..100: │
│ │ sleep 100ms │
│ │ if signal(0) err: break │ ← 只等 init 死
│ │ │ 不等其他子进程
│ │ destroy(container) │
│ └──────────────┬───────────────┘
│ │
└───────────────────────▼───────────────────┐
destroy(container) ★
│
▼
container.Destroy()
│
▼
signalAllProcesses(SIGKILL)
│
│ Freeze cgroup
│ 列出 pids
│ 发 SIGKILL
│ Thaw cgroup
│ for p in procs:
│ p.Wait() ← ECHILD, 立即返回 ◄── BUG: 不真正等进程退出
▼
cgroupManager.Destroy()
│
│ RemovePaths(5 次重试=310ms)
│ 第 1 次 rmdir → EBUSY
│ ...
│ 第 5 次 rmdir → EBUSY ◄── BUG: 重试窗口太短
│ return "Failed to remove..."
▼
destroy 函数签名无返回值,仅 logrus.Error ◄── BUG #1
│
▼
delete.go return nil → exit 0 ◄── BUG #2
缺陷 1:runc/delete.go 中 destroy() 的返回值被丢弃
runc/delete.go:73-85:
s, err := container.Status()
switch s {
case libcontainer.Stopped:
destroy(container) // ← NPD 走这条:init 已死,status = Stopped
case libcontainer.Created:
return killContainer(container)
default:
if force {
return killContainer(container)
}
return fmt.Errorf(...)
}
return nil // ← 无论 destroy 内部发生什么,这里都返回 nil
注意:即使加了 --force,只要 init 已死(status=Stopped),runc delete 不会走 killContainer(该函数内有 100 次 × 100ms 的轮询等待),而是直接调 destroy。而 destroy(container) 的返回值被完全忽略。
缺陷 2:runc/utils_linux.go 中 destroy() 函数无返回值
runc/utils_linux.go:115-119:
func destroy(container libcontainer.Container) { // ← 无返回值
if err := container.Destroy(); err != nil {
logrus.Error(err) // 只打 log 到 <bundle>/log.json
}
}
container.Destroy() 返回的错误只被写进了 bundle 目录下的 log.json 文件。而 destroy() 的函数签名决定了这个错误无法向上传递。
缺陷 3:RemovePaths 重试窗口远小于实际 OOM 清理所需时间
runc/libcontainer/cgroups/utils.go:272-312:
func RemovePaths(paths map[string]string) (err error) {
const retries = 5 // ← 只有 5 次重试
delay := 10 * time.Millisecond
for i := 0; i < retries; i++ {
if i != 0 {
time.Sleep(delay)
delay *= 2 // 指数退避:10 → 20 → 40 → 80 → 160ms
}
for s, p := range paths {
if err := RemovePath(p); err != nil { ... }
_, err := os.Stat(p)
if os.IsNotExist(err) { delete(paths, s) }
}
}
return fmt.Errorf("Failed to remove paths: %v", paths)
}
总重试窗口 = 10+20+40+80+160 = 310ms。
而 Linux memcg OOM killer 的行为是异步 + 逐个给 cgroup 内所有进程发送 SIGKILL。在 NPD 场景下,cgroup 内共有 19 个进程,kernel 完成全部清理实测需要约 2000ms。runc 的 310ms 重试窗口对此远远不够。
4.3 signalAllProcesses 的等待失效问题
runc/libcontainer/state_linux.go:38-60 中的 destroy 函数:
func destroy(c *linuxContainer) error {
if !c.config.Namespaces.Contains(configs.NEWPID) ||
c.config.Namespaces.PathOf(configs.NEWPID) != "" {
if err := signalAllProcesses(c.cgroupManager, unix.SIGKILL); err != nil {
logrus.Warn(err)
}
}
err := c.cgroupManager.Destroy()
...
}
对于 NPD 容器(hostPID=true),OCI spec 里 pid namespace 的 path 指向 sandbox 的 pid ns,非空,因此上述条件满足,会调用 signalAllProcesses。
但 signalAllProcesses 的实现(runc/libcontainer/init_linux.go:597-621)存在等待失效问题:
for _, p := range procs {
...
if subreaper == 0 {
if _, err := p.Wait(); err != nil {
if !errors.Is(err, unix.ECHILD) {
logrus.Warn("wait: ", err)
}
}
}
}
runc delete --force 是 containerd 在 cleanupAfterDeadShim 中 fork 出的全新临时进程,和 NPD cgroup 里的 19 个进程不是父子关系。p.Wait() 对每一个非子进程都立刻返回 ECHILD(“no child processes”),随后错误被跳过——signalAllProcesses 事实上在发完 SIGKILL 之后没有等待任何进程退出便返回了。
4.4 关键时序矛盾
通过每 100ms 采样一次的脚本,从 NPD 启动到 OOM 清理完成,捕获到了决定性的时序证据。
采集 4 个状态字段(后文简写为 procs/cg/rs/bd)。字段含义如下:
| 简写 | 全名 | 含义 | 检查方式 |
|---|---|---|---|
| procs | cgroup processes | 容器 memory cgroup 里当前还活着的进程数 | wc -l < /sys/fs/cgroup/memory/kubepods/.../<container-id>/cgroup.procs |
| cg | cgroup dir | 该容器的 cgroup 目录是否还存在 | test -d /sys/fs/cgroup/memory/.../<container-id> → Y/N |
| rs | runc state | runc 维护的容器状态文件是否还存在 | test -f /run/containerd/runc/k8s.io/<id>/state.json → Y/N |
| bd | bundle | containerd 的容器 bundle 目录(含 config.json、log.json、rootfs 挂载点)是否还存在 | test -d /run/containerd/io.containerd.runtime.v2.task/k8s.io/<id> → Y/N |
判读指南:
- 正常生命周期:procs>0, cg=Y, rs=Y, bd=Y →(清理时)→ procs=0, cg=N, rs=N, bd=N
- bug 特征:rs=N, bd=N, cg=Y(state 和 bundle 都删了,但 cgroup 目录没删),就是残留
- 时序矛盾特征:某一帧出现 procs>0, rs=N, bd=N —— 意味着 runc delete 在 cgroup 还非空时就已经返回并清理了 state/bundle,这是 cgroup 清理失败的直接证据
08:21:05.582 procs=1 ← NPD 启动
08:21:05.796 procs=3
08:21:06.008 procs=13 ← plugin 开始 fork
08:21:06.233 procs=19 ← 稳态(19 个进程)
...
08:21:11.537 procs=7 ← OOM 开始,kernel 逐个 SIGKILL
08:21:12.074 procs=6
08:21:13.823 procs=4 rs=N bd=N ← runc delete 已完成(state/bundle 已删)
但 cgroup 里还有 4 个进程!
08:21:13.931 procs=0 rs=N bd=N ← 100ms 后进程才全部退出
最终:cgroup 目录残留 12 个
runc 的 310ms 重试窗口结束时,cgroup 里还有 4 个以上进程未完成退出,所有 rmdir 必然返回 EBUSY。
这直接证明 cgroupManager.Destroy() 内部的 rmdir 是在非空 cgroup 上尝试的,必然失败,但 runc 却返回了 exit 0。
4.5 错误链如何被完全吞没
RemovePaths返回"Failed to remove paths: ..."错误;cgroupManager.Destroy()返回该错误;state_linux.go:destroy(c)返回该错误;container.Destroy()返回该错误;runc/utils_linux.go:115的destroy()只调用logrus.Error(err)写入<bundle>/log.json,函数无返回值;runc/delete.go:75的destroy(container)调用后 fall through 到return nil→ runc 进程 exit 0;- go-runc 的
runOrError看到 exit 0 → 返回 nil; - containerd 的
r.Delete(Force:true)返回 nil; manager.Stop不打印"failed to remove runc container",继续执行mount.UnmountAll;binary.Delete调用b.bundle.Delete()→ 删除 bundle 目录,连同 log.json 一起丢失;- containerd 日志里完全看不到任何 runc 错误。
至此,cgroup 清理失败的事实被完全掩盖,在日志中不留任何痕迹。
5 kubelet 为何不兜底
kubelet 中存在一个名为 cleanupOrphanedPodCgroups 的兜底机制(kubernetes/pkg/kubelet/kubelet_pods.go:2460),但其触发条件是 Pod 整体消失(成为孤儿 Pod)。
在 NPD 的故障场景中,Pod 本身一直处于 CrashLoopBackOff 状态,Pod 对象始终存在,因此 kubelet 永远不会对该 Pod 触发 cleanupOrphanedPodCgroups。残留的容器子 cgroup 没有任何组件负责清理。
6 Pod 重建时节点高负载的放大机制
6.1 三条叠加的放大路径
路径 A:kubelet 主动 walk 所有子 cgroup 查找进程(主要 CPU 消耗来源)
Pod 被删除 → kubelet 进入终止清理流程 → 调用 podContainerManagerImpl.Destroy(pkg/kubelet/cm/pod_container_manager_linux.go:196):
func (m *podContainerManagerImpl) Destroy(podCgroup CgroupName) error {
if err := m.tryKillingCgroupProcesses(podCgroup); err != nil { ... }
...
if err := m.cgroupManager.Destroy(containerConfig); err != nil { ... }
}
tryKillingCgroupProcesses 最多重试 5 次(pod_container_manager_linux.go:171),每次都调用 cgroupManager.Pids(podCgroup)。
cgroupManagerImpl.Pids(cgroup_manager_linux.go:483-530)的实现是关键爆炸点:
func (m *cgroupManagerImpl) Pids(name CgroupName) []int {
...
for _, val := range m.subsystems.MountPoints { // ← 12 个 subsystem
dir := path.Join(val, cgroupFsName)
...
visitor := func(path string, info os.FileInfo, err error) error {
...
pids, err = getCgroupProcs(path) // ← 对每个子目录都 open/read cgroup.procs
...
}
if err = filepath.Walk(dir, visitor); err != nil { ... }
}
return sets.List(pidsToKill)
}
filepath.Walk 会递归遍历 pod cgroup 下的所有子目录,对每个目录都执行 stat + open/read cgroup.procs。
计算量估算:
12 subsystem × 8147 残留子 cgroup × 5 次重试
≈ 488,820 次 stat + 488,820 次 open/read
即使单次 I/O 只消耗几十微秒,总耗时也达到数十秒,累积的系统调用和 cgroupfs inode 锁压力足以将 sys% 打高。
路径 B:libcontainer 递归 rmdir(连锁 cgroup_mutex 争抢)
tryKillingCgroupProcesses 之后,kubelet 调用 libcontainer 的 manager.Destroy(cgroup_manager_linux.go:306)。libcontainer 的 v1 fs driver Destroy 实现是 cgroups.RemovePaths(m.paths)。
RemovePath 的递归逻辑(runc/libcontainer/cgroups/utils.go:241):
func RemovePath(path string) error {
if err := rmdir(path); err == nil { return nil }
infos, err := os.ReadDir(path)
...
for _, info := range infos {
if info.IsDir() {
if err = RemovePath(filepath.Join(path, info.Name())); err != nil { break }
}
}
if err == nil { err = rmdir(path) }
return err
}
RemovePaths 外层还有 5 次重试(utils.go:272)。
计算量估算:
12 subsystem × 8147 子 cgroup × 最多 5 次外层重试
≈ 488,820 次 rmdir 系统调用
cgroup v1 的 rmdir 在内核侧需要执行以下步骤:
- 获取全局锁
cgroup_mutex(kernel/cgroup/cgroup-v1.c); - 执行各控制器回调
css_offline; - 等待 RCU grace period;
- 释放引用计数并真正删除 kernfs 节点。
cgroup_mutex 是全局锁,意味着在这段时间内:
- 其他 Pod 启动时的 cgroup 创建操作会排队;
- kubelet / cAdvisor 的周期性 cgroup stats 读取会排队;
- containerd shim 创建新容器的 cgroup 操作会排队;
- 整个节点的 cgroup 子系统都被这一个 Pod 的删除动作"堵住"。
路径 C:cAdvisor 持续扫描(Pod 存活期间的常态负担)
即使不删除 Pod,这 8147 个残留目录也不是"零成本"的。kubelet 内置的 cAdvisor 每 10-15 秒扫描一次所有 cgroup:
pkg/kubelet/stats/cadvisor_stats_provider.go调用ContainerInfoV2;- cAdvisor 对每个发现的 cgroup 子目录读取
memory.stat、cpu.stat、cpuacct.usage、blkio.*等文件。
稳态下这部分 I/O 虽然未达到"高负载"级别,但会持续消耗 CPU、增加 cgroupfs 访问压力,同时也可能污染 kubelet 的 stats 上报数据(将大量空容器的 zero stats 一并上报)。Pod 重建时,该扫描任务与路径 A/B 叠加,进一步放大短时 spike。
6.2 放大模型总览
NPD memory=200Mi 配置过小
│
▼
反复 OOM → cgroup 残留 8147 个(runc bug,详见 §4)
│
kubectl delete pod / Pod 被驱逐
│
▼
┌───────────────────────────────────────────────────────┐
│ kubelet: PodContainerManager.Destroy() │
│ (pod_container_manager_linux.go:196) │
└─┬────────────────────────────┬────────────────────────┘
│ │
▼ ▼
tryKillingCgroupProcesses cgroupManager.Destroy
(最多 5 次外层重试) (libcontainer.RemovePaths)
│ │
│ 每次调用 │ 每次外层 retries=5
│ cgroupManagerImpl.Pids │ rmdir 每个路径
│ filepath.Walk │
│ │
│ × 12 个 subsystem │ × 12 个 subsystem
│ × 5 次重试 │ × 5 次重试
│ × 8147 个子 cgroup │ × 8147 个子 cgroup
▼ ▼
≈ 488,820 次 stat + read ≈ 488,820 次 rmdir 系统调用
(stat + open + read) (每次都需 cgroup_mutex)
│ │
└────────────┬───────────────┘
│ 另外还有:
│ • cAdvisor 周期扫描(10-15s)
│ 读所有 memory.stat/cpu.stat
│ • 其他 Pod 启动时的 cgroup 创建
│ • containerd 新建容器的 cgroup 操作
▼
╔═════════════════════════════════════════════════╗
║ Linux kernel: cgroup_mutex (全局锁) ║
║ + VFS inode lock ║
║ 所有 cgroup 相关操作串行化 ║
╚════════════════════════╤════════════════════════╝
▼
sys% ↑↑ softirq ↑↑ context switch ↑↑
│
▼
节点 load 暴涨 → 其他 Pod 受影响(IO / 调度延迟)
6.3 为何表现为"高负载"而非单纯的"变慢"
- sys% 飙高而非 us%:主要开销在内核态——
cgroup_mutex的 spinlock、rcu_sync、VFS inode lock; - 全节点波及:
cgroup_mutex是全局锁,所有 cgroup 操作被串行化; - CPU 上下文切换飙升:大量 kubelet / containerd / 其他组件的 goroutine/线程阻塞在锁上 → runqueue 变长;
- 内存回收链路受影响:memcg 的
css_offline路径也要获取cgroup_mutex; - 监控指标表现:
vmstat 1看到sy列飙高、cs飙高、in飙高;top看到si(softirq)和sy异常,us相对正常。
7 跨组件完整时序图
以下为一次 NPD OOM 事件中 kernel / NPD / shim / containerd / runc 的完整互动时序:
时间轴 kernel/memcg NPD 进程组 shim containerd runc
────── ─────────── ─────────── ─────────── ──────────── ─────
T=0 init (pid=X)
19 个进程
T=0.0s memcg 检测到
超 limit
T=0.0s OOM killer
挑选 victim
T=0.0s+ SIGKILL init init 退出
│
T=0.1s SIGKILL ...其余进程
子进程... 陆续退出中
收到 SIGCHLD
processExits
处理 init 退出
│
ttrpc 连接
在事件发送
前后断开 ╳
[WithOnClose 回调]
"shim disconnected"
"cleaning up after..."
│
binaryCall.Delete
│ fork 新 shim ──┐
▼
(新 shim binary)
manager.Stop
│
r.Delete(Force:true) ──fork──> runc delete
│ status=Stopped
│ destroy(c)
│ signalAll
│ SIGKILL (已是)
│ p.Wait ECHILD (不等待)
│ cgroupManager
│ .Destroy
│ RemovePaths
│ rmdir#1..5
T=0.5s ...进程仍在 │ 全部 EBUSY
逐个退出... │ ✗ 清理失败
│ 错误→log.json
│ os.RemoveAll
│ runc state ✓
▼ exit 0 (!)
r.Delete 返回 nil
(无任何 error 日志)
UnmountAll rootfs
bundle.Delete()
(log.json 一并删)
shim.Delete 返回
发 TaskExit/TaskDelete
│
T=2.0s 最后一个进程
退出
cgroup 为空
但 12 个目录
仍然存在 ←───── (无人再 rmdir)
T=... cAdvisor 仍会周期扫描残留 cgroup 读 stats,数量持续累积
图中标注了三个关键断点(任意一个修复都能减轻问题):
p.Wait ECHILD未等待:runc 1.x 未修复,runc 1.2 有一定改进;rmdir 全 EBUSY:runc 1.2 commitd3d7f7d改为持续重试,彻底修复;exit 0:runc 1.2 commit7396ca9改为错误透传,让问题可见。
8 修复方案
8.1 应用侧修复(推荐,最小改动)
根本思路:不让 NPD 发生 OOM,自然不触发 runc bug。
方案 A(必做):调大 NPD memory limit
resources:
limits:
cpu: 500m
memory: 512Mi # 从 200Mi → 512Mi(或 1Gi)
requests:
cpu: 100m
memory: 256Mi
依据:NPD v2.1.2 + 23 个 plugin + 19 个稳态进程,200Mi 严重偏紧。dmesg 里 OOM 受害者多为 container-scann 这类 shell-based plugin 子进程。
方案 B(可选):精简 NPD plugin 配置
检查 configmap/npdplus-npd-config,考虑移除或延长轮询间隔:
custom-container-scanner.json(OOM 日志里的直接触发点);custom-plugin-stuck-process.json(扫描全节点进程,fork 数量多);log-counter-kubelet.json(持续扫描 kubelet 日志)。
方案 C(可选):升级 NPD 版本
v2.1.2 → v0.8.20+ 或 v2.x 新版本,修复了若干内存泄漏与 fork 开销问题。
8.2 历史残留一次性清理
在修复根因之前,需要先清理已累积的残留 cgroup 目录。推荐的分批清理脚本逻辑如下(完整脚本见 systemd/cleanup-orphan-cgroups.sh):
#!/bin/bash
# 安全三重校验:cgroup 空 + bundle 不存在 + runc state 不存在
for sub in /sys/fs/cgroup/*/kubepods; do
[ -d "$sub" ] || continue
find "$sub" -mindepth 3 -maxdepth 3 -type d 2>/dev/null | while read d; do
id=$(basename "$d")
if [ -z "$(cat "$d/cgroup.procs" 2>/dev/null)" ] \
&& [ ! -d "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$id" ] \
&& [ ! -f "/run/containerd/runc/k8s.io/$id/state.json" ]; then
rmdir "$d" 2>/dev/null
fi
done
done
要点:
- 分批 + sleep:每清理 500 个 rmdir 之后 sleep 1 秒,错峰释放
cgroup_mutex; - 三重校验:cgroup.procs 为空 + bundle 不存在 + runc state 不存在,确保只清理真正的孤儿目录;
- 清理期间用
vmstat 1监控 sys%,如发现 sys% 持续超过 30%,加大 sleep 间隔; - 清理干净后再 delete Pod:重建时 kubelet Destroy 处理的子 cgroup 数量接近 0,几乎无负载。
该脚本可以打包成 DaemonSet 定时任务作为长期兜底,每 10-30 分钟运行一次。
8.3 上游 runc 修复(彻底但侵入较大)
本报告涉及的两个关键 runc bug,在 runc 1.2 中已有官方修复。
修复 1:让 destroy() 错误向上传递
社区 commit:opencontainers/runc@7396ca9
要点:
destroy()函数签名增加error返回值;- 所有
delete.go里的调用点都改为透传错误(case libcontainer.Stopped: return destroy(container)以及类似位置)。
效果:cgroup 清理失败时,runc 进程以非零状态退出;go-runc 层面能捕捉到 exit code,r.Delete(Force:true) 返回 error;containerd 的 manager.Stop 能打出 "failed to remove runc container" 日志——问题变得可观测。
注意:仅此修复只是让错误可见,containerd 的 manager.Stop 收到错误后也只是 Warn 而不做进一步处理(manager_linux.go:265-268),cgroup 残留仍会发生,需要配合修复 2 才能根治。
修复 2:RemovePath 改为持续重试直到成功或确认失败
社区 commit:opencontainers/runc@d3d7f7d
要点:
- 不再用固定次数重试,改为"一直重试直到 cgroup 成功删除或判定不可删除";
- 重试由"cgroup 是否还存在"驱动(通过
os.Stat返回IsNotExist判定成功),而非凑够 N 次就放弃; - 短延迟轮询(几十毫秒级),配合上下文超时控制。
效果:无论 OOM 清理需要 0.3 秒还是 5 秒,runc 都会一直轮询直到 cgroup 为空并 rmdir 成功;彻底解决"重试次数不够"的根因,不再受经验值限制。
修复 3(可选):优化 signalAllProcesses 的等待语义
runc 1.1.12 的 signalAllProcesses 用 p.Wait() 等待进程退出,对"runc 不是这些进程父进程"的场景直接返回 ECHILD 而不等待(init_linux.go:615-621)。
改进思路:改用 kill(pid, 0) 或 /proc/<pid> 的存在性作为轮询退出条件,配合超时控制。runc 1.2 系列有相关改进,但不如修复 2 那样有一个集中的 commit,更多是增量优化。
在修复 2 生效后,修复 3 的紧迫性大幅下降:即使 signalAllProcesses 不等待进程退出,RemovePath 的持续轮询也会顶上,最终一定能完成删除。
8.4 修复方案对比
| 方案 | 改动范围 | 见效时间 | 风险 | 推荐度 |
|---|---|---|---|---|
| 调大 NPD memory limit | 改 1 个 DaemonSet | 立即 | 极低 | ⭐⭐⭐⭐⭐ |
| 精简 NPD plugin | 改 configmap | 立即 | 低 | ⭐⭐⭐⭐ |
| 升级 NPD 版本 | 改镜像 tag | 立即 | 中 | ⭐⭐⭐ |
| 历史残留清理脚本 | 新增 DaemonSet | 立即 | 极低 | ⭐⭐⭐⭐⭐(必做) |
| 升级 runc 到 1.2+ | 替换 runc 二进制并全节点重启 | 周级 | 中 | ⭐⭐⭐⭐(根治上游 bug) |
| Backport 1.2 的 2 个 commit 到当前 runc | 自行编译 runc 分发 | 周级 | 较高 | ⭐⭐(过渡方案) |
综合建议:先按 §8.2 分批清理历史残留 → 再调大 NPD memory limit(§8.1)避免再次触发 → 长期推动升级 runc 到 1.2+(§8.3,已包含这两个 bug 的官方修复)。
9 所有事实的自洽验证
| 观察事实 | 根因解释 |
|---|---|
| 12 个 subsystem 全残留 | RemovePaths 对所有 subsystem 的 rmdir 都返回 EBUSY |
| bundle、runc state 被删 | runc 其余清理步骤正常完成 |
| containerd 无任何 error 日志 | runc exit 0 + 错误日志写到 log.json 然后 log.json 被删 |
| 手动 rmdir 瞬间成功 | 事后 cgroup 已空,只是没有组件再来执行 rmdir |
手动 kill -9 init 不残留 | cgroup 里只有 1 个进程,瞬间清理完毕 |
采样到 procs=4, rs=N, bd=N | 直接证据:runc delete 返回时 cgroup 非空 |
procs: 4 → 0 需 100+ms | 验证了实际清理时间远超 runc 重试窗口 |
| 8147 个累积残留 | 每次 OOM 必现 × 约一个月 |
10 排查过程中用到的关键命令
以下命令保留给后续类似问题的排查复用:
# 1) 查 cgroup 残留
find /sys/fs/cgroup -type d -name "<container-id>*" 2>/dev/null
# 2) 确认残留 cgroup 里已无进程
for d in $(find /sys/fs/cgroup -type d -name "<container-id>*"); do
echo "== $d =="
cat "$d/cgroup.procs" 2>/dev/null
done
# 3) 查看累积残留规模
ls /sys/fs/cgroup/memory/kubepods/burstable/pod<UID>/ | wc -l
# 4) 对比 runc state 与 bundle 一致性
comm -23 \
<(ls /run/containerd/runc/k8s.io/ | sort) \
<(ls /run/containerd/io.containerd.runtime.v2.task/k8s.io/ | sort)
# 5) 实时观察单次容器生命周期(精度 100ms)
while true; do
F=$(crictl ps --name <container-name> --no-trunc -q 2>/dev/null | head -1)
[ -n "$F" ] && break
sleep 0.1
done
CG=/sys/fs/cgroup/memory/kubepods/burstable/pod<UID>/$F
for i in $(seq 1 200); do
TS=$(date +%H:%M:%S.%N | cut -c1-15)
PROCS=$(cat $CG/cgroup.procs 2>/dev/null | wc -l)
RS=$(test -f /run/containerd/runc/k8s.io/$F/state.json && echo Y || echo N)
BD=$(test -d /run/containerd/io.containerd.runtime.v2.task/k8s.io/$F && echo Y || echo N)
echo "$TS procs=$PROCS rs=$RS bd=$BD"
sleep 0.1
done
# 6) 读 OCI spec 确认 namespace 配置
cat /run/containerd/io.containerd.runtime.v2.task/k8s.io/$F/config.json \
| python3 -c "import sys,json; [print(n) for n in json.load(sys.stdin)['linux']['namespaces']]"
# 7) 读 runc state.json 查看 cgroup_paths
cat /run/containerd/runc/k8s.io/$F/state.json | python3 -m json.tool | grep -iE "cgroup|bundle"
11 结论
NPD Pod 重建时节点出现高负载的根本原因是一条多环节连锁的缺陷链:NPD 的 memory limit 配置过小(200Mi)导致容器频繁 memcg OOM;OOM 后 containerd 通过 cleanupAfterDeadShim 路径调用 runc delete;runc 1.1.12 的 delete.go 和 utils_linux.go 两处错误丢弃缺陷,加上 RemovePaths 的 310ms 重试窗口远小于 memcg OOM 实际清理进程所需的 ~2000ms,导致每次容器退出后 cgroup 目录均未被正确删除;经过约一个月的累积,8147 个残留子 cgroup 在 Pod 重建时被 kubelet 的 Destroy 流程递归遍历和 rmdir,在 cgroup v1 的全局锁 cgroup_mutex 上形成严重争抢,最终引发节点 sys% 飙高并波及全节点。
修复顺序:先分批清理历史残留 → 调大 NPD memory limit 避免再次触发 → 长期推动升级 runc 到 1.2+ 以彻底修复上游缺陷。