libvirt 外部快照删除:从 QCOW2 链到 AppArmor 权限陷阱
十月 09, 2026 [虚拟化与容器] #libvirt #qemu #kvm #安全 #调试 #debian背景
清理一台 Debian 13 宿主机上 debian13 虚拟机的快照链:它积累了 4~5 层外部快照(含两个带内存的完整快照),目标是合并到只保留两个纯磁盘快照。
过程中踩了两个坑:
- 对运行中的 VM 误建了内部快照(不带
--disk-only),VM 被暂停、24G 内存写进了 qcow2,并发命令撞上域锁超时; - 删除外部快照时报
block-commit: Could not open '...': Permission denied,根因是 Debian 的 libvirt + AppArmor 已知 bug:动态生成的 profile 给只读 backing 层加了deny ... w,而 block-commit 必须要写 backing 层。
本文把外部快照链的原理、删除的真实机制、这两个坑的完整排查过程整理成文,后半部分附带 qcow2 / blockcommit 的常用命令速查。
一、外部快照链基础
1.1 QCOW2 overlay 与 backing file
qcow2 支持 copy-on-write:以一个完整镜像为 backing file,在其上创建 overlay,overlay 只保存相对 backing 被改过的数据块。
# 手动创建一层 overlay
qemu-img create -f qcow2 -b base.qcow2 -F qcow2 overlay.qcow2
# 查看整个 backing 链
qemu-img info --backing-chain overlay.qcow2
要点:
- 它是块级 diff(簇,通常 64KiB):读的时候 overlay 里没有的簇逐层向下穿透;写的时候以簇为单位分配到最顶层;
qemu-img info里的disk size(实际占用)和virtual size(表观容量)是两回事,文件"看起来 130G"不等于要搬 130G 数据;- libvirt 对外部快照层的格式有要求:默认 qcow2,且只接受 qcow2 或 qed(源码
qemuSnapshotPrepareDiskExternal里对格式做了白名单校验)。即使原盘是 raw,新建的快照层也会是 qcow2,raw 只当最底层的 backing。
1.2 链上每个文件到底装了什么
这是最容易误会的地方。以实际的链为例:
[活动层 RW] debian13.snap-20261009-20 ← 该快照创建后至今的增量
└── debian13.2026-10-09T09:37 ← 16:13~17:37 的增量
└── debian13.2026-10-09T08:13 ← 9/16~10/9 的增量
└── debian13.snap-20260916-10 ← 6/25~9/16 的增量
└── debian13.snap-20260625-17
└── debian13.qcow2 ← 原始数据(链底)
libvirt 的命名规则是:创建快照时新建的 overlay 用快照名命名(文档:把原文件名最后一个点之后的部分替换为快照名)。
所以:
- 名为快照 X 的文件不是 X 的还原数据,而是"X 之后的新增写入";
- X 的还原点 = 它对应文件下面的整条链的冻结内容(+ 内存文件,如果有);
- 快照 XML 里的
<domain>段记录的是"创建快照前的 domain",其中磁盘 source 指向的才是快照数据所在的那层。
这解释了为什么删除快照时"数据不会丢":删除做的是把文件里的增量合并进下层,链上的数据总和不变。
1.3 内部快照 vs 外部快照
| 内部快照 | 外部快照 | |
|---|---|---|
| 存储位置 | 当前 qcow2 文件内部(snapshot 表) | 每层一个独立 qcow2 文件 |
| 是否产生新文件 | 否 | 是 |
| 运行中 VM + 内存 | 写进 qcow2 内部,需要暂停 VM 且很慢 | 内存单独存 .save 文件 |
| 回滚 | 依赖 qcow2 内部表 | 创建新 overlay 指向链上某点 |
| 链长度影响 | 单文件变大 | 文件数量变多 |
| 与另一种混用 | 尽量别混 | 尽量别混 |
对运行中的 VM 执行:
# 外部"纯磁盘"快照:秒级、不暂停、无内存文件
virsh snapshot-create-as debian13 snap-20261009-20 --disk-only --atomic
# 不带 --disk-only(默认)会做内部全系统快照:
# - 暂停 VM 数分钟
# - 内存 + 磁盘状态写进活动 qcow2 内部
virsh snapshot-create-as debian13 snap-xxx --atomic # ← 坑,见第三节
1.4 内存快照与 .save 文件
带内存的完整快照在 XML 里长这样:
<state>running</state>
<memory snapshot='external' file='/var/lib/libvirt/images/debian13.2026-10-09T09:37.save'/>
.save是 libvirt 的 save image(头部 magic 为LibvirtQemudSave,后面接 QEMU 的 vmstate 流),不是 qcow2,大小约等于 VM 内存;snapshot-revert恢复 running 状态快照时必须打开它,删掉就等于这个快照无法恢复(没有"忽略内存、只回滚磁盘"的开关);- 正确地删除整个快照时,
.save会被一并清理;不要手动rm; virsh snapshot-delete --metadata只删元数据、不做磁盘合并,属于危险操作,生产环境别用。
二、删除快照时到底发生了什么
virsh snapshot-delete 不是删文件,而是先做在线 block-commit:把被删层已分配的数据块合并进它的 backing 层,重接上层指针,最后才删除文件与元数据。
2.1 删中间层(非当前快照)
例如删除 snap-20260625-17:
- 把该层增量合并进下层(对最老的快照就是合并进
debian13.qcow2); - 把上层文件的 backing 指针改指到合并后的结果(libvirt 调
qemu-img rebase -u); - 活动层完全不动,VM 无感,只是链缩短。
2.2 删当前快照:active commit + pivot
libvirt 源码 qemuSnapshotDiscardExternal 里的关键分支:
unsigned int commitFlags = VIR_DOMAIN_BLOCK_COMMIT_DELETE;
if (data->merge) {
if (data->domDisk->src == data->diskSrc) { /* 删的是当前快照 */
commitFlags |= VIR_DOMAIN_BLOCK_COMMIT_ACTIVE; /* 活动层 commit */
autofinalize = VIR_TRISTATE_BOOL_YES;
}
qemuBlockCommit(vm, domDisk, parentDiskSrc, diskSrc, prevDiskSrc, ...);
}
“活动层被吸收进下一层”的含义:
- QEMU 把活动层里已分配的数据复制进它的 backing 层(合并目标永远是链上更老的那一层);
- 复制完成后做一次 graph pivot:把"活动层"指针从旧文件切到 backing 层,之后新写入落在新的活动层上;
- 再删掉旧活动层文件和它的
.save,current 指针回到父快照。
区别:
| 删中间层 | 删当前层 | |
|---|---|---|
| 合并方向 | 向下一层 | 向下一层 |
| 活动层 | 不动 | commit 完成后 pivot 到下一层 |
| VM 感受 | 全程无感 | commit 期间照常运行,最后切换有毫秒级 I/O 停顿 |
| 数据总量 | 不变 | 不变 |
所以删除顺序无所谓,最终链的内容一致;每个快照失去的只是"回到那个时间点"的能力。
2.3 删除的外部约束
libvirt 11.3 对删除外部快照的校验(qemuSnapshotDeleteValidate):
- 有其它活动 block job 时不能删;
--children/--children-only不支持(会报deletion of external disk snapshots with children not supported);- 非当前、多子、或不在当前活动链上的非叶快照会被拒绝——拒绝是保护;
- 上一次删除未完成(元数据里有
<snapshotDeleteInProgress/>)时,相关快照会被拒绝删除,必须把未完成的那次删除重新执行并跑完来清除标记。
另外:运行中的 VM 即使文件被误删,QEMU 手里还持有 fd,可能暂时无感;但重启或重开文件时就再也打不开了——永远不要手动 rm 链上的文件。
三、坑一:不带 --disk-only 的内部快照 + 域锁超时
3.1 现象
virsh snapshot-list里冒出一个状态 = running的新快照;- 磁盘目录里没有任何新文件;
- 活动层 qcow2 从 59M 暴涨到 25G;
- VM 一度显示"已暂停";
- 紧接着再敲 snapshot 命令,报:
error: 操作超时:无法获取状态更改锁(由 monitor=remoteDispatchDomainSnapshotCreateXML 持有)
3.2 原因
对运行中的 VM,不带 --disk-only 的 snapshot-create-as 默认创建内部全系统快照:
- 暂停 VM,用 savevm 把内存(本例 24G)+ 磁盘状态写进活动 qcow2 内部;
- 整个过程可能持续数分钟,期间域锁一直被占;
- 第二条命令只是在等锁,等到超时就报"无法获取状态更改锁"。
验证方式:
virsh snapshot-info debian13 <name> # 位置:内部
virsh snapshot-dumpxml debian13 <name> # <memory snapshot='internal'/>、<disk snapshot='internal'/>
3.3 处理与经验
- 等它完成或直接
virsh snapshot-delete删掉这个内部快照(内存数据会从 qcow2 里回收); - 对运行中的 VM 要磁盘快照,必须带
--disk-only --atomic; - 看到"无法获取状态更改锁 / 操作超时"时:先
virsh domjobinfo/virsh list看是否有并发操作在跑,不要反复重敲命令——每次重试都会去抢同一个锁。
四、坑二:snapshot-delete 报 Permission denied(AppArmor)
4.1 现象
sudo virsh snapshot-delete --domain debian13 "2026-10-09T09:37"
error: 删除快照失败:2026-10-09T09:37
error: 内部错误:无法执行 QEMU 命令 'block-commit':
Could not open '/var/lib/libvirt/images/debian13.2026-10-09T08:13': Permission denied
报错的文件不是待删快照本身,而是它的合并目标层(backing 层)。
4.2 排查过程
- 排除常见原因:文件属主和权限正常(
libvirt-qemu:libvirt-qemu 0600,QEMU 也以 libvirt-qemu 运行);qemu-img check无异常;没有 block job 在跑; - 检查 libvirt 动态生成的 AppArmor 文件清单:
cat /etc/apparmor.d/libvirt/libvirt-<uuid>.files
"/var/lib/libvirt/images/debian13.2026-10-09T08:13" rk,
# don't audit writes to readonly files
deny "/var/lib/libvirt/images/debian13.2026-10-09T08:13" w,
- 结论:AppArmor 规则把只读 backing 层的写权限 deny 了,而 block-commit 恰好要写进这一层;
- 关键细节:AppArmor 的 deny 规则优先级高于 allow。文件后面虽然还有
rwk允许规则,也无法覆盖前面的 deny。
4.3 这是已知 bug,不是你的链坏了
- Debian bug #1093955、#1061725 记录的都是同一现象;
- Ubuntu 有一个补丁
virt-aa-helper: Ask for no deny rule for readonly disks,Debian trixie 的 libvirt 11.3 尚未包含; - 和文件名无关:
debian13.qcow2、debian13.snap-20260625-17这些不带冒号的普通名字,同样带着deny ... w; - 症状在各种发行版/文件系统上都有人报告(Gentoo 论坛也有同款
Could not open ...: Permission denied)。
4.4 profile 是怎么来的
/etc/apparmor.d/libvirt/libvirt-<uuid>:由 libvirt 从TEMPLATE.qemu复制生成的每 VM profile,用 UUID 命名(UUID 是稳定唯一标识,改名不影响沙箱绑定);/etc/apparmor.d/libvirt/libvirt-<uuid>.files:virt-aa-helper按 domain XML 动态生成,VM 启动/磁盘变化时重建,头部写着DO NOT EDIT ... MANAGED BY LIBVIRT;- 问题就出在这个自动生成的 deny 规则上。
4.5 临时绕过
在删除操作前临时卸载该 VM 的 profile,操作完再装回:
UUID=$(virsh domuuid debian13)
# 1) 确认 profile 已加载
sudo aa-status | grep "$UUID"
# 2) 卸载(等于临时解除这台 VM 的 AppArmor 限制)
sudo apparmor_parser -R /etc/apparmor.d/libvirt/libvirt-"$UUID"
# 3) ...执行快照删除...
# 4) 恢复
sudo apparmor_parser -r /etc/apparmor.d/libvirt/libvirt-"$UUID"
注意事项:
aa-complain(complain 模式)没用——deny 规则在 complain 下依然生效;apparmor_parser在apparmor包里;也可以用apt install apparmor-utils后改用aa-disable/aa-enforce(aa-disable 还会建 disable 符号链接,防止重启后自动加载);- 恢复 profile 后,正在运行的 QEMU 进程要等 VM 下次重启才会重新被限制(profile 只在 exec 时挂载);
- 不推荐的做法:改
/etc/libvirt/qemu.conf把security_driver设为"none"——那是整体关闭安全驱动,还要重启 VM。
五、完整操作记录(Runbook)
针对"保留两个纯磁盘快照、删掉含内存的快照"的实际步骤:
# 0. 检查
sudo virsh list
sudo virsh snapshot-list --domain debian13 --topological --parent
sudo virsh domjobinfo debian13 # 应为"任务类型:无"
df -h /var
# 1. 临时卸载 AppArmor profile
UUID=$(virsh domuuid debian13)
sudo apparmor_parser -R /etc/apparmor.d/libvirt/libvirt-"$UUID"
# 2. 按顺序删除(每条等它跑完;另开终端可观察合并进度)
# 先删带遗留标记那条,成功后会清掉其父快照上的 snapshotDeleteInProgress
sudo virsh snapshot-delete --domain debian13 2026-10-09T09:37
sudo virsh snapshot-delete --domain debian13 2026-10-09T08:13
sudo virsh snapshot-delete --domain debian13 snap-20260625-17
# 进度观察:
# watch -n 2 'sudo virsh blockjob debian13 vda --info'
# 3. 恢复 AppArmor
sudo apparmor_parser -r /etc/apparmor.d/libvirt/libvirt-"$UUID"
# 4. 验收
sudo virsh snapshot-list --domain debian13 --topological --parent
sudo virsh domblklist debian13 --details
ls -lh /var/lib/libvirt/images/ | grep debian13 # 对应的 .save 应消失
如何查看删除(block-commit)进度
snapshot-delete 把合并任务提交给后台后,用 blockjob --info 看进度(建议配 watch 刷新):
# 空闲时
sudo virsh blockjob debian13 vda --info
# 没有 vda 的当前块作业
# 合并进行中(实时百分比)
watch -n 2 'sudo virsh blockjob debian13 vda --info'
# 块指派: [97.60 %]
由于任务跑在 libvirt/QEMU 后台,终端断开、被覆盖都不影响执行——重新开一个终端继续观察即可,千万不要 kill 进程。
除百分比外,还可以从"文件侧"确认任务在推进而不是卡死:
# 隔几秒分别执行,观察合并目标文件的大小/时间戳是否持续变化
stat -c '%n size=%s mtime=%y' /var/lib/libvirt/images/debian13.qcow2
# size=207242067968 mtime=2026-10-09 21:00:43
# 6 秒后再看:
stat -c '%n size=%s mtime=%y' /var/lib/libvirt/images/debian13.qcow2
# size=207779004416 mtime=2026-10-09 21:00:50
百分比 + 文件增长速度可以粗略估算剩余时间(本例最后删除的那层合并耗时约 38 分钟,最后 2.4% 在几分钟内跑完)。
完成的信号(三个都查一遍最稳):
# 1) blockjob 回到"无作业"
sudo virsh blockjob debian13 vda --info # 没有 vda 的当前块作业
# 2) 快照元数据里被删的快照消失
sudo virsh snapshot-list --domain debian13 --topological --parent
# 3) 被删层的 qcow2 文件消失、活动链收敛
ls -lh /var/lib/libvirt/images/ | grep debian13
两个小坑:
domjobinfo在块作业进行中不一定可用:本例中它报error: 内部错误:无效的作业统计类型,所以进度以blockjob --info为准;ps里能看到sudo/virsh进程只能说明"客户端还在等",真正的判据是上面 1)~3)。
要点:
- 先删"链上有未完成删除标记"的那条(顺序不能反,否则父快照会被标记拒绝);
- 每条
snapshot-delete是在线 block-commit,按数据量可能要几分钟到几十分钟,不要 Ctrl-C; - 如果删除又报 Permission denied:
sudo aa-status | grep "$UUID"看看 profile 是否被重新加载,再卸载一次重试。
六、为什么这么慢:block-commit 的性能账
实际清理中,最后删除 snap-20260625-17 这一步耗时约 38 分钟(20:23 → 21:02)。看起来"只有百来 G",为什么会花这么久?把账算清楚就不奇怪了。
6.1 实际搬运量比"表观大小"大
- 最后删除的
debian13.snap-20260625-17表观大小 176 GiB(≈189GB); - 删除是级联的:先删的
snap-20260916-10不是凭空消失,而是被合并进了下一层(即 6/25 这层)——实测中 6/25 文件从 130G 涨到 176G 就是证据。所以最后一步搬的是"多轮合并后"的整坨数据; - 表观大小 ≠ 实际拷贝量:
ls -l是文件长度,实际占用要看qemu-img info的disk size(allocated)。
6.2 实测速率只有 ~90MB/s
观察合并目标文件(base)的增长:
stat -c '%n size=%s mtime=%y' /var/lib/libvirt/images/debian13.qcow2
# 21:00:43 size=207242067968
# 21:00:50 size=207779004416
# 6.2 秒增长 537MB ≈ 87MB/s
对账:176GiB ≈ 189GB ÷ ~87MB/s ≈ 37 分钟,与 20:23 → 21:02 的实测吻合。
6.3 为什么 NVMe 上也慢
block-commit 不是顺序拷贝:
- 簇级随机 I/O:按 64KiB 簇从上层读、判断 backing 层有没有、再写入合并目标——本质是随机小 IO;
- qcow2 元数据写放大:写一个数据簇还要分配簇、更新 L2 表、更新 refcount 块,元数据自身也是 CoW;
- 碎片:base 是 150G+ 的大文件,经历多轮快照/合并后碎片化严重,分配更慢;
- 单线程 block 层:block job 与 guest I/O 在同一 aio 上下文里协作调度,并会刻意不拖慢 guest;
- 在线执行:VM 一直在跑,磁盘 I/O 会互相竞争。
结论:慢是慢在机制,不是卡住——进度一直在走(最后观察到 97.60%),只是 qcow2 的随机 I/O + 元数据开销把有效速率压到了百 MB/s 量级。
6.4 下次如何更快
| 做法 | 说明 |
|---|---|
| 关机后删除/合并(离线 commit) | 不与 guest I/O 竞争,通常明显更快 |
关机后用 qemu-img convert 生成独立镜像 | 顺序写为主,比随机 commit 快;但需重建/重挂磁盘 |
| 别攒链:每次只合 1~2 层 | 单次数据量小、碎片少,避免"最后一口吃 200G" |
| 避开 VM 高负载时段 | 减少 I/O 争抢 |
预估数据量看 qemu-img info 的 disk size | 按实际占用而非表观大小估算时间 |
virsh blockjob <dom> <disk> --raw | 输出原始进度详情(字节数),便于精确估算 |
七、qemu-img / blockcommit 速查
6.1 建链与看链
qemu-img create -f qcow2 -b base.qcow2 -F qcow2 overlay.qcow2
qemu-img info --backing-chain overlay.qcow2
virsh domblklist debian13 --details
6.2 libvirt 层面的合并(推荐,可在线)
# blockcommit:把 top 层合并到 base 层(自下而上地缩短链)
virsh blockcommit debian13 vda --base /path/sn1.qcow2 --top /path/sn3.qcow2 --wait --verbose
# 把活动层合并进 base 并切换(等价于删"当前快照"的合并动作)
virsh blockcommit debian13 vda --active --pivot --wait --verbose
# blockpull:把 backing 链的数据合并进活动层(反向)
virsh blockpull debian13 vda --base /path/sn1.qcow2 --wait --verbose
6.3 qemu-img 层面的操作(停机时用,注意元数据同步)
# 只改 backing 指针、不搬数据(unsafe mode,危险:必须确认指向的确实是对应数据)
qemu-img rebase -u -b /path/sn1.qcow2 /path/sn3.qcow2
# 默认 rebase:会把数据一起搬过去再改指针
qemu-img rebase -b /path/sn1.qcow2 /path/sn3.qcow2
# ⚠️ 危险:把当前层数据写回 backing 文件,会污染 base 镜像
qemu-img commit /path/vm-disk.qcow2
# 去掉 backing,把 backing 数据并入当前文件
qemu-img rebase -b "" /path/vm-disk.qcow2
# 生成不带 backing 的独立镜像(推荐用于迁移/彻底拆链)
qemu-img convert -f qcow2 -O qcow2 vm-disk.qcow2 vm-disk-standalone.qcow2
注意:VM 由 libvirt 管理时,优先用 virsh 的 blockcommit/blockpull/snapshot-delete——它们会同步更新 libvirt 元数据。手动 qemu-img 操作会让宿主机上的链与 libvirt 记录不一致,之后再用快照/删除功能容易出错。
八、经验总结
- 快照链是串联结构:任何一环缺失,上层都打不开;永远不要
rm/mv链上的文件,删除只走virsh snapshot-delete。 - 删除快照 = 向下合并 + 重接链:当前数据和其它快照的还原点都不受影响,失去的只是被删快照的回滚能力。删当前快照多一次活动层 pivot。
- 对运行中的 VM 建磁盘快照必须
--disk-only --atomic:否则默认是内部全系统快照(暂停 VM、内存写进 qcow2、极慢)。 - 内存快照的
.save删不得:它决定 running 状态快照能否恢复;也不要--metadata式删除。 - "无法获取状态更改锁 / 操作超时" = 有并发操作:先
domjobinfo/snapshot-list看状态,别重复敲命令。 - Debian + AppArmor 的
deny ... w坑:snapshot-delete报Could not open '...': Permission denied时,查/etc/apparmor.d/libvirt/libvirt-<uuid>.files,临时apparmor_parser -R/-r绕过(complain 模式无效)。 - 删除失败的收尾:元数据里会出现
<snapshotDeleteInProgress/>,重新执行那次删除并跑完即可清除。 - 链别攒太长:及时合并(本文场景就是攒了 4~5 层后的一次清理),合并前预留充足磁盘空间。
- 查看删除/合并进度:
virsh blockjob <domain> <disk> --info看百分比;任务在后台执行,终端断开不影响,但别 kill 进程;判定完成的依据是"无 block job + 快照元数据消失 + 文件消失"。 - 大链合并很慢:实测有效速率约 90MB/s(簇级随机 IO + qcow2 元数据写放大),能关机就离线合并,或者别攒链、定期小批量清理。