首页 / 资讯中心 / 文章详情

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

Docker架构深度解析:从客户端到运行时的分层原理与排查实践 ★ FEATURED ARTICLE
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-WriteCoW机制。当容器启动时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 标准。OCIOpen 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 网络和存储出问题时怎么快速定位网络和存储的问题往往最让人头疼因为它们不像应用报错那样有明确的堆栈。我总结了一套自己的排查顺序分享出来。网络问题先看三件事容器有没有拿到 IPdocker inspect看网络配置、能不能通外网进容器ping或curl、端口映射对不对docker port看映射关系。如果容器间不通重点检查是不是在同一个自定义网络里以及防火墙规则有没有拦截。我遇到过宿主机防火墙把 bridge 网桥的转发给拦了导致容器完全没网排查了半天才发现。存储问题先看磁盘空间docker system df看各类占用、再看挂载是否正确docker inspect看 Mounts 字段、最后看权限进容器看目标目录的属主。磁盘被镜像和容器日志吃满是特别常见的问题尤其是日志默认情况下容器的标准输出会被写到宿主机的日志文件里长期运行的服务如果不做日志轮转能把磁盘撑爆。我现在的习惯是给每个容器都配置日志大小上限防患于未然。5. 把这些知识用起来几个真实的排查场景5.1 容器莫名退出怎么顺着架构往下查回到开头提到的exit code 137。现在有了架构认知排查就有了章法。137 这个退出码的含义是进程被信号 9SIGKILL杀掉了而最常见的 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 到底是怎么配置的有一个非常具体的感知排查问题时心里也更有底。这个习惯看起来不起眼但长期坚持下来对建立架构直觉帮助很大。
阅读完成 · 觉得有帮助?
咨询建站