1. 先弄清楚:全网都在说overlay,我们到底聊的是哪一个
war3 replay overlay、[drm] failed to init overlay plane cluster0-win1、docker清理overlay数据……"overlay"这个词在技术圈里泛滥到什么程度?打游戏录像要提overlay,显卡驱动报错要提overlay,容器存储还要提overlay。我甚至见过有人把汇编里"warning l16: uncalled segment, ignored for overlay process"当成存储问题来查——那是链接器在处理覆盖段,跟磁盘镜像八竿子打不着。
这篇文章里的overlay,特指虚拟化磁盘镜像和容器镜像里的写时复制(Copy-on-Write, CoW)机制。你有一个基础镜像,我们管它叫backing file,然后基于它生成一个外部快照文件,这个外部快照就是overlay。所有的新数据、修改数据全部落到overlay上,底层的backing file完全不动。
这么说可能有点干巴。打个比方:基础镜像是一本已经印刷好的参考书,overlay是你在书页上贴的一沓便利贴。你在便利贴上记新笔记、改错别字,翻书的时候看到的效果是"便利贴覆盖在书页之上"的合成视图,但书本身一个字都没改。你想恢复成原样,把便利贴全撕掉就行。这就是backing file和overlay最核心的关系。
这个机制在KVM/QEMU虚拟化里用得最多,也是Docker镜像分层的底层原理。我最早接触这套东西是在管理一批KVM虚拟机的时候,系统盘紧张,每台虚拟机都装一整份完整的系统镜像,几百GB的宿主机硬盘很快就见底了。后来用外部快照的方式,所有虚拟机共享同一个基础镜像,每台只占增量数据,硬盘一下子就宽裕了。
这篇文章我会把整条链路讲透——从qcow2镜像的原理、backing file和overlay怎么配合、外部快照怎么做、怎么提交合并数据,再延伸到Docker的overlay2存储驱动和日常清理。你既能在KVM场景直接操作,也能把底层原理迁移到容器场景。适合正在管理虚拟化环境、被磁盘占用逼到头秃的运维,也适合刚接触容器存储、想搞明白镜像层到底是什么的新手。
2. backing file和overlay的底层原理:为什么能做到"只存增量"
2.1 qcow2镜像的数据组织方式
要理解backing file和overlay的关系,先得搞清楚qcow2这种镜像格式本身是怎么存数据的。qcow2全称QEMU Copy On Write Version 2,它天生就是为写时复制设计的。一个qcow2文件从结构上分三个部分:文件头、L1表、L2表,最后才是真正的数据块。
文件头存的是镜像的基础信息:版本号、镜像大小、簇大小、加密方式、还有指向backing file的路径。L1表是一个一维数组,每个表项指向一个L2表;L2表又是一个一维数组,每个表项指向一个实际的数据簇。当你读写某个扇区时,QEMU先把虚拟磁盘偏移换算成簇号,查L1表找到L2表,再查L2表找到数据簇,拿到真正的数据。
这套两级索引的设计,本质上就是一个"页表"。学过操作系统的同学应该秒懂,这和CPU虚拟内存的页表机制是一模一样的思路。为什么要搞两级而不是一张大表?因为qcow2镜像可能非常大,动辄几十GB上百GB,如果用一张平铺的表来记录所有簇的位置,这张表本身就得占掉天量空间。用两级表,L1表只存指向L2表的指针,L2表按需分配,镜像文件越小,表占的空间就越少。
默认簇大小是64KB,也就是说L2表里每一项管64KB的数据块。一个L2表默认有512个表项(每项8字节),所以一个L2表能覆盖512×64KB=32MB的文件空间。文件再大,就增加L1表的表项数来多挂几个L2表。这套设计非常精巧,理解了它,你就能明白overlay机制为什么能做到"只存增量"。
2.2 写时复制:新数据落到overlay,老数据依然从backing file读
现在把backing file和overlay放到一起看。当你执行:
qemu-img create -f qcow2 -b ubuntu-base.qcow2 ubuntu-instance.qcow2这条命令创建了一个新的qcow2文件ubuntu-instance.qcow2,它的文件头里记录了backing file的路径是ubuntu-base.qcow2。此时ubuntu-instance.qcow2本身几乎不占空间,L1表、L2表都是空的,所有数据都从backing file读。
当你往ubuntu-instance.qcow2写入数据时,QEMU检查L2表:目标簇如果有映射,直接写入;如果没有映射,说明这个簇的数据在backing file里,于是先从backing file把整个簇读出来,在内存里把要修改的部分改掉,然后把整个簇写入ubuntu-instance.qcow2并更新L2表映射。这就是"写时复制"四个字的由来——只有当你试图修改某个簇的数据时,才发生复制动作,把数据从backing file复制到overlay。
读数据时也一样,QEMU先查overlay的L2表,如果查到了就返回overlay的数据;查不到,自动顺着backing file的路径去读底层数据。最终你能看到的、能操作的,是"overlay覆盖在backing file之上"的合成结果。这就是外部快照的"外部"二字的含义:快照数据不在backing file里面,而是在外部的另一个文件里。
有一个关键细节:overlay里标记"这个簇被写过"靠的就是L2表是否有映射。如果L2表里该表项是0,表示"本层没有数据,请找backing file";如果非0,表示"本层有这个簇,直接读我这里"。所以QEMU判断是否要复制数据,只需要一次查表,效率非常高。
2.3 为什么基础镜像必须只读:写坏的教训
这地方有一个铁律:backing file在overlay存活期间,不能被写入,甚至不能被移动、重命名。原因不复杂,但很多人栽过跟头。
想象一下,backing file里的某个簇是A,overlay基于A做了修改,overlay里存了A'。如果此时backing file的A被改成了B,那么overlay读取旧数据时就会出现混乱——有些簇读到的是B,有些簇读到的是A',整个文件系统结构可能彻底崩塌。
我实际踩过这个坑。当时图省事,直接把基础镜像做成模板文件,多台虚拟机共用。某天为了给另一批虚拟机升级软件,我直接在基础镜像上跑了软件包更新命令。结果所有基于这个基础镜像的外部快照虚拟机,一夜之间出现了大量随机性的文件损坏,ssh-keygen报key mismatch、web服务起不来、mysql表损坏,排查了整整两天才定位到是backing file被污染了。
正确做法是:基础镜像做出来后,立刻把文件权限改成只读,甚至放到一个专门的模板目录,通过文件系统权限挡死写入行为。虽然qcow2本身不会阻止你写,但权限能保护你。
提示:backing file的路径是记录在overlay的文件头里的,而且是纯文本路径。如果你移动了backing file,overlay再启动时就会报"Could not open backing file",逻辑上就断了。要恢复,得用qemu-img rebase重新指定路径。
3. 外部快照实操:从创建、使用到链式扩展
3.1 创建第一张外部快照:三条命令搞定
创建外部快照本身非常简单,核心命令就是上面那条qemu-img create。完整流程是这样的:
首先准备基础镜像。假设我有一台装好系统、打完补丁的虚拟机镜像ubuntu-base.qcow2,把它放到/templates目录:
mkdir -p /templates mv ubuntu-base.qcow2 /templates/ chmod 444 /templates/ubuntu-base.qcow2 # 只读保护然后基于它创建实例镜像:
qemu-img create -f qcow2 -b /templates/ubuntu-base.qcow2 /vms/instance01.qcow2这条命令有两个关键参数。-f qcow2指定目标格式;-b指定backing file路径。执行完之后,instance01.qcow2是一个几乎空白的文件,只有文件头和必要的表结构,大小大概几百KB。用qemu-img info看一眼:
qemu-img info /vms/instance01.qcow2输出里会出现一行backing file: /templates/ubuntu-base.qcow2,这就是两者的关联证据。如果这行路径不对,虚拟机起不来。我建议任何时候都用绝对路径写backing file,相对路径容易在目录切换时踩坑。
创建完实例镜像,用这个镜像启动一台新虚拟机,虚拟机的系统盘指向instance01.qcow2。此时虚拟机里能看到完整的系统,运行状态和直接用基础镜像启动一模一样。区别在于,你做的任何修改都会写到instance01.qcow2里,基础镜像像什么都没发生过一样。
3.2 磁盘占用实测:增量到底能省多少
我说一个实际数据。我手头有一个精简安装后的Ubuntu Server 22.04基础镜像,打完基础安全补丁,大小大约2.1GB。基于它创建了8台虚拟机,分别跑Nginx、MySQL、Redis、日志采集等不同业务。
跑了一个月之后,我统计了每台虚拟机的镜像实际占用:
| 虚拟机用途 | overlay镜像大小 | 增量占比 |
|---|---|---|
| Nginx负载均衡 | 286MB | 13.6% |
| MySQL主库 | 1.8GB | 85.7% |
| Redis缓存 | 512MB | 24.4% |
| 日志采集器 | 96MB | 4.6% |
MySQL增量最大,因为它的数据文件本身就在写。日志采集器增量最小,因为系统装完就没怎么动过。总体算下来,如果之前每台虚拟机都要完整复制2.1GB系统盘,8台就是16.8GB;用了外部快照之后,基础镜像2.1GB加上全部增量约3.4GB,加起来不到6GB,省了六成以上空间。
不过有一说一,省空间不是唯一目的。外部快照更大的价值在于快速部署:新开一台虚拟机,不用复制文件,qemu-img create瞬间完成,虚拟机秒级启动。这在批量交付开发环境、测试环境时特别香。
3.3 快照链:层层叠加的正确玩法
外部快照不只是"一层",它可以无限往下叠。比如我在instance01上跑了一段时间业务,想再做一次快照作为新的回滚点,操作方式是在现有实例之上再套一层:
qemu-img create -f qcow2 -b /vms/instance01.qcow2 /vms/instance01-snap.qcow2这样一来就形成了一条链:ubuntu-base.qcow2 → instance01.qcow2 → instance01-snap.qcow2。虚拟机改用instance01-snap.qcow2启动,读取数据时逐层往上找:先查最顶层,没有就查下一层,直到基础镜像。
链式快照对开发环境的"试错"场景非常友好。我要在系统里测一个高危脚本,先做一层快照,测完不满意,直接把顶层快照删掉重来,宿主机的数据分毫未损。这个删除操作要小心:删的只是最顶层那个快照文件,它底下的backing file不会被删。
链式快照的代价是性能损耗和复杂度。每一层多一次查表跳转,虽然kvm/qemu做了缓存优化,但层数多了IO延迟仍然会上升。另外链越长,排查问题和手工合并就越麻烦。我个人的建议是:链深控制在3层以内,超过就考虑用commit把数据合并回底层。
3.4 合并数据:qemu-img commit的正确姿势
增量攒多了,overlay文件越来越大,读写性能也开始下降,这时候就需要把overlay的数据合并回backing file。用的命令是:
qemu-img commit /vms/instance01.qcow2这条命令把instance01.qcow2里所有"属于自己的数据"写回它的backing file,也就是ubuntu-base.qcow2。执行完,instance01.qcow2里的数据被清空,之后再读数据就直接走backing file了。
commit有几个注意事项。第一,执行commit时虚拟机必须关机,否则正在写入的数据会造成不一致。第二,commit后overlay文件本身还在,但内容已清空,如果你反复提交,文件会变成一个悬空的空壳。第三,也是最容易被忽略的:commit会修改backing file——前面刚说完backing file必须只读,commit就是一个合法的写操作。这也就是为什么我一直强调:只有你知道"这个基础镜像不会再被其他overlay引用"时,才允许commit。
qemu-img rebase是用来换backing file的:
qemu-img rebase -b /templates/ubuntu-base-v2.qcow2 /vms/instance01.qcow2rebase是把overlay从一个backing file切换到另一个。如果新旧backing file内容相同,rebase只是改个路径;如果内容不同,rebase会尝试把overlay中已有的数据和旧backing file的差异合并到新backing file的语义下。这在基础镜像升级时特别有用:系统镜像升级版本后,你不用重新创建所有实例,只需要rebase一下,补上缺失的差异数据就行。
注意:rebase操作比较重,如果overlay和backing file差异巨大,耗时长且风险高。稳妥起见,执行前先备份overlay文件。我在生产环境里基本只对差异可控的镜像层做rebase,差异大的直接新建实例迁移业务。
4. Docker世界里的overlay2:镜像分层是外部快照的孪生兄弟
4.1 镜像层、读写层和容器层的关系
聊完KVM/QEMU的外部快照,再看Docker的存储,你会发现逻辑完全一样。Docker镜像由一层一层的只读层叠加而成,Dockerfile里每一条指令产生一个新的镜像层。这些只读层就是backing file们。启动容器时,Docker在只读层之上创建一层可写层——这就是overlay。
Docker默认的存储驱动是overlay2,它的实现和qcow2外部快照如出一辙:lowerdir存放多个只读层,upperdir是可写层,merged是两者的联合挂载视图。你在容器里写文件,数据进upperdir;读文件时,overlay文件系统先查upperdir,查不到再往下查lowerdir。这不就是backing file和overlay嘛。
我举一个实际的例子。拉一个Nginx镜像,它的构成大概是这样的:基础层(debian或alpine)、nginx安装层、配置文件层、可能还有entrypoint脚本层。每一层都是一个独立的目录,存放在/var/lib/docker/overlay2/下面。容器运行时,这些目录被按顺序组装成lowerdir,上面再叠加一个可写的容器层。镜像层是只读的,所有容器共享;容器层是每个容器自己独享的,容器删除时容器层跟着销毁。
4.2 清理overlay数据的正经姿势
热搜词里有"docker怎么清理overlay数据",我猜问这个问题的人,多半是看到了/var/lib/docker/overlay2目录占了几个GB甚至几十GB,不知道能不能直接删。先给结论:不要手动rm -rf,要用docker的清理命令。
Docker本身提供了三件套:
docker system df # 查看磁盘占用分布 docker system prune # 清理悬空镜像、停止的容器、无用网络和构建缓存 docker system prune -a # 更激进,删除没有被容器使用的所有镜像执行docker system df,输出会分Type、Total、Active、Size、Reclaimed几列,一眼能看出什么在占空间。镜像(Images)占了最多的往往就是你拉的一堆大镜像;Build Cache是构建镜像时的中间层缓存,这个经常是最占空间的。我遇到过一台CI机器,Build Cache占了40多GB,docker system prune一键清了30GB出来。
为什么不能直接去/var/lib/docker/overlay2目录里手动删文件夹?因为overlay2目录下的文件夹名是一串哈希ID,它们之间靠元数据文件关联,你根本不知道哪一层属于哪个镜像或容器。删错了,可能导致某个镜像无法使用、容器起不来,而且Docker内部会认为存储数据不一致,比磁盘满还难处理。更安全的做法是:如果确实想物理腾空间,用docker system prune -a --volumes清理一切不再使用的资源,然后重启Docker daemon收尾。
如果真的出现了Docker的overlay2数据和daemon记录的元数据不一致(这种情况多见于暴力删除目录后),最稳妥的办法是彻底重置Docker存储:
systemctl stop docker rm -rf /var/lib/docker systemctl start docker这一招等于把所有镜像和容器全部格式化,代价大,但能解决所有存储不一致问题。执行前务必确认没有需要保留的容器数据卷,镜像可以从仓库重新拉取。
4.3 容器镜像层和qcow2快照的设计同源
KVM/QEMU的外部快照和Docker的overlay2存储驱动,设计思路高度一致,可以互相验证。两者的核心都是:只读层+可写层+层级查找。
差别在于维护粒度。qcow2外部快照以"簇"为单位,64KB一个块;Docker overlay2以"文件"为单位,合并视图在文件系统层面完成。qcow2的快照是把整个虚拟磁盘作为快照对象,粒度粗;Docker的镜像层以Dockerfile指令为边界,每一层都是独立的可复用对象。这也解释了为什么Docker镜像可以共享——多个镜像如果底层基础镜像一样,那基础镜像的层直接被复用,不需要重复存储。
理解了这层统一性之后,你再看容器里的"commit容器为镜像"操作:
docker commit <container-id> my-image:v2这条命令把容器可写层保存成一个新的镜像层——把overlay层固化成一个新的backing file。这恰恰就是qcow2外部快照场景里,你把overlay直接拿去当新的基础镜像用的逻辑。一个概念,在两个工具链里反复出现,说明这套设计已经被大规模生产环境验证过无数次了。
5. 常见问题与排查实录:那些文档里不会写的事
5.1 qemu报错Could not open backing file
这是外部快照场景最常见的故障,原因是overlay文件头里记录的backing file路径找不到了。症状是虚拟机启动失败,错误信息类似:
qemu-system-x86_64: -drive file=/vms/instance01.qcow2: Could not open backing file: Could not open '/templates/ubuntu-base.qcow2': No such file or directory排查步骤很简单。先用qemu-img info看overlay文件头:
qemu-img info /vms/instance01.qcow2输出里的backing file那一行会显示记录的路径。如果路径和实际位置对不上,用rebase修正:
qemu-img rebase -u -b /new/path/ubuntu-base.qcow2 /vms/instance01.qcow2这里的-u参数很关键,它让rebase只更新文件头里的路径,不对底层数据做任何合并,因此瞬间完成。如果你忘了加-u,rebase会尝试把整个overlay的数据和旧backing file做比对合并,耗时可能长达几小时,还会因为找不到旧文件直接报错。
还有一种情况:backing file路径没变,但文件本身被替换成了同名的新镜像。这种情况比路径问题更危险,因为rebase也救不了——新镜像的数据和旧镜像可能完全不同,但overlay里引用的是旧数据的坐标。遇到这种情况,老老实实新建overlay重新部署,不要心存侥幸。
5.2 overlay文件越写越大:这是特性不是bug
很多人看到overlay文件膨胀就开始慌,担心是不是数据写坏了。实际上overlay变大是正常的。你每次修改一个文件,修改后的整个簇都会复制到overlay;文件删掉了,overlay里占的簇也不会自动释放。所以overlay的体积只会单调递增。
我见过一个极端案例:一台虚拟机长期跑数据库,基础镜像只有2GB,overlay撑到了40GB。这个不算异常。如果想要让overlay瘦身,办法是:利用qcow2的discard或者fstrim,让guest里的空间回收动作传递到宿主机上的qcow2文件。但注意,fstrim只对"未分配块"有作用,对于overlay上已存在的、被标记为删除的数据,也不一定能完全回收。
如果一定要压缩overlay,官方做法是把overlay里的数据导出到一个全新的qcow2:
qemu-img convert -f qcow2 -O qcow2 /vms/instance01.qcow2 /vms/instance01-compact.qcow2convert会按当前实际分配的数据重建一个干净的镜像文件,未分配的块(包括曾经写过后来删除的)不会进入新文件。转换完了,把新文件替换旧文件即可。这是物理层面的瘦身,效果最显著。
还有一点要注意:convert出来的新镜像,默认不会再带上backing file信息。如果你希望它继续依赖基础镜像,要加-B参数:
qemu-img convert -f qcow2 -O qcow2 -B /templates/ubuntu-base.qcow2 /vms/instance01.qcow2 /vms/instance01-compact.qcow25.3 [drm] failed to init overlay plane和存储overlay无关
把[drm] failed to init overlay plane cluster0-win1这条热搜词一并说一下,因为它极具迷惑性。这个报错来自Linux内核的DRM显示子系统,意思是显卡驱动初始化显示合成层(overlay plane)失败。它跟磁盘镜像存储半毛钱关系都没有,但它确实叫overlay plane。调试思路应该转向显卡驱动、内核模块和显示服务器方向,而不是去查qcow2。
同样,warning l16: uncalled segment, ignored for overlay process是汇编器或链接器的警告,出现在嵌入式开发里,表示某个代码段没有被调用因此不参与覆盖段处理。也是另一个领域的overlay。这提醒我们:当报错信息里出现overlay时,先想想上下文是什么——是存储、显示、还是代码链接——不要一上来就对着镜像文件猛查。
5.4 Docker容器数据卷和镜像层的清理边界
清理Docker overlay2数据时要分清边界。镜像层可清理,容器层可清理,但命名数据卷不要随便删。数据卷(volume)存放在/var/lib/docker/volumes/下,独立于overlay2目录,直接删会导致业务数据永久丢失。
我用一条命令区分哪些资源能清:
docker system df -v这个命令会列出每个资源的详细占用和是否被引用。你重点关注Images和Build Cache两部分的Reclaimed大小,这两个是最安全的清理对象。Local Volumes除非你明确知道对应业务已经下线,否则跳过。
还有一个很多人不知道的细节:docker system prune默认不清理未被使用但带名字的数据卷。如果你要全清,必须显式加--volumes参数。而docker system prune -a --volumes全敲下去,效果相当于把本机Docker"重置"到几乎全新状态——凡是没在运行的容器的数据、镜像、网络、缓存、数据卷,全部消失。这条命令我用过不少次,每次执行前都会再三确认有没有-a和--volumes。
5.5 快照链断裂后的数据恢复尝试
最后讲一个灾后现场。有一次我把基础镜像从/templates目录挪到一个新的存储池,但忘记同步更新一批overlay的backing file路径。结果这批虚拟机全部失联。我的恢复思路是:
先尝试rebase -u修正路径。但这要求新路径下的文件和原文件完全一样。我犯的错误是,我把ubuntu-base.qcow2连同它的多个快照子文件一起拷到了新目录,原文件本身的inode和内容虽然没有变,但rebase -u仍然失败了——因为rebase检查的是"旧backing file是否存在",旧路径已经不存在了,它拒绝执行。
这时我用了一个绕法:先在原路径建一个软链接指到新路径,让rebase -u认为旧路径"存在",从而允许更新文件头:
ln -s /new/pool/ubuntu-base.qcow2 /old/path/ubuntu-base.qcow2 qemu-img rebase -u -b /new/pool/ubuntu-base.qcow2 /vms/instance01.qcow2执行成功后,再把软链接删掉。虚拟机全部恢复。这个方法不保证每次都成功,但如果你的backing file只是搬家没有改动内容,值得一试。
6. 我在实际项目里沉淀的几条经验
做这套backing file和overlay方案这几年,最有价值的体会是:快照链不是越深越好,省空间也不是唯一目标,最重要的永远是数据的可恢复性。
第一,基础镜像一旦打磨完,立刻只读。权限设成只读,目录位置固定,坚决不允许任何人直接在基础镜像上做修改。如果基础镜像需要升级,不要在原有文件上动,而是新建一个基础镜像,然后用rebase把overlay切到新镜像上。这套流程虽然多几步,但每一步都可回滚,不会出现"底层被写坏,全部overlay跟着遭殃"的连环事故。
第二,overlay文件一定要放在和backing file不同的目录,并且定期做备份。backing file是模板,可以随时从模板库恢复;overlay是业务数据,丢了你只在工具层面的工作就全白做了。我会用rsync定期把overlay文件同步到备份服务器,执行前先qemu-img snapshot做一次内部快照夹心保护。
第三,别贪图"省空间"把手伸到脏数据上去。qcow2之所以设计成稀疏文件,就是为了让空间按需分配。你非要手动开预分配、非要频繁convert紧凑,都会带来不必要的IO开销和操作风险。该省的地方是"归档已下线虚拟机的overlay"——归档后及时清理原文件,这才是安全的腾挪空间方式。
当初我刚接触qemu-img create -b这条命令时,只觉得套娃很好玩,一层一层的镜像像俄罗斯方块一样叠上去。后来踩过镜像污染、踩过软链接绕路、踩过docker prune误删,才真正理解"只读层+可写层"这个组合有多聪明,又有多少需要敬畏的地方。你在自己的环境里照着上面的命令走一遍,就会明白我为什么反复强调"基础镜像必须只读、快照深度必须克制、清理之前必须看清引用"这三句话。这三句话,是我能给你的最实在的经验。