Docker镜像基础:镜像分层原理、UnionFS、镜像层缓存机制
我记得刚开始用 Docker 那会儿,一拉镜像就看到一串层(Layer)在下载,心里老嘀咕:这玩意到底怎么存的?每一层是独立的?后来被坑过一次,才彻底搞明白。
说白了,Docker 镜像不是个扁平的大文件包。它由一堆只读层堆叠起来,每一层只记录与上一层的差异。你拉个 Ubuntu 镜像,其实拉的是十几层 tiny 的差异文件。这个设计,让镜像构建、分发、缓存都变得非常快。
1. 镜像分层原理
每个 Dockerfile 指令,都会生成一个新的镜像层。比如:
FROM ubuntu:22.04 # 层1:基础层
RUN apt update # 层2:更新包缓存
RUN apt install -y nginx # 层3:安装 nginx
COPY index.html /var/www/ # 层4:复制文件
CMD ["nginx", "-g", "daemon off;"] # 层5:元数据层
上面 5 条指令,除了最后一条 CMD,前面 4 条都会产生物理文件层。层与层之间共享下层的文件——也就是下层文件对上层可见,但反过来不行。我有个朋友试图在 COPY 层之后修改 /etc/nginx/nginx.conf,结果发现改不了——因为那个文件在下层,上层的改变只在当前层生效。
你想想看:如果所有指令都放一层里,每次改个 index.html 就要重装整个 nginx,那得多慢?分层之后,只有 COPY 那层会变化,前面三层直接从缓存里取。
2. UnionFS:把层粘起来的技术
分层是概念,真正把层合并成一个统一文件系统的,叫 UnionFS(联合文件系统)。Docker 默认用的是 OverlayFS,它比早期的 AUFS 更稳定,性能也好。
OverlayFS 的工作模式很简单:它有 lowerdir(下层只读目录)和 upperdir(上层可写目录)。下层就是你 pull 下来镜像的各个只读层,上层是容器的可写层。Docker 把这些目录层层叠起来,最终呈现给容器一个完整的、可读写的文件系统。
我举个具体例子。假设容器要访问 /etc/passwd:
- 先看最上层(容器可写层)有没有这个文件
- 没有的话,一层一层往下找
- 找到即返回,不再看更下层
- 所有层都找不到,回报 File not found
这其实就是写时复制(Copy-on-Write)的核心。容器修改文件时,Docker 会先把下层文件拷贝到上层,再修改。所以容器内删一个系统文件,不会影响镜像——因为只在上层做了“隐藏”操作,下层原文件还在。
docker inspect <容器名>,找 GraphDriver 字段下的 LowerDir 和 UpperDir。
3. 镜像层缓存机制
这个机制,说白了就是 Docker 构建镜像时,会检查每一层对应的指令和上下文有没有变。没变就直接用缓存,变了就重新执行那条指令及以后的所有指令。
我遇到过最经典的翻车现场:
COPY . /app
RUN pip install -r /app/requirements.txt
这两行顺序写反了。实际应该先把 requirements.txt 单独 COPY 进来,装完依赖,再 COPY 其他代码。否则你只要改了一行业务代码,整个 pip install 都得重跑——明明依赖没变,白白浪费时间。
正确的写法:
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app
这样只要 requirements.txt 没变,pip install 那层就能命中缓存。我团队里有个项目,优化了 Dockerfile 指令顺序之后,构建时间从 8 分钟降到了 1 分钟——就靠这一招。
缓存失效的几种情况
- 指令前后上下文变化:比如 COPY 的文件内容变了,或者 ADD 的 URL 返回内容变了。
- 基础镜像更新:父镜像的层 ID 变化,导致全部失效。
- 指令本身不支持缓存:比如
ARG、ENV值变化,会影响后续层。 - –no-cache 参数:强制禁用缓存,常用于确保每次都拉取最新包。
–no-cache 构建,结果每次都要重装所有系统包,白白浪费数小时。后来仔细分析,发现其实只有 COPY 那层需要强制重跑,前面系统安装层完全可以缓存。所以,请尽量别全局禁用缓存,精准失效才是正道。
4. 共享缓存与构建加速
除了指令顺序,Docker 还支持跨构建共享缓存。如果你在本地反复构建同一个项目,且基础镜像没换,Docker 会自动复用之前的构建缓存层。
CI 环境里,我推荐把 docker buildx 的缓存导出到远程 registry 或本地目录:
docker buildx build \
--cache-from=type=registry,ref=myregistry.com/myimage:cache \
--cache-to=type=registry,ref=myregistry.com/myimage:cache,mode=max \
-t myapp:latest .
这样不同机器、不同构建都能共享同一份缓存。我以前做微服务 CI 优化时,把 30 个服务的全量构建从 40 分钟砍到了 12 分钟,靠的就是 remote cache + 分层优化。
5. 核心逻辑图
下面这张 SVG 图整理了本节的核心逻辑:镜像层怎么堆叠,UnionFS 怎么合并,缓存怎么命中或失效。
6. 避坑总结
| 常见问题 | 根因 | 解决方案 |
|---|---|---|
| 构建每次都很慢 | COPY . 放太前面,导致后面所有层缓存失效 | 把 COPY . 放到最后,稳定依赖先装好 |
| 缓存一直不生效 | 基础镜像 tag 用了 latest,每次 pull 到不同 digest | 用固定 digest 或语义版本号,比如 ubuntu:22.04 |
| 容器修改文件后,镜像体积变大 | 不了解写时复制,以为修改不占空间 | 注意 upperdir 会积累修改,定期清理或重建容器 |
| CI 里跨机器缓存无效 | 没有启用 remote cache | 用 docker buildx 配合 registry cache |
镜像分层是基础,UnionFS 是胶水,缓存是效率命脉。把不变的东西放底层,把变化的东西放顶层,让大部分缓存能命中——这是我优化 Docker 构建的唯一原则。