运行时内部原理:cgroups、runc 以及 containerd 的位置
究竟是什么在隔离和限制容器,从内核特性一路到 Kubernetes 所通信的守护进程。
“容器”描述的是一组被连接在一起的 Linux 内核特性,而不是一个自成一体的内核对象。本指南从底层开始逐层讲解整个栈:限制资源的内核特性、用它组装出容器的工具,以及大规模管理该工具的守护进程。
cgroups:内核层面的资源控制
控制组(cgroups)是 Linux 内核的一项特性,可追溯到 2008 年,用于在一组进程之间分配和限制系统资源,如 CPU、内存、I/O。这个体系下的一种资源类型称为控制器(controller)(或子系统),受它管辖的进程被组织成一个与文件系统相似的层级结构:一个 cgroup 就是一个目录,进程通过将自己的 PID 写入该目录而加入其中。
两条规则决定了 cgroups 如何组合:
- 单个层级结构可以挂载多个控制器,但某一个控制器(例如 CPU)同一时间只能挂载到一个层级结构上。
- 一个进程在给定层级结构内恰好属于一个 cgroup。将它移到同一层级结构中的另一个 cgroup 会使它离开原来的那个;它永远不会同时位于同一层级结构中的两个位置。
cgroups 有 v1 和 v2 两个版本,主要区别在于层级结构与控制器之间的关系;v2 将各控制器统一在单一层级结构下,而不是允许 v1 所支持的那种更灵活(也更复杂)的安排。现代发行版和 Kubernetes 默认使用 v2。
cgroups 本身只管理资源限制。它不涉及文件系统、镜像格式,也不涉及对进程所见的网络或进程树进行命名空间隔离,而所有这些才是真正让某个东西感觉像一个被隔离的容器、而不仅仅是一个受资源限制的进程的原因。
runc:用内核原语组装容器
runc 是 OCI 运行时规范的参考实现:一个底层工具,它的全部工作就是接收一个文件系统和一个配置文件,并把它们变成一个运行中的、被隔离的进程。它需要两样东西来运行一个容器:
- 一个根文件系统(
rootfs):容器的文件系统内容,从镜像中提取而来。 - 一个 config.json:由
runc spec生成,描述该进程应如何运行的一切,包括 Linux capabilities、挂载点、要创建或共享哪些命名空间、通过 cgroups 应用的资源限制。
mkdir my-container && cd my-container
mkdir rootfs
# populate rootfs from an image's exported filesystem layers
runc spec
runc run my-container-id
如果你写过 Kubernetes 的 Pod spec,config.json 的形态会让你觉得眼熟:capabilities、挂载和命名空间配置,与 Kubernetes 通过自己的 API 暴露的是同样的底层概念,只是更靠近内核一层。runc 有意保持狭窄:它按该配置启动和停止一个容器的进程。它没有拉取镜像、长期运行的守护进程,或与 Kubernetes 通信的任何概念。
containerd:大规模管理 runc
containerd 是负责 runc 周边一切事务的守护进程:拉取和解包镜像、管理这些镜像被解压到的存储快照、跟踪运行中的容器,并暴露一个供直接客户端和 Kubernetes(通过 CRI)使用的 gRPC API。它就是 kubelet 实际通信的组件。
containerd 并不直接运行你容器的进程,它将其委托给一个 shim,这是位于 containerd 与容器实际进程之间的一个小型长驻进程。shim 的任务是让容器独立于 containerd 本身继续运行,这样重启或升级 containerd 守护进程就不会杀死节点上的每一个运行中的容器。containerd 1.x 随附了三种 shim 实现;containerd 2.0 移除了其中最旧的两个,只留下基于 runc 的 shim v2,而它早已是多年来的默认值。
kubelet ── CRI (gRPC) ──▶ containerd ── shim ──▶ runc ──▶ container process
│
└── manages images, snapshots, storage
由于这是一个分层架构,containerd 并不被锁定在 runc 上。一个 shim 可以对接一个完全不同的执行引擎,例如用于更强沙箱隔离的 gVisor、用于虚拟机级别隔离的 Kata Containers 等等,而 containerd 的镜像管理、CRI 实现,以及一切面向 Kubernetes 的部分保持不变。
把整条路径串起来
一个 Pod 的容器,从头到尾:kubelet 向 containerd 发送 CRI 请求。containerd 在镜像尚不存在时拉取它,将其解包为节点文件系统上的一个快照,然后交给 shim。shim 用生成的 config.json 调用 runc,并把该快照作为 rootfs。runc 创建 Linux 命名空间,应用来自 Pod 资源请求和限制的 cgroup 限制,并启动进程。从那时起,shim 监管着这个运行中的进程,并通过 containerd 将其状态汇报回 kubelet,而这正是 kubectl get pods 最终读取的内容。
每一层解决一个问题:cgroups 限制资源,runc 用 rootfs 和配置组装出符合 OCI 的容器,shim 让该容器独立于守护进程受到监管,而 containerd 编排这一切,并暴露 Kubernetes 所需的 API。这些部分没有一个在做别的部分的工作,这正是在不触及 shim 层之上任何东西的情况下替换 runc 为另一个低层运行时成为可能的原因。