容器镜像分层的核心秘密,其实是一套“共享只读层 + 临时可写层”的存储复用体系:每一层都是不可变的历史快照,相同内容只保存一份,多个镜像和容器复用同一批底层数据,从根本上解决了镜像体积和分发效率的矛盾。
docker镜像分层原理是什么:只读层如何完成复用
凡是接触过Docker的人,都听过“镜像是由多个只读层叠加而成”这句话,但真到排查问题或做镜像瘦身时,能说清这层关系的人并不多。
打个比方:镜像像一摞透明的硫酸纸,每张纸上只记录文件系统的改动,最底下是基础系统,往上依次叠加环境变量、依赖库、业务代码。每一张纸都是只读的,谁都不许改,当你运行容器时,Docker在最上面加一张空白纸,叫可写层,你的一切写入操作都发生在这张空白纸上,下面的纸永远保持原样。
这个设计带来了三个直接结果:
- 多个容器可以共享同一份镜像层,不需要各自拷贝一份完整文件系统
- 修改文件时,Docker先把文件从只读层复制到可写层,再修改副本(写时复制)
- 删除容器或重建容器,只清理可写层,镜像层原封不动
层与层之间不是简单的压缩包叠加
很多人以为,分层就是把文件系统打包成几个tar包,再一层层套上去,Docker操作的是文件系统的差量。
从docker history命令可以看到每一层的构建指令,执行RUN apt-get install会产生一个层,执行COPY app /app又产生一个层,每个层只保存相对于父层的改动:新增了哪些文件、修改了哪些文件、删除了哪些文件。
这些层由存储驱动(如overlay2)挂载成完整的根文件系统,挂载时,层与层之间有严格的顺序和遮蔽关系:上层的同名文件会掩盖下层的文件,下层的不可见文件在逻辑上被“删除”了,Docker不删除底层文件,只是在更上层做了一个可见性标记,这也是为什么镜像层的物理体积并不等于最终容器可用空间。
构建缓存:分层机制的副产品
构建缓存是分层复用最直观的收益,Dockerfile每执行一条指令,构建器都会检查本地是否已有相同指令结果对应的层,如果之前的层没变,后边的层构建时间可以大幅缩短。
比如你修改了代码文件,但基础镜像和依赖安装指令没变,那么构建工具会跳过apt-get install这一步,直接复用缓存层,只重新生成COPY代码的那一层。多数情况下,这种缓存命中机制让迭代一次构建从几分钟压缩到几十秒。

镜像层存储逻辑拆解:内容寻址与写时复制
层与层之间的引用关系不靠名字,靠一串哈希值,也就是digest,Docker镜像清单(manifest)会把每一层的内容哈希记录下来,仓库和宿主机都以这个哈希为唯一标识,这套机制叫内容寻址存储。
它的好处在不言而喻:同一份相同内容,无论被多少个镜像引用,物理存储只有一份,镜像仓库去重是这样,宿主机本地缓存也是这样。
overlay2:当前主流的层挂载引擎
在Linux环境中,overlay2是绝大多数Docker宿主机默认的存储驱动,它的工作目录如下图结构(文字模拟):
/var/lib/docker/overlay2/
├── l/ # 符号链接索引
├── <layer-id>/
│ ├── diff/ # 这一层实际的文件改动(只读)
│ ├── link # 指向 l/ 下的短链接
│ └── lower # 记录下层ID列表
└── <container-id>/
├── diff/ # 容器可写层(读写)
└── merged/ # 所有层叠加后的视图
lower文件记录了下游依赖的层ID,diff存放本层改动,merged是容器内实际运行时的文件视图,写入时,overlay2只修改diff目录,不触碰任何只读层,删除文件时,它在上层创建一个字符设备文件(类似“白out”文件),把下层的同名文件遮住。
这种结构极其轻量,启动一个容器,不需要复制文件系统,只需要拼装目录并设置挂载参数,时间在毫秒级。
容器内修改文件,实际发生了什么
以修改/etc/nginx/nginx.conf为例:
- 容器内打开文件,overlay2发现该文件在底层只读层,先在可写层复制完整副本
- 修改操作落在可写层副本上,底层原文件依然保留
- 从容器视角看,文件内容被修改了;从宿主视角看,镜像底层没动过
如果业务代码往容器里写大量临时文件,这些文件只属于当前容器所属可写层,容器删除,数据消失。保持容器无状态的好习惯,和这个机制配合起来效率最高。
镜像分层在仓库与多宿主机之间的复用
镜像层的复用逻辑不仅在单机运行层面,贯穿了构建、推送、拉取、运行全链路。
拉取镜像时能省多少流量
推送镜像到仓库时,Docker CLI会先计算本地每一层的digest,与仓库现有的digest对比,仓库中已经存在的层,直接跳过上传,拉取时同理:宿主机本地已有同digest的层,直接从本地读取,不重复下载。

