尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Docker镜像分层与容器网络深度解析:从构建缓存到多容器通信

发布时间:2026/9/9 23:34:30

资讯中心
01
ARTICLE

Docker镜像分层与容器网络深度解析:从构建缓存到多容器通信

Docker镜像分层与容器网络深度解析:从构建缓存到多容器通信
1. 先把“镜像”这件事彻底讲明白1.1 镜像不是一个大文件而是一摞只读层我第一次深入接触 Docker 镜像时总以为它就是一个被打包好的大文件类似虚拟机里的 vmdk 镜像里面装了完整的操作系统和文件系统。真正去看镜像的拉取和构建过程之后才意识到这个理解虽然大方向没错但细节上差了十万八千里。Docker 镜像在物理上是由很多层只读文件系统叠加在一起的。每一层都有唯一的 hash 作为标识对应一个构建步骤或者文件系统的变更集合。你执行docker pull ubuntu时会看到终端里一行一行地下载$ docker pull ubuntu:22.04 22.04: Pulling from library/ubuntu 2f34e1a58496: Pull complete 1bbcad693666: Pull complete f3472758fd9e: Pull complete这三行下载记录每一行都代表镜像中的一层。你可能会好奇Ubuntu 的 rootfs 明明是同一个文件系统为什么要拆成好几层答案是复用和缓存。举个例子假设你在本地基于同一个基础镜像做了 Java 环境镜像、Python 构建镜像、Node 开发镜像如果它们的第一层基础层完全相同那么磁盘上只需要保留一份基础层数据三个镜像分别用自己的上层来叠出差异。Docker 的存储驱动会把相同 hash 的层直接复用这就是为什么你拉取一个新镜像时如果已经有相同的基础层它会显示“Already exists”瞬间完成大部分下载。想直观看到镜像的分层结构可以用docker history$ docker history nginx:latest IMAGE CREATED CREATED BY SIZE 2d9e0b6a5f09 3 weeks ago /bin/sh -c #(nop) CMD [nginx -g daemon off;] 0B ...CREATED BY 里记录的是这一层在构建时执行的指令SIZE 是这一层带来的文件系统增量。注意有些层 SIZE 是 0B说明它只修改了镜像的元信息比如设置环境变量、暴露端口、设置默认命令没有实际写入文件。明白镜像层这个概念之后很多问题就迎刃而解了。比如为什么 Docker 镜像通常比虚拟机镜像小很多因为每个镜像只保留增量层而不是每次从零开始拼装一个完整 rootfs为什么 Docker Hub 上同一个标签的镜像会有不同的 “digest”因为每一层的内容变了整个镜像的 manifest 摘要就会变。1.2 OverlayFS 和写时复制容器改了文件镜像却毫发无损镜像分层解决了存储复用问题但还有一个关键问题没有回答容器运行时进程要去读写文件而镜像层是只读的怎么处理这就轮到联合文件系统登场了。Linux 生态里我们最常接触的是 OverlayFS而 Docker 目前默认的存储驱动overlay2就是基于它实现的。你可以把它的工作机制理解成三块板子底层是一组只读的镜像层术语叫lowerdir上层是一块可写的空层术语叫upperdir最终用户看到的合并视图叫merged容器启动时Docker 会把镜像的所有只读层作为 lowerdir再创建一个可写层作为 upperdir最后把两者合并挂载到容器的根文件系统位置。用户进程看到的文件系统是完整的但写入只会落在这个可写的 upperdir 里。这里有一个特别重要的机制叫写时复制。用生活里的话说就是不改就不动要改才复制。当容器里的进程读取一个存在于镜像层中的文件时数据直接从 lowerdir 读取不需要复制只有当进程第一次尝试修改这个文件时存储驱动才会把文件从 lowerdir 拷贝到 upperdir并在 upperdir 里做修改。之后这个文件的读取和写入都走 upperdir 的副本下层镜像里的原始文件完全不受影响。你可以在容器里跑一个命令验证这个过程$ docker run -it --rm ubuntu:22.04 bash rootf5a6b7c8d9e0:/# echo hello overlay /tmp/test.txt rootf5a6b7c8d9e0:/# exit运行完之后容器被删掉了/tmp/test.txt 连同整个可写层一起被清理但镜像层里没有任何变化。这也是容器的另一大特点不显式提交容器的一切变更都会随容器生命周期结束而消失。还有一个细节很多老手也不一定留意删除文件在 OverlayFS 里不是一个真正的“删除”而是在 upperdir 里放一个 whiteout 标记。因为 lowerdir 是只读的不可能真的把文件抹掉只能靠这种标记告诉合并层“这个文件在这个合并视图里不存在”。这也是为什么有些人会发现容器里删掉一个大文件后容器层的磁盘占用并没有减少——文件本体还在下层镜像里只是被隐藏了。2. 镜像构建与缓存每一层都算数2.1 Dockerfile 的每一行指令都可能变成一层理解镜像分层之后再来写 Dockerfile思路会清晰很多。你会发现Dockerfile 里每一条会改变文件系统的指令都会在构建时生成一个新的镜像层。比如这样一个简单的 DockerfileFROM ubuntu:22.04 RUN apt-get update apt-get install -y curl COPY app.py /app/ CMD [python3, /app/app.py]这个镜像会有四层基础镜像的层、apt 安装 curl 产生的层、COPY 文件产生的层、CMD 元信息层大小为 0。每一层之间是独立的构建过程中会把上一层的状态完整继承下来。这里有两个容易踩坑的地方。第一个坑是RUN指令里的“脏缓存”。如果你在 RUN 里执行了apt-get updateapt 会把索引文件写到这一层里。这些索引文件占用的空间并不小但它们在运行时毫无用处。如果不主动清理它们就会永久固化在镜像层里成为镜像体积虚高的元凶之一。所以安装软件包的标准写法是RUN apt-get update \ apt-get install -y --no-install-recommends curl \ rm -rf /var/lib/apt/lists/*把清理动作放在同一个 RUN 指令里而不是单独一行。为什么因为每一行 RUN 是一个独立层如果你分两行写第一层里留下的 apt 缓存文件即使在后面一层删掉了前面的层仍然会保留那份空间占用。镜像的最终可见文件系统是干净的但磁盘上那些“隐藏的死重”一点都不会少。第二个坑是层数和缓存命中之间的博弈。理论上层数越少越好但无脑合并所有指令也会带来问题每次修改一下文件内容整个大层的缓存就会全部失效导致构建时间急剧变长。所以合理的做法是把“稳定不变的部分”放前面把“经常变动的部分”放后面。2.2 镜像瘦身实战多阶段构建与层缓存原理镜像瘦身是运维和开发同学绕不开的话题。我见过有人把 Python 项目打成一个 2GB 的镜像里面塞了编译器、构建工具、测试依赖运行时的东西全部揉在一起。优化之后镜像可能只有 300MB但功能完全一样。这背后的核心手段就是多阶段构建。多阶段构建的思路是用第一个阶段安装所有构建依赖构建出产物然后把产物复制到一个全新的、干净的运行时阶段里。后者的基础镜像本身很小所以最终镜像只包含“运行所需的文件”。举一个 Go 项目的例子# 构建阶段 FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o server . # 运行阶段 FROM alpine:3.19 COPY --frombuilder /app/server /usr/local/bin/server EXPOSE 8080 CMD [server]最终镜像里的运行时文件只有一个二进制文件。由于是用CGO_ENABLED0静态编译的它不依赖任何动态链接库可以稳稳地跑在 alpine 上整个镜像通常不超过 20MB。如果你做的是 Node 项目优化思路类似FROM node:18-bullseye-slim WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction npm cache clean --force COPY --frombuilder /app/dist ./dist CMD [node, dist/server.js]这里把COPY package*.json放在复制源码之前是为了充分利用 Docker 构建缓存。因为依赖声明文件package.json不会频繁变动而源码几乎每次提交都会变。如果把它们放在同一个 COPY 指令里每次源码变化都会让 npm install 重新执行一遍白白浪费时间。构建缓存的具体匹配规则是这样的对于 COPY 和 ADD 指令Docker 会计算指令中涉及文件的校验和只要文件内容没变这一层缓存就还能用对于 RUN、ENV 等指令Docker 会比较指令字符串本身只要字符串不变就继续沿用这一层的结果。这也是为什么我强烈建议把“改动频率低的操作”放在 Dockerfile 上游本质上是利用缓存原理降低构建成本。3. Docker 网络模型一次 HTTP 请求的完整旅程3.1 docker0 网桥和 veth pair容器如何连上外部世界镜像部分搞定之后我们进入网络。平时使用 Docker 时-p 8080:80可能是用得最多的参数了。一条命令就能把容器里的 80 端口暴露到宿主机 8080 端口但这条命令背后发生的事情远比大多数人想象的复杂。默认情况下Docker 装好之后会自动创建一个名为docker0的 Linux 网桥IP 段通常是172.17.0.0/16。网桥的作用很像一台虚拟交换机所有连接到它的容器都进入同一个二层网络互相之间可以直接通信。但容器的网络命名空间是隔离的怎么把容器“插”到这台虚拟交换机上呢答案是 veth pair一对虚拟网线。一端在容器里叫eth0另一端在宿主机上叫vethxxx前者连进容器的网络命名空间后者挂到 docker0 网桥上。数据从任一端的网卡进去一定会从另一端出来。我们以访问一个部署在容器里的 Nginx 服务为例看看一次请求的完整路径浏览器发起http://192.168.1.100:8080/宿主机 IP 是192.168.1.100。请求到达宿主机8080端口此时 Linux 内核的 iptables NAT 表里Docker 已经提前写好了一条 DNAT 规则把目标地址和端口改写为容器 IP172.17.0.2:80。数据包被送到 docker0 网桥。网桥根据目标 MAC 地址找到对应的 veth pair数据包进入容器的 eth0。容器内的 Nginx 处理请求响应数据按原路返回。用一条命令能查到 Docker 写入的端口映射规则$ iptables -t nat -L DOCKER -n Chain DOCKER (2 references) target prot opt source destination DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80这行规则的意思是只要目标端口是 8080 的 TCP 包全部转到172.17.0.2:80。容器访问外网时路径则反过来。容器里发出一个访问www.example.com的包源地址是172.17.0.2这个内网地址在外部网络是不可路由的。数据包到达宿主机后内核通过一条 MASQUERADE 规则把源地址改成宿主机的外网网卡地址外部服务收到请求后把响应发回宿主机docker0 再根据连接跟踪表把响应转回容器。这就是容器不用额外配置就能上网的核心原因也是我们常说的“NAT 出口”。3.2 四种原生网络模式各有各的脾气Docker 默认提供了几种网络模式简单理解就是“容器以什么方式接入网络”。我最常用的是以下四种对比如下网络模式配置方式网络隔离性能适用场景bridge默认容器间隔离有 NAT 损耗单机多容器、端口映射host--network host无隔离最好对网络性能要求极高的场景none--network none完全隔离无网络纯计算任务、离线容器container--network container:xxx共享指定容器网络栈无额外损耗sidecar 模式、网络调试host模式特别适合压测或者对延迟极度敏感的服务。这种模式下容器直接共享宿主机的网络命名空间不经过网桥、不经过 veth pair、不经过 NAT性能损耗几乎为零。代价是失去了网络隔离容器里监听的端口会直接暴露在宿主机上而且端口冲突要靠你手动规避。none模式用得相对少但在离线计算、任务型容器里它是很好的选择——没有网络就意味着少了很多被攻击面。container模式有一个非常经典的玩法当你需要在一台跑着业务容器的机器上临时开一个调试容器又希望调试容器能直接用 localhost 或者 127.0.0.1 去访问业务容器的端口时就可以把调试容器挂到业务容器的网络命名空间里$ docker run -it --rm --network container:my-app nicolaka/netshoot这样 netshoot 容器和 my-app 容器共享同一个网络栈curl http://127.0.0.1:8080就能直接打到我自己的业务服务上。里面的netshoot镜像包含了 tcpdump、dig、nslookup、curl 等一大批网络调试工具是我排查容器网络问题时最常用的方式之一。4. 自定义网络与多容器通信从能用走向好用4.1 默认 bridge 网络的隐藏短板没有 DNS 解析单容器场景下默认的 bridge 网络够用了。但一旦进入多容器协作比如一个 Web 服务要连一个 MySQL、一个 Redis默认网络的短板就暴露出来了。在默认 docker0 网桥上容器之间虽然可以互通但只能靠 IP 地址。问题是容器重启之后 IP 常常会变你总不能把数据库地址写死成一个随时可能变化的 IP。Docker 也提供了--link参数但那只是一个即将淘汰的兼容做法本质上是在容器 hosts 文件里加静态映射既不灵活也不优雅。真正推荐的方案是创建自定义桥接网络$ docker network create myapp-net $ docker run -d --name db --network myapp-net mysql:8.0 $ docker run -d --name web --network myapp-net nginx在自定义网络里Docker 会内置一个 DNS 解析服务。web 容器里直接写db这个名字就能解析到 MySQL 容器的 IP$ docker exec -it web ping db PING db (172.18.0.2) 56(84) bytes of data. 64 bytes from db (172.18.0.2): icmp_seq1 ttl64 time0.076 ms这个能力为什么默认网络没有自定义网络有因为 Docker 自带的嵌入式 DNS 服务只作用于自定义网络。它的实现也很直观容器启动加入网络时Docker 会把容器名和 IP 注册进内置 DNS容器内部/etc/resolv.conf会指向一个 127.0.0.11 的内部 DNS 地址所有主机名解析请求都走这里。使用 Docker Compose 时这种感觉最明显。如果服务名是mysql另一个服务里直接用jdbc:mysql://mysql:3306就能连上完全不需要写 IP。这就是自定义网络带来的价值。4.2 端口映射背后的真相以及跨主机网络的思路回到端口映射-p 8080:80和--expose 80并不是一回事。--expose只是声明“这个容器运行时会监听 80 端口”它不会产生任何端口转发规则只起到文档和元信息的作用。真正生效的是-p参数映射出来的那套 DNAT 规则。另外注意一个细节比较老版本的 Docker 在启动端口映射时会跑一个docker-proxy进程它通过用户态程序把宿主机端口上的请求转发给容器。但真正执行流量转发的核心还是 iptables 的 DNAT 规则docker-proxy 只在某些特殊情况比如宿主机开启 ip_forward 异常时兜底绝大多数场景流量不会经过它。你用ss -tlnp看到 docker-proxy 绑定端口时不用紧张那只是常驻监护角色。如果项目场景已经超出单机需要跨主机通信就有两个主流方向路由模式修改容器的网络方案让每个容器拿到一个宿主机所在局域网里可路由的 IP比如 macvlan 驱动。跨主机通信时数据包直接走物理网络路由性能最好但需要交换机配合IP 规划要提前想清楚。叠加网络模式在底层网络上再架一层虚拟隧道网络比如 VXLAN。Docker Swarm 和 Kubernetes 的 CNI 插件大多采用这种思路。容器之间的流量被封装在 UDP 隧道里底层物理网络只需要有限的配置跨主机互联很灵活代价是封包和解包会带来额外的 CPU 开销。简单说如果你只是想在几台机器上把 Docker 容器互相连通又不想引入 Kubernetes 那套复杂编排可以考虑 Docker Swarm 的 overlay 网络如果觉得 Swarm 已经过时也可以直接上 Kubernetes 加 Calico 或者 Cilium但那就完全是另一个话题了。5. 常见问题与排查技巧实录5.1 镜像与构建相关的坑镜像下载慢是初学者第一道坎。Docker Hub 在国内的访问速度经常让人怀疑人生几十 MB 的镜像拉个十几分钟是常有的事。解决办法是在/etc/docker/daemon.json里配置镜像加速器{ registry-mirrors: [https://docker.m.daocloud.io] }配置完成后重启 Docker 服务再执行 pull速度和体验完全是两个世界。需要提醒的是不要把加速器写太多条选一个稳定可用的即可如果你推送到自己的私有仓库还需要在 daemon.json 里把仓库地址加入insecure-registries或者配置好证书。构建缓存不生效是另一个高频问题。表现是源码只改了一个字结果 npm install / pip install / go mod download 全部重新执行了一遍构建时间从 20 秒变成 5 分钟。绝大多数情况下是因为你把源码和依赖文件放在同一个 COPY 指令里拷贝进去了COPY . /app RUN npm ci --onlyproduction由于 COPY 会计算文件校验和源码一变整个层缓存就失效后续所有层都跟着重新执行。正确的顺序永远是先拷贝依赖文件、安装依赖、再拷贝源码。这不是玄学是每一层缓存都要依赖前一层 hash 的必然结果。还有一类问题是操作层面、系统层面的比如/var/lib/docker目录不断膨胀最后把磁盘塞满。你删除不用的容器或镜像后会发现磁盘空间没有马上释放。这是存储驱动和镜像层的特性决定的不是 bug。可以用docker system prune -a清理悬空镜像和未使用的缓存层实在不够再到/var/lib/docker里确认是哪个目录占了大头。5.2 网络故障排查速查表容器网络问题的排查路径比较固定整理一个我实践后认为最有效率的顺序现象排查命令判断依据容器间 ping 不通docker network inspect查看两个容器是否在同一网络IP 是否为同一网段容器访问外网失败iptables -t nat -L POSTROUTING -n确认是否有 MASQUERADE 规则宿主机访问容器端口失败iptables -t nat -L DOCKER -n确认 DNAT 规则是否存在容器内 DNS 解析失败cat /etc/resolv.conf看 nameserver 是否为 127.0.0.11容器内路由异常ip route是否有一条走 eth0 的默认路由说到 DNS有个很常见的问题容器内访问自定义域名失败但使用 IP 一切正常。原因往往很简单内部 DNS 只能解析同网络内的容器名解析不了外网域名——原本外网域名的 DNS 配置是靠主机的 DNS 服务器和几个公共 DNS 共同合作的容器里如果/etc/resolv.conf没有继承到可用的上游 DNS解析就会失败。可以先在容器里手动nslookup一下看看是网络层不通还是 DNS 配置问题。一个非常有用的调试技巧是进入容器的网络命名空间直接用宿主机工具抓包$ docker inspect -f {{.State.Pid}} my-app $ nsenter -t PID -n tcpdump -i eth0 -nn port 80通过 nsenter 进入到容器的网络命名空间后可以使用宿主机上安装的任何网络工具尤其是 tcpdump。这就是我前面说到 netshoot 容器之外的另一个方案不动容器环境直接抓包。最后提醒一个新手常忽略的问题如果宿主机开启了防火墙比如 firewalld 或 ufw即使 Docker 的 DNAT 规则已经生效外部请求也可能被系统防火墙拒之门外。排查外部访问问题时先把宿主机防火墙规则看清楚再回头查 Docker 自己的规则能省不少时间。说句老实话Docker 网络的排障本质上就是在“宿主机网络”和“容器网络”之间理清边界。每个命令、每条规则都有它明确的作用范围。把 docker0、veth pair、iptables 这几个关键节点吃透绝大多数网络问题都可以自己找到答案。这也是为什么我强烈建议在学“怎么用 Docker”的同时花时间弄明白“Docker 为什么这么用”——尤其在镜像分层和网络转发这两个方向上这些原理层面的认知往往会决定你在真正的故障现场是手忙脚乱还是三分钟定位问题。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。