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 参数:强制禁用缓存,常用于确保每次都拉取最新包。
注意:我曾经在 CI 里一直开着 –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 怎么合并,缓存怎么命中或失效。

镜像分层 · UnionFS · 缓存机制 核心逻辑 镜像层(只读) Layer 5: CMD 元数据 Layer 4: COPY index.html Layer 3: RUN apt install nginx Layer 2: RUN apt update Layer 1: FROM ubuntu:22.04 容器可写层 (upperdir) UnionFS (OverlayFS) lowerdir: 层1 ~ 层5 upperdir: 容器可写层 merged: 统一视图 写时复制 (Copy-on-Write) 文件查找:从上往下 找到即返回 下层文件对上层只读可见 缓存机制 指令/上下文未变 → 命中缓存 指令/上下文变化 → 缓存失效 后续所有层 → 全部重跑 优化策略: 稳定层放前面 变化层放后面 精准失效

6. 避坑总结

常见问题 根因 解决方案
构建每次都很慢 COPY . 放太前面,导致后面所有层缓存失效 把 COPY . 放到最后,稳定依赖先装好
缓存一直不生效 基础镜像 tag 用了 latest,每次 pull 到不同 digest 用固定 digest 或语义版本号,比如 ubuntu:22.04
容器修改文件后,镜像体积变大 不了解写时复制,以为修改不占空间 注意 upperdir 会积累修改,定期清理或重建容器
CI 里跨机器缓存无效 没有启用 remote cache 用 docker buildx 配合 registry cache
核心一句话:
镜像分层是基础,UnionFS 是胶水,缓存是效率命脉。把不变的东西放底层,把变化的东西放顶层,让大部分缓存能命中——这是我优化 Docker 构建的唯一原则。