这意味着一个组织内部如果都用同一个基础镜像,每次构建业务镜像时,只需要上传业务层,几百MB的基础镜像几乎永远只传一次,对于使用容器镜像仓库价格相对敏感的中小团队来说,流量成本可以比对整体存储开销更先落地。
跨镜像共享:同一台机器上的复用密度
用docker image ls -a查看镜像列表,你能看到大量<none>标签的中间层镜像,它们是被最终镜像引用的中间产物。
假设一台机器上有10个基于node:20-alpine构建的微服务镜像,虽然镜像列表显示10个镜像,但底层的alpine基础层、node运行时层、yarn依赖层全部只存一份,物理磁盘占用大约只是“基础层 + 每个应用各自特有层”的总和,而每个镜像单独打包的体积之和,可能超过3到5倍。
行业共识认为,这个复用机制是容器生态迅猛发展的地基之一,正是因为有它,几十个微服务跑在一台机器上,才不会被磁盘占用拖垮。
磁盘占用从哪里查
用docker system df可以看每个镜像实际占用的可回收空间,要定位某个镜像各层的大小,执行docker history <镜像名> --no-trunc,输出列表里会有每一层的构建指令和大小,要注意的是,历史输出的大小只是该层的增量数据,不是累积占用。
分层机制隐含的代价与治理方案
任何机制都有两面性,分层虽然解决了复用问题,但也带来新麻烦。
过多层数造成的inode与性能问题
镜像层数越多,挂载时元数据链越长,overlay2在读取文件时,需要按层自上而下查找,层数多了会增加路径解析开销,更常见的是inode耗尽:每多一层,对应存储驱动目录就多一份元数据,如果Dockerfile里大量使用独立的RUN指令,每一层都带着独立的文件系统改动,镜像“膨胀”成为必然。
一个典型错误写法:
- 用多个
RUN分别执行apt-get update和apt-get install,导致中间缓存层永久保留,且每一层都记录了不同的文件状态 - 每执行一次
RUN,层内新增文件和删除文件都被记录成永久历史
这样构建出的镜像,一个普通开发环境可能膨胀到1GB以上。
镜像瘦身的实操路径
修剪分层的通用步骤,业内专家指出关键在于构建阶段就控制层数,而不是等打包后再压缩:
- 合并多条
RUN为一条,用&&连接,减少无关中间层 - 使用多阶段构建:编译阶段使用完整工具链镜像,运行阶段只拷贝编译产物到精简运行时镜像
- 选择
alpine等精简基础镜像作为运行时底座 - 利用
slimtoolkit(前身是docker-slim)对已有镜像做自动探测,移除未运行到的文件与依赖 - 用
docker build --squash将多层压缩为单层(注意此做法会丢失层历史,适合最终交付镜像)

多阶段构建是社区里实操最多、见效最快的方式,它不改变运行逻辑,只是在构建流程中将“编译环境”和“运行环境”分离,最终镜像只保留必要程序文件,体积降幅通常在50%以上。
overlay2与不同文件系统的兼容性
overlay2要求宿主机文件系统支持d_type,否则会出现白文件功能失效的问题,实践中,xfs文件系统需要开启ftype=1,ext4默认支持,在云服务器上使用简米云容器镜像服务或华为云SWR等托管仓库时,通常不需要自维护底层存储驱动,但宿主机是自建K8s节点时,务必确认内核版本和文件系统挂载选项。
镜像复用机制相关常见问题
问题:docker镜像层删除的文件还会占空间吗?
会占,镜像层是不可变对象,即使某层删除了文件,该文件依然存在于底层物理存储中,只是在新层中被做遮蔽标记,要从源头减少这种占用,唯一办法是在构建时就层内清理,让删除操作发生在同一层内,不要跨越两个层。
问题:为什么容器停止后磁盘占用不释放?
容器停止时,可写层数据默认保留(除非使用--rm运行),用于支持下次启动继续写入,使用docker system prune -a可以清理停止的容器、悬空镜像、无用的构建缓存,释放的是这些可写层和游离层的内容,镜像层只要被任一个镜像引用,就不会被清理。
问题:镜像仓库可以用不同容器运行时复用同样的层吗?
可以,分层的存取标准由OCI镜像规范定义,containerd、CRI-O等容器运行时只要实现该规范,就能复用同一份层数据,拉取仓库的manifest后,按digest取出对应层,运行时挂载时再以各自存储驱动方式组合,底层的层复用逻辑完全一致。
容器的分层思想深刻影响了软件交付的整个链条,把不可变基础设施、增量分发、写时复制这些概念落到具体存储层上,每一个运维决策背后,都有这套机制在权衡空间、效率与一致性,理清层与层的关系,镜像构建、故障排查、容量治理都会变得清晰顺手。