k8s 和容器运行时(Docker)

官网所示,要使用 k8s,首先需要安装一种容器运行时

Docker

安装 Docker 来使用 K8s:

  • 基于 Debian/Ubuntu 的 Linux 系统上,docker 的安装命令:apt-get install docker-ce docker-ce-cli containerd.io
  • 该命令安装了三个组件:
    • docker-ce:Docker Community Edition(社区版),是 Docker 的主程序,也就是 Docker 引擎本身。提供了 dockerd 守护进程,负责管理容器、镜像、网络、存储卷等。安装后,可以使用 systemctl start docker 启动 Docker 服务。
    • docker-ce-cli:Docker CE Command Line Interface,是 Docker 的命令行工具,即你日常使用的 docker 命令(如 docker rundocker psdocker build 等)。
    • containerd.io:是 Docker 使用的底层容器运行时(container runtime)。负责:
      • 管理容器的生命周期(创建、启动、停止、删除)
      • 镜像的拉取与存储
      • 网络和文件系统挂载等底层操作
    • 需要注意:
      • Docker 引擎(docker-ce)并不直接操作容器,而是通过调用 containerd 来完成。
      • containerd.io 是由 Docker 官方打包并维护的 containerd 版本,确保与 Docker CE 兼容。

但是,可以注意到的是,该图上有一条说明:dockershim 已从 k8s 中移除。这里对其做一下解释:

首先需要明确:k8s 本身并不直接管理容器,而是通过 CRI(Container Runtime Interface,容器运行时接口) 与底层容器运行时交互。

  • CRI 是一套标准接口(gRPC API),定义了 kubelet 如何与容器运行时通信(如创建 Pod、拉取镜像等)。
  • Docker 诞生早于 CRI 标准,它不原生支持 CRI
  • 为了让 Kubernetes 能在早期使用 Docker(当时最流行的运行时),K8s 团队在 kubelet 内部写了一个“翻译器”——这就是 dockershim

dockershim 的作用:
把 CRI 的调用“翻译”成 Docker Engine 能理解的命令(通过 Docker API)

从下面的示意图中可以看出 dockershim 的作用

移除 dockershim 的原因有以下几点:

  1. 维护负担重
    dockershim 是 Kubernetes 社区维护的代码,但 Docker 公司(现为 Mirantis)并不参与维护,导致兼容性问题频发。
  2. 不符合 CRI 标准化目标
    Kubernetes 希望所有运行时都通过标准 CRI 接入,而不是为某个特定运行时(如 Docker)开后门。
  3. Docker 本身不是“纯”运行时
    Docker 是一个完整的开发者工具链(含 CLI、构建、网络、存储等),而 K8s 只需要“运行容器”的能力。用 containerd 更轻量、更专注。
  4. 安全与稳定性
    绕过 Docker Engine,直接使用 containerd/CRI-O,减少中间层,提升安全性和可靠性。

而事实上,containerd 本身是一个 CNCF(云原生计算基金会)毕业项目,可被 Kubernetes 等其他系统直接使用。

看到这里,我们可能会有一个问题:能否用 Docker 安装的 containerd 作为 Kubernetes 的运行时?

根据通义的回答,理论上应该是可以。但是 Docker 安装的 containerd.io 默认关闭了 CRI 插件,而 Kubernetes 依赖 CRI 接口通信。所以需要手动启动 CRI 插件。不过存在风险:如果 Docker 和 kubelet 同时操作同一个 containerd 实例,可能导致资源冲突(如镜像、容器命名空间),不推荐在生产环境混用

cgroup

我们在使用 docker 的时候,经常会修改如下的配置文件:

# /etc/docker/daemon.json

{
  "exec-opts": ["native.cgroupdriver=cgroupfs"],
  "registry-mirrors": ["https://docker.1ms.run"]
}

一般情况下,我们会指定一个镜像仓库来提高镜像的下载速度。但是,当涉及到 k8s 时,我们还需要设置一个选项 cgroupdriver 为 cgroupfs 或者 systemd。

cgroup 的版本

