ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker架构深度解析:从客户端到运行时的分层原理与排查实践

Docker架构深度解析:从客户端到运行时的分层原理与排查实践

Docker 这个词现在几乎成了"打包交付"的代名词,但很多人用了两三年,依然停留在docker run和docker build的层面,一旦遇到容器起不来、镜像拉取慢、磁盘被吃满、网络不通这类问题,就只能靠搜索引擎碰运气。我自己也经历过这个阶段——早期部署一套服务,容器莫名其妙退出,日志里只有一句exit code 137,当时完全不知道从哪下手,折腾了大半天才反应过来是内存被系统回收了。后来我逼着自己把 Docker 的整体架构从头到尾捋了一遍,才发现很多"玄学问题"其实都能在架构层面找到答案。

这篇内容就是把我这些年对 Docker 架构的理解整理出来,从客户端到守护进程,从镜像分层到容器运行时,从网络模型到存储驱动,尽量用大白话加上类比讲清楚。它适合已经会基本命令、但想搞明白"底下到底发生了什么"的开发者,也适合运维同学排查问题时建立整体认知。读完你至少能明白:为什么镜像能复用、为什么容器删了数据就没了、为什么同样是容器,有的启动快有的慢。这些问题的答案,全都藏在架构里。

1. 先把 Docker 的全局骨架搭起来

1.1 客户端-守护进程这套 C/S 结构到底怎么跑

Docker 本质上是一个客户端-服务端(C/S)架构的程序,这一点很多人知道,但未必真正理解它的含义。你敲的每一条docker命令,其实都是一个独立的客户端进程,它自己不干活,只负责把请求通过 REST API 发给背后的守护进程dockerd,由守护进程去真正执行。这就好比你去餐厅点菜,你(客户端)只是把需求告诉服务员,真正做菜的是后厨(守护进程)。

这个设计带来的第一个直接好处是远程管理。因为客户端和守护进程之间走的是 HTTP 协议,所以你完全可以在本地机器上操作一台远在机房的服务器,只要配置好DOCKER_HOST环境变量指向那台机器的守护进程地址就行。我早期做多机部署时,就是靠这个特性在一台跳板机上统一管理好几台宿主机的容器,省去了反复登录的麻烦。

第二个好处是解耦。客户端可以有很多种形态:命令行工具、图形界面、各种语言的 SDK,甚至你自己写个脚本直接调 REST API 都行。它们都只是"点菜的人",后厨的逻辑完全不用改。理解这一点之后,你再看那些第三方 Docker 管理面板,就不会觉得神秘了——它们无非是换了个方式调同一套 API。

不过这里有个容易踩的坑:守护进程默认监听的是本地的 Unix socket(/var/run/docker.sock),而不是 TCP 端口。这个 socket 文件权限很高,谁能访问它,谁就等同于拿到了宿主机 root 权限。所以千万不要图省事把它随便暴露到公网上,这是非常危险的操作。如果确实需要远程访问,正确做法是配置 TLS 证书做双向认证,而不是简单地把端口开出去。

1.2 守护进程内部到底分了哪些活

dockerd这个进程看起来只是一个后台服务,但它内部其实是个"大管家",管着一大摊子事。我把它内部的职责大致分成几块来理解,会清晰很多。

第一块是API 服务层。它负责接收客户端发来的 HTTP 请求,做鉴权、路由、参数校验,然后把请求分发给对应的模块。你敲的docker run、docker ps、docker pull,最终都是在这里被解析的。

第二块是镜像管理。负责镜像的拉取、存储、分层管理。当你执行docker pull时,是这一层在跟镜像仓库打交道,把一层层的镜像数据下载下来并缓存到本地。

第三块是容器生命周期管理。负责容器的创建、启动、停止、删除。它不直接去操作内核,而是把具体的"跑容器"这件事委托给更底层的运行时组件。

第四块是网络和存储管理。负责给容器分配网络、挂载卷、管理各种驱动。

这四块之间通过内部的接口协作,对外则统一暴露成 REST API。理解这个分层之后,你排查问题时就能快速定位:如果是拉镜像报错,问题多半在镜像管理层;如果是容器起不来,重点看生命周期和运行时;如果是网络不通,就往网络管理那块查。这种"按层定位"的思路,比盲目看日志高效得多。

