1. 为什么镜像分层值得单独拎出来讲
很多人用 Docker 用了大半年,docker run、docker build、docker push敲得飞起,但一旦遇到镜像体积莫名膨胀、构建缓存突然失效、容器删了数据跟着没了这类问题,就开始抓瞎。根子往往不在命令本身,而在于没搞明白镜像到底是怎么一层一层摞起来的。镜像分层原理、容器数据卷、网络通信架构这三块内容,基本构成了 Docker 日常使用的“地基”——地基不牢,后面编排、CI/CD、多环境部署全是空中楼阁。
这篇内容面向的是已经能跑起容器、但想真正吃透底层机制的朋友。我会把镜像分层、数据卷、网络这三块拆开揉碎,讲清楚每一层设计背后的取舍逻辑,配上可以直接抄作业的实操步骤和参数计算,再把我自己踩过的坑和排查思路一并倒出来。读完你至少能做到:看到docker history的输出能判断哪层该合并、容器数据丢了知道往哪找、容器之间连不通能按图索骥定位到是网桥还是 DNS 的问题。
先说清楚一个基本认知:Docker 镜像不是一个“大文件”,而是一组只读层的叠加。容器启动时,在这摞只读层顶上再盖一个可写层,所有运行时改动都落在可写层里。这个模型决定了镜像复用、构建缓存、数据持久化、网络隔离的全部行为逻辑。理解了这一层,后面所有“为什么”都能自己推出来。
2. 镜像分层原理:从一次构建说起
2.1 分层到底分的是什么
Dockerfile 里每一条能产生文件系统变更的指令,都会生成一个新的镜像层。FROM拉基础层,RUN执行命令产生变更层,COPY/ADD把文件塞进去又是一层。每一层只记录“相对于上一层改了什么”,也就是增量。这种设计的好处很直接:多个镜像如果共用同一个基础层,本地只需要存一份,拉取时也能按层复用。
我拿一个典型的构建过程举例。假设 Dockerfile 长这样:
FROM ubuntu:22.04 RUN apt-get update && apt-get install -y curl COPY app.sh /usr/local/bin/app.sh RUN chmod +x /usr/local/bin/app.sh CMD ["app.sh"]这里会产生四层:基础层(ubuntu:22.04)、安装 curl 的层、拷贝脚本的层、改权限的层。注意CMD不产生文件系统变更,它只是写进镜像配置的元数据,不算独立层。这一点很多人搞混,以为每条指令都是一层,其实只有涉及文件系统写入的才算。
用docker history能直观看到每层的大小和来源指令:
docker history --no-trunc myimage:latest输出里CREATED BY一列会显示每层对应的指令,SIZE显示该层增量大小。如果看到某一层特别大,基本就是那一步装了大包或者拷了大文件。我习惯在构建完先跑一遍这个命令,体积异常时第一时间定位到具体指令。
2.2 写时复制与联合文件系统
分层能成立,靠的是联合文件系统(UnionFS)的写时复制机制。简单类比:把每层想象成一张半透明的描图纸,叠在一起看是一张完整图,但每张纸只画了自己新增的部分。读取文件时从上往下找,找到第一个有该文件的层就用它;写入文件时,如果文件在只读层,先把它复制到可写层再改,只读层纹丝不动。
这个机制带来两个直接后果。第一,容器里的改动不会影响镜像,镜像始终是只读的,所以同一个镜像可以起无数个容器互不干扰。第二,可写层的写入是有成本的——第一次修改某个只读层文件时要先复制,文件越大复制越慢。这也是为什么数据库这类频繁写大文件的服务,强烈建议把数据目录挂成数据卷,绕开可写层的复制开销。
注意:可写层的生命周期和容器绑定,容器一删,可写层跟着销毁。这是“容器删了数据没了”的根本原因,不是 bug,是设计如此。
2.3 分层对构建缓存的影响
分层结构直接决定了构建缓存的命中规则:某层及其之前的所有层都没变,该层才能复用缓存;一旦某层变了,它之后的所有层缓存全部失效。这个规则解释了一个经典现象——为什么改一行代码会导致整个依赖安装重来。
看这个反例:
FROM node:18 COPY . /app RUN npm install CMD ["node", "/app/index.js"]COPY . /app把整个项目拷进去,只要改任何一个源文件,这一层的哈希就变了,后面的npm install缓存直接作废,每次构建都要重新装依赖。正确做法是把依赖描述文件和源码分开拷:
FROM node:18 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . CMD ["node", "index.js"]这样只要package.json和锁文件没动,npm install那层就一直命中缓存,改源码只影响最后的COPY . .。这个调整在依赖多的项目里能把构建时间从几分钟压到十几秒,是我认为性价比最高的一个 Dockerfile 优化。
2.4 层数控制与镜像瘦身
层不是越多越好。每层都有元数据开销,层数过多会让镜像管理变臃肿,拉取时也要处理更多层。常见的合并手段是把多个RUN用&&串起来,减少层数:
RUN apt-get update \ && apt-get install -y curl git \ && rm -rf /var/lib/apt/lists/*这里有个细节值得说:apt-get update和apt-get install必须放在同一条RUN里。如果分成两条,update那层会被缓存,过段时间缓存里的包索引过期,install就可能装到旧版本甚至失败。这是分层缓存机制带来的一个隐蔽陷阱,我见过不少团队在这上面栽跟头。
另外rm -rf /var/lib/apt/lists/*放在同一条RUN里才能真正减小镜像——如果单独一条RUN rm,被删的文件其实还在上一层里躺着,只是被“遮住”了,镜像体积并不会变小。这是写时复制机制决定的:删除操作只是在可写层记一笔“此文件已删”,底层数据依然存在。要真正瘦身,清理动作必须和产生文件的动作在同一层完成。
2.5 多阶段构建:把构建环境和运行环境彻底切开
镜像体积大的另一个常见原因是把编译工具链也打进了运行镜像。多阶段构建就是为解决这个而生的:前面阶段负责编译,最后阶段只从前面阶段拷产物。
FROM golang:1.21 AS builder WORKDIR /src COPY . . RUN go build -o /out/app . FROM alpine:3.19 COPY --from=builder /out/app /usr/local/bin/app CMD ["app"]最终镜像只包含 alpine 基础层加一个二进制文件,编译工具链全部留在 builder 阶段被丢弃。我实测过一个 Go 服务,单阶段构建出来 800 多 MB,多阶段之后压到 20 MB 以内。这个差距在镜像仓库存储和节点拉取速度上是数量级的区别。
多阶段构建的层复用逻辑也值得留意:COPY --from=builder只拷指定文件,不会把 builder 阶段的层带进最终镜像。所以不用担心前面阶段的体积泄漏到后面。
3. 容器数据卷:让数据活过容器的生命周期
3.1 为什么需要数据卷
前面说过,容器可写层随容器销毁而消失。但数据库文件、用户上传、日志这些数据显然不能跟着容器一起没。数据卷就是挂在容器外部、生命周期独立于容器的存储。它绕开了联合文件系统的写时复制,直接读写宿主机上的目录,性能和持久性都更有保障。
数据卷分两类:命名卷(named volume)由 Docker 管理,存在/var/lib/docker/volumes/下;绑定挂载(bind mount)直接把宿主机某个路径挂进容器。两者用法相似,适用场景不同。
3.2 命名卷与绑定挂载怎么选
| 对比项 | 命名卷 | 绑定挂载 |
|---|---|---|
| 管理方 | Docker 统一管理 | 用户自己管理宿主机路径 |
| 路径依赖 | 不依赖宿主机具体路径 | 强依赖宿主机目录结构 |
| 跨平台一致性 | 好 | 差(路径格式因系统而异) |
| 适合场景 | 数据库数据、持久化状态 | 开发时挂源码、挂配置文件 |
| 备份迁移 | 通过卷操作较规范 | 直接操作宿主机目录 |
我的经验是:生产环境的数据一律用命名卷,开发环境挂源码用绑定挂载。命名卷的好处是路径由 Docker 管,迁移时不用关心宿主机目录布局;绑定挂载的好处是改宿主机文件容器里立刻可见,适合热更新开发。
创建和使用命名卷:
docker volume create appdata docker run -d --name db -v appdata:/var/lib/mysql mysql:8绑定挂载:
docker run -d --name web -v /home/dev/site:/usr/share/nginx/html nginx注意:绑定挂载时如果宿主机路径不存在,Docker 会自动创建一个空目录,容器里看到的也是空目录。这个“自动创建”行为经常让人误以为挂载成功但数据没同步,排查时先确认宿主机路径和内容是否真的存在。
3.3 数据卷的底层位置与备份
命名卷在宿主机上的实际路径可以通过docker volume inspect查到:
docker volume inspect appdata输出里的Mountpoint就是真实路径。备份命名卷的稳妥做法是起一个临时容器把卷内容打包出来:
docker run --rm -v appdata:/data -v $(pwd):/backup alpine \ tar czf /backup/appdata.tar.gz -C /data .这个命令的思路是:把待备份的卷挂到/data,把当前目录挂到/backup,用 alpine 里的 tar 打包。临时容器用完即删,不污染环境。恢复时反过来解包即可。这套流程我在多个项目里用过,比直接去/var/lib/docker/volumes/下拷贝更安全,因为不依赖宿主机上 Docker 的存储布局。
3.4 数据卷的几个实操坑
第一个坑是权限。容器内进程以非 root 用户运行时,挂载的卷目录属主可能不对,导致写不进去。解决办法是在 Dockerfile 里提前chown,或者在启动脚本里调整。我倾向于在构建阶段就把目录属主设好,避免运行时再折腾。
第二个坑是-v和--mount的差异。-v语法简洁但行为隐式,路径不存在会自动建;--mount语法啰嗦但语义明确,路径不存在会直接报错。生产脚本里我推荐用--mount,报错比默默建个空目录强:
docker run -d \ --mount type=volume,source=appdata,target=/var/lib/mysql \ mysql:8第三个坑是容器间共享卷。多个容器挂同一个命名卷时,写入是并发的,没有额外锁机制。数据库这类有自己文件锁的服务一般没问题,但如果是自定义应用共享写同一批文件,要自己保证并发安全,别指望 Docker 帮你协调。
4. 网络通信架构:容器之间怎么说话
4.1 默认网络模型速览
Docker 装好之后自带三种网络:bridge(默认)、host、none。不指定网络启动的容器都落在默认 bridge 上。这个默认 bridge 有个特点:容器之间只能用 IP 互访,不能用容器名解析。这就是为什么很多人第一次尝试两个容器按名字通信会失败。
docker network ls输出能看到bridge、host、none三条。host模式下容器直接用宿主机网络栈,没有隔离,端口不映射也能通,但端口冲突风险高;none模式只有 loopback,适合完全隔离的场景。日常用得最多的是自定义 bridge 网络。
4.2 自定义 bridge 网络与内建 DNS
自定义 bridge 网络和默认 bridge 最大的区别是:自定义网络自带 DNS 解析,容器可以用名字互访。这是容器编排里服务发现的基础。
docker network create appnet docker run -d --name api --network appnet myapi:latest docker run -d --name web --network appnet myweb:latest这样web容器里直接curl http://api:端口就能通,不用查 IP。原理是 Docker 在自定义网络里跑了一个内嵌 DNS,把容器名解析到对应 IP。容器重启 IP 变了也不影响,因为解析是动态的。
注意:默认 bridge 网络不支持名字解析,这是历史遗留行为。新项目一律用自定义网络,别在默认 bridge 上折腾。
4.3 端口映射与流量路径
外部访问容器要走端口映射:
docker run -d -p 8080:80 --name web --network appnet myweb:latest-p 宿主机端口:容器端口。流量路径是:外部请求到宿主机 8080,Docker 的转发规则把它转到容器 80。这条规则底层是 iptables 的 DNAT,docker port可以查看映射关系。
端口映射有几个细节。第一,-p 8080:80默认绑所有网卡,只想绑本机就写-p 127.0.0.1:8080:80。第二,同一宿主机端口不能被两个容器同时占用,冲突时启动直接报错。第三,容器间通信走自定义网络时不需要端口映射,映射只针对宿主机外部访问。我见过有人在容器间调用时也去映射端口,纯属多余,还增加暴露面。
4.4 网络排查的常用手法
容器连不通时,我一般按这个顺序查:先确认两个容器是否在同一网络(docker network inspect appnet看成员列表),再确认目标容器是否在监听预期端口(进容器ss -tlnp或netstat),然后从源容器里ping或curl目标容器名验证 DNS 和连通性,最后才怀疑宿主机防火墙或转发规则。
一个高频问题是容器内服务监听了127.0.0.1而不是0.0.0.0。监听 loopback 的话,容器外部(包括同网络的其他容器)都连不进来。这个在应用配置里改一下绑定地址即可,跟 Docker 本身没关系,但排查时特别容易被忽略。
另一个高频问题是自定义网络创建后,老容器没加进去。容器一旦启动,网络归属就定了,要换网络得重建容器或者docker network connect动态接入:
docker network connect appnet existing_container5. 三块内容怎么串起来用
镜像分层、数据卷、网络这三块不是孤立的。一个典型的多容器应用里,它们是这样配合的:镜像分层负责构建出精简、可复用的运行单元;数据卷负责把有状态的数据从容器生命周期里剥离出来;自定义网络负责让这些容器按名字互相发现、按需通信。
举个我常用来验证环境的组合:一个应用容器加一个数据库容器。应用镜像用多阶段构建压体积,数据库数据挂命名卷,两者放同一个自定义网络。应用通过容器名连数据库,数据库端口不映射到宿主机,只在内网可达。这个组合跑通,基本就覆盖了日常开发的大部分场景。
docker network create demo-net docker volume create db-data docker run -d --name db --network demo-net \ --mount type=volume,source=db-data,target=/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=example mysql:8 docker run -d --name app --network demo-net -p 8080:8080 myapp:latest应用配置里数据库地址写db:3306,不用关心 IP。数据库数据在db-data卷里,db容器删了重建数据还在。应用镜像改了重新构建,只影响app容器。三块机制各司其职,边界清晰。
6. 常见问题速查与避坑清单
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 镜像体积异常大 | 清理命令没和产生命令同层;构建工具链进了运行镜像 | docker history定位大层;改多阶段构建 |
| 构建缓存频繁失效 | COPY 顺序不合理,源码变更触发依赖重装 | 先拷依赖描述文件再拷源码 |
| 容器删除后数据丢失 | 数据写在可写层,没挂卷 | 检查是否用了-v或--mount |
| 容器间按名字连不通 | 用了默认 bridge 网络 | 换自定义网络 |
| 外部访问容器失败 | 端口没映射或映射错;服务监听 127.0.0.1 | 查docker port;查容器内监听地址 |
| 挂载目录为空 | 绑定挂载路径不存在被自动创建 | 确认宿主机路径和内容 |
| 卷写入权限拒绝 | 容器内用户与卷目录属主不匹配 | 构建阶段 chown 或启动脚本调整 |
再补几条清单外的经验。第一,docker system prune会清掉未使用的镜像、容器、网络,但默认不动命名卷,清理前确认没有依赖悬空卷的数据。第二,镜像标签别只用latest,分层缓存和回滚都依赖明确的标签,latest在多人协作里是灾难。第三,网络排查时docker exec进容器用curl比在宿主机猜要快得多,容器里往往没装 curl,可以临时用wget或直接看/etc/hosts和/etc/resolv.conf确认 DNS 配置。
我个人在实际操作中的体会是,这三块内容真正吃透的标志,不是能背出概念,而是遇到问题时能快速判断“这是分层缓存的事、数据卷的事还是网络的事”。判断对了方向,排查就成功了一半。镜像分层决定了构建和存储的行为,数据卷决定了状态的归属,网络决定了连通性——把这三条线理清,Docker 的日常使用基本就没有盲区了。