要理解为什么需要设置该选项,我们需要先了解 cgroup:

  • cgroup(Control Group,控制组)是 Linux 内核提供的一种机制,用于限制、记录和隔离进程组(process groups)所使用的系统资源(如 CPU、内存、磁盘 I/O、网络等)。它是容器技术(如 Docker、Kubernetes)实现资源隔离与限制的核心基础之一。

  • “cgroup” 代表 “control group”(控制组),并且永远不应大写。单数形式用于指代整个特性,也用作限定词,如 “cgroup 控制器”。 当明确引用多个单独的控制组时,使用复数形式 “cgroups”。

  • cgroup 在 Linux 中主要有两个版本:

    • cgroup v1
      • 从 Linux 2.6.24(2008 年)引入。
      • 每种资源(CPU、内存等)由一个独立的 子系统(subsystem) 管理。
      • 各子系统可以挂载到不同的层级结构(hierarchy),配置灵活但复杂。
      • 存在**“多层级混乱”问题**:不同资源可能属于不同控制组树,难以统一管理。
    • cgroup v2
      • 从 Linux 4.5(2016 年)起稳定支持,推荐使用
      • 统一层级结构:所有资源控制器(controllers)挂载到同一个层级下,避免 v1 的碎片化问题。
      • 更强的安全性和一致性(如原子性资源分配)。
      • 支持“线程模式”(thread mode),可对线程而非仅进程组进行控制。
      • Kubernetes 从 v1.25+ 开始正式支持 cgroup v2(需 containerd v1.6+ 等配合)。
      • 现代系统(如 Ubuntu 22.04、RHEL 9、CentOS Stream 9)默认启用 cgroup v2。
    • v1 和 v2 不能同时启用。系统只能运行其中一种模式。
  • cgroup v1 的“多层级混乱

    • 在 cgroup v1 中:

      • 每种资源类型(CPU、内存、I/O 等)被称为一个 子系统(subsystem)
      • 每个子系统可以独立挂载到自己的控制组树(hierarchy)
      • 不同子系统的树可以结构完全不同
    • 举个例子:

      假设你有两个进程:webdb,你可能希望:

      • 限制 webdb 总共用 2GB 内存
      • 但让 web 最多用 1 核 CPU,db 最多用 2 核 CPU

      在 cgroup v1 中,你可能会这样组织:

      内存子系统(memory)的层级:

      /memory
        ├── web_db_group    ← web 和 db 都在这里,限制总内存 2GB
        │   ├── web
        │   └── db
      

      CPU子系统(cpu)的层级:

      /cpu
        ├── web_group       ← 只有 web,限制 1 核
        └── db_group        ← 只有 db,限制 2 核
      

      ❗ 问题来了:同一个进程(如 web)在 memory 树和 cpu 树中属于不同的父组!

    • 这种组织方式,会导致如下的问题:

      • 进程归属不一致
        • 一个进程在不同资源维度下属于不同的控制组
        • 无法回答:“这个进程到底属于哪个‘容器’或‘服务’?”
      • 资源策略难以统一
        • 你想对“某个应用”整体限资源,但必须分别在每个子系统里手动同步配置
        • 容易出错:比如加了一个新进程到 memory 组,却忘了加到 cpu 组
    • 如下两张图所展示的就是 cgroup v1 下的Docker 守护进程(dockerd)所在的 cgroup 目录,用于限制或监控 dockerd 自身的资源使用(只展示了 cpu 和 memory 的目录)

  • 如下两张图所展示的是 cgroup v2 下的 Docker 守护进程(dockerd)所在的 cgroup 目录,可以看到有关 docker 的资源控制文件都在同一个目录下,不再按子系统拆分。

cgroup 驱动:cgroupfs 和 systemd

说完 cgroup,我们接着看 cgroup 驱动:

cgroupfs

