每隔几个月就有人问我:要不要”把 Docker 换成 containerd”?言下之意好像它们是两个功能重叠的竞品,一场 Docker vs containerd 的对决,总得分出个胜负。我已经不凭记忆回答这种问题了,我直接 ps aux。就比如我写这篇文章的这台机器——Arch Linux、内核 7.2.9、Docker 29.9.0——containerd v2.4.1 在跑,runc 1.5.2 装着,Docker 也在,因为 Docker 从来没有”取代”containerd:它是架在containerd 上面的。故事继续往下走——runc,这个真正发起系统调用的 OCI 运行时,才是 Docker 和 Kubernetes 最后都会调用的东西。一旦你看清了这些层,“Docker vs containerd” 就不再是对立关系,而是一套管路。
下面所有输出都来自这台机器——没有 sudo,没有 --privileged,我启动的每个容器在写完时都已销毁。哪些我没能跑起来,我会明说。
一张图看懂整个分层栈
容器不是一个程序,而是五层结构,每层把活儿往下交。你日常打交道的命令行,只是大多数人唯一见过的那一层:
有两点要记住:你敲的 docker 是个客户端,它下面每一层都通过 Unix socket 说着不同的协议。
这台机器上到底装了什么:docker version
想证明”Docker 里包含着 containerd”,最快的办法就是问 Docker 自己:
$ docker version
Client:
Version: 29.9.0
API version: 1.56
Go version: go1.27.1-X:nodwarf5
Git commit: f415da838b
Built: Fri Oct 9 00:19:20 2026
OS/Arch: linux/amd64
Context: default
Server:
Engine:
Version: 29.9.0
API version: 1.56 (minimum version 1.40)
containerd:
Version: v2.4.1
GitCommit: f2551031d7276a770f65f98c9b52e57e7dad07e8.m
runc:
Version: 1.5.2
docker-init:
Version: 0.19.0
仔细读 server 那半段。客户端是 29.9.0,Engine——也就是守护进程 dockerd——也是 29.9.0;但注意它把活儿派给了谁:containerd v2.4.1、runc 1.5.2、docker-init 0.19.0(一个加固版 tini,唯一职责就是当一个守规矩的 PID 1:回收僵尸进程、转发信号)。它们不是附带打包的依赖,它们是真正干活的部分。
客户端和 server 是打包在一起的两个程序:客户端只负责通过 /var/run/docker.sock 说 HTTP,剩下全归 server。所以你可以在容器里、在 CI 里、在另一台机器上驱动一个守护进程——也正因如此,socket 的访问权限是一条安全边界,这一点我在容器安全实践里写过。
在 ps aux 里看 Docker vs containerd
接下来这一屏通常能直接终结争论。在这台机器上,containerd 作为独立服务运行,而 dockerd 被指向了它:
$ ps aux | grep -E 'containerd|dockerd' | grep -v grep
root 147850 1.7 0.3 2047448 48084 ? Ssl Oct12 15:36 /usr/bin/containerd
root 147879 0.4 0.7 2115664 89156 ? Ssl Oct12 4:22 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
这一个参数就是全部关系:dockerd 启动时指向了已存在的 containerd——它没有自己另起一个,而是连了上去。下面是各容器的 shim,五个运行中的容器,五个 shim:
$ ps aux | grep containerd-shim | grep -v grep
root 605897 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id a98048d4dcd0941e50f66dd421b8a00e12e610321c27393f54547f4517e5e651 -address /run/containerd/containerd.sock
root 606458 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 680910beb06c0192fda511b7aa91a8a7788d9398c9667a57031a297d2b68638b -address /run/containerd/containerd.sock
root 970577 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 3d994363756589deb76c6bc8837b8839e8648f58699b59a9ca0c8fc27eb787ea -address /run/containerd/containerd.sock
root 971592 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 200df4ce0d236f1020a2ad4499e0d6b67217412eb68db96174ee125fe16ed6fb -address /run/containerd/containerd.sock
root 972001 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id 6bcecf7782771f033093a99b9c30be889764f29574d3cc06f8a0f969a983bf5d -address /run/containerd/containerd.sock
三个细节解释了”容器是谁的”这件事:
-namespace moby——从 containerd 的视角看,Docker 的容器不过是它moby命名空间里的普通容器。(Kubernetes 用的是k8s.io。)“Docker 容器就是 containerd 容器”这句话正是这么来的。- 每个容器一个 shim:它接管容器的标准输入输出,比守护进程活得久,
docker logs能工作靠的就是它。 - 每个 shim 的父进程是 PID 1 而不是 containerd——这是故意的,升级 containerd 不会连累容器。
docker info 从另一侧讲同一个故事,还顺手说明 Docker 的镜像存储已经搬进了 containerd:
$ docker info
Server:
Server Version: 29.9.0
Storage Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
Logging Driver: json-file
Cgroup Driver: systemd
Cgroup Version: 2
Runtimes: io.containerd.runc.v2 runc
Default Runtime: runc
Init Binary: docker-init
containerd version: f2551031d7276a770f65f98c9b52e57e7dad07e8.m
Security Options:
seccomp
Profile: builtin
cgroupns
Kernel Version: 7.2.9-arch1-1
Operating System: Arch Linux
Default Runtime: runc 回答了”这里到底是谁在跑容器”——runc 这个 OCI 运行时,不管请求来自 Docker 还是直接来自 containerd。而 io.containerd.snapshotter.v1 意味着 Docker 的镜像层是由 containerd 的 snapshotter 解包和挂载的。(分层本身我在Docker 网络详解里聊过。)
跟着一个容器一路下沉到内核
一路追到底。挑一个正在运行的 nginx,从它的进程往上走到 shim:
$ CPID=$(docker inspect -f '{{.State.Pid}}' 680910beb06c) # nginx 容器
$ ps -o pid,ppid,cmd -p $CPID
PID PPID CMD
606482 606458 nginx: master process nginx -g daemon off;
$ ps -o pid,ppid,cmd -p 606458
PID PPID CMD
606458 1 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 680910beb06c0192fda511b7... -address /run/containerd/containerd.sock
$ cat /proc/$CPID/cgroup
0::/system.slice/docker-680910beb06c0192fda511b7aa91a8a7788d9398c9667a57031a297d2b68638b.scope
图景是这样的:containerd 拉起了 shim,shim 监管着容器的 init 进程,而内核把这个进程放进了一个叫 docker-<id>.scope 的 cgroup。注意缺了什么:dockerd——容器一旦跑起来,守护进程压根不在它的进程谱系里;dockerd 停了,这个 nginx 照常服务。runc 也不在:它只在创建时跑一次就退出。init 是长命的,造出它的运行时却是短命的。
再看内核在进程周边搭的东西,从一个我专为此启动的容器里头看:
$ docker run -d --name ns-demo alpine sh -c 'sleep 60'
$ docker exec ns-demo sh -c 'ls -l /proc/1/ns/'
lrwxrwxrwx cgroup -> cgroup:[4026533017]
lrwxrwxrwx ipc -> ipc:[4026533015]
lrwxrwxrwx mnt -> mnt:[4026533012]
lrwxrwxrwx net -> net:[4026533018]
lrwxrwxrwx pid -> pid:[4026533016]
lrwxrwxrwx pid_for_children -> pid:[4026533016]
lrwxrwxrwx time -> time:[4026533179]
lrwxrwxrwx time_for_children -> time:[4026533179]
lrwxrwxrwx user -> user:[4026531837]
lrwxrwxrwx uts -> uts:[4026533014]
$ docker exec ns-demo sh -c 'cat /proc/1/cgroup; ps aux'
0::/
PID USER TIME COMMAND
1 root 0:00 sleep 60
八个不同的命名空间 ID;一个以为自己是 PID 1、PID 命名空间里别无他进程的进程;一个根植于 / 的 cgroup 视图。sleep 60 就是”容器”。根文件系统同样说明问题:
$ docker exec ns-demo mount | head -2
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/2682/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/981/fs,upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/2683/fs,workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/2683/work,index=off)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
docker info 说 “Docker Root Dir: /var/lib/docker”,但容器真正看到的合并视图来自 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/...。同一个内核特性——overlayfs——安装、挂载、归属都是 containerd。这一个目录名就是本文的全部论点。
containerd 是什么,Kubernetes 为什么也在用
containerd 是一个 CNCF 毕业项目,一个把容器里那些无聊活儿干好的守护进程:拉取和校验镜像、存储镜像、把它们解包进 snapshotter,以及通过 runtime shim 启动、停止和监管容器。它在 2017 年从 Docker 中析出。它不是 Docker 的竞品,而是 Docker 自己也在用的组件;从 dockershim 在 1.20 被弃用、1.24 被移除开始,Kubernetes 也直接和它对话——kubelet 通过 socket 对它说 CRI,和 dockerd 做的完全一样。
值得偷师的设计是 shim:每个容器一个独立进程,且被 reparent 到 init,于是 containerd 可以随意升级而不伤及任何容器。守护进程来了又走,负载照常运行——这才是无论你敲 docker run 还是 kubectl apply,背后都是这两个守护进程的原因。
Docker 在上面加了什么
Docker 还剩什么?所有与”跑容器”无关的东西:
$ docker --version
Docker version 29.9.0, build f415da838b
$ docker buildx version
github.com/docker/buildx 0.38.0 0dbe87f36ed472fe36b6796c3f70b4210a731fc5
$ docker compose version
Docker Compose version 5.6.0
具体说,在”拉镜像、跑容器”之上:
- 不止
run的命令行——docker build走 BuildKit,有正经的分层缓存;docker compose把三个参数加一个网络收进一个 YAML 文件;再加上volume、network等等。 - REST API。
/var/run/docker.sock是一份文档化的 HTTP API;每个 CI 系统、IDE 插件、家庭实验室面板对接的都是它,不是 containerd 的 gRPC API。 - 生态。 Docker Hub、官方镜像、插件,以及全世界教程都默认
docker存在这个事实。这些没一样是技术,全是协同。 - runc 之外的稳妥默认值。 seccomp profile、cgroup 命名空间、capability 集合、
--init——我之前写过的整套安全模型,被打包和维护好。
这些没有一样是 OCI 意义上的”容器运行时”特性。Docker 管前面,containerd 管中间,runc 管底下;内核悄悄管着一切。
为什么 OCI 规范很重要
这套分层栈之所以能随意拼装,靠的是 OCI(Open Container Initiative,开放容器倡议)的规范,其中两个最要紧:
- 镜像规范——一个分层 tar 包加一份 JSON manifest。containerd 能拉的任何 OCI 镜像 Docker 都能读,反之亦然。这不是营销话术,是规范。
- 运行时规范——一份描述怎么跑一个镜像的 JSON 文档:用哪些命名空间、挂哪些文件系统、给哪些 capability、设哪些 rlimit。这就是 containerd 与 runc 之间的接口。
第二份规范可以用本机的 runc 直接生成一份默认的看看:
$ runc spec && head -c 700 config.json
{
"ociVersion": "1.3.0",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": [ "sh" ],
"capabilities": {
"bounding": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ],
...
},
"root": { "path": "rootfs", "readonly": true },
...
}
再往下一点,就是 ns-demo 里那堆命名空间的出处:
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" },
{ "type": "cgroup" }
],
"maskedPaths": [ "/proc/acpi", "/proc/sysrq_trigger", ... ],
"readonlyPaths": [ "/proc/bus", "/proc/irq", "/proc/sys", ... ]
runc --version 甚至会声明它实现的规范版本——我这份报告 spec: 1.3.0。这就是镜像层和运行时层都没有锁定效应的原因:规范就是契约。
ctr、nerdctl,以及我没跑成功的部分
下面的层可以直接手动使用——它们带 CLI,意义正在于此。ctr 是 containerd 的官方客户端,在这台机器上装着,版本与守护进程一致(v2.4.1)。但以我这个非特权用户去连:
$ ctr namespaces list
time="2026-10-13T07:44:54+08:00" level=warning msg="Failed to check deprecations" error="connection error: desc = \"transport: Error while dialing: dial unix /run/containerd/containerd.sock: connect: permission denied\""
ctr: rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: dial unix /run/containerd/containerd.sock: connect: permission denied"
权限拒绝,意料之中:socket 是 root:root 660,而我是 uid 1000。至于 nerdctl——大家说”containerd 配个好用的 CLI”时指的就是它——这台机器上根本没装。
我特意没有做的事:把 /run/containerd/containerd.sock 挂进容器,好让 ctr 连上真正的守护进程。这里的容器是实打实的 root(没有 user-namespace 重映射),所以这个操作不算”访问 containerd”——等同于把宿主机完整 root 交给任何能读这个 socket 的东西。想给 ctr/nerdctl 找练手场地,用一次性虚拟机或者 rootless 环境,别打工作机守护进程的主意。
你大概率不需要替换掉 Docker
如果 Docker 用得顺手,那就继续用。这次交换给你的东西是”进程更少、离 runc 更近一层”,账单却立刻到来——compose 文件、容器 DNS、日志驱动、团队对 docker 的肌肉记忆。遇到这些情况再考虑动身:
- 你已经在跑 Kubernetes。 那 containerd 就在你脚底下,不管你乐不乐意;
ctr/crictl是排障素养,不是迁移项目。 - 你需要 rootless 或边缘部署。 rootless Docker 能用,但小机器上 rootless nerdctl + containerd 往往更顺。
- 你想要的是没有守护进程的运行时——“加载一个 OCI 镜像,跑起来”,不要 API server。那 containerd + nerdctl 是更薄的一叠。
- 你只是好奇。 可以——但在虚拟机上折腾,别在跑着数据库的机器上。
值得带走的习惯只有一个:容器出问题时,别停在 docker logs。ps aux | grep containerd-shim、cat /proc/$(docker inspect -f '{{.State.Pid}}' <id>)/cgroup,再 docker exec 看一眼命名空间,比任何仪表盘都更能说明问题——而你现在知道,这三条命令看的正是同一台机器的不同层。