1. 镜像加载到底在加载什么
很多人第一次接触容器技术时,都会有一个直觉性的误解:以为镜像就是一个压缩包,运行容器就是把压缩包解压到某个目录里,然后启动里面的程序。这个理解不能算全错,但它漏掉了最关键的一层——镜像并不是一个“整体文件”,而是一层一层叠出来的只读文件系统,容器启动时也不是“解压”,而是“挂载并叠加”。
我在早期排查一个容器启动慢的问题时,才真正把这件事想明白。当时有个业务镜像构建出来接近 2GB,每次在测试环境拉起容器都要等很久,我一开始以为是网络拉取慢,后来发现镜像早就缓存在本地了,慢的是加载和挂载环节。从那以后我才认真去研究镜像的分层结构和加载机制,也就是这篇要聊的核心:镜像加载原理。
先把结论摆出来,方便你带着框架往下看。镜像加载本质上是三件事的组合:第一,把镜像的各层从存储驱动管理的目录中准备好;第二,用一个可写层盖在所有只读层之上,形成容器的根文件系统;第三,通过联合挂载技术把这些层“拼”成一个看起来完整的目录树,交给容器进程使用。理解了这三步,你就能解释很多现象,比如为什么两个镜像能共享磁盘空间、为什么删了容器镜像还在、为什么改一个文件会触发“写时复制”。
这篇文章适合谁看?如果你已经会敲docker run、会写 Dockerfile,但对“镜像为什么能复用”“容器里的文件到底存在哪”“为什么容器一删数据就没了”这些问题还模棱两可,那这篇就是写给你的。我会尽量用生活化的类比把底层机制讲透,同时给出可以直接上手验证的命令,让你不只是“看懂”,而是能自己动手复现整个过程。
2. 从分层结构理解镜像的本质
2.1 镜像不是一块铁板,而是一摞“只读层”
要理解加载,先得理解镜像的构成。一个镜像由若干“层”组成,每一层都是一个文件系统的变更集,记录的是“相对于上一层,我新增、修改、删除了哪些文件”。这种设计不是拍脑袋想出来的,而是为了解决一个非常现实的问题:如果每个镜像都是一整块完整的文件系统,那存储和传输的成本会高到无法接受。
打个比方。假设你要寄三本书给三个朋友,这三本书的前 200 页完全一样,只有最后 50 页不同。笨办法是把三本完整的书各印一份寄出去,浪费纸张也浪费邮费。聪明的办法是把那 200 页公共部分单独印一份,每个人再各自附上自己那 50 页,收件人自己拼起来读。镜像分层就是这个“公共部分复用”的思路。
具体到实现上,Dockerfile 里的每一条会改动文件系统的指令,通常都会产生一层。比如FROM引入基础层,RUN apt-get install产生一层,COPY拷贝文件产生一层,ENV这种只改元数据的指令则不产生文件系统层。这些层从下往上堆叠,越靠下的越基础、越共享,越靠上的越个性化。
这里有个容易被忽略的点:层的“只读”属性是硬性的。镜像里的任何一层一旦构建完成,就不能再被修改。你在容器里改文件,改的绝不是镜像层,而是后面要讲的可写层。这个约束保证了镜像可以被多个容器安全共享,谁都不会把别人的“地基”挖了。
2.2 为什么要用联合文件系统来“拼”层
有了分层,下一个问题就是:怎么让这些独立的层在运行时表现为一个完整的目录树?答案就是联合文件系统,英文叫 Union File System。它的核心能力是把多个目录“叠加”挂载到同一个挂载点上,形成一个统一的视图。
你可以把它想象成几张透明胶片叠在一起看。最底下那张画了操作系统的基础轮廓,中间几张分别画了运行时、依赖库、应用代码,最上面那张是空白的、可以随时涂改的。你从正上方看下去,看到的是所有胶片叠加后的完整画面。如果上层某个位置画了东西,就会盖住下层同一位置的图案;如果上层是透明的,下层的图案就透出来。
联合文件系统的关键规则有两条,理解了这两条,后面所有现象都能推出来。
第一条是“上层覆盖下层”。当多个层里存在同一个路径的文件时,位置更靠上的层优先。这就解释了为什么你在应用层放一个同名配置文件,能覆盖掉基础层里的默认配置。
第二条是“写时复制”。当容器需要修改一个位于只读层的文件时,系统不会去动那个只读层,而是先把文件复制到可写层,然后在可写层里修改。原文件在只读层里纹丝不动。这个机制是容器“秒级启动”和“镜像共享”的技术基石。
提示:不同存储驱动对联合挂载的实现方式不一样,早期常用的有 AUFS、OverlayFS 等。现在主流环境大多默认使用 OverlayFS 系列,因为它在主流内核里原生支持、性能也比较好。你不需要死记某个驱动的细节,但要理解它们共同遵循的“分层加联合挂载”这套模型。
2.3 可写层:容器一切改动的落脚点
镜像的所有层都是只读的,那容器里为什么能写文件、能装软件、能改配置?靠的就是在镜像最顶上再盖一个可写层。这个可写层在容器创建时生成,在容器销毁时一并消失。
这一点极其重要,因为它直接决定了容器的数据生命周期。你在容器里touch一个新文件,它落在可写层;你rm一个镜像里的文件,实际是在可写层记了一条“这个文件被删了”的标记,镜像层里的原文件还在;你改一个配置文件,触发的是写时复制,文件被复制到可写层再改。
所以容器天然是“无状态”的——不是说容器里不能存数据,而是说这些数据默认活不过容器的生命周期。想让数据持久化,就得用数据卷把目录挂到宿主机上,让数据落在容器可写层之外。我见过太多新手把数据库直接跑在容器里、不挂卷,然后一次误删容器,数据全没了。这个坑的根源,就是对可写层的生命周期没有概念。
3. 镜像加载的完整流程拆解
3.1 加载前的准备:镜像层在磁盘上长什么样
在讲加载流程之前,得先知道镜像层平时“躺”在磁盘的哪里、以什么形式存在。当你执行docker pull或docker build之后,镜像的每一层都会被存储驱动接管,落到宿主机的某个存储目录下。这个目录的具体位置和存储驱动有关,但结构逻辑是相通的:每一层对应一个独立的目录,目录里放的是这一层的文件内容,另外还有元数据记录层与层之间的父子关系。
这里有个很值得玩味的细节:层与层之间是靠“父子链”串起来的。一个镜像的顶层,会指向它的父层,父层再指向祖父层,一直追溯到最基础的那一层。加载镜像时,系统就是顺着这条链,从底往上把每一层都找出来、准备好。
为什么这个设计重要?因为它让“共享”成为可能。假设你有两个镜像,都基于同一个基础镜像构建。那么这两个镜像的底层链在某个节点之前是完全重合的,磁盘上只存一份。只有当它们各自开始“分叉”之后,才产生各自独有的层。这就是为什么你拉十个基于同一基础镜像的应用镜像,磁盘占用远小于十个镜像体积之和。
你可以用docker history命令直观地看到某个镜像的分层情况,每一行大致对应一层,还能看到每层的大小和创建它的指令。这个命令是我排查镜像体积问题时的第一把工具,哪一层特别大,一眼就能看出来。
3.2 加载第一步:定位并校验各层
容器启动时,运行时首先拿到的是镜像的引用,比如某个镜像名加标签。它会先在本地的镜像元数据里找到这个镜像对应的顶层 ID,然后顺着父子链一路向下,把所有层的 ID 都收集起来。
收集的过程中会做校验。每一层在存储时都有一个内容摘要,加载时会核对这个摘要,确保层的内容没有被损坏或篡改。这一步是静默进行的,你平时感知不到,但如果磁盘出问题导致层损坏,容器启动就会在这一步报错。我遇到过一次因为磁盘坏道导致某层读取异常、容器死活起不来的情况,最后就是靠重新拉取镜像解决的。
校验通过后,运行时会把每一层的挂载点信息准备好。注意,这时候还没有真正“拼”成完整目录树,只是把每一层的物理位置都确认了。
3.3 加载第二步:创建可写层并确定挂载顺序
层都定位好之后,运行时会在镜像顶层之上创建一个新的可写层。这个可写层是空的,但它会被放在整个层栈的最上面,成为容器所有写操作的入口。
接着就是确定挂载顺序。顺序的原则很简单:从最底层的基础层开始,依次往上叠加,最后是可写层。这个顺序不能乱,因为联合文件系统的“上层覆盖下层”规则完全依赖顺序。如果顺序错了,本该被覆盖的旧文件就会露出来,容器里的程序可能读到错误的版本。
挂载顺序确定后,运行时会调用存储驱动的接口,把这些层联合挂载到容器根文件系统对应的挂载点上。挂载完成后,从容器内部看,就是一个完整的、可读写的根目录,你根本看不出它是好几层拼出来的。
3.4 加载第三步:切换根目录并启动进程
根文件系统准备好之后,最后一步是把容器进程的根目录切换到这个联合挂载点上,然后启动镜像里指定的入口程序。这一步在操作系统层面叫“改变根目录”,目的是让容器进程只能看到这个挂载点里的内容,看不到宿主机的其他目录,从而实现隔离。
到这里,一次完整的镜像加载就算完成了。整个过程听起来步骤不少,但实际执行非常快,因为大部分工作只是“挂载”而非“复制”。这也是容器能秒级启动的根本原因——它不需要像虚拟机那样复制一整个操作系统,只需要把已有的层拼起来、盖个可写层就行。
注意:加载快的前提是层已经在本地。如果镜像还没拉取到本地,那第一步之前还得加上网络下载各层的过程,这时候耗时主要花在网络传输上,跟加载机制本身关系不大。排查启动慢的问题时,一定要先分清是“拉取慢”还是“加载慢”。
4. 写时复制与删除操作的真实行为
4.1 写时复制:改一个文件到底发生了什么
写时复制是镜像加载机制里最值得单独拎出来讲的部分,因为它直接决定了容器的性能和磁盘占用特征。
假设镜像的某一层里有个 100MB 的大文件,容器启动后你要修改它。系统不会去动只读层里的原文件,而是把这个文件完整复制到可写层,然后在可写层里做修改。这意味着,哪怕你只改了文件里的一个字节,磁盘上也会多出一份 100MB 的副本。原文件还在只读层里占着空间,新文件在可写层里又占一份。
这个行为解释了一个常见困惑:为什么容器跑一段时间后,磁盘占用涨得比预期快?很可能就是频繁修改大文件触发了大量复制。我处理过一个日志场景,应用往一个位于镜像层的大文件里持续追加内容,结果可写层迅速膨胀。后来把日志目录改成挂载数据卷,问题立刻缓解。
所以有一条实操经验值得记住:凡是需要频繁写入的目录,都应该用数据卷挂出去,而不是留在可写层里。数据库数据目录、日志目录、缓存目录,都属于这一类。
4.2 删除文件:不是真删,而是“盖个白条”
删除操作的行为更反直觉。当你在容器里删除一个位于只读层的文件时,系统并不会真的去只读层里把文件抹掉——只读层根本不允许改。它做的是在可写层里创建一个特殊的“白障”标记,表示“这个路径在联合视图里应该被视为不存在”。
从容器内部看,文件确实没了,ls不出来,程序也读不到。但从磁盘上看,只读层里的原文件还在,只是被可写层的白障挡住了。这个机制保证了镜像层永远不被破坏,多个容器共享同一镜像时互不影响。
这个特性带来一个实际影响:如果你在容器里删了大量镜像自带的文件,可写层会记录大量白障标记,磁盘占用不降反升。所以“在容器里删文件来瘦身”是行不通的,真要瘦身得回到镜像构建阶段,用多阶段构建或者精简基础镜像来解决。
4.3 用实验验证写时复制的存在
光讲理论不够,我建议你亲手做一次实验,印象会深刻得多。下面这套操作可以在任何一台装了容器运行时的机器上复现。
先启动一个容器,在里面创建一个文件,然后退出但保留容器:
docker run -it --name cot-test ubuntu:latest bash # 进入容器后执行 echo "hello" > /tmp/testfile.txt exit接着查看这个容器的可写层在宿主机上的位置:
docker inspect cot-test | grep UpperDir你会看到一个路径,进去之后能找到/tmp/testfile.txt。这就证明了:容器里新建的文件,实际落在可写层对应的目录里,而不是镜像层。
再做一个删除实验。重新进入容器,删掉一个镜像自带的文件,然后去可写层目录里找找看,你会发现多了一个特殊命名的白障文件,而镜像层里的原文件依然健在。这个对比能让你彻底记住“删除是标记而非抹除”。
5. 存储驱动与加载性能的关系
5.1 存储驱动在加载中扮演什么角色
前面反复提到“存储驱动”,但一直没展开。存储驱动是容器运行时和底层文件系统之间的中间层,它负责把“分层加联合挂载”这套抽象,翻译成具体文件系统能执行的操作。镜像层的存储、可写层的创建、联合挂载的建立,全都由它操办。
不同存储驱动的实现思路和性能特征差别不小。有的驱动对层数敏感,层数一多性能就下降;有的驱动在写时复制上做了优化,复制大文件时更快;有的驱动对磁盘空间利用更高效。你不需要成为每个驱动的专家,但要知道你当前环境用的是哪个,以及它的基本脾气。
查看当前存储驱动很简单:
docker info | grep "Storage Driver"输出会告诉你用的是哪个驱动。这个信息在排查性能问题、磁盘占用问题时是必看的。
5.2 层数过多为什么会拖慢加载
层数对加载性能的影响,是很多人容易忽视的一点。每多一层,联合挂载时就多一次叠加操作,查找文件时也可能要多穿透几层。层数少的时候感知不明显,但层数一多,累积效应就出来了。
我见过一个构建脚本,把几十条RUN指令拆开写,每条都产生一层,最后镜像有四十多层。构建慢、拉取慢、启动也慢。后来把这些指令合并成少数几条,层数降到个位数,整体速度明显改善。
这里的原则是:把相关的操作合并到同一条指令里,减少层数。比如安装软件时,把更新索引、安装、清理缓存放在同一条RUN里用&&串起来,既减少层数,又避免缓存残留在中间层里。这是镜像构建的基本功,也是优化加载性能最直接的手段。
5.3 镜像体积与加载速度的取舍
镜像体积和加载速度不是简单的线性关系,但总体趋势是体积越大、加载越慢。不过更准确的说法是:影响加载速度的主要是层数和层的分布,而不只是总体积。
举个例子,一个 1GB 的单层镜像,加载时只需要挂载一层,可能比一个 800MB 但有二十层的镜像还快。因为前者的挂载操作少,后者的层间查找和叠加开销大。
所以在优化时,不能只盯着“把体积压小”,还要关注“把层数控制住”。一个实用的做法是:基础层尽量用精简的官方镜像,中间层合并同类操作,把变化最频繁的内容放在最上层。这样既控制了体积,又优化了层结构。
6. 常见问题与排查技巧实录
6.1 容器启动报错与层损坏的排查
容器启动失败,报错信息里如果出现跟层、挂载、文件系统相关的关键词,大概率是层出了问题。常见原因有磁盘空间不足、磁盘坏道、镜像拉取不完整。
排查思路可以按这个顺序走。先看磁盘空间,df -h确认存储目录所在分区没满。再看镜像完整性,尝试重新拉取镜像,如果重新拉取后能启动,说明原来的层确实损坏了。最后看存储驱动状态,有些驱动在异常后需要清理才能恢复。
我整理了一个速查表,把常见现象和对应思路列出来,方便你对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动报挂载失败 | 层损坏或磁盘满 | 检查磁盘空间,重新拉取镜像 |
| 启动后文件内容不对 | 层顺序异常或缓存问题 | 清理无用镜像,重建容器 |
| 磁盘占用异常增长 | 可写层膨胀 | 检查是否有大文件频繁写入 |
| 删除文件后空间不降 | 白障标记堆积 | 重建容器,改用数据卷 |
| 加载速度突然变慢 | 层数过多或磁盘性能下降 | 查看层数,检查磁盘健康 |
6.2 磁盘占用居高不下的处理经验
磁盘占用问题是运维中最常遇到的。镜像层、可写层、悬空镜像、停止的容器,都会占空间。清理时要分清对象,别一上来就prune把所有东西删了,那可能把还在用的镜像也清掉。
我的习惯是分步来。先看整体占用,docker system df能给出镜像、容器、数据卷、构建缓存各自占了多少。然后针对性清理:悬空镜像用专门命令清,停止的容器确认无用后再删,构建缓存定期清。数据卷要格外小心,里面可能有业务数据,删之前一定确认。
提示:清理操作前,养成先
docker system df看一眼的习惯。这个命令花不了几秒,但能帮你避免误删。我见过有人直接跑清理命令,把正在用的构建缓存清了,结果下次构建从头再来,白白浪费半小时。
6.3 镜像共享带来的“意外”与应对
镜像共享是好事,但偶尔也会带来“意外”。最典型的是:你删了某个镜像,以为磁盘能释放一大块,结果发现释放的空间远小于预期。原因就是这个镜像的很多层被其他镜像共享着,只有它独有的层才会被真正释放。
理解这一点后,你在做磁盘规划时就会更理性。不要指望删一个镜像能释放它的全部体积,实际释放的是它独有的那部分。想知道一个镜像到底独占多少空间,可以结合docker history和docker system df综合判断。
另一个“意外”是构建缓存导致的。有时候你改了 Dockerfile 里靠前的指令,按理说后面所有层都该重建,但如果缓存命中判断有偏差,可能复用了不该复用的层,导致镜像内容不符合预期。遇到这种情况,构建时加--no-cache强制不用缓存,能排除缓存干扰。
7. 把加载原理用到日常实践里
7.1 构建镜像时的分层策略
理解了加载原理,构建镜像时就应该有意识地去设计分层。核心思路是:把变化频率低的内容放底层,变化频率高的放上层。这样每次重新构建时,只有上层需要重建,底层可以复用缓存,构建速度快,推拉镜像时传输的层也少。
具体到 Dockerfile,通常的顺序是:先FROM基础镜像,然后装系统级依赖,再装应用依赖,最后拷贝应用代码。应用代码变化最频繁,放最上面;系统依赖几乎不变,放下面。这个顺序不是随便定的,而是为了让缓存尽可能命中。
还有一个技巧是善用.dockerignore。构建时如果上下文目录里有一堆无关文件,它们会被打进构建上下文,既拖慢构建,又可能意外进入镜像。把不需要的文件排除掉,构建更干净。
7.2 运行容器时的数据卷规划
运行阶段的核心是数据卷规划。原则很简单:需要持久化的、需要频繁写的、体积大的,统统挂数据卷。数据库数据、上传文件、日志、缓存,都属于这一类。
数据卷的好处不只是持久化,还有性能。数据卷直接落在宿主机文件系统上,绕过了可写层的写时复制,写入大文件时不会触发整文件复制,性能更好,也不会让可写层膨胀。
规划数据卷时要注意权限问题。容器里的进程以某个用户身份运行,挂载的数据卷目录权限得匹配,否则会出现“容器里写不进去”的情况。这个坑很常见,排查时先看容器内进程的用户 ID,再看宿主机目录的权限,两边对上就行。
7.3 排查性能问题时的切入顺序
遇到容器性能问题,我一般按这个顺序切入。先确认是加载阶段慢还是运行阶段慢。加载慢,看层数和镜像体积;运行慢,看可写层写入和资源限制。
加载慢的排查,前面讲过,重点看层数和存储驱动。运行慢的排查,重点看是不是有大量写操作落在可写层。如果是,把对应目录改成数据卷挂载,往往立竿见影。
还有一类性能问题跟存储驱动有关。某些驱动在特定文件操作模式下性能不佳,比如大量小文件读写。这种时候可以考虑换驱动,或者调整挂载参数。不过换驱动影响面大,得在测试环境充分验证后再上生产。
8. 几个容易被问到的细节问题
8.1 镜像层能不能被单独导出和导入
可以。镜像的层本质上是文件系统的变更集,理论上可以单独导出。实践中更常见的是导出整个镜像,用docker save把镜像存成一个归档文件,再用docker load导入到另一台机器。这个过程中,层与层的关系会被完整保留。
这个能力在离线环境部署时特别有用。没有网络的环境里,没法直接拉镜像,就可以在有网环境把镜像导出,拷贝过去再导入。导入后加载机制跟正常拉取的镜像完全一样,该分层还是分层,该共享还是共享。
8.2 为什么同一个镜像在不同机器上体积可能不同
这个现象确实存在,原因通常有两个。一是存储驱动不同,不同驱动对层的存储方式、压缩方式不一样,占用的磁盘空间自然不同。二是文件系统不同,同样的数据在不同文件系统上的实际占用可能有差异。
所以对比镜像体积时,要保证环境一致,否则数字没有可比性。跨环境对比时,更靠谱的指标是镜像的逻辑大小,也就是各层大小之和,而不是磁盘实际占用。
8.3 容器里看到的文件系统跟宿主机是什么关系
容器里看到的根文件系统,是联合挂载后的视图,跟宿主机的根文件系统是隔离的。容器进程看不到宿主机的目录,除非你显式挂载进去。这个隔离是靠改变根目录实现的,不是靠权限限制,所以隔离性比较强。
但要注意,数据卷挂载是打破这层隔离的通道。挂载进去的目录,容器和宿主机都能访问,改动是双向可见的。这既是数据持久化的手段,也是需要谨慎对待的地方——挂载了宿主机敏感目录的容器,风险会明显上升。
9. 我在实际使用中的几点体会
踩过几次坑之后,我对镜像加载这件事最大的体会是:很多看似玄学的问题,根源都在分层和写时复制上。磁盘莫名增长、启动莫名变慢、文件内容莫名不对,往这两个方向想,往往能找到答案。
第二个体会是,不要等到出问题才去理解原理。平时构建镜像时就有意识地控制层数、规划数据卷,能省掉后面大量的排查时间。我现在的习惯是,每写一个 Dockerfile,都会用docker history看一眼层数和各层大小,超标了就回头优化。
第三个体会是关于缓存的。缓存是双刃剑,用好了构建飞快,用不好会掩盖问题。我的做法是,日常构建依赖缓存提速,但在做重要发布前,用--no-cache完整构建一次,确保镜像内容完全符合预期,不依赖任何可能过期的缓存。
最后分享一个小技巧:如果你想知道某个容器的可写层到底改了什么,可以对比可写层目录和镜像层目录的差异。虽然手动对比有点笨,但在排查“容器里到底改了什么”这类问题时,比瞎猜靠谱得多。理解了加载原理,这些排查手段你都能自己推导出来,而不是死记命令。