资讯动态

深入Docker镜像分层存储与overlay2优化实践

发布时间:2026/10/14 18:52:55 来源:尧图企业网站定制
平时排查容器存储问题的时候我经常被问到“镜像为什么占了这么大”或者“为什么几个层加起来比实际文件大”。这些问题说到底都指向同一件事Docker 镜像的分层存储机制。这篇内容我会从镜像分层是怎么运作的讲起再落到 storage optimization 上带你把镜像分层的原理、存储驱动的读写逻辑、以及日常优化手法全部串起来。对正在学容器底层机制、或者想把线上镜像体积和磁盘占用压下来的开发者来说这篇会比较对口。1. 镜像分层存储到底解决了什么问题1.1 从“套娃式拷贝”说起早期没有分层存储的时候虚拟化方案基本上是把整个根文件系统打包成一个大块。你用的时候把它解出来改一个文件就得把整个文件系统重新复制一份。这种“套娃式拷贝”最大的问题是明明十个容器跑的是同一个基础系统磁盘上却要存十份完整副本空间浪费非常夸张。Docker 对这个问题给出的答案是 layer层也就是把文件系统的变更拆成一个个只读层。基础镜像是一层每执行一条修改操作再产生一层最后所有层按顺序叠在一起形成一个完整的文件系统视图。容器只是在最上面多了一个可写层所有运行时产生的修改都落在这个可写层里不去动底层任何只读层。这个方案的直接收益有两个。一个是一份基础层可以被大量镜像和容器共享不管拉取了多少个镜像只要底层相同就不再重复存储。另一个是镜像传输只需要传输新增的层不用整个包重新下发。比如某个镜像的基础层是几百 MB后续每次更新只多了几十 KB那真正要推的文件就只有那几十 KB。提示理解分层机制之后你会明白“复制文件”和“修改文件”在容器存储里其实是两码事。前者大概率不会新增多少空间占用后者却可能触发写时复制产生新的数据块。1.2 层与层之间如何合并分层存储的实现依赖联合文件系统。不同的存储驱动用不同的联合技术overlay2 用得最多它依托内核的 OverlayFS。简单来说OverlayFS 把多个目录叠加在一起对外呈现一个统一的文件系统视图这些目录分别叫 lowerdir、upperdir合成的视图叫 merged。Docker 镜像的每一层对应一个 lowerdir 目录容器的可写层就是 upperdir。当容器进程读一个文件时OverlayFS 会从上面往下找先看 upperdir 有没有没有就往下找 lowerdir找到哪个用哪个。当容器进程修改一个底层文件时OverlayFS 会把底层文件复制一份到 upperdir再在副本上做修改这种机制就是写时复制Copy-on-WriteCoW。对容器来说它看到的是合并后的完整文件对存储系统来说底层的镜像层完全没动。这个设计带来一个很实用的特性容器删了之后它对应的可写层整层销毁底层镜像还是那几层一点不浪费。线上经常出现“容器签名变了镜像没变但是宿主机磁盘涨了”多半就是有很多可写层堆积在那里需要定期清理容器而不是只删镜像。2. 存储驱动选型overlay2 为何成为事实标准2.1 主流存储驱动都有哪些存储驱动不只有 overlay2历史上 Docker 用过很多种方案现在最常见的几个分别是驱动原理概括现状aufs早期 Docker 默认方案理念也是层叠合并内核一直没合入运维兼容性差devicemapper基于块设备快照性能抖动明显基本退出主流仅特殊环境存在overlayOverlayFS 第一版只有两层已被 overlay2 取代overlay2支持多层 OverlayFS性能稳定当前默认方案几乎成为事实标准vfs不合并直接完整复制用于容器文件系统隔离测试空间占用大很多人在选驱动时会纠结“到底哪个快”实际上在绝大多数云主机和物理机上overlay2 就是综合最优解。它底层直接对接内核的 OverlayFS不存在用户态额外拷贝读写性能接近原生文件系统。别的驱动要么因为内核兼容性差被淘汰要么因为底层机制导致高并发下 IO 延迟明显。2.2 overlay2 读写路径拆解overlay2 在宿主机上的存储目录默认是/var/lib/docker/overlay2进去之后能看到一堆由镜像层哈希命名的目录每个目录一般都包含一个diff子目录里面就是这一层的真实文件内容。容器运行的时候还会多出一个merged目录这就是容器看到的完整文件系统视图。读操作的大致路径是进程发起 readVFS 层交给 OverlayFSOverlayFS 先查 upperdir找不到再查 lowerdir按需取文件。写操作则是进程发起 write如果文件在 lowerdirOverlayFS 先复制到 upperdir再写入数据。这个规则意味着修改一个底层大文件的开销不仅是写入本身还包括一次完整复制。这里有个很实际的经验如果某个镜像内有个非常大的文件容器启动后高频修改它的部分内容应该考虑把该文件放到独立的数据卷volume里绕开写时复制。挂载 volume 之后文件读写直接走宿主机原生路径不再经过 overlay 合并逻辑性能和磁盘占用都会好看很多。2.3 什么时候要换驱动虽然 overlay2 是默认选型但也不是所有场景都非它不可。如果你的内核版本比较旧比如还在用尚未支持 overlay2 的旧内核就只能退回 vfs 或者老式 overlay这时候优先考虑升级内核而不是强行兼容。如果你跑的是高 IO 的数据库容器且对数据落盘路径的确定性要求极高建议把数据目录直接挂 volume不依赖 overlay2 的数据面。另一个特殊场景是容器内跑一些特殊文件系统操作例如需要在容器里再挂载文件系统这类操作和存储驱动的兼容性需要单独验证。注意不要看到某个驱动“性能好”就立刻全局替换。存储驱动的切换会影响已有镜像层的本地映射方式切换过程中的镜像和容器可能需要重建。变更之前先备份数据在测试环境完整演练一遍再上生产。3. 镜像体积膨胀的根因与优化实操3.1 先搞清楚哪些东西在占空间做优化之前第一步永远是先看现状。docker system df这个命令能快速列出镜像、容器、数据卷、构建缓存各自的占用情况是排查空间问题的首选工具。举一个实际输出的例子镜像部分显示 REPOSITORY 和 SIZE你会发现很多镜像显示的大小和ls看目录大小不一致这在分层存储下很正常。镜像的 SIZE 是镜像所有层合并后的逻辑大小而层中有很多白out标记和删除记录这些并不会让空间立刻释放。真正占磁盘的是/var/lib/docker下面实际存在的那些层目录。看单镜像的层结构可以执行docker image history 镜像名输出每一层的编号、构建指令、产生的时间以及该层大小。这个命令能帮你快速判断“哪一层是胖子”。如果发现某层是包管理器缓存、临时编译文件那就拿到了开刀点。3.2 构建层面的瘦身优化镜像体积最直观的手段就是在构建时就控制层的内容。第一合并 RUN 指令。每一条 RUN 都会产生一个新的层而层的删除操作并不会让上一层变小。所以常见做法是使用把连续的 apt、yum、pip 指令串起来比如RUN apt-get update \ apt-get install -y --no-install-recommends build-essential \ rm -rf /var/lib/apt/lists/*这样整个安装过程只产生一个层临时缓存文件在该层结束时删掉最终层里只留下安装结果没有缓存垃圾。第二注意包管理器的临时缓存。apt 下载的.deb文件默认会留在/var/lib/apt/lists里yum、apk 也有类似机制。安装完不清理这些内容就会被写进层里。所以安装包之后顺手删掉缓存应该成为固定习惯。第三用.dockerignore限制构建上下文。执行docker build时Docker 会把当前目录作为上下文整个发送给守护进程如果目录里有node_modules、.git、日志文件之类的大块头构建过程会又慢又占空间。node_modules .git *.log dist提示构建上下文虽然不会直接进入镜像层但会被临时解析上下文过大会拖慢构建并占用守护进程的临时空间。养成写.dockerignore的习惯比事后清理省心得多。3.3 多阶段构建多阶段构建是镜像瘦身的“大杀器”核心思想是编译环境只需要出现在构建阶段最终镜像只需要产物。用一个典型的思路来演示。假设你有一个项目需要先编译然后再跑编译产物。没有多阶段时你的镜像里会同时存在编译器、源码和运行环境体积非常尴尬。多阶段构建的写法类似这样FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN go build -o myapp . FROM alpine:3.20 COPY --frombuilder /app/myapp /usr/local/bin/myapp CMD [myapp]最终镜像只包含第二阶段的内容也就是 alpine 基础系统加一个编译好的二进制。源码、编译器、中间产物统统不会进到最终镜像里。我在实际项目里用这个方案把镜像从 800 MB 压到 80 MB 左右效果非常明显。多阶段构建对任何编译型项目都适用不只是 Go。Java 项目可以在第一阶段做 Maven 打包第二阶段只放 JRE 和 jar 包前端项目可以在第一阶段安装依赖并构建静态文件第二阶段用 nginx 镜像只拷贝产物。这类写法还有一个附带好处构建阶段环境独立不会污染运行时。3.4 镜像层局部替换构建缓存复用一次完整的docker build如果每次都全量执行优化效果再好也会浪费时间。Docker 构建是有缓存机制的如果某条指令的输入没有变化它会直接复用之前构建出的层。这个机制在优化镜像体积的时候同样有用。缓存复用的规则是一条指令的前置层没有变化且指令本身没有变化构建系统就会复用对应的层。所以调整指令顺序也可能改变缓存命中率。习惯上把“容易变化的步骤”放在 Dockerfile 靠后的位置“不怎么变化的步骤”放在靠前的位置。比如先COPY requirements.txt再执行依赖安装最后COPY . .拷贝整个项目这样只要依赖文件没变依赖安装层就可以复用缓存。有些时候我们故意想绕过缓存可以用docker build --no-cache或者用 BuildKit 的细粒度控制。新版 Docker 默认启用 BuildKit构建缓存的管理更精细docker builder prune可以单独清理构建缓存不影响镜像层。日常我会把“构建缓存”和“镜像层”分开看前者是构建过程的中间态后者是产物两者都可能占用磁盘空间但清理策略完全不同。4. 磁盘上的层管理与回收4.1 清理悬挂镜像与孤儿层时间长了宿主机上会积累很多不再被任何镜像引用的层目录。这些层来源很典型每次用相同 tag 构建新镜像旧的镜像和它独有的层就成了悬挂状态。用docker images查看时你能看到一些镜像的仓库名和标签都显示为none这就是悬挂镜像dangling image。它们中的层并非全部无用但整体上已经没有任何活跃引用。清理这类残留最直接的命令是docker image prune -f这个命令会清除所有悬挂镜像。它的效果是只清理没有被容器和活跃镜像引用的层和镜像元数据对于仍然被使用的内容不会误伤。如果想要更彻底把停止的容器、悬空的网络、构建缓存一起清掉可以执行docker system prune -a-a的含义是删除所有未使用的镜像而不只是悬挂镜像。使用前务必确认当前环境没有你还需要保留但暂时没运行的镜像否则容易误删。对生产环境来说“宁可多留不可误删”是基本原则。4.2 手动分析镜像层空间如果只是看整体占用docker system df够用但有时候你想知道具体哪一层占了多少空间就需要docker image history加上--no-trunc参数docker image history myimage:latest --no-trunc输出会列出每一层的创建指令和大小。你会看到有些层标记为 0B那是元数据层比如ENV、CMD这类指令不产生文件系统内容。真正的大头通常在RUN apt-get ...或者COPY ...指令产生的层里。另一种查看方式是用docker inspect查看镜像的RootFS.Layers字段这里按顺序列出了所有层的 ID。这些 ID 对应/var/lib/docker/overlay2下的层目录。不过要注意层 ID 和目录名不是一一直接对应的宿主机上会有一套映射关系一般排查时不建议直接钻到目录里去翻名通过镜像 history 和 docker 命令看更准确。对于构建缓存占用的空间用docker system df能看到Build Cache条目。如果这个值异常大可以在系统低峰期执行docker builder prune释放构建过程的中间产物。注意这个操作会影响后续构建的缓存命中相当于下次构建可能重新执行部分步骤。5. 我在实际排障中遇到的高频问题5.1 层数太多会不会出问题OverlayFS 对 lowerdir 的层数是有上限的实际操作中 Docker 构建工具也会有合并限制。虽然现代内核和 Docker 已经把这个阈值提得足够高日常业务很难触达但在构建一些复杂的应用镜像时如果 Dockerfile 里有几十条指令还是要留意层数这个问题。我给的建议是通过合并 RUN 指令控制层数一方面是为了体积另一方面也是避免潜在的层数边界问题。一个原则是“指令该合并的合并该分开的分开”——合并同类项保留逻辑上的变更点。不要把每一条小操作都散成一个层那既不利用缓存策略也拉高层数。5.2 修改了底层文件但容器里看不到这个问题很经典表象是开发者在 Dockerfile 里改了某个文件重新构建镜像后旧容器日志里看到的还是旧文件。原因要从分层机制说起。假设镜像 A 的底层有一个文件镜像 B 在上层修改了同一个文件那么 B 的层里就会有一个新版本。如果 C 是从 A 构建的它看到的依然是 A 层里的旧版本除非 C 显式覆盖这个文件。对运行中的容器来说如果可写层里已经存在某个文件的副本那么即使底层镜像发生变化容器内看到的永远是可写层里的副本。遇到这种情况先用docker diff 容器名确认容器可写层改了哪些文件。如果确认文件被固化在可写层最省事的解决方法是直接删掉旧容器基于新镜像重新创建容器而不是在容器里手动改文件。手动改文件只是又产生一层可写副本问题还会复发。5.3 删除文件之后镜像反而变大了新手最容易踩的坑就是这个。你在 Dockerfile 里写了两步RUN wget http://example.com/bigfile.tar.gz RUN rm bigfile.tar.gz结果镜像 size 依然把 bigfile 占的空间算进去了。原因就是我前面说的层机制第一条 RUN 执行的下载动作产生了一个层里面包含了 bigfile第二条 RUN 只是在上层记录“删除这个文件”底层的数据在镜像里依然存在。最终镜像的逻辑大小等于各层内容累加删除并不能让上一层缩水。正确做法是把下载和删除放在同一条 RUN 里RUN wget http://example.com/bigfile.tar.gz \ tar -xzf bigfile.tar.gz \ rm bigfile.tar.gz这样整个过程只产生一层最终层里只留有解压后的必要文件下载残留不会进入镜像。这个坑我踩过好几次后来凡是“临时文件”操作一律直接串进同一条指令里。5.4 宿主机磁盘为什么还在涨镜像层本身不可变理想情况下老镜像删掉之后它独有的层应该被联动清理。但现实不是这么简单磁盘空间增长通常来自两个位置一个是容器可写层另一个是日志文件。容器跑起来之后写进可写层的数据不会因为容器停止就立刻归还空间。只有把容器删除其可写层才会释放。生产环境里常见的问题是不停构建、不停启动容器最后/var/lib/docker越来越大。排查这个问题的标准化流程是docker system df df -h /var/lib/dockerdocker system df查看各类缓存用量df -h看分区真实占用。如果占用在涨先看CONTAINERS列的可写空间大小把长时间不退出的测试容器删掉再考虑清理构建缓存。日志文件的处理则是另一个话题容器 stdout 日志如果不用 json-file 以外的驱动默认会在宿主机累积需要配置 log rotation 或在容器内做日志分流。就我个人实际操作的体会来说镜像分层的优化不是一个一次性的动作它会跟着你的项目持续演进。每写一条 Dockerfile 指令心里过一遍“这条会产生层吗、这个文件会留在层里吗”基本就能避开大多数体积陷阱。等这套习惯建立起来你会发现镜像构建变快了磁盘压力小了线上问题也好排查了。希望这篇内容对你有实际帮助尤其是那些正在被镜像体积和存储占用折磨的朋友值得照着操作一遍试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