1.3 为什么 Docker 要拆出这么多组件

很多人会问:既然是一个程序,为什么不把所有功能都塞进dockerd里,非要拆成 containerd、runc 这么多组件?这个问题问到了 Docker 架构设计的核心。

最根本的原因是职责分离和标准化。早期 Docker 确实是把所有东西都揉在一起的,但后来社区意识到,"管理容器生命周期"和"真正运行一个容器"是两件不同层次的事。前者是编排层面的逻辑,后者是跟操作系统内核打交道的底层操作。把它们拆开,好处是每一层都可以独立演进、独立替换。

举个实际例子:containerd后来被设计成一个通用的容器运行时,它不仅能被 Docker 用,也能被 Kubernetes 直接调用。这就是拆分的价值——底层能力被抽象出来,成了行业公共基础设施。而runc则是遵循 OCI(开放容器倡议)标准的一个具体实现,负责真正调用 Linux 内核的 namespace 和 cgroup 来创建容器进程。

这种分层还带来了稳定性。dockerd如果因为某个 bug 崩了,已经跑起来的容器不会受影响,因为真正管着它们的是containerd和runc。我在生产环境遇到过dockerd重启的情况,容器全程无感,这就是分层带来的好处。

2. 镜像分层:Docker 最巧妙的设计之一

2.1 一层一层叠起来的镜像到底是什么

镜像分层是 Docker 最核心也最巧妙的设计,理解了它,很多"为什么"就迎刃而解了。简单说,一个镜像不是一整块数据,而是由若干"层"(layer)叠加而成的,每一层都是只读的,代表一次文件系统变更。

打个比方,你可以把镜像想象成一摞透明的玻璃片,每片玻璃上画了点东西。最底下那片画的是操作系统的基础文件,往上每一片都画了新的内容或者修改。把它们叠在一起看,就是完整的镜像。这种设计的好处是层可以被复用。

假设你有两个镜像,都基于同一个基础镜像构建,那么它们共享最底下那几层。当你拉取第二个镜像时,如果基础层本地已经有了,就只需要下载差异的那几层。这就是为什么有时候拉一个看起来很大的镜像,实际下载量却很小——因为大部分层已经缓存过了。

每一层在存储上都有一个唯一的摘要(digest),也就是内容的哈希值。这个哈希值保证了层的完整性:内容一样,哈希就一样;哈希一样,就认为是同一层。这种基于内容寻址的方式,让镜像的传输和校验都变得非常可靠。

2.2 写时复制:容器怎么在只读镜像上"写"数据

镜像的层都是只读的,但容器运行起来肯定要写文件,这就引出了写时复制(Copy-on-Write,CoW)机制。当容器启动时,Docker 会在所有只读层的最上面,再加一个可写的容器层。容器里所有的写操作,都发生在这个可写层上。

关键点在于"写时复制"这四个字:当容器要修改一个位于只读层的文件时,系统不会直接改那个只读文件(也改不了),而是先把文件复制到可写层,然后在可写层里修改。原来的只读层纹丝不动。这就好比你在图书馆的书上不能直接涂改,只能复印一份出来,在复印件上改。

这个机制带来的直接后果是:容器删了,可写层就没了,数据也就丢了。这是新手最容易踩的坑之一。很多人第一次用容器跑数据库,容器一删数据全没了,就是因为数据写在了可写层里。正确做法是用数据卷(volume)把需要持久化的目录挂载出来,让数据存在宿主机上,跟容器的生命周期解耦。

写时复制还有一个性能上的特点:第一次修改某个文件时会有一次复制开销,之后就在可写层里操作了。所以对于写操作非常频繁的场景,可写层的性能会成为一个考量点。这也是为什么生产环境跑数据库这类 IO 密集服务时,通常会把数据目录直接挂载成卷,绕过写时复制。

2.3 分层带来的缓存机制与构建优化

分层设计在镜像构建时带来的最大红利就是构建缓存。当你执行docker build时,Docker 会逐条执行 Dockerfile 里的指令,每条指令如果和上次构建时对应的层内容一致,就直接复用缓存,不重新执行。

