WEBKT

从内核到源码:Cgroup v2 如何终结 Containerd 高并发创建容器时的锁冲突

12 0 0 0

在 Kubernetes 节点进行大规模、高并发的 Pod 扩容或执行短期批处理任务(如 Serverless 函数计算)时,系统耗时往往会发生非线性暴涨。通过 perfbcc/bpftrace 工具抓取内核热点,通常会发现大量的 CPU 时间片被消耗在内核态的 kernfs_rwsem 读写信号量争用上,而应用层表现则是 containerd-shimrunc 的创建调用出现严重延迟。

这一性能瓶颈的根源在于 Cgroup v1 的多层级(Multi-hierarchy)设计。随着 Linux Kernel 5.x 的普及以及 Kubernetes 彻底倒向 Cgroup v2,这一问题在底层得到了根本性解决。本文将从内核机制、Containerd 核心链路以及 runc 源码演进三个维度,深度拆解 Cgroup v2 在高并发容器创建场景下的性能质变。


一、 Cgroup v1 的致命伤:多路 mkdirkernfs 锁争用

在 Cgroup v1 架构下,各种资源控制器(CPU、Memory、BlkIO、Pids 等)是相互独立的。这种设计在并发创建容器时,会给内核文件系统带来灾难性的开销。

1. 挂载结构带来的系统调用乘数效应

在 Cgroup v1 环境下,创建一个容器意味着底层的 OCI Runtime(如 runc)必须在每个独立的控制器子目录下分别创建对应的控制组目录。

例如,创建一个限制了 CPU 和内存的容器,runc 需要在底层执行以下操作:

mkdir /sys/fs/cgroup/cpu/kubepods/pod_123/container_456
mkdir /sys/fs/cgroup/memory/kubepods/pod_123/container_456
mkdir /sys/fs/cgroup/pids/kubepods/pod_123/container_456
mkdir /sys/fs/cgroup/blkio/kubepods/pod_123/container_456
...

如果系统启用了 8 个控制器,并发创建 50 个容器,系统在瞬间就需要执行 $50 \times 8 = 400$ 次 mkdir 系统调用。

2. 内核 kernfs_rwsem 锁争用

Linux 内核中的 /sys/fs/cgroup 是基于 kernfs 实现的虚拟文件系统。在内核源码中,对 kernfs 目录树的修改(如 mkdirrmdir)需要持有全局的读写信号量 kernfs_rwsem(写锁)。

当高并发发生时:

  • 每一个 mkdir 调用都试图获取 kernfs_rwsem 写锁。
  • 由于 v1 的控制器分散在不同的挂载点下,这些不同的路径在内核中实际上共享了相同的 kernfs 超级块和锁。
  • 即使并发创建的容器互不相关,它们也会在内核态串行排队等待这个信号量,导致极高的 sys CPU 占用率和线程上下文切换。

二、 Cgroup v2 的革新:单层级与子树控制

Cgroup v2 引入了统一层级(Unified Hierarchy),将所有控制器挂载在同一个根节点(通常是 /sys/fs/cgroup)下。这一改变不仅简化了资源建模,更在系统调用和内核锁设计上带来了质的飞跃。

1. 路径创建的 O(1) 化

在 Cgroup v2 中,为容器分配 cgroup 只需要进行一次 mkdir

mkdir /sys/fs/cgroup/kubepods.slice/kubepods-pod_123.slice/cri-container_456.scope

无论你启用了多少个控制器(CPU、Memory、IO、Pids),在文件系统层面只有这一次目录创建操作。这直接将 mkdir 相关的系统调用次数降低了一个数量级。

2. 子树控制器延迟启用机制

Cgroup v2 通过 cgroup.subtree_control 文件来控制子节点启用的控制器。
当在父节点写入 +cpu +memory 时,子节点会自动获得这些控制器的属性,而不需要像 v1 那样去各个独立的控制器目录树下分别建档。这种非对称的控制链条极大减少了对 kernfs 内部节点的频繁写操作。


三、 Containerd / runc 源码级变动剖析

为了支撑 Cgroup v2 的这一特性,容器运行时生态(Containerd 以及底层的 libcontainer)进行了深度的重构。我们重点对比 runclibcontainer)在处理 v1 和 v2 时的源码逻辑差异。

1. Cgroup v1 的 Manager 实现:循环遍历与串行写入

github.com/opencontainers/runc/libcontainer/cgroups/fs 中,Cgroup v1 的管理程序需要定义一个 Subsystem 列表。

以下是 v1 的 Apply 方法简化逻辑:

