镜像体积动不动几百MB、磁盘突然告警、docker build缓存失效后构建时间翻倍——这些事儿我见得太多,而且几乎所有问题的根源都指向同一个:对Docker镜像原理理解不够。很多人把镜像当成一个“压缩包”或者“只读模板”,这么说不能算错,但一旦遇到分层、写时复制、构建缓存这类实际操作问题,就抓瞎了。
这篇文章我会从存储结构、文件系统、容器关系、构建机制这几个角度,把Docker镜像原理完整拆开讲一遍,顺带把我这些年踩过、填过的坑一并整理出来。适合刚接触容器的人建立底层认知,也适合正在排查镜像体积、磁盘占用、构建效率问题的同学参考。
1. 镜像到底是什么:先打破“镜像=压缩包”的误解
1.1 镜像不是一个文件,而是一组只读层的堆叠
刚开始学Docker的时候,我也以为镜像就是一个大文件,docker pull就是把一个压缩包下载到本地。直到我手动去看过本地的镜像存储目录,才发现完全不是这么回事。
Docker镜像的本质是一组只读层的组合。每执行一条会改变文件系统内容的指令(比如RUN、COPY、ADD),Docker就会生成一个新的层。层与层之间像千层饼一样叠在一起,最底层是基础镜像,越往上越接近最终产物。用户看到的“镜像”其实是这些层的统一视图,docker images里显示的IMAGE ID,只是最上面那层的ID派生出来的标识。
我打一个比方:把镜像层想象成一本打印书的不同透明塑料片,每张片子上印了一部分内容,叠加起来才看清完整页面。这个类比能解释很多实际现象,比如为什么同一个基础镜像只占一份磁盘空间——因为几十个镜像可能共享着同一个底层,物理上并没有重复拷贝。第一次把这个理解透了,后面看什么都顺眼。
1.2 镜像ID、标签与digest的区别
实操中很多人分不清nginx:latest、IMAGE ID、digest这几个概念,导致排查问题的时候对不上号。
标签(tag)是给人看的名字,它可以随时改动,同一个镜像可以打多个标签,也可以在不同时间指向不同内容。镜像ID是内容哈希派生的标识,记录的是镜像配置和层信息,内容变了ID就变。而digest严格来说指向的是镜像清单(manifest)的哈希,尤其在docker pull下载时,真正保证“拉下来就是我要的那个版本”的是digest。
有一次我在某生产环境排查,发现一个节点上nginx:latest指代的内容居然跟另一个节点不一样。原因很简单:在不同时间拉取,latest已经漂移了。后来我统一改成通过digest引用镜像,才彻底消除这类不一致。所以说,理解镜像的概念不只是为了考试,是真的能救命。
2. 分层存储与联合挂载:搞懂OverlayFS是看懂镜像的一半
2.1 联合挂载:把多层“拼”成一个根目录
镜像层是一块一块的,那容器看到的是一个完整的根文件系统,这中间靠的是联合文件系统(Union Filesystem)。当前Docker默认的存储驱动是overlay2,核心机制是把多个目录通过联合挂载合并成一个目录视图。
具体来说,挂载的时候会区分lowerdir和upperdir。lowerdir是只读的镜像层,按照顺序从下往上叠加;upperdir是可读写层,容器运行时的所有变更都发生在这里;最后两者合并成merged目录,这个merged目录就是容器进程看到的文件系统根。
关于lowerdir有多层的情况,overlay2会通过硬链接、索引等方式把多只读层组织起来,而不是粗暴地把所有层平铺。物理上,每一层在磁盘中其实是一个独立的目录,包含该层发生变更的文件。镜像的层数据存放在/var/lib/docker/overlay2/下面,没有打标签的中间层会以哈希字符串命名,这也是为什么手动翻这个目录会感觉像在看乱码。
我自己调试镜像体积的常规方法之一,就是进到overlay2目录里看每一层实际占用,能非常直观地发现是哪一层引入了大文件。这种做法比一次次改Dockerfile重新构建高效得多。
2.2 写时复制:修改文件时到底发生了什么
镜像层是只读的,容器层是可写的,两者结合的关键机制叫写时复制(Copy-on-Write,CoW)。它解决的核心问题是:容器的读写不能改动镜像层本身,但又不能每次全量复制镜像文件。
第一次写某个文件时,内核会把这个文件从lowerdir拷贝到upperdir,然后再在upperdir里执行修改。进程看到的是修改后的结果,底层镜像层纹丝不动。这就叫“写时复制”——不写就不拷,写了才复制。
这里有个在实际排障中容易迷惑的点:如果一个容器修改了一个大文件,磁盘占用会临时上升,因为upperdir保留了一份完整的副本。频繁写入同一个大文件,或者容器里持续产生增量数据,容器层会迅速膨胀。我见过有人把日志直接写到容器内,几天下来容器层比镜像本身还大好几倍。日志、临时文件这类东西应该挂载volume或者写到外部存储,而不是留在容器可写层。
2.3 不同存储驱动的选择思路
overlay2是目前Linux上的默认选择,但它依赖内核的overlayfs支持。如果文件系统是xfs,必须在格式化时开启ftype=1,否则overlay2挂载会报错。ex4下一般没有这个问题。
还有一类场景,比如在用户态跑容器,没权限加载内核模块,有些人会切换成fuse-overlayfs。性能比overlay2差,但能解决权限受限环境的问题。至于老旧的aufs,它的工作原理也是联合挂载,但每次挂载都会复制一份rootfs指针,层数多的时候开销明显,现在基本只有历史包袱才会用。
在部分不依赖本地存储的场景(比如CI环境每次都用全新机器),vfs驱动就会被用到。vfs不是联合文件系统,它是真正把每层复制一份放平。用vfs跑容器的代价是磁盘和内存占用成倍增加,但换来了兼容性。如果哪天你发现容器读写性能慢得离谱,先别怀疑业务,去查一下当前用的存储驱动是不是vfs。驱动选型大多时候保持默认就好,但知道它背后的取舍逻辑,能让排查性能问题时少绕很多弯。
3. 镜像和容器的关系:只读层与可写层怎么协作
3.1 容器启动就是“镜像层+可写层”的组合
镜像本身是静态的,运行起来的进程需要可写空间。docker run做的事情相当于是:把镜像的只读层挂成lowerdir,新建一个空白的upperdir,然后联合挂载成容器的根文件系统。容器消失后,默认情况下这个可写层也会被删除,容器内的变更随之消失。
所以“容器 = 镜像层 + 可写层”这个说法非常精确。镜像层保证每个容器看到的初始文件系统完全一致,可写层隔离了不同容器之间的运行时差异。理解这一点之后,你就明白为什么Docker的设计哲学强调“不要往运行中的容器里写重要数据”——那层数据天生是临时的。
还有一个实操知识:docker ps显示的容器大小(如果再配合docker ps -s)包含两层含义,一个是可写层大小,一个是容器独占的镜像大小。如果可写层异常巨大,优先去排查容器内是不是有程序在疯狂写文件。
3.2 whiteout:在只读层上“删除”文件的补救措施
写时复制能处理修改和新增,但删除文件这件事需要单独解释。由于lowerdir是只读的,内核不能真的把底层文件删掉,overlayfs采用的方式是在upperdir里放置一个特殊的whiteout标记文件。当合并视图遍历到这里时,发现这个标记,就会把lowerdir里对应的文件“隐藏”掉。
有一个日常容易踩的坑:在容器里删掉了一个大文件,但宿主机的磁盘空间并没有立刻释放。因为文件本体还在镜像层里存在着,只是对当前容器不可见而已。如果想用这个方式瘦身,必须基于这个容器重新构建镜像,并且重新构建时确保那个文件不进入新的层,否则空间还是被占着。这个现象导致不少人误以为“Docker有bug”,其实是没理解镜像层不可变的本质。
3.3 不要用docker commit“备份”镜像
docker commit可以把一个容器的当前文件系统状态打包成一个新镜像。这个功能看起来很方便,很多新手也喜欢这么干:进去容器装一堆东西,然后commit一下,当作部署产物。
从我个人的实践经验来看,docker commit基本是反模式。它会把可写层和whiteout标记一起打包,产生一个既不可复现、又层次混乱的镜像。回溯构建历史完全做不到了,镜像里还往往夹着临时文件、shell历史、包管理器缓存。更麻烦的是,commit出来的镜像一层叠一层,体积膨胀非常快,推送到仓库也要花更久时间。
如果你有“想保留容器状态”的需求,正确的做法是:把变更写进Dockerfile,用代码管好镜像构建,保留可复现性。实在只是临时想看看容器里发生了什么,用docker diff对比容器和镜像的区别就行。
4. Dockerfile构建原理:每一条指令背后发生了什么
4.1 构建上下文与.dockerignore
docker build执行时,客户端会把当前目录(以及Dockerfile里用到的其他路径)打包发送给Docker守护进程,这个包就是“构建上下文”。如果你的目录里放了几个GB的临时文件、日志、node_modules,哪怕Dockerfile里根本没有用到,构建时也会被完整传过去,不仅慢,还可能因为网络或内存问题直接失败。
我见过一个真实案例,某团队一直抱怨构建慢,后来一查发现在项目根目录构建,而根目录下有个几百MB的备份压缩包。在Dockerfile里加一行.dockerignore之后就立竿见影了。跟.gitignore一样,.dockerignore不是装饰品,它是构建链路里的第一道性能关口。
4.2 缓存失效的判定条件:为什么调整指令顺序会影响速度
每一条Dockerfile指令对应一个缓存节点,但Docker判断缓存是否能复用的方式,比很多人想象得更严格。不只是指令内容要一样,基础镜像、上下文里被COPY/ADD进去的文件内容、甚至父层的ID,任何一样变了,从那条指令往下全部重新执行。
举个例子:
COPY . /app RUN pip install -r requirements.txt你改了项目里一个README,COPY层的缓存就已经失效了,下一层RUN pip install被迫从头执行。如果依赖安装特别耗时,这个改动简直是灾难。
正确做法是把依赖安装相关的指令放在COPY代码之前:
COPY requirements.txt /app/ RUN pip install -r requirements.txt COPY . /app/这样只有requirements.txt变了才会重新装依赖。这类“指令排序优化”其实就是利用镜像分层和缓存的底层机制,属于性价比极高的优化手段。
还有一个小细节,RUN执行的命令本身参与缓存判定。有些人在Dockerfile里执行RUN apt-get update && apt-get install -y xxx,如果源地址带了时间戳或者某条命令每次都输出不同的元数据,缓存也会失效。不过Docker只根据指令字符串和上下文文件哈希判断,不看命令执行结果,所以有时候缓存看似失效并不是命令变了,而是镜像元数据或父层变化引起的。
4.3 多阶段构建的底层收益
多阶段构建之所以受欢迎,根子还是镜像分层原理。第一阶段把编译工具、依赖包全部装好,把源码编译成产物;第二阶段从第一阶段的镜像里把产物拷贝出来,再基于一个干净的运行镜像打包最终产物。
关键就在这个“拷贝”上。最终Dockerfile生成的镜像是最后一个阶段的层集合,不包含第一阶段的编译器和缓存文件。结果就是镜像小了一截,构建依赖还完全隔离。以前没多阶段构建的时候,大家习惯用“构建容器”和“运行容器”两种镜像手动维护,麻烦且容易漏。现在多阶段构建是标准操作,二进制产物、.jar包、编译后的静态文件,都很适合这种方式。
我常用的另一个思路是,尽量选小的基础镜像,比如alpine或distroless镜像,这跟你后续层数无关,是直接降低“基数”。如果一个镜像从1.2GB缩到180MB,收益基本来自三件事:多阶段构建、切换基础镜像、清理包管理器缓存。
5. 镜像搬运与磁盘管理:save、load和空间清理的底层逻辑
5.1 save/load 与 push/pull的差异
跨机器传镜像通常有两条路:docker save | docker load和docker push | docker pull。它们的底层行为很不一样。
docker save导出的是一个tar归档文件,里面包含了镜像的所有层和元数据。docker load则是把这个归档重新展开成镜像。好处是可以在没有网络仓库的环境离线传输。缺点是tar文件没有“按需下载”的能力,要么全有要么全无。
docker push/pull则依赖镜像仓库(registry),利用的是层级复用。假设本地已经有拉过一个ubuntu基础镜像,再pull一个基于ubuntu构建的nginx镜像时,公共层不会被重复下载,只拉取差异部分。仓库端也可以配置清理策略,删除不被引用的层。
这两种方式在镜像不大时没区别,一旦镜像到GB级别,save/load在跨网传输时非常痛苦。离线传输经常有人踩坑:save出来的tar中包含了本地没有的悬空层(dangling layer),导致文件比预期大很多。
5.2 镜像清理与磁盘释放:为什么删了镜像空间没变
清理磁盘时很多人会执行docker image prune -a,但发现空间释放不明显。原因可能不止一个:有些镜像正被运行中的容器引用,删不掉;有些镜像属于中间层,但因为被某个tag的镜像引用,实际还“活着”;还有一些容器日志、可写层数据,根本不属于镜像层面,image prune管不到。
docker system df是个好工具,它能按镜像、容器、数据卷、构建缓存四类统计磁盘占用。排查哪类占用异常,比盲目清理高效得多。如果发现Build Cache占了很大空间,用docker builder prune清理构建缓存即可。另外,容器日志默认存在/var/lib/docker/containers/<id>/*-json.log,这个跟镜像没有任何关系,但经常占据大量磁盘,记得单独处理。
6. 常见问题与排查经验:镜像相关的坑我基本都踩过
6.1 典型问题的排查速查
| 现象 | 直接原因 | 处理建议 |
|---|---|---|
| 镜像删了但磁盘空间没释放 | 容器还在运行,或镜像被其他层引用 | 先确认容器状态,再执行image prune,必要时用system df看数据归属 |
| 同样的Dockerfile构建耗时忽高忽低 | 缓存失效,上下文文件发生变化 | 依赖安装指令放到COPY之前,精简构建上下文 |
| 容器磁盘占用暴涨 | 可写层或日志持续写入 | 挂载volume,关掉不必要日志,限制容器可写层大小 |
| overlay2挂载失败,报fts错误 | 文件系统未开启ftype=1 | 数据目录格式化时加参数,或换ext4 |
| pull镜像时层校验失败 | 网络传输损坏或仓库不一致 | 重新pull,或改用digest确定版本 |
| commit出来的镜像特别大 | 可写层包含临时文件、日志,whiteout标记被固化 | 不要用commit,改成Dockerfile构建 |
这张表我建议收藏,实际排查的时候照着做基本能覆盖九成以上问题。
6.2 性能与稳定性相关的注意点
镜像分层设计对运行性能的影响,不少人容易忽略。读写路径上,文件在lowerdir时读取是最快的,因为直接走内核;一旦文件被拷贝到upperdir,所有后续读写都走upperdir,某些场景可能带来额外IO开销。高IO应用,比如数据库类容器,强烈建议把数据目录挂载到volume或宿主机目录,绕开容器可写层。
还有一个隐藏比较深的坑:层数过多会让镜像的元数据变大,pull和push时间变长,但这并不影响运行性能。真正影响运行性能的是overlayfs的嵌套深度和inode占用。长期构建镜像的机器,orphan层和中间层堆积可能耗尽inode,文件系统报“no space left”但其实磁盘还有富余。遇到这种情况,往存储目录里看一眼inode使用率,往往会恍然大悟。
构建缓存这块,需要注意docker build默认会利用本地缓存,但有些CI环境每次构建都用全新节点,缓存利用率低,这时更要把“变更频繁的指令放后面”当作铁律。我以前有个项目,为了让每次构建都尽量命中缓存,把依赖锁定文件单独COPY进来,再把源代码COPY进来,构建速度提升了大概三倍。
镜像损毁比较少见,但一旦发生就很头疼。表现多为容器启动时报某个层文件找不到,docker image inspect能看层信息但实际文件缺失。这种一般是宿主机异常断电或者存储损坏导致的。处理方式很有限:把该镜像删掉、重新pull。这提醒我们,镜像的层数据不备份,构建产物一定要推到仓库,别只依赖宿主机上的本地镜像。
写在最后的一些体会
上面这些原理和坑,我基本上是边踩边总结出来的。最初我也犯过错:为了“省事”用docker commit当部署物,结果镜像越来越大,部署越来越慢;为了“不乱”加了一堆.dockerignore规则,才发现构建上下文才是构建慢的最大元凶。后来我把镜像原理当成基础设施一样对待,很多问题从“查半天查不出”变成“扫一眼就知道怎么回事”。
如果你现在正准备给项目写Dockerfile,或者正在排查一台磁盘告警的机器,我建议你先别急着改配置,打开docker system df、docker image inspect,把镜像是怎么构成、每一层装了什么、容器可写层涨了多少搞清楚。这些数据比任何经验都诚实。
最后分享一个我常用的收尾操作:镜像构建完成后,不要忘记做一次docker build --squash的替代方案。有些人偏好用squash把层压缩成一层,省空间但失去缓存能力,二进制发行场景可以接受。要不是这种情况,我更愿意保留多阶段构建的结构化分层,这样后续定位“哪一层引入了大文件”会非常方便。镜像原理的价值就在这儿——它不是纯粹的理论,而是真正能帮你在构建、存储、排障上少踩坑的底层知识。