4. 镜像分层原理归纳总结
镜像分层是 Docker 最核心的设计之一。说白了,一个镜像不是一块大泥巴,而是由一堆只读的层(Layer)堆叠起来的。我刚开始接触 Docker 时总觉得这东西有点玄乎,直到有一次在排查镜像大量重复下载时才真正明白分层的妙处——原来多个镜像能共享同一层,省空间省带宽,这才是 Docker 高效的根本。
4.1 什么是镜像分层
每一层对应 Dockerfile 中的一条指令。比如 FROM ubuntu:22.04 拉取基础层,RUN apt-get install curl 在上面新建一层。这些层都是只读的,容器运行时会在最上面加一个可写层(容器层)。
我习惯把镜像想象成一沓透明胶片。每张胶片上画着文件系统的变更内容。堆叠起来,从上往下看,就看到了完整的文件系统。你改一个文件,并不会改到底下的胶片,而是复制到最上面的空胶片上改——这就是写时复制(Copy-on-Write)。
docker history 命令查看镜像的分层历史。比如:
docker history nginx:latest
你会看到每一层的 ID、大小和创建命令。这能帮你理解镜像是怎么搭积木搭起来的。
4.2 联合文件系统(UnionFS)
Docker 靠联合文件系统把多个层合并成一个统一的视图。常用的驱动有 overlay2(现在默认)、aufs(已淘汰)、devicemapper(不推荐)。我踩过 devicemapper 的坑,磁盘直接撑爆,后来全线换 overlay2 就再没出过事。
写时复制是怎么工作的?当容器要修改一个文件时,联合文件系统先把文件从下层的只读层复制到最上层的可写层,再在上面改。这样底下的层完全不受影响。多个容器共享同一个镜像,底层一模一样,只有各自的可写层不同。
| UnionFS 驱动 | 特点 | 当前状态 |
|---|---|---|
| overlay2 | 性能好,稳定,支持页缓存共享 | 推荐(Linux 默认) |
| aufs | 老牌驱动,但未合入内核主线 | 已弃用 |
| devicemapper | 基于块设备,磁盘空间易浪费 | 不推荐 |
| btrfs/zfs | 原生快照支持,但配置复杂 | 小众选择 |
4.3 镜像构建与层缓存
Docker 构建镜像时,每一条 RUN、COPY、ADD 指令都会生成一个新层。构建器会检查每个指令的缓存:如果指令内容没变,而且前一层缓存命中,就直接复用已有的层;一旦某条指令变了,后面所有层都要重新构建。
我曾经在 CI 流水线里因为层缓存频繁失效,每次构建都得十几分钟,后来发现是 COPY . . 放在了前面——哪怕只改了一个 README,整个依赖安装层都得重跑。优化方法是先复制 package.json、requirements.txt 这类不常变的文件,安装完依赖后再复制业务代码。
FROM node:18-alpine
WORKDIR /app
# 先复制依赖描述文件
COPY package.json yarn.lock ./
RUN yarn install --production
# 再复制业务代码(变化频繁)
COPY . .
CMD ["node", "server.js"]
这里有个坑:yarn install 生成的 node_modules 被冻在那一层里。后续改代码并不会触发重新安装,构建秒回。我见过很多人把 COPY . . 写在安装之前,结果每次改代码都重装依赖,白白浪费几十分钟。
4.4 分层带来的优势
说一千道一万,分层的价值体现在三个地方:
- 节省空间:多个镜像共享底层基础层。我本地拉了三四个基于 Ubuntu 的镜像,ubuntu:22.04 那层只存一份,总量少了好几 G。
- 加速部署:推送或拉取镜像时,只传输本地没有的层。你想想,一个 1GB 的镜像,如果只改了一行代码,实际推送的可能就是几十 KB 的新层。
- 安全可控:只读层不会被污染,容器层在容器删除后自动丢弃。即使容器里折腾坏了,重启一个新容器又能回到干净状态。
4.5 常见陷阱与最佳实践
总结几个我实战中踩过的坑:
- 层不是越少越好,而是顺序要好。把变化频率最低的层放最前面。我一个同事把
RUN apt-get update和RUN apt-get install分开写,后来觉得合并成一层更“干净”,结果每次改软件包列表都得重下所有包。其实保持合理分层更利于缓存。 - 不要在单层里留敏感文件。比如
RUN wget http://internal-secret/config.json && process && rm config.json,虽然最后删了,但该文件依然存在于中间层里。别人用docker history或docker save能看到。我建议用多阶段构建来彻底甩掉敏感文件。 - 留意 .dockerignore。我曾把整个项目目录(含 .git 和 node_modules)都 COPY 进去,导致镜像飙到 2GB。加个 .dockerignore 能省掉大量无用层内容。
--squash 参数可以合并所有层为一个,但注意这会导致缓存失效——所有基础层都要重新构建。生产环境除非有特殊合规要求,否则不建议 squash。
4.6 分层核心知识图谱
下面这张图展示了分层、写时复制和层共享的核心逻辑。你可以把它当成理解镜像分层的“一页纸速览”。
这张图里,左侧是完整的镜像堆叠——4 层只读层加上一个容器可写层。右侧另一个容器也使用了同一个基础镜像,它的下面 4 层和左侧完全共享,只在最上面有一层自己的可写层。你想想看,如果这台机器上跑了 50 个 Nginx 容器,核心系统层就只存一份,多划算。