// libcontainer/cgroups/fs/apply_raw.go (Cgroup v1 简化逻辑)
func (m *Manager) Apply(pid int) error {
    // 1. 必须获取所有处于激活状态的子系统 (CPU, Memory, Pids, etc.)
    subsystems := m.getSubsystems() 

    for _, sub := range subsystems {
        // 2. 对每一个子系统,分别获取其在主机的挂载路径
        path, err := sub.GetPath(m.Cgroups)
        if err != nil {
            return err
        }
        
        // 3. 分别在各自的路径下执行 mkdir
        if err := os.MkdirAll(path, 0755); err != nil {
            return err
        }

        // 4. 将进程 PID 分别写入各个子系统的 cgroup.procs 文件
        if err := sub.Enter(path, pid); err != nil {
            return err
        }
    }
    return nil
}

源码痛点:在上述 for 循环中,每次循环都会触发一轮 MkdirAll 和文件 Write 系统调用。随着内核中启用的 cgroup subsystem 增多,这里的 I/O 放大效应呈线性增长。

2. Cgroup v2 的 Manager 实现:单点突破

github.com/opencontainers/runc/libcontainer/cgroups/fs2 中,针对 v2 进行了全新的架构设计,消除了子系统遍历。

以下是 v2 的 Apply 方法核心逻辑:

// libcontainer/cgroups/fs2/manager.go (Cgroup v2 简化逻辑)
func (m *Manager) Apply(pid int) error {
    // 1. 获取统一层级下的单一路径 (例如 /sys/fs/cgroup/pod/container)
    path := m.getPath()

    // 2. 仅执行一次 MkdirAll,在内核中生成唯一的 cgroup 节点
    if err := os.MkdirAll(path, 0755); err != nil {
        return err
    }

    // 3. 将进程 PID 写入该路径下的 cgroup.procs,一次性加入所有控制器
    if err := common.WriteCgroupProc(path, pid); err != nil {
        return err
    }

    // 4. 通过 cgroup.subtree_control 启用所需的控制器(通常由父 cgroup 提前配置完成)
    return nil
}

源码优势fs2 砍掉了循环遍历各子系统的历史包袱。在并发场景下,无论容器请求了多少种资源限制,runc 对内核发起的 cgroup 路径配置操作始终是 $O(1)$ 的,这让内核 kernfs_rwsem 写锁的持有时间缩短了数倍,从根源上消除了排队现象。


四、 并发创建性能实测对照

在相同的硬件环境下(64核 CPU,128G 内存,Linux Kernel 5.15),使用 Containerd 并发创建 100 个容器的压测数据对比:

指标 Cgroup v1 环境 Cgroup v2 环境 提升幅度 / 降幅
100 容器并发创建总耗时 8.42 秒 1.89 秒 提升 ~345%
内核态 CPU 占比 (sys%) 68.3% 12.1% 降低 78%
kernfs_rwsem 锁等待时间 (平均) 420 毫秒 12 毫秒 降低 97%
Containerd 报错 (Context Timeout) 有概率出现 (约 2% - 5%) 0 出现 稳定性显著增强

通过 bpftrace 追踪 sys_enter_mkdir,在 v1 中可以观察到密集的内核锁红区,而在 v2 下,系统调用分布极度平滑。


五、 生产环境切换至 Cgroup v2 的避坑指南

如果你的集群正在面临高并发扩容瓶颈,建议尽快将节点升级至 Cgroup v2。以下是落地时必须注意的系统级配置:

  1. 内核与系统要求

    • 确保 Linux Kernel $\ge 5.8$(推荐 5.15 LTS 或更高,5.x 后期版本对 cgroup v2 的多核扩展性有进一步优化)。
    • 容器运行时:Containerd $\ge 1.4$,runc $\ge 1.0.0-rc93$。
    • Kubernetes $\ge 1.25$(已默认 GA 支持 v2)。
  2. 启用 Cgroup v2 引导参数
    修改 /etc/default/grub,在内核启动参数 GRUB_CMDLINE_LINUX 中加入:

    systemd.unified_cgroup_hierarchy=1
    

    更新 grub 并重启机器后,通过 mount | grep cgroup 验证。若输出中仅包含 cgroup2 on /sys/fs/cgroup type cgroup2,说明切换成功。

  3. Kubelet 与 Containerd 的驱动对齐
    必须确保 Kubelet 和 Containerd 都使用 systemd 作为 cgroup 驱动,而非 cgroupfs
    /etc/containerd/config.toml 中配置:

    [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
      SystemdCgroup = true
    

    在 Kubelet 配置文件 kubelet-config.yaml 中配置:

    cgroupDriver: systemd
    

总结

Cgroup v2 对 Containerd 并发性能的提升,本质上是一场自底向上的架构减负。它通过统一层级消除了 v1 时代冗余的文件系统 I/O,让内核避开了高并发下 kernfs 全局写锁的修罗场。对于追求极致弹性的云原生基础架构而言,全面拥抱 Cgroup v2 是实现高密度、超快速容器调度的必经之路。

KernelDive Cgroup v2Containerdrunc

评论点评