☰
从单体到模块化:Docker架构演进全面拆解
2026/10/17 5:28:38 网站建设 项目流程

看到这个标题的时候我愣了一下,“它还是从前那个Docker吗”——这问题问得是真好啊。我从2016年就开始用Docker了,那时候它几乎是“容器”的代名词,一条docker run打天下。后来项目越做越多,Kubernetes进来了,containerd独立了,CRI-O也出现了,连Docker自己的构建链都换成了BuildKit。很多老朋友跟我说,现在看Docker越来越陌生,docker ps照用,但底层那套东西早不是当年那个monolithic的大胖子了。

这篇文章就想把Docker的架构彻底拆开给你看。我会从“它解决了什么问题”讲起,再把经典架构里每一层——客户端、守护进程、containerd、runc、镜像存储、网络——逐个掰碎,最后结合这些年的架构演进,回答标题里那个问题:今天的Docker,到底还在不在。适合刚接触容器想建立整体认知的新人,也适合用了很久Docker但没空深挖底层原理的开发者。看完你会明白一条docker run背后,到底有多少个进程在替你干活。

1. 容器为什么非火不可:先看Docker解决的问题

聊架构之前,得先想明白一件事:我们到底为什么需要容器?

我记得特别清楚,早年间做交付,最怕的就是“在我机器上是好的”。开发环境用Windows,测试环境是CentOS,生产环境是Ubuntu,同一个Jar包、同一套配置,到哪儿哪儿出问题。那时候主流的解法是虚拟机,但虚拟机太重了,每个虚机一个完整操作系统,启动按分钟算,资源开销大得肉疼。后来出现了Linux Containers(LXC),能在共享内核的前提下做资源隔离,但LXC用起来很别扭,没有标准化的打包格式,也没有好用的镜像分发机制。

Docker真正的贡献不是发明了容器,而是把容器做成了“能用的产品”。

1.1 从“环境不一致”到“镜像即交付”

Docker引入了一个非常关键的概念:镜像(Image)。镜像把应用、依赖、系统库、配置文件全部打成一个不可变的包,像集装箱一样。这个集装箱在开发者的笔记本上是什么样,到服务器上还是什么样。过去我们要花半天时间写部署文档,一个新同事照着文档折腾一天都跑不起来;现在只需要docker run一条命令,三分钟搞定。

这个转变的本质,是把“环境配置”这件极其容易出错的事,从运维环节里彻底拿掉。你不再需要关心目标机器上装的是哪个发行版、有没有缺lib,只要它有内核、能跑Docker,就能跑你的应用。我自己的体会是,Docker火起来的那两年,团队里“环境问题”这个词出现的频率直线下降,这就是它解决的最大痛点。

1.2 Docker到底给了我们什么

从产品角度看,Docker这几件事是它当年脱颖而出的核心:

  • 标准化的镜像格式和Dockerfile构建方式,让“如何打包应用”变得有章可循;
  • 分层镜像+UnionFS,使得镜像可以复用和增量传输,拉一个基础镜像,不同应用在上面叠不同层,极大降低了存储和带宽成本;
  • 一条命令同时搞定创建、启动、进入、停止、删除,心智负担低到离谱;
  • Docker Hub这种公共镜像仓库解决了“镜像往哪放、别人怎么拉”的分发问题。

但要真正理解Docker,不能停留在命令行层面。你得看到,一条docker run从敲下回车到容器跑起来,中间发生了多少事情。这就要进入正题了。

2. 经典Docker架构逐层拆解

很多教程一上来就画一张架构图,什么Client、Docker_Host、Registry,然后就没有然后了。我打算换个讲法:从你敲下命令的那一刻开始,按执行路径一层层往下走,看每一步是谁在干活。这是我觉得理解Docker架构最有效的方式。

2.1 分层的整体架构:client、daemon、containerd、runc

经典的Docker架构是典型的C/S架构。你在终端敲的docker命令是客户端(Docker Client),真正干活的是后台的守护进程(Docker Daemon,也就是dockerd)。客户端和守护进程通过REST API通信,你自己也可以直接调这个API,docker run本质就是往daemon的/containers/create接口发了个POST请求。