这个机制用好了能极大加速构建,用不好则会让你"改了代码却没生效"。我见过不少人在 Dockerfile 里先COPY . .再RUN npm install,结果每次改一行代码,依赖都要重新装一遍,构建慢得让人抓狂。正确的顺序应该是先拷贝依赖描述文件(比如package.json),装完依赖,再拷贝源代码。这样只要依赖没变,装依赖那层就一直命中缓存。

这里有个经验:把变化频率低的操作放在 Dockerfile 前面,变化频率高的放后面。因为一旦某一层缓存失效,它后面的所有层都会失效重建。这个原则看起来简单,但实际写 Dockerfile 时特别容易忽略。我自己的习惯是,写完 Dockerfile 后专门想一遍"哪些内容经常变",把它们尽量往后挪。

另外,多阶段构建也是基于分层机制的进阶用法。你可以在一个 Dockerfile 里用多个FROM,前面的阶段负责编译,后面的阶段只拷贝编译产物。这样最终镜像里不会包含编译工具链,体积能小一大截。我做过一个对比,一个 Go 服务用单阶段构建出来的镜像有 800 多 MB,改成多阶段后只有 20 多 MB,差距非常明显。

3. 容器运行时:从 dockerd 到真正跑起来的进程

3.1 containerd 在中间扮演什么角色

前面提到dockerd不直接跑容器,那它把活交给谁?答案是containerd。这是一个独立的守护进程,专门负责容器的生命周期管理——镜像的拉取和存储、容器的创建和销毁、快照管理等等。

你可以把containerd理解成"容器的大管家",而dockerd更像是"面向用户的前台"。用户通过dockerd下达指令,dockerd把具体执行交给containerd。这种分工让dockerd可以专注于 API、编排、用户体验这些上层的事,而把脏活累活交给更专业的containerd。

containerd的一个重要特点是它不绑定 Docker。它是一个通用的容器运行时,Kubernetes 也可以直接用它(通过 CRI 接口)。这意味着你在 Docker 里学到的容器知识,在 Kubernetes 场景下大部分是通用的,底层跑的是同一套东西。

从排查问题的角度看,containerd的日志往往比dockerd的日志更接近真相。当容器启动失败、镜像拉取异常时,我通常会同时看两边的日志:dockerd的日志告诉你请求层面的信息,containerd的日志则更接近底层实际发生的错误。

3.2 runc 和 OCI 标准:容器到底是怎么被创建的

containerd收到"启动一个容器"的请求后,它自己也不直接创建进程,而是调用runc。runc是一个轻量级的命令行工具,它做的事情非常底层:按照 OCI 标准定义的配置,调用 Linux 内核的 namespace 做隔离、用 cgroup 做资源限制、然后启动容器里的第一个进程。

这里要重点说一下OCI 标准。OCI(Open Container Initiative)是一个制定容器格式和运行时标准的组织,它定义了两样东西:一是镜像的格式标准,二是运行时的标准。runc就是运行时标准的一个参考实现。因为有了这个标准,不同工具做出来的镜像可以互相通用,不同的运行时也可以互相替换。

runc的工作方式很有意思:它启动容器进程后就"退出"了,把容器的管理交给containerd里的一个 shim 进程。这个 shim 进程负责转发标准输入输出、上报退出状态等。这种设计让containerd本身不需要一直盯着每个容器,即使containerd重启,容器也不受影响。

理解这一层之后,你就能明白为什么容器的隔离性来自 Linux 内核,而不是 Docker 本身。Docker 只是把内核的这些能力包装成了好用的工具。所以容器的隔离强度,本质上取决于内核的 namespace 和 cgroup 机制,而不是 Docker 的某个开关。

3.3 容器和虚拟机到底差在哪

既然讲到了运行时,就不得不澄清一个老生常谈但很多人依然模糊的问题:容器和虚拟机的区别。

虚拟机是在硬件层面做虚拟化,它模拟出一整套虚拟硬件,然后在上面装一个完整的操作系统。每个虚拟机里都有一个独立的内核。而容器是共享宿主机内核的,它不模拟硬件,也不装独立内核,只是用 namespace 做视图隔离、用 cgroup 做资源限制,让进程以为自己在一个独立的环境里。