cgroupfs 驱动

  • Control Group File System 的缩写。是一个 通用的、直接操作 /sys/fs/cgroup/ 文件系统 的方式。用户空间的进程(比如 Docker 引擎)直接通过文件操作创建 cgroup 目录、写入 PID、设置限制。Docker 早期默认使用 cgroupfs

  • cgroupfs 的“实现”

    • 首先需要理解 cgroup 是如何发挥作用的:

    • Linux 内核将 cgroup 暴露为一个 虚拟文件系统(pseudo filesystem),挂载后表现为普通目录和文件:

      • cgroup v1:多个挂载点,如 /sys/fs/cgroup/cpu/sys/fs/cgroup/memory
      • cgroup v2:单一挂载点 /sys/fs/cgroup

      这些目录中的文件(如 memory.maxcgroup.procs不是普通文件,而是内核的接口:

      • 写入 memory.max → 内核设置内存限制
      • 读取 cpu.stat → 内核返回 CPU 使用统计
      • 写入 PID 到 cgroup.procs → 内核将进程加入该 cgroup
    • cgroupfs 的“实现” = 直接读写这些虚拟文件纯文件 I/O

  • cgroupfs 工作流程(以容器启动为例)

    假设一个容器运行时(如早期 Docker)使用 cgroupfs 方式为容器设置资源限制:

    步骤 1:创建 cgroup 目录

    mkdir -p /sys/fs/cgroup/memory/mycontainer
    mkdir -p /sys/fs/cgroup/cpu/mycontainer
    # (cgroup v1 需要为每个子系统单独建目录)
    # cgroup v2 只需:
    mkdir /sys/fs/cgroup/mycontainer
    

    步骤 2:设置资源限制

    # cgroup v1
    echo "536870912" > /sys/fs/cgroup/memory/mycontainer/memory.limit_in_bytes
    echo "100000" > /sys/fs/cgroup/cpu/mycontainer/cpu.cfs_quota_us
    
    # cgroup v2
    echo "512M" > /sys/fs/cgroup/mycontainer/memory.max
    echo "100000 100000" > /sys/fs/cgroup/mycontainer/cpu.max
    

    步骤 3:将容器进程加入 cgroup

    echo $CONTAINER_PID > /sys/fs/cgroup/mycontainer/cgroup.procs
    

    步骤 4:启动容器进程(已在 cgroup 中)

    🔁 整个过程没有调用任何特殊 API,全是标准文件操作。

systemd

systemd 驱动

首先,systemd 是一个很复杂的工具,有很多其他功能,想要了解的可以看一下最下面的参考文章。本文只解释其作为 cgroup 驱动是如何工作的。

  • Systemd 负责管理整个系统的服务和服务进程的生命周期。为了统一管理,systemd 自己也接管了 cgroup 的层级结构。它通过其 D-Bus API 来创建和管理 cgroup。所有通过 systemd 启动的单元(unit,如服务、切片 slice、作用域 scope)都会自动成为一个 cgroup。
  • 简单理解:就像你使用一个高级的仓库管理软件(systemd)来下达指令(通过 API),由这个软件来精确地创建和管理仓库(cgroup)里的货架和物品。你不再直接接触文件柜。
  • 工作流程:
    1. 放弃直接文件操作,转而调用 Systemd API:不同于 cgroupfs,在 systemd 方式下,用户空间的进程在运行时不再直接操作这些文件。相反,它会通过 D-Bussystemd 发送请求,说:“请为我创建一个新的、瞬态的 systemd 作用域(scope)或服务(service)单元,并将这个进程放入其中。”
    2. Systemd 创建并管理 Cgroup:systemd 接收到请求后:
      • 它会根据请求的参数(比如来自 K8s 的 Pod 元数据)创建一个名称格式化的 transient scope 或 service。
      • 例如,在 Kubernetes 环境下,一个 Pod 的 cgroup 路径可能看起来像:
        kubepods.slice:kubepods-burstable.slice:kubepods-burstable-pod<pod_id>.slice:cri-containerd-<container_id>.scope
      • systemd 在背后会自动在 /sys/fs/cgroup/ 下的相应位置(通常是 unified 层级,即 cgroup v2)创建出这个复杂的目录结构。
    3. 资源限制的传递:当需要设置资源限制时(比如在 Kubernetes 的 YAML 中设置了 limits.memory: 200Mi),在 **systemd **方式下,用户空间的进程再次通过 D-Bus API 告诉 systemd:“请为我刚刚创建的那个 scope 单元设置 MemoryMax=200M 属性。” systemd 会负责将这个限制值写入到正确的 cgroup 文件中。
  • 为什么更推荐使用 systemd cgroup 驱动?
    1. 单一管理者:在大多数现代 Linux 发行版上,systemd 是 PID 1 的初始化进程,它已经是系统服务和进程的自然管理者。让两个不同的实体(systemd 和容器运行时)同时通过不同的方式(API 和 直接文件操作)去管理同一个 cgroup 层级,容易导致冲突和不可预知的行为。使用 systemd 驱动可以避免这种“两虎相争”的局面。
    2. 与系统服务更好的集成:使用 systemd 驱动,容器 cgroup 会像其他系统服务一样,出现在 systemctl 的层级视图中(例如 systemd-cgls),并且资源使用情况可以被 systemd 自身的日志和监控工具更好地捕获。
    3. cgroup v2 的必然选择:随着 Linux 向 cgroup v2 迁移,cgroupfs 驱动的方式变得越来越不推荐,甚至在某些场景下无法正常工作。systemd 是 cgroup v2 的“一等公民”,它对 cgroup v2 的支持最完善、最稳定。Kubernetes 从 1.25 版本开始,要求节点使用 cgroup v2 时,运行时必须配置为 systemd cgroup 驱动。
    4. 性能和稳定性:通过 API 进行集中管理,通常比大量并发地直接操作文件系统更可靠、更高效。

回到最初的问题

在知道了上述有关 cgroup 的知识后,我们回到最初的问题:当涉及到 k8s 时,我们为什么还需要设置一个选项 cgroupdriver 为 cgroupfs 或者 systemd?

我们之所以要对 docker 进行配置,是因为要符合 k8s 的一些设计目标,具体原因如下:

核心原因:Kubernetes 需要确定性和可移植性

K8s 的核心目标之一是在任何基础设施上都能有一致的行为。为了实现这一点,它需要对其依赖的核心组件有明确的控制,而不是依赖隐式的、可能变化的默认值。


详细解释

1、容器运行时并非唯一,且行为不一致

Kubernetes 被设计为支持多种容器运行时(Docker, containerd, CRI-O, kata-containers 等)。在早期,这些运行时的行为并不统一:

  • Docker:在默认使用 systemd 作为 init 的系统上,Docker 默认会使用 systemd cgroup 驱动;而在其他系统上可能默认使用 cgroupfs
  • containerd/CRI-O:它们也有自己的默认配置逻辑。

如果 Kubelet 说“我不管,你用啥我就用啥”,那么一个集群的节点可能因为 Docker 版本或 OS 发行版的不同,而混杂使用不同的 cgroup 驱动。这会导致严重的问题。

2、Kubelet 是资源的最终管理者,而非运行时

这是最关键的一点。Kubelet 的职责是成为 Pod 和容器资源的“大脑”,而容器运行时(如 containerd)更像是执行命令的“手臂”。

  • Kubelet 需要精确地知道 cgroup 的路径结构,以便:
    • 资源监控:从正确的 cgroup 路径文件中读取 cpuacct.usagememory.usage_in_bytes 等数据,提供给 Metrics Server 和 HPA。
    • 资源执行:在正确的 cgroup 路径文件中设置 CPU 配额(cpu.cfs_quota_us)、内存限制(memory.limit_in_bytes)等。
    • Pod 驱逐:在内存压力下,Kubelet 需要根据 Pod 的 cgroup 内存使用情况来决定驱逐哪个 Pod。

如果 Kubelet 不知道 cgroup 的精确布局,它就无法完成这些核心任务。通过明确指定驱动,Kubelet 就锁定了 cgroup 的路径生成算法。

举个例子:
一个名为 foo 的 Pod,在 QoS 类型为 Burstable 时:

  • 使用 cgroupfs 驱动,它的 cgroup 路径可能是:
    /sys/fs/cgroup/cpu/kubepods/burstable/pod<uid>/<container-id>
  • 使用 systemd 驱动,它的 cgroup 路径则是由 systemd 单元名定义的,类似于:
    /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/...

如果 Kubelet 不指定驱动,它就无法知道该去哪个路径下查找和设置数据。

3. 避免“两虎相争”,确保一致性

正如上一个问题中提到的,当系统由 systemd 管理时,如果 Kubelet 和容器运行时错误地混合使用了驱动,就会导致冲突。通过让 Kubelet 明确指定确保容器运行时使用与之匹配的驱动,Kubernetes 强制实现了一致性。

Kubelet 在启动时会进行检测,如果发现容器运行时的 cgroup 驱动与自己的配置不匹配,它会报错并退出,从而避免进入一个不可预测的状态。


现代的发展:从显式配置到自动检测

问题也反映了社区的发展方向。随着 Kubernetes 和容器运行时的成熟,Kubernetes 正在朝着更智能的自动检测方向发展

  • Kubelet 的自动检测:从 Kubernetes v1.22 开始,如果用户未在 Kubelet 配置中显式设置 cgroupDriver,并且使用的是 systemd 作为 init 的系统,Kubelet 会默认使用 systemd 驱动。这可以看作是对“为什么不直接用运行时的”的一种回应——它现在有了一个足够智能的默认值。
  • CRI 的标准化:容器运行时接口(CRI)也在不断演进,未来可能会在 CRI API 中更明确地沟通 cgroup 信息,进一步减少配置负担。
总结

为什么 Kubernetes 不直接使用容器运行时的 cgroup 驱动方式?

  1. 历史原因:早期运行时多样,默认行为不一,K8s 需要强制统一。
  2. 架构设计:Kubelet 是资源的主动管理者,而不是被动的消费者。它必须精确控制 cgroup 的布局以实现监控、限制和驱逐等核心功能。它不能将如此关键的责任“外包”给另一个组件。
  3. 确定性与可靠性:显式配置避免了因底层组件默认值变化而导致的集群不一致和潜在故障。
  4. 发展趋势:虽然最初是强制显式配置,但社区正通过提供智能默认值和改进标准来自动化这一过程,平衡了控制力和易用性。

参考:

[1] Linux 内核文档

[2] kubernetes 文档

[3] 通义千问

[4] Systemd 入门教程:命令篇

[5] deepseek

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