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 常见陷阱与最佳实践

总结几个我实战中踩过的坑:

  1. 层不是越少越好,而是顺序要好。把变化频率最低的层放最前面。我一个同事把 RUN apt-get update 和 RUN apt-get install 分开写,后来觉得合并成一层更“干净”,结果每次改软件包列表都得重下所有包。其实保持合理分层更利于缓存。
  2. 不要在单层里留敏感文件。比如 RUN wget http://internal-secret/config.json && process && rm config.json,虽然最后删了,但该文件依然存在于中间层里。别人用 docker history 或 docker save 能看到。我建议用多阶段构建来彻底甩掉敏感文件。
  3. 留意 .dockerignore。我曾把整个项目目录(含 .git 和 node_modules)都 COPY 进去,导致镜像飙到 2GB。加个 .dockerignore 能省掉大量无用层内容。
警告:使用 --squash 参数可以合并所有层为一个,但注意这会导致缓存失效——所有基础层都要重新构建。生产环境除非有特殊合规要求,否则不建议 squash。

4.6 分层核心知识图谱

下面这张图展示了分层、写时复制和层共享的核心逻辑。你可以把它当成理解镜像分层的“一页纸速览”。

Docker镜像分层核心结构图 镜像分层核心结构 镜像层(只读) ubuntu:22.04 基础层 安装 curl 配置安全更新 应用代码层 容器可写层 (写时复制:修改时拷贝) 多个容器 共享相同镜像层 另一个容器 共享下面 4 层 ubuntu基础层 容器B可写层 写时复制(CoW)工作流程 1. 容器要修改 /etc/nginx/nginx.conf 2. overlay2 将该文件从只读层复制到容器可写层 3. 在可写层修改,底层保持不变 → 多个容器依然共享同一份只读底层

这张图里,左侧是完整的镜像堆叠——4 层只读层加上一个容器可写层。右侧另一个容器也使用了同一个基础镜像,它的下面 4 层和左侧完全共享,只在最上面有一层自己的可写层。你想想看,如果这台机器上跑了 50 个 Nginx 容器,核心系统层就只存一份,多划算。