容器运行时到底管什么
很多人每天都在用 docker run 创建容器,但如果你问一句:这个容器到底是怎么被操作系统拉起来的?可能不少人和我一样,真正能在纸上画清楚的人并不多。这是正常的,因为容器生态在过去十年里不断拆分和重组,Docker 已经不再像早年那样扮演全家桶角色。

今天想沿着一条主线聊聊容器运行时(container runtime)的演进:从最初 Docker 里的 daemon,到中间层 containerd,再到目前的轻量级 OCI runtime crun。这条路径不是一次技术变革的纪念碑,而是规模、标准、效率共同推动的结果。理解了这条路径,你再看 Kubernetes 节点上的运行时选型、容器启动速度问题、甚至某些性能怪问题,都会有更清楚的判断。
先理一个最基础的问题:容器运行时本质上到底做哪些事?
容器运行时到底管什么
如果把“运行一个容器”拆开,会看到这样几件事:镜像的解压与存储,文件系统的准备,cgroup 与 namespace 的创建,进程的启动和监控。日常开发里这些动作都被 Docker 封装了,所以我们常常无感知。但底层这些步骤并不归属于一个单一进程,而是很早就被 OCI 标准切割成了不同模块。
2015 年左右,Docker 开始牵头制定开放容器标准(OCI),目的是让镜像格式和容器运行方式不绑定在某家公司身上。于是有了两个关键产物:OCI image-spec(镜像格式)和OCI runtime-spec(运行时行为)。后者定义了如何根据一个 bundle 目录去启动一个进程,这个进程就是真正跑在 namespace 和 cgroup 里的容器进程。
如果从“能不能把命令执行起来”这个角度分,容器运行时其实有上下层之分。
上层叫做 高层运行时(High-level runtime),比如 Docker Engine、containerd、CRI-O。它们负责镜像管理、访问控制、API 暴露,以及把用户请求转换成一份描述进程的 runtime-spec bundle。
下层叫做 低层运行时(Low-level runtime),真正负责利用 Linux 内核能力把进程启动起来,比如 runc、crun、gVisor、Firecracker。这一层大多数用户根本不直接接触。
+------------------+ +------------------+ +------------------+
| Docker CLI | | Kubernetes | | Podman, nerdctl |
+------------------+ +------------------+ +------------------+
| | |
v v v
+------------------+ +------------------+ +------------------+
| Docker Engine | | containerd | | containerd |
+------------------+ +------------------+ +------------------+
| | |
+------------+-------------+ |
v v
+------------------+ +------------------+
| runc / crun | | runc / crun |
+------------------+ +------------------+
所以可以看到,containerd 已经逐渐成为事实上的“标准中间层”,Docker 只是一个面向开发者更友好的前端。
Docker 时代:一个大而全的 daemon
时间退回 2014 年前后。那时候如果我们要在部署服务器上跑容器,基本只有一个选择:安装 Docker。Docker 早期架构总体上是一神论:一个 dockerd 守护进程负责镜像构建、镜像存储、容器管理、网络、卷,甚至日志;它会 fork 一个负责进程启动的条目,并把整条船的舵都握在自己手里。
这种架构在单机或小集群时代非常方便,因为一切默认就是刚好的。
但真正的麻烦出现在编排系统出现之后。Kubernetes 想在每台节点上同时支持多个容器运行时,不能允许某个 daemon“挟持”了容器的全部生命周期。如果 Kubernetes 直接通过 Docker 的完整 API 来创建容器,那么 containerd 作为 Docker 内部组件无法独立使用,数据也变得越来越不透明。这时候,容器生态必须把“运行时”从“重型管理前端”中解耦出来。
于是 2017 年,Docker 将 containerd 捐献给了 CNCF,作为独立项目继续发展。containerd 被定位成企业级容器运行时,专注于容器生命周期、镜像传输与存储、网络接口,以及微量资源管理,但砍掉了构建镜像、Swarm 编排和用户友好的 CLI 层。Docker 从此变成了一个“前端 + containerd 作为后端”的组合拳。
这也给很多运维者带来了第一次认知冲击:原来 docker ps 看到的容器,底层其实由 containerd 管理;如果 containerd 出问题,Docker 也可能受影响。
containerd:从 Docker 附属到 Kubernetes 缺省选择
containerd 被独立出来后,逐渐成长为 Kubernetes 最常用的运行时。如果你打开一个 Kubernetes 1.29 节点的 /etc/containerd/config.toml,会看到默认运行时的配置如下:
[plugins."io.containerd.grpc.v1.cri".containerd]
default_runtime_name = "runc"
...
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
这里的默认运行时 runc 就是 OCI runtime-spec 的标准实现,用 Go 编写,由 Docker 和 containerd 社区共同维护。而 containerd 通过 CRI(Container Runtime Interface)插件,为 Kubernetes 提供了稳定接口。换句话说,Kubernetes 节点上最常见的容器创建链路是:kubelet → CRI 插件(containerd)→ runc,创建 pod 的每个容器。
containerd 相对 Docker 的优势在哪里?主要在于 API 更精炼、依赖更少、资源占用更小,同时它直接面向 Kubernetes 提供了原生 CRI 实现,不需要像 Docker 那样经过 dockershim 中转。在 Kubernetes 1.24 之后,dockershim 被正式移除,Docker 在 K8s 节点上其实就是通过 containerd 来干活。很多人误以为 Kubernetes 弃用了 Docker,实际上弃用的只是 Docker 作为运行时的特殊适配层,Docker 的容器镜像和大部分概念仍然继续用在 containerd 生态里。
到了这一步,“运行时”已经和 Docker 解绑,但 containerd 之下又暴露出一个新的问题:runc 的启动代价。
runc 的软肋和 crun 的出现
runc 是一个非常优秀的实现,它稳定、兼容性高,但有一个不那么显眼的缺点:性能开销。
这个开销并不体现在 CPU 密集或内存密集的业务执行上,而是在容器的创建和销毁过程中。runc 用 Go 编写,这意味着每次要执行 exec 或 clone 时,都需要初始化 Go 运行环境,进而带来镜像加载、二进制初始化、fork/exec 等环节的额外开销。你如果每隔几百毫秒就要创建和销毁一批短生命周期容器,比如 Serverless 函数、批处理任务、CI 或事件驱动任务,runc 的资源消耗和延迟会让成本被放大。
crun 就是在这样的背景下由红帽工程师主导开发的。它在设计上直接使用 C 语言实现 OCI runtime-spec,放弃 Go 运行时,大幅减少内存占用和启动延迟。目前 crun 已经被 Fedora、RHEL 等系统的默认运行时或可选运行时覆盖,也兼容 OCI 标准,可以无缝替代 runc 在大多数场景中的位置。
一个常见的误解是:crun 的隔离能力比 runc 更弱。实际上两者都调用相同的内核 namespace/cgroup 机制,本质都是利用「可执行文件 + 配置 bundle」来启动进程。区别在于实现语言和内部架构,而不是安全模型本身。crun 没有引入什么新的内核隔离技术,它只是让自己变得更轻、更快。
我们直接放一组常见对比,帮助你在实际选型时做判断。
| 对比项 | runc | crun |
|---|---|---|
| 实现语言 | Go | C |
| 启动容器延迟 | 相对高(毫秒级加常数) | 相对低(微秒到毫秒级) |
| 内存占用 | 每个容器实例有一定固定开销 | 更少,尤其在高密度容器场景 |
| OCI 兼容性 | 参考实现,默认最保守稳定 | 兼容良好,但个别边缘特性略晚支持 |
| 典型场景 | Kubernetes 默认、通用生产 | 大规模短生命周期容器、边缘/嵌入式、性能敏感场景 |
这里的“相对”有没有具体数字?我只能说没有统一数字,因为不同硬件、内核版本、镜像大小会导致差异很大。不过在容器密度很高的节点上,crun 对 RSS 内存的节省往往能低到肉眼可见的程度;如果还没有条件做压测,至少可以先在本地用短生命周期容器跑一遍,观察系统平均负载和响应时间。
实际工程里,什么时候会想到 crun
场景一:边缘网关设备上同时跑几十个逻辑隔离的组件。设备内存只有 1GB,内核版本又比较老,CPU 资源也有限。如果用 runc,每个容器 fork 时都带着 Go 运行时的初始化开销,肉眼看不出大问题,但一旦 pod 数量上百,系统的内存使用明显偏高。这种场景下,切换成 crun 后,容器数量能上升一截,而 CPU 峰值也平和了许多。当然,前提是你愿意接受 cgroup v2 或某些运行时功能上的小差异。
场景二:一个典型的 CI 系统。每个构建任务可以拆成上万个短命容器,例如编译任务、依赖包安装、并行测试。runc 在创建和销毁上会成为瓶颈,整个 pipeline 跑下来,大量时间耗在容器的生命周期操作上。crun 在这里的价值不仅仅是快一两个毫秒,而是降低整个系统的锁竞争和内存分配压力。
场景三:你只想解决 Kubernetes 节点上的一个诡异问题。比如在某些高版本内核上,runc 的 Go 运行时执行 clone 时会触发较大的延迟;你用 perf 定位后,发现瓶颈并不在主进程,而是 Go runtime 的调度器。这时候如果只依赖 runc,排查会非常痛苦;好在你可以使用 strace 或 bpftrace 看看系统调用出口,同时临时把运行时换成 crun,观察是否有所缓解。这叫“降低变量”,是排障时比较狠但有效的办法。
这些都是基于真实环境中的常见需求,并不一定每个团队都会遇到。但有一个原则是通用的:当容器密度开始成为节点的主要矛盾时,高频容器创建销毁带来的性能成本,一定要纳入运行时选型的考量。
两个最常见的误区
误区一:containerd 是 Docker 的“继承者”。这句话只能算一半。containerd 确实从 Docker 中分离出来,但它不是一个完整的容器平台。Docker 依然存在、依旧维护,并且是许多开发者非常顺手的构建和调试工具。containerd 也不直接提供 CLI 到镜像构建的完整闭环,它是基础设施组件。正确的理解是:Docker 和 containerd 分工,Docker 选择 containerd 作为运行时,但 containerd 也可以被 Podman、nerdctl 等前端独立使用。
误区二:crun 必然比 runc 快,所以生产环境应该全都换成 crun。这样过于乐观。crun 在启动密度和内存占用上有优势,但生态成熟度、某些文件系统挂载细节、以及和老内核的兼容性都不及 runc 做得久。很多 Kubernetes 发行版默认仍是 runc,因为它在最广泛的硬件和操作系统组合下验证过。如果要切换,第一步不是对比基准测试,而是先在预发环境小流量试跑,确认监控、日志、存储插件等行为没有变化。
如何在 containerd 上切换 crun
既然 crun 是 OCI runtime,要做的就是把 containerd 的默认 runtime 指向到 crun 可执行文件。通常分为两步。
第一步,安装 crun。在 Debian/Ubuntu 中可以直接 apt install crun,在 RHEL 系系统上通常也已经有包的路径。安装完确认版本:
$ crun --version
crun version 1.14.3
...
第二步,修改 containerd 配置。以版本 2.x(或 1.7 以前)为例,修改 /etc/containerd/config.toml,增加类似下面的内容:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
runtime_type = "io.containerd.runc.v2"
runtime_path = "/usr/local/bin/crun" # 根据实际路径
# 如果希望默认运行时就用 crun
[plugins."io.containerd.grpc.v1.cri".containerd]
default_runtime_name = "crun"
然后重启 containerd,再创建一个 pod,检查节点的 runtime 信息。通常在 kubelet 日志或 containerd 状态里可以看到使用了 crun 运行时。使用 ctr 或 crictl 也可以确认:
crictl info | grep runtime
需要特别注意的是,不同 containerd 版本对配置结构有差异。升级版本前务必先备份配置,并利用 containerd config dump 生成当前默认配置再改动,不要凭记忆硬写。
选型建议:不一定是最新,但一定是最匹配
对于绝大多数生产系统,我仍然建议先使用 containerd + runc 的组合。这套组合能让你拥有 Kubernetes 社区最好的默认支持,遇到问题时更容易在社区找到现成的传播性线索。这不是说 crun 不好,而是说在收益不明确时,不要为了追逐功能而增加变量。
如果你的业务属于以下情况,才值得认真考虑 crun:
- 容器生命周期极短,创建销毁频率非常高,runc 的延迟已成为实际瓶颈;
- 节点资源紧张,或需要单节点部署大量容器,内存占用不可忽视;
- 边缘计算、嵌入式环境中,Go 运行时体积和初始化成本带来的负担太明显;
- 已经具备可靠的预发验证能力,能够在灰度环境中快速回滚。
反过来,如果你的团队还在快速迭代业务功能,运维人力有限,我更建议尽量跟随默认选择。容器运行时这一层本质上是“稳定的基础设施”,不大适合频繁更换。等规模真正逼近极限时再考虑切换,收益才比较明显。
演进的方向不止一条
从 Docker 到 containerd 再到 crun,本质上经历了三个阶段:把容器能力从单一前端解耦出来,形成标准化中间层;然后把标准中间层跟具体实现解耦,让上层业务可以独立演进;最后再对最底层的启动器做性能压榨。这条路径也解释了为什么容器生态会同时存在这么多看似重叠的项目:每一层都在追求自己的极致,同时又不破坏标准。
当你以后再看某个云原生项目时,可以先把它放到这个分层框架里:它是像 Docker 一样提供用户交互?还是像 containerd 一样做资源管理和镜像传输?又或者像 crun 一样紧贴内核去启动进程?有了这个框架,你就不会再把“运行时”当成一个黑盒。
容器运行时的演进并不会停在 crun,前面还有 gVisor、Firecracker,甚至更激进的用户态内核方案。但无论未来怎么变,背后的思考方式都一样:标准会继续拆分,实现会继续精简,而你的系统选型,始终要建立在理解这些组件边界的基础上。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/608/