ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker核心原理详解:镜像分层机制与容器网络全解析

Docker核心原理详解:镜像分层机制与容器网络全解析 Docker这个系列我写了第一部分之后就一直有朋友催更问镜像底层到底是什么、容器网络又是怎么做到既能隔离又能互通。说实话很多用了两三年Docker的人docker run、docker build、docker compose up这些命令溜得很但一遇到“镜像为什么分层”“容器之间为什么ping不通”“端口映射为什么不生效”这类问题就卡壳了。原因很简单只记住了命令的手感没搞懂底层的工作方式。这篇是系列的第二部分我打算把Docker里两个最核心的模块一次讲透镜像原理和网络原理。镜像这块我会把分层结构、写时复制、内容寻址这些机制拆开来讲让你明白一条Dockerfile指令背后到底发生了什么网络这块我会从网络命名空间讲起把bridge、host、overlay几种模式的运行机制、数据包的流转路径全部梳理一遍。如果你已经会跑容器但总觉得隔着一层窗户纸这文章就是帮你把那层纸捅破的。1. 镜像分层为什么同一个镜像能在几秒内变成容器镜像是Docker最基础的概念但很多人对它的理解停留在“一个打包了运行环境的文件”这个层面。这个理解不能说错但距离“懂原理”还差一大截。实际上一个Docker镜像是一摞只读层的叠加每个层都记录了一次文件系统的变化。理解了这个结构很多现象你就能解释通了。1.1 镜像不是一个大盘子而是一摞只读层你可以把Docker镜像想象成一个千层饼每一层都是一次文件系统变更的记录。比如你写一个Dockerfile第一条指令是FROM ubuntu:22.04这一步引入了ubuntu基础镜像的所有层第二条指令是RUN apt-get install -y nginx这会在基础层上面再叠一层记录“我新增了nginx相关文件”第三条COPY index.html /usr/share/nginx/html/又会生成新的一层。这里的关键是每一层都只保存“相对于下面一层的变化”而不是整个文件系统的快照。这样做的好处极其明显如果你在一台机器上同时跑基于ubuntu的MySQL容器和基于ubuntu的Redis容器它们可以共享底部那些ubuntu的系统层只有上面的业务层各自独立。这也是docker pull的时候你能看到Already exists提示的原因本地已经有那些层了不需要重复下载。在宿主机的磁盘上这些层会落在/var/lib/docker/overlay2/目录下你会看到一堆以哈希值命名的目录每个目录对应一个层。这些层通过联合文件系统叠加起来最终呈现为一个完整的文件系统。联合文件系统的活儿就是把这么多层“拼接”成一个统一的视图给容器用容器里的进程看到的是一个正常的根目录完全感觉不到底下是分层拼接出来的。1.2 写时复制容器改动为什么不会污染镜像一个镜像本身是只读的你docker run的时候Docker会在这一摞只读层之上再挂载一个“可写层”。容器运行时的所有写操作——改文件、删文件、装软件——都发生在这个可写层里。这就引出了写时复制机制你可以把它理解成一个“记录板”当容器进程要修改某个文件时Docker不会直接去改动底层的只读文件而是把这个文件从下面的层里复制一份到可写层然后在可写层里进行修改。从进程的角度看它读到的还是那个路径、那个文件名但实际访问的已经是可写层里的副本了。有一个比较反直觉的现象是删除文件。在容器里执行rm删除一个文件底层那个只读层里的文件其实还在只是在可写层里生成了一条“把这个文件标记为删除”的记录联合文件系统在合并视图的时候把这个文件隐藏掉了。所以镜像的分层结构依然没变删除操作本身也只是可写层多了一条看不见的白障记录。理解了写时复制你就能明白为什么容器特别适合共享基础层无论同一个镜像启动了多少个容器每个容器在可写层之外都共用同一套只读文件内存里的页缓存也能共享。这既节省了磁盘空间又大大降低了容器启动的耗时。1.3 内容寻址与增量传输镜像为什么能高效分发Docker镜像的每个层都用一串SHA256的哈希值来标识。注意这里有个细节层的ID是基于内容计算出来的。也就是说如果你在另一台机器上构建出了内容完全一致的层得到的哈希值也是一样的。基于这个特性镜像仓库在传输的时候只要比对哈希值就能知道客户端本地是否已经有这个层了有就直接跳过只传输缺失的层。从架构上看镜像仓库的交互可以分为两步先下载一个“清单文件”记录这个镜像包含哪些层、每层的digest是什么、层的大小是多少然后再按照清单去逐个拽取层数据。层拽下来之后本地的镜像存储会再次校验digest保证数据在传输过程中没被篡改或损坏。这个机制解释了为什么有时候docker pull一个很庞大的镜像反而会“秒下完”——不是网速突飞猛进了而是镜像的大部分层在本地cache里已经存在真正需要下载的只有几个新层。反过来如果你被热搜词里那个“docker镜像下载慢”的问题折磨过大概率是遇到了新层多、单个层体积大再加上从国外镜像仓库拉取链路拥堵的情况。解决方案后面排障章节我会专门说。2. 从tar包到容器镜像的构建、拉取与仓库交互全链路分层机制讲清楚了接下来可以顺着一条完整的链路把镜像是怎么来的、怎么落的、怎么变成容器的梳理一遍。这部分会涉及实际操作中肉眼可见的行为比如docker build时每次RUN都会生成一层、docker pull时的下载粒度、commit命令为什么被劝退等。2.1 pull 一个镜像Docker在背后做了什么执行docker pull nginx:latest的时候你会看到下载进度条分成了好多条每条对应一个layer。完整的流程是这样的首先Docker客户端向镜像仓库发起清单查询拿到这个镜像的manifest里面包含了平台架构amd64还是arm64、每一层的digest、层大小等元信息。然后客户端会检查本地已有的层跳过那些digest已经匹配的对缺失的层会并行发起下载请求。层数据全部落地后会验证digest再解压到overlay2目录。最后元数据写入本地的镜像管理数据库里完成整个pull操作。这里有一个容易被忽略的点manifest本身还分普通manifest和manifest列表。当你在MacARM架构上pull一个镜像客户端会优先去找适配ARM架构的清单找到对应的层进行下载。如果你的Dockerfile里没有做多架构适配在ARM机器上pull x86镜像就会因为找不到匹配清单而报错或者被平台模拟层硬着头皮转译运行性能自然会受影响。2.2 Dockerfile的每条指令本质上都在叠一层docker build实际上就是逐条执行Dockerfile里的指令每执行一条就往镜像上叠一个新的层。RUN、COPY、ADD这类指令都会产生新的层而ENV、WORKDIR这类指令虽然也生成层但层里主要记录的是配置元数据占不了多少磁盘空间。构建缓存机制也是因为分层才有了可能Docker会记录每一层执行完之后的镜像ID下次构建时如果发现基础镜像没变、指令没变、涉及的文件内容也没变就直接复用之前的层不再重新执行这条指令。用大白话说只要中间某一层的结果没有变化它后面所有层的缓存可能都会失效这也是为什么很多人改了一行代码整个build流程从几十秒变成几分钟。经验是把“变化频率低的指令写在Dockerfile前面变化频率高的写在后面”这样能最大化命中缓存。2.3 镜像体积管理为什么我不建议频繁用commit很多新手会犯一个习惯性错误容器里手动装了一堆东西调试通过后直接docker commit打包成新镜像。这个操作本身是可以用的但我强烈不建议你把它当作常规操作。原因有三个。第一commit会把可写层的所有变更打成一个黑盒层这层里包含什么文件、装了什么软件、删了什么配置别人完全不知道也不可复现。第二因为可写层是累积变更的总和往往包含大量临时文件、日志、包缓存commit出来的镜像体积通常偏大。第三commit丢失了Dockerfile的构建信息后面想优化镜像结构、做安全修复无从下手。如果要做体积控制我一般遵循这三个原则尽量用官方镜像或精简基础镜像比如很多场景下alpine比ubuntu合适用多阶段构建把编译环境和运行环境分开最终镜像只保留可执行文件和运行依赖清理层内缓存在同一个RUN指令里装完包之后顺手rm -rf掉apt或apk的缓存文件。写成一个RUN而不是多个RUN串行执行也能有效避免缓存哪些已经被删除的中间文件让每一层都“瘦”一点。3. 容器网络隔离、互通与转发到底是怎么实现的网络是初学者最容易犯怵的部分。Docker的网络模式很多命令也简单但真正说起来底层离不开Linux内核的网络命名空间和虚拟网卡技术。理解了这两个基础点Docker网络的逻辑就变得非常线性和简单了。3.1 每个容器都有一套独立的网络栈网络命名空间是Linux内核提供的一种隔离机制它可以给一组进程单独搞一套完整的网络协议栈包括网卡、路由表、防火墙规则、socket等。Docker在创建每个容器的时候都会帮这个容器新建一个独立的网络命名空间容器里的进程看到的所有网络相关的资源都在这个命名空间里跟宿主机和其他容器互不可见。光有隔离还不够容器总得跟外面通信。这时候靠的是一种叫veth pair的虚拟网卡设备。你可以把它想象成一根虚拟的“网线”两端各连一个网卡一端放进容器的网络命名空间里叫eth0另一端挂在宿主机的虚拟网桥上叫veth xxx之类。默认情况下Docker在宿主机上会创建一个名为docker0的虚拟网桥默认网段是172.17.0.0/16。veth pair挂在docker0上就相当于把一个容器接入了这台虚拟交换机。同宿主机上所有接到docker0的容器就处于同一个二层网络里可以互相通信。这个逻辑非常像一台二层交换机只要接了同一个网桥就能在同一个网段内互联。这也是为什么很多解释Docker网络的文章会拿“容器像插在交换机上的主机”来类比。3.2 bridge模式下数据包怎么从容器走到外部默认bridge模式下一个数据包从容器访问外部网络要经历这样的路径容器进程发出一个目标IP不是本网段的请求数据包先经过容器内的eth0通过veth pair到达宿主机的docker0网桥。网桥发现目标MAC地址不在本地已知范围内就把数据包交到宿主机的网络栈来处理。接下来宿主机会做路由判断发现目标IP需要走默认网关发出于是数据包要经过内核的IP转发功能同时还被打上了NAT源地址转换的标记。这里NAT的角色很关键。容器内网卡上的IP地址是私有网段的比如172.17.0.2这个地址在外部网络中是不路由的。内核把数据包发出去之前会把源IP改成宿主机的IP同时记录一条映射关系。外部返回的数据包到达宿主机后再根据映射关系把目标地址改回容器的IP沿原路送回去。这一进一出的过程就是iptables里MASQUERADE规则干的事。端口映射是另一个方向的工作。你在-p 8080:80时Docker会在宿主机上设置一条DNAT规则所有访问宿主机8080端口的TCP包目标地址和目标端口会被改写成容器的IP和80端口。需要注意的是这里有一个叫docker-proxy的用户态进程当你用ss -lntp查看宿主机监听端口时会看到127.0.0.1:8080上有docker-proxy在监听。它主要负责处理从宿主机本机不是外部访问映射端口的流量而真正从外网进来的流量更多是靠内核里的DNAT规则直接转发。这两种路径同时存在很多人查问题的时候容易在这里绕晕。3.3 host、none、container三种模式分别用在什么场景除了默认bridgeDocker还有一个经常用的host模式。host模式最简单粗暴不给容器单独建网络命名空间直接用宿主机的网络栈。容器里的进程监听端口就等同于监听在宿主机上没有NAT、没有端口映射性能是最好的。代价是隔离性几乎为零容器里跑的服务和宿主机服务共享端口端口冲突就靠你自觉避让。一般我只有在性能敏感、且明确知道自己在干什么的时候才会用host模式比如一些日志采集器、网络观测工具。none模式意味着Docker不帮容器配置任何网络设备容器里只有回环地址。看起来啥也干不了但有些场景反而需要它特殊安全需求下脚本在隔离环境里跑批处理任务或者你想完全自定义网络栈时可以先以none模式启动容器再手动把veth或物理网卡塞进它的命名空间。container模式是指新容器共享另一个容器的网络命名空间也就是两个容器共用同一个IP和端口空间。典型场景是开发调试时你起一个容器跑服务另一个容器用localhost直接连它不需要考虑端口映射和网络地址。这比bridge模式少走一层NAT链路路径也清晰很多。3.4 跨主机网络overlay和VXLAN的简单路线图单台宿主机上的容器通信靠docker0就能解决但生产环境很少只有一台机器。当你有多台宿主机容器之间要跨机器通信、并且还要基于IP直接互通的时候就要考虑overlay网络了。overlay网络的思路是在已有的物理网络之上再虚拟出一层网络让分布在不同宿主机上的容器处于同一个虚拟子网内。Docker Swarm模式下内置了overlay网络驱动原理上依赖VXLAN隧道容器发出的数据包先被封装在宿主机之间的UDP隧道包里到达目标宿主机后再解封装还原成原始容器流量交付给目标容器。对容器来说它感知不到底层物理网络看到的只是自己所属的虚拟子网。跨主机网络这里我不展开讲得太深因为展开下去就是另一个主题了。但基本原理要知道跨主机容器互通的本质就是隧道封装与解封装容器不关心百公里外的对端在物理上怎么到达它只需要把包发给“下一站”剩下的由网络插件Calico、Flannel、Weave等去完成。4. 镜像与网络的排障实录我踩过的坑和排查心法原理讲再多最后都要落到排障上。我把自己遇到过的镜像和网络相关的典型问题整理了一遍有些是你大概率也会碰到的每个问题我都会给排查思路和解决方案。4.1 镜像拉取慢或者一直超时这是被热搜词反复验证的高频问题。罪魁祸首无非两点目标镜像仓库的网络带宽受限或者本地与远程仓库之间的链路质量太差。排查时可以先用docker pull一个小镜像试试如果连小镜像都卡基本能定位是网络链路问题而不是镜像本身的问题。常用的解法是给Docker配置加速器。在/etc/docker/daemon.json里加registry-mirrors配置指定一个本地访问更快的镜像仓库地址然后重启docker服务。这个方案在不同场景下的效果差异很大有的加速服务侧重公共镜像有的侧重特定仓库你需要实测几个常见的镜像地址对比拉取速度再决定哪个适合你。另一个容易被忽略的点是层数据校验失败。pull过程中如果网络抖动可能导致层数据不完整Docker会报digest verification failed之类的错。这种情况不需要太折腾把失败的镜像删掉重新docker pull一次大概率就恢复了。如果反复失败就要看看磁盘空间是不是满了/var/lib/docker分区撑爆会导致镜像层写不进去表现也是pull异常。4.2 为什么我新创建的容器跟别的容器ping不通如果你用的是默认的bridge网络不同容器之间能互通但无法通过容器名互相访问。原因很简单默认bridge网络没有内置DNS服务容器间的解析依赖IP地址而容器的IP每次创建都可能变化。这时候你该做的是创建一个自定义bridge网络docker network create mynet启动容器时都加--network mynet。自定义bridge会自动给容器注册DNS记录容器之间直接用服务名或容器名就能解析到对方。还有一个隐蔽的坑容器里ping外网IP能通但ping域名不通。这种情况通常是容器内的DNS配置问题。默认bridge模式下Docker会把宿主机的DNS配置拷贝给容器如果宿主机用的是systemd-resolved容器可能拿到一个虚拟的DNS地址解析不了外网域名。排查时先cat /etc/resolv.conf看看容器里的DNS配置再手动nslookup验证。解决方式是给容器单独指定DNS服务器比如常见的公共DNS地址或者自定义网络里用Docker自带的127.0.0.11内嵌DNS让它走宿主机代理解析。4.3 端口映射不生效容器服务起不来还是防火墙拦了端口映射不生效是个多因素问题我的排查顺序是固定的先用docker ps确认端口映射配置确实存在比如0.0.0.0:8080-80/tcp再到宿主机上试curl localhost:8080这一步能区分问题发生在容器内部还是外部链路如果宿主机本机访问是通的外部访问不通重点查宿主机防火墙比如firewalld或者ufw有没有放行对应端口如果宿主机本机也不通进容器里试curl localhost确认容器内服务监听的是不是正确的端口和网卡地址。请注意一个细节容器内服务如果监听的是127.0.0.1:80那Docker的端口映射再努力也是白搭因为流量进来之后目标地址虽然被DNAT改成了容器IP但容器内的进程压根没监听在容器网卡上。解决办法是让容器里的服务监听0.0.0.0这在配置文件里调整一下就好。这类问题我见过太多次了有时候不是Docker的锅是容器内进程自己的监听地址写死了。4.4 容器重启后IP变了配置全乱了默认bridge网络下容器IP是不固定的每次重启都可能变。如果你的应用配置里写了其他容器的IP重启一次就得改一次这就很痛苦了。解决方式很简单把容器加入自定义bridge网络然后一律通过容器名来访问。自定义bridge的DNS解析会自动把容器名映射到最新的IP容器怎么重启都不影响访问。另外一个容易踩的坑是容器启动顺序。即便在自定义bridge里A容器依赖B容器如果B还没启动A里面解析B的名字是失败的。解决办法有两个在A的启动脚本里加等待重试逻辑或者用docker compose编排它会在容器启动顺序上有自己的处理策略。总的来说容器间通信永远优先用服务名/容器名不要用硬编码的IP。下面这张表是我日常排障时的“快捷键”分享出来给大家症状核心排查点常见根因处理优先级镜像pull超时/慢网络链路、镜像仓库可达性本地到仓库链路差、层未复用先试小镜像再配加速器pull digest mismatch本地镜像缓存、磁盘空间网络传输异常或磁盘写满清理缓存后重新pull容器间ping不通网络模式、自定义网络用了默认bridge且IP变了创建自定义bridge用容器名通信容器内无法解析域名resolv.conf、systemd-resolvedDNS配置被错误继承手动指定DNS或换自定义网络端口外部不通防火墙、docker-proxy、容器监听地址宿主机防火墙未放行或服务只监听127.0.0.1按节点逐层排查先本机后外部容器重启后失联固定IP依赖默认bridge的IP漂移使用容器名自定义网络把镜像和网络的原理理清楚之后很多Docker的“悬案”都能化简为常识。我自己的体会是Docker本身并不神秘命令再多背后的逻辑就那么几条主线分层叠加文件系统、命名空间隔离资源、veth连通彼此、NAT完成转发。遇到问题先在脑子里把数据包的路径走一遍或者把文件的层叠关系画一遍通常答案就浮出水面了。这套思路也不只在Docker上有效后面你去接触containerd、Kubernetes会发现它们只是把同样的内核原语搬到了更大规模的舞台上。原理这东西越早吃透越值钱。
返回列表