这里的重点来了。很多人以为dockerd直接负责启动容器,其实不是。从Docker 1.11开始,Docker把容器执行的活儿拆出去了,交给了containerd。再往下,containerd也没亲自动手,而是调用runc去真正创建和启动容器进程。

我画一条链路你就清楚了:

docker CLI → REST API → dockerd(API网关、镜像管理、网络管理、存储管理) → containerd(容器生命周期管理、镜像解包、事件管理) → containerd-shim(每个容器一个shim进程,用于维持stdin/stdout等) → runc(真正调用Linux内核namespace/cgroup创建容器进程)

runc是一个极其轻量的OCI运行时,它做的事听起来很简单:准备好rootfs、设置好namespaces和cgroups、然后execve启动容器里的进程。但恰恰是这一层,让整个系统变得优雅。

2.2 镜像与存储:层、UnionFS与写时复制

镜像层(Layer)是Docker最值得称道的设计。一个Dockerfile里的每条指令,比如RUN apt-get install,都会产生一个新层。这些层是只读的,每一层都记录着和下一层的差异。多个层叠在一起,通过UnionFS合并成容器看到的完整文件系统。

这带来两个直接影响。第一,镜像可以复用:两个不同应用都基于同一个Ubuntu基础镜像,那它们在磁盘上和网络上只需要存一份基础层。第二,写时复制(Copy-on-Write):容器启动时不用把镜像整体拷贝一份,而是在最上面叠加一个可写层。容器里改文件只是把要改的文件从只读层复制到可写层再修改,这个设计让容器启动快到亚秒级。

存储驱动(Storage Driver)就是实现这套机制的具体组件。早期最出名的是AUFS,后来OverlayFS因为性能更好、更简洁,成了绝对主流。Overlay2到现在依然是绝大多数Docker安装的默认驱动。

2.3 网络与Volume:容器不是孤立的小黑盒

容器启动后,它不是个与世隔绝的进程。Docker默认会创建一个docker0网桥,每个容器通过veth pair接入这个网桥,获得一个虚拟IP。这就是容器能互相通信、能被宿主机访问的基础。

而Volume解决的是数据持久化问题。容器本身是无状态的,容器删了,可写层也没了。如果数据库就跑在容器里,那删容器等于删数据。Volume相当于把宿主机的某个目录挂载进容器,生命周期独立于容器,这才是生产环境里数据应该待的地方。

经典Docker架构的完整拼图大概是这样的:Client下命令,dockerd管资源,containerd管容器,runc真正拉起进程,镜像靠分层存储,网络靠网桥和veth,数据靠Volume。这也是“从前那个Docker”的基本样貌。

3. “从前的Docker”和“现在的Docker”:架构演进对比

现在可以正面回答标题里那个问题了。它还是从前那个Docker吗?

我的答案是:从用户视角看,它还是。docker run还是那样用,Dockerfile写起来还是那个味道,镜像概念也一脉相承。但从架构视角看,它早就不是了。这些年Docker经历了两次重大拆分,每一次都让它离“胖客户端”更远,离“模块化基础设施”更近。

3.1 一场拆分:从monolithic到模块化

早期的Docker是一个大单体程序,一个进程里同时干了API处理、镜像管理、容器执行、网络配置所有事情。好处是部署简单,一个docker daemon搞定全部。但坏处也很明显:所有功能耦合在一起,改一处可能会影响全局,而且容器执行这部分本该是轻量级、高可靠的,却不得不跟着整个daemon共存亡。想想看,如果dockerd崩溃了,正在运行的容器是不是也会跟着遭殃?

2015年前后,Docker开始推动拆分,这就是“从前的Docker”和“现在的Docker”最重要的分水岭。

  • runc被拆出来,作为独立的OCI容器运行时,负责最底层的容器启停;
  • containerd被拆出来,负责容器生命周期的上层管理,包括镜像解包、容器的暂停/恢复/销毁等;
  • dockerd自己则专注于高层的编排、网络、存储、API。

这个拆分还有一个关键产物:containerd-shim。每个容器启动时,containerd会为它创建一个shim进程,容器主进程死后shim依然存活,用来收集状态并向containerd汇报,同时维持着容器的I/O通道。为什么需要shim?因为runc是“一次性”的——它创建完容器主进程就退出了,没人接管主进程的退出状态和输出,容器就变成孤儿了。shim就是那个负责任务“善后工作”的监护人。

3.2 OCI标准如何重塑了容器生态

拆分背后,是一套标准的诞生,即Open Container Initiative(OCI)。OCI定义了两份关键规范:

  • Runtime Specification:容器运行时应该怎么实现,包括文件系统打包、运行配置、生命周期等;
  • Image Specification:镜像应该长什么样,各层怎么组织、配置怎么存储。

这两份规范最大的意义,是让容器生态彻底解耦。以前“容器”几乎等同于“Docker”,但现在任何人只要按OCI标准实现了运行时或镜像格式,就能和生态无缝对接。runc是OCI运行时的一个参考实现,但后来出现了很多其他实现,比如性能更强的gVisor(注重安全隔离)、Kata Containers(基于轻量虚机),它们都能直接运行Docker或containerd托管的容器。镜像也一样,现在你用别的工具构建出的OCI镜像,照样能被docker run跑起来。

可以说,Docker当年把容器技术送进了标准化轨道,然后自己变成了轨道上的一趟列车。这个选择看似“自降身段”,实际上是让容器技术走向生产环境的必经之路。没有标准化,就没有Kubernetes今天繁荣的容器运行时生态。

3.3 BuildKit、WSL2、containerd独立之后

除了运行时拆分,Docker的构建链也变了。从Docker 23.0开始,docker build默认使用的构建器是BuildKit。传统的构建器是一条指令一个层,串行执行,性能一般;BuildKit支持并发执行构建步骤、更好的缓存复用、能直接构建OCI格式的镜像,还支持不把整个构建上下文传给daemon。我实测的感受是,依赖多的项目用BuildKit构建,速度提升非常明显,缓存命中率高一个量级。

另一个大变化是Docker对macOS和Windows的拥抱方式——WSL2集成。早期Docker Desktop在Mac上靠的是HyperKit虚拟机和轻量Linux VM,体验其实挺好,但总隔了一层。后来在Windows上直接利用WSL2来跑Linux内核,Docker Engine跑在WSL2里,性能接近原生,文件共享也顺畅很多。我自己用下来,WSL2方案比早期VirtualBox方案爽了不知道多少倍。

再就是containerd自身的发展。它后来被捐赠给了CNCF,成了Kubernetes默认的容器运行时,地位举足轻重。这个结果多少有点戏剧性,当年Docker为了“瘦身”拆出来的部件,如今成了云原生时代的地基。甚至很多人的机器上,Kubernetes已经直接经由containerd来运行容器,Docker已经不在调用链里了。所以你现在如果问“Docker还是从前那个Docker吗”,答案真的有点复杂:命令行还是那个味道,但底下那套体系,早就长成了整个云原生生态的一部分。

4. 用命令亲眼验证架构变化

光看架构图不过瘾,我建议你自己在机器上跑几条命令,亲眼看看现在这套体系长什么样。不夸张地说,第一次看到真实进程树的时候,我自己都惊了一下。

4.1 docker version / docker info 读出了什么

第一招,用docker version看组件版本。注意输出是分段的(不同版本输出格式有差异),但Comunity版也好,Engine版也好,关键要区分客户端和服务端两部分。在较新的版本里,你甚至能看到containerd和runc的版本信息。

docker version --format '{{.Server.Components}}'

在旧版Docker上会直接报错,因为Server里没有Components字段。新版输出会列出Engine、containerd、runc的版本号——这就是模块化最好的证明:一个接口背后的实现,是由多个独立组件协同完成的。

再看docker info,这里有个很值得留意的字段:Runtimes。

docker info | grep -A 5 "Runtimes"

老版本这个字段基本只有runc,版本新一点的能看到io.containerd.runc.v2,这是containerd插件化运行时架构的体现。如果你看到多个runtime列在那儿,说明这台机器上已经不仅仅是“Docker的Docker”了。

4.2 docker inspect 里的运行时细节

当你有一个正在运行的容器,最直接的观察路径是docker inspect:

docker inspect $(docker ps -q) | less

在输出里找Path和Args,那是容器真正启动执行的命令。再找HostConfig里的Runtime字段,确认容器是跑在哪个运行时之上的。接着看GraphDriver,它的Name通常会显示为overlay2,下面的LowerDir和UpperDir就是分层文件系统在宿主机上的真实目录。

这些字段看起来枯燥,全是指向存储和运行时机制的线索:你的镜像层被打散存储在各个目录里,容器启动时才由运行时把它们叠起来。

4.3 进程视图下的真相

如果上面的命令都不够直观,那就直接看进程树。

ps -ef | grep -E "dockerd|containerd|shim|runc"

你大概率会看到这样一组进程:

root 1234 1 0 ... dockerd root 5678 1 0 ... /usr/bin/containerd root 9012 5678 0 ... containerd-shim -namespace moby -workdir ... -address /run/containerd/containerd.sock

注意几个细节:dockerd和containerd的父进程都是1(init进程),说明它们都是独立守护进程;containerd-shim的父进程是containerd,它是每个容器的“贴身管家”;你还可能看到runc进程,但更多时候容器主进程已经直接接管了。这套进程链条,就是架构拆分后最真实的样子。你在宿主上找不到一个巨大的“Docker进程”包揽一切,取而代之的是一群分工明确的进程协同干活。这不是退化,这是工程上典型的解耦:每个组件只做一件事,但把这件事做好。

5. 踩坑记录:架构演进带来的常见问题

架构变复杂了,踩坑的面自然也就变了。这些年我在生产环境遇到过几个特别有代表性的问题,写出来给大家排雷。

5.1 /var/lib/docker 的清理与迁移

/var/lib/docker是Docker所有数据的大本营,包括镜像层、容器可写层、Volume、网络配置等等。这个目录膨胀极快,尤其是镜像多、日志不打到stdout的应用。我见过最夸张的一次,200GB的磁盘直接被干满了。

问题是,你不敢随便删里面的文件,因为docker用的是分层存储,目录之间是硬链接还是元数据关联,普通用户根本分不清。正确做法是用docker system prune清理悬空镜像和停止的容器:

docker system prune -a --volumes

这条命令会清掉所有未被运行的容器、未使用的镜像和匿名卷,但也会误伤一些你想保留的构建缓存,执行前先核对清楚。系统盘实在不够,就要考虑迁移整个/var/lib/docker目录到数据盘:修改daemon.json里的>docker exec <container> pkill -TERM -P 1 docker stop <container>

如果已经删掉了容器但shim还赖着不走,那就动手找一下残留进程的接管对象。正常情况shim会很快退出,只有卡在某些I/O上时会变成僵尸。生产环境如果发现这种进程堆积,优先检查是不是应用在容器里用了nohup或者daemonize这类操作——容器里不应该把进程“甩到后台”,这正是容器和传统进程管理最大的不同。

5.3 存储驱动迁移要谨慎

早期AUFS时代,很多老镜像的层是基于AUFS生成的,后来Docker默认驱动切换到Overlay2,部分老镜像在迁移时会报storage-driver相关错误。这也是很多从老版本升级到新版本的同学遇到过的问题。

避免踩坑的要点:升级Docker前,检查daemon配置里的存储驱动设置,确认目标驱动是否支持当前的内核。Overlay2要求内核版本支持overlayfs,正常情况下主流发行版都没问题。但在一些老内核上,可能需要手动加载overlay模块,并在内核启动参数里加上overlayfs相关选项。我自己在给一台非常老的CentOS机器装新版Docker时,就遇到过devicemapper和Overlay2不兼容的问题,最后只能重装系统,教训深刻。

除非有非常明确的理由,否则不建议在生产环境强行切换存储驱动。存储驱动一变,所有镜像层都要重新拉取,等于做了一次大手术,能不动就不动。

结尾

回到标题那个问题:它还是从前那个Docker吗?我的看法是,Docker的“灵魂”没变——容器、镜像、分层存储、一键运行,这些核心思想在今天依然是它的底色。但它的“身体”早已天翻地覆:从一个大单体拆成了client、dockerd、containerd、shim、runc各司其职的协同体系,从私有标准走向了OCI开放标准,从“容器的一切”变成了“容器生态的一个组件”。

这篇文章先讲到这里,重点是Docker自身的架构演进。下一部分,我打算接着聊它和Kubernetes的关系——为什么K8s后来把Docker“踢出”了调用链,containerd又是怎么一步步成为云原生时代的默认运行时,以及这些变化对我们日常写Dockerfile、排障到底有什么实际影响。到时候见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询