====================
== Hi, I'm Vimiix ==
====================
Get hands dirty.

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
containerdv1.6.9
runc1.1.12
OCI runtime wrappernvidia-container-runtime 1.17.4
cgroup drivercgroupfs(SystemdCgroup=false
NPD memory limit200Mi(严重偏小)
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.godestroy() 的返回值被丢弃

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.godestroy() 函数无返回值

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)。字段含义如下:

简写全名含义检查方式
procscgroup processes容器 memory cgroup 里当前还活着的进程数wc -l < /sys/fs/cgroup/memory/kubepods/.../<container-id>/cgroup.procs
cgcgroup dir该容器的 cgroup 目录是否还存在test -d /sys/fs/cgroup/memory/.../<container-id> → Y/N
rsrunc staterunc 维护的容器状态文件是否还存在test -f /run/containerd/runc/k8s.io/<id>/state.json → Y/N
bdbundlecontainerd 的容器 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 错误链如何被完全吞没

  1. RemovePaths 返回 "Failed to remove paths: ..." 错误;
  2. cgroupManager.Destroy() 返回该错误;
  3. state_linux.go:destroy(c) 返回该错误;
  4. container.Destroy() 返回该错误;
  5. runc/utils_linux.go:115destroy() 只调用 logrus.Error(err) 写入 <bundle>/log.json函数无返回值
  6. runc/delete.go:75destroy(container) 调用后 fall through 到 return nilrunc 进程 exit 0
  7. go-runc 的 runOrError 看到 exit 0 → 返回 nil;
  8. containerd 的 r.Delete(Force:true) 返回 nil;
  9. manager.Stop 不打印 "failed to remove runc container",继续执行 mount.UnmountAll
  10. binary.Delete 调用 b.bundle.Delete()删除 bundle 目录,连同 log.json 一起丢失
  11. 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.Destroypkg/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.Pidscgroup_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.Destroycgroup_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 在内核侧需要执行以下步骤:

  1. 获取全局锁 cgroup_mutexkernel/cgroup/cgroup-v1.c);
  2. 执行各控制器回调 css_offline
  3. 等待 RCU grace period;
  4. 释放引用计数并真正删除 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.statcpu.statcpuacct.usageblkio.* 等文件。

稳态下这部分 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 commit d3d7f7d 改为持续重试,彻底修复
  • exit 0:runc 1.2 commit 7396ca9 改为错误透传,让问题可见

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 的 signalAllProcessesp.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.goutils_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+ 以彻底修复上游缺陷。