Docker刚问世时,凭借一条docker run命令就能把应用打包运行起来,迅速风靡整个技术圈。但很多人不知道的是,如今我们使用的Docker和2013年初版的Docker,在进程架构上已经完全是两副面孔。理解这段演进历史,不仅能让你看懂容器技术的设计取舍,也能帮你在搭建Kubernetes集群时做出更合理的技术选型。

一、单体时代:一个进程包打天下
在Docker 1.11版本之前,整个Docker就是一个典型的单体架构。dockerd进程承担了几乎所有职责:接收REST API请求、解析Dockerfile并构建镜像、管理镜像层的存储、创建容器命名空间和cgroup、配置容器网络、挂载卷、维护日志驱动,甚至容器的STDIN和STDOUT流处理也在里面。你在宿主机上执行ps aux | grep docker,通常只能看到一个docker进程(加上它fork出来的容器进程)。
这种设计在早期带来了明显的开发效率优势。所有模块跑在同一进程里,函数调用即可完成协作,不存在进程间通信的开销,代码组织简单,迭代速度极快。对于单机场景和中小规模部署,单体守护进程完全够用。
但问题随着生态扩张逐渐暴露。首先是升级困难:dockerd要重启,所有由它管理的容器进程都会受影响,业务方对这种"升级即停机"的模式怨声载道。其次是耦合严重:想复用Docker的镜像管理能力做自己的工具,只能通过Docker API间接操作,无法以库的形式嵌入。第三是生态冲突:Google、CoreOS等厂商希望容器运行时能更中立、更标准化,而不是绑定在Docker一家公司的实现上。Kubernetes当时通过Docker API操作容器,任何Docker版本变动都可能引发兼容性问题。
二、第一次拆分:OCI标准与runc的诞生
2015年是容器历史上的关键节点。这一年,Docker联合CoreOS等公司成立了Open Container Initiative(OCI)组织,制定了两个核心规范:镜像格式规范和运行时规范。运行时规范定义了一个标准化的接口,任何符合规范的运行时,只要能读取一份描述容器配置的bundle目录(包含config.json和根文件系统),就能创建出符合标准的容器。
Docker将自己的libcontainer项目捐给了OCI,作为参考实现,这就是runc的由来。runc是一个极简的命令行工具,它的职责只有一个:根据OCI bundle创建、启动、停止容器进程。注意runc的定位是"生命周期管理工具",它本身不负责镜像下载、不管理网络,容器启动后runc进程甚至可以退出,容器照样运行。
可以简单看一下runc创建容器的本质,它做的事情其实并不神秘:
# 使用runc运行一个容器 runc run mycontainer # runc内部核心步骤(伪代码描述): # 1. 读取 config.json 中的 namespaces 配置 # 2. clone() 系统调用创建子进程,传入 CLONE_NEWPID | CLONE_NEWNS 等标志 # 3. 设置 cgroup 限制 CPU 和内存 # 4. 在新的 Mount Namespace 中 pivot_root 切换根文件系统 # 5. exec 用户指定的入口进程
这一步拆分的意义在于:容器"怎么创建"从此有了行业标准。任何厂商都可以实现自己的运行时,只要符合OCI规范,就能和整个生态兼容。这为后续更激进的架构拆分打下了基础。
三、第二次拆分:containerd接管容器生命周期
runc解决了"创建容器"这一层,但如果每个容器都由dockerd直接调用runc管理,dockerd仍然承担着镜像分发、快照管理、容器状态机维护等大量工作。于是在Docker 1.11版本,Docker将内部代码进一步重构,抽出了containerd这个独立组件。
拆分后的架构变成了清晰的三层:dockerd位于最上层,负责API、构建镜像、卷管理和网络配置等高级功能;containerd作为守护进程独立运行,负责管理容器的完整生命周期,包括镜像存储、快照、执行runc、监控容器状态;runc在最底层负责实际创建容器进程。我们可以用ctr工具(containerd自带的命令行客户端)验证这一点:
# 查看docker容器在containerd中的实际呈现 docker ps # 输出 CONTAINER ID: e4c1a2b3... ctr -n moby containers list # 会看到同一个容器ID,命名空间为 moby
注意上面命令中的-n moby参数。Docker使用一个名为moby的命名空间隔离自己的容器数据,避免和其他直接使用containerd的用户互相干扰。这也是containerd设计上的一个亮点:它通过namespace机制支持多个客户端共存。
containerd自身也并非铁板一块,内部采用了插件化设计,快照管理支持overlayfs、btrfs等多种存储驱动,镜像拉取、内容分发都是独立的插件。这种设计让containerd后来能够脱离Docker独立发展,成为CNCF的毕业项目。
四、containerd-shim:守护进程热升级的关键
拆分出containerd之后还有一个遗留问题:containerd仍然通过runc的父子关系管理容器,如果containerd重启,容器进程怎么办?解决方案是引入containerd-shim(垫片)进程。
shim的工作方式是:每启动一个容器,containerd就fork一个对应的shim进程,由shim去调用runc创建容器,之后runc退出,容器进程的父进程变成shim。这样容器和containerd之间就彻底解耦了——containerd挂掉或升级,shim和容器照常运行,containerd恢复后通过shim重新接管状态即可。
shim还顺带解决了两个问题:一是容器输出流的转发,容器的stdout和stderr由shim持有并写入日志文件,dockerd重启不会导致日志丢失;二是信号传递和退出码收集,保证docker stop等命令的行为在任何情况下都正确。这个看似不起眼的小进程,正是Docker实现"守护进程升级不影响业务容器"的核心功臣。
五、CRI时代:Kubernetes为何弃用Dockershim
Kubernetes早期通过Docker API管理容器,为此在kubelet里维护了一套专门的适配代码,社区内部也把这套代码戏称为dockershim。2016年Kubernetes推出了CRI(Container Runtime Interface)gRPC接口标准,希望运行时厂商直接实现该接口,而不是让kubelet去适配各家私有API。
containerd很快实现了CRI插件,kubelet可以直接通过gRPC调用containerd,链路变成kubelet到containerd到shim到runc,中间不再需要dockerd。而Docker Engine由于历史包袱,其CRI支持一直要通过dockershim中转,链路更长、内存占用更高、出问题的环节更多。于是Kubernetes在1.24版本正式移除了dockershim,这并不意味着Docker容器不能在Kubernetes上运行,而是说kubelet不再通过Docker那套链路,用户应直接选择containerd或CRI-O作为运行时。
今天的生产环境中,containerd已经成为事实标准。各大云厂商的托管Kubernetes集群默认运行时几乎清一色是containerd。而Docker Engine依然在开发测试场景中广泛使用,因为它的用户体验和生态工具确实优秀。Docker Desktop内部同样是containerd在支撑,只是外面套了一层友好的CLI和GUI。
回看这段演进历程,Docker守护进程从一个大包大揽的单体,逐步拆分为dockerd、containerd、shim、runc四个职责清晰的组件,每一层都遵循标准接口。这不仅是Docker自身的重构史,更反映了基础设施软件走向标准化、组件化的普遍规律:单一软件很难同时做好所有事情,把边界定义清楚,让每个组件专注自己的领域,生态才能健康成长。
Docker架构containerd容器运行时修改时间:2026-09-15 14:20:44