这个根本差异带来几个实际影响。第一是启动速度:虚拟机要启动整个操作系统,通常几十秒;容器只是启动一个进程,通常几百毫秒甚至更快。第二是资源开销:虚拟机有完整内核和系统进程的开销,容器几乎没有额外开销。第三是隔离强度:虚拟机的隔离更彻底,因为内核都是独立的;容器共享内核,隔离相对弱一些,一个内核漏洞可能影响所有容器。

所以选型时要看场景:需要强隔离、跑不可信负载,虚拟机更合适;需要快速启动、高密度部署、跑自己可控的服务,容器更合适。实际生产中两者经常混用,比如在虚拟机里跑容器,兼顾隔离和效率。

4. 网络与存储:容器怎么跟外界打交道

4.1 几种网络模式各自适合什么场景

Docker 的网络模型是另一个容易让人迷糊的地方。默认情况下,容器启动后会连到一个叫bridge的虚拟网桥上,拿到一个内网 IP,通过宿主机的 NAT 规则访问外网。这是最常用的模式,适合大多数单机场景。

除了默认的 bridge 模式,还有几种常见模式。host模式让容器直接使用宿主机的网络栈,不隔离网络,性能最好但端口会冲突,适合对网络性能要求高、且能接受端口占用的场景。none模式给容器一个只有 loopback 的网络,完全隔离,适合不需要网络的批处理任务。container模式让新容器共享另一个容器的网络,适合需要紧密协作的容器组。

我踩过的一个坑是端口映射的理解。-p 8080:80的意思是"把宿主机的 8080 端口映射到容器的 80 端口",前面是宿主机,后面是容器,顺序千万别搞反。而且要注意,容器之间通过自定义网络通信时,可以直接用容器名当主机名,不需要走端口映射。这也是为什么推荐用自定义 bridge 网络而不是默认的——自定义网络自带 DNS 解析,容器间通信方便很多。

还有一个实际经验:默认 bridge 网络下的容器互相通信只能靠 IP,而 IP 在容器重启后可能变化,非常不稳定。所以只要涉及多容器协作,我都会先创建一个自定义网络,把所有相关容器接进去,用名字通信,省心很多。

4.2 存储驱动和卷:数据到底存在哪

存储这块要分两个层面看:一个是镜像和容器层用什么存储驱动,另一个是数据持久化用什么卷。

存储驱动负责实现前面说的分层和写时复制。常见的驱动有 overlay2、devicemapper 等,现在主流 Linux 发行版上基本都是 overlay2。它性能好、实现简洁,是默认选择。不同的存储驱动在性能和稳定性上有差异,但大多数情况下用默认的就行,除非有特殊需求。

卷(volume)则是解决数据持久化的。前面说过,容器可写层的数据会随容器删除而丢失,卷就是用来把数据存到容器生命周期之外的地方。Docker 管理的卷默认存在宿主机的/var/lib/docker/volumes/下,由 Docker 统一管理,用起来最省心。另一种方式是绑定挂载(bind mount),直接把宿主机的某个目录挂进容器,适合开发时挂代码目录,或者需要访问宿主机特定路径的场景。

我个人的选择习惯是:生产环境的数据持久化优先用卷,因为 Docker 帮你管理路径和权限,迁移和备份都方便;开发环境挂代码用绑定挂载,改完代码立即生效,不用重新构建镜像。这里有个细节,绑定挂载时如果宿主机目录不存在,Docker 会自动创建,但创建出来的是 root 权限的目录,普通用户可能没权限写,这个坑我踩过好几次。

4.3 网络和存储出问题时怎么快速定位

网络和存储的问题往往最让人头疼,因为它们不像应用报错那样有明确的堆栈。我总结了一套自己的排查顺序,分享出来。

网络问题先看三件事:容器有没有拿到 IP(docker inspect看网络配置)、能不能通外网(进容器ping或curl)、端口映射对不对(docker port看映射关系)。如果容器间不通,重点检查是不是在同一个自定义网络里,以及防火墙规则有没有拦截。我遇到过宿主机防火墙把 bridge 网桥的转发给拦了,导致容器完全没网,排查了半天才发现。

存储问题先看磁盘空间(docker system df看各类占用)、再看挂载是否正确(docker inspect看 Mounts 字段)、最后看权限(进容器看目标目录的属主)。磁盘被镜像和容器日志吃满是特别常见的问题,尤其是日志,默认情况下容器的标准输出会被写到宿主机的日志文件里,长期运行的服务如果不做日志轮转,能把磁盘撑爆。我现在的习惯是给每个容器都配置日志大小上限,防患于未然。

5. 把这些知识用起来:几个真实的排查场景

5.1 容器莫名退出,怎么顺着架构往下查

回到开头提到的exit code 137。现在有了架构认知,排查就有了章法。137 这个退出码的含义是进程被信号 9(SIGKILL)杀掉了,而最常见的 SIGKILL 来源就是系统的 OOM killer——内存不够,内核直接杀进程。

顺着架构往下查:先看容器有没有设置内存限制(docker inspect看 HostConfig 里的 Memory),如果设了限制且容器用超了,就会被杀。再看宿主机整体内存是不是紧张,如果宿主机本身内存不足,也可能触发全局的 OOM。定位到原因后,解决方案要么是调大内存限制,要么是优化应用的内存占用,要么是给宿主机加内存。

这个排查过程之所以能这么顺,就是因为理解了"容器本质是受 cgroup 限制的进程"这个架构事实。如果不理解这层,看到 137 只会一脸懵。

5.2 镜像越积越多,磁盘告急怎么清理

用久了 Docker 的人几乎都会遇到磁盘告急。这时候要理解镜像、容器、卷、构建缓存这几类东西各自占了多少空间。docker system df这个命令能一目了然地看到各类占用,我建议养成定期看的习惯。

清理的时候要分清哪些能删哪些不能删。悬空镜像(dangling image,也就是没有标签、没有被任何容器引用的层)可以放心删。停止的容器如果确认不需要了也可以删。但卷要特别小心,卷里往往存着数据,删之前一定确认清楚。我见过有人图省事直接docker system prune -a --volumes,结果把数据库的数据卷一起删了,追悔莫及。

我的习惯是分步清理:先docker image prune清悬空镜像,再docker container prune清停止的容器,最后才考虑卷,而且卷一定是一个个确认过再删。这种谨慎在数据面前永远不嫌多。

5.3 从架构视角看,哪些操作其实很危险

理解了架构,就能识别出哪些操作是"看着方便实则危险"的。第一个是前面说的暴露docker.sock,等于把宿主机 root 权限拱手让人。第二个是在容器里跑需要特权的操作,比如挂载宿主机设备、修改内核参数,这些会削弱隔离性。第三个是用latest标签部署生产环境,因为latest会变,今天拉的和明天拉的可能是两个不同的镜像,导致环境不一致。

还有一个容易被忽视的:把敏感信息直接写进镜像。因为镜像分层是可追溯的,即使你在后面的层里删掉了敏感文件,前面的层里依然留着,别人拉下镜像一层层解开就能看到。正确做法是用构建参数、密钥管理或者运行时挂载的方式传入敏感信息,绝不写进镜像。

这些"危险操作"的共同点是:它们都利用了 Docker 的便利性,但忽略了架构层面的安全边界。理解了架构,你就能在享受便利的同时,知道边界在哪里。

6. 我对这套架构的一点个人体会

把 Docker 架构捋清楚这件事,对我来说最大的价值不是记住了多少组件名字,而是建立起了一种"分层定位"的思维习惯。遇到问题不再是从上到下瞎试,而是先判断问题出在哪一层:是客户端和守护进程的通信问题,是镜像层的问题,是运行时的问题,还是网络存储的问题。定位到层,再往下钻,效率完全不一样。

另外一个体会是,Docker 的很多设计都是"用简单的方式解决复杂问题"的典范。分层镜像用内容寻址解决了分发和复用,写时复制用最小的代价实现了隔离,C/S 架构用一套 API 支撑了各种客户端。这些设计思路本身,比具体命令更值得琢磨。我后来做其他系统设计时,经常会回想 Docker 是怎么权衡这些问题的,受益良多。

最后分享一个小习惯:我每隔一段时间会重新看一遍docker info的输出,里面包含了存储驱动、运行时版本、cgroup 驱动、各类资源限制等大量架构相关的信息。看多了之后,你会对"我这台机器上的 Docker 到底是怎么配置的"有一个非常具体的感知,排查问题时心里也更有底。这个习惯看起来不起眼,但长期坚持下来,对建立架构直觉帮助很大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进