libvirt 外部快照删除:从 QCOW2 链到 AppArmor 权限陷阱

十月 09, 2026 [虚拟化与容器] #libvirt #qemu #kvm #安全 #调试 #debian

背景

清理一台 Debian 13 宿主机上 debian13 虚拟机的快照链:它积累了 4~5 层外部快照(含两个带内存的完整快照),目标是合并到只保留两个纯磁盘快照。

过程中踩了两个坑:

  1. 对运行中的 VM 误建了内部快照(不带 --disk-only),VM 被暂停、24G 内存写进了 qcow2,并发命令撞上域锁超时;
  2. 删除外部快照时报 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

要点:

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 用快照名命名(文档:把原文件名最后一个点之后的部分替换为快照名)。

所以:

这解释了为什么删除快照时"数据不会丢":删除做的是把文件里的增量合并进下层,链上的数据总和不变。

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'/>

二、删除快照时到底发生了什么

virsh snapshot-delete 不是删文件,而是先做在线 block-commit:把被删层已分配的数据块合并进它的 backing 层,重接上层指针,最后才删除文件与元数据。

2.1 删中间层(非当前快照)

例如删除 snap-20260625-17:

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, ...);
}

“活动层被吸收进下一层”的含义:

  1. QEMU 把活动层里已分配的数据复制进它的 backing 层(合并目标永远是链上更老的那一层);
  2. 复制完成后做一次 graph pivot:把"活动层"指针从旧文件切到 backing 层,之后新写入落在新的活动层上;
  3. 再删掉旧活动层文件和它的 .save,current 指针回到父快照。

区别:

删中间层删当前层
合并方向向下一层向下一层
活动层不动commit 完成后 pivot 到下一层
VM 感受全程无感commit 期间照常运行,最后切换有毫秒级 I/O 停顿
数据总量不变不变

所以删除顺序无所谓,最终链的内容一致;每个快照失去的只是"回到那个时间点"的能力。

2.3 删除的外部约束

libvirt 11.3 对删除外部快照的校验(qemuSnapshotDeleteValidate):

另外:运行中的 VM 即使文件被误删,QEMU 手里还持有 fd,可能暂时无感;但重启或重开文件时就再也打不开了——永远不要手动 rm 链上的文件。

三、坑一:不带 --disk-only 的内部快照 + 域锁超时

3.1 现象

error: 操作超时:无法获取状态更改锁(由 monitor=remoteDispatchDomainSnapshotCreateXML 持有)

3.2 原因

对运行中的 VM,不带 --disk-only 的 snapshot-create-as 默认创建内部全系统快照:

验证方式:

virsh snapshot-info debian13 <name>       # 位置:内部
virsh snapshot-dumpxml debian13 <name>    # <memory snapshot='internal'/>、<disk snapshot='internal'/>

3.3 处理与经验

四、坑二: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 排查过程

  1. 排除常见原因:文件属主和权限正常(libvirt-qemu:libvirt-qemu 0600,QEMU 也以 libvirt-qemu 运行);qemu-img check 无异常;没有 block job 在跑;
  2. 检查 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,
  1. 结论:AppArmor 规则把只读 backing 层的写权限 deny 了,而 block-commit 恰好要写进这一层;
  2. 关键细节:AppArmor 的 deny 规则优先级高于 allow。文件后面虽然还有 rwk 允许规则,也无法覆盖前面的 deny。

4.3 这是已知 bug,不是你的链坏了

4.4 profile 是怎么来的

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"

注意事项:

五、完整操作记录(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

两个小坑:

要点:

六、为什么这么慢:block-commit 的性能账

实际清理中,最后删除 snap-20260625-17 这一步耗时约 38 分钟(20:23 → 21:02)。看起来"只有百来 G",为什么会花这么久?把账算清楚就不奇怪了。

6.1 实际搬运量比"表观大小"大

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 不是顺序拷贝:

  1. 簇级随机 I/O:按 64KiB 簇从上层读、判断 backing 层有没有、再写入合并目标——本质是随机小 IO;
  2. qcow2 元数据写放大:写一个数据簇还要分配簇、更新 L2 表、更新 refcount 块,元数据自身也是 CoW;
  3. 碎片:base 是 150G+ 的大文件,经历多轮快照/合并后碎片化严重,分配更慢;
  4. 单线程 block 层:block job 与 guest I/O 在同一 aio 上下文里协作调度,并会刻意不拖慢 guest;
  5. 在线执行: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 记录不一致,之后再用快照/删除功能容易出错。

八、经验总结

  1. 快照链是串联结构:任何一环缺失,上层都打不开;永远不要 rm/mv 链上的文件,删除只走 virsh snapshot-delete。
  2. 删除快照 = 向下合并 + 重接链:当前数据和其它快照的还原点都不受影响,失去的只是被删快照的回滚能力。删当前快照多一次活动层 pivot。
  3. 对运行中的 VM 建磁盘快照必须 --disk-only --atomic:否则默认是内部全系统快照(暂停 VM、内存写进 qcow2、极慢)。
  4. 内存快照的 .save 删不得:它决定 running 状态快照能否恢复;也不要 --metadata 式删除。
  5. "无法获取状态更改锁 / 操作超时" = 有并发操作:先 domjobinfo/snapshot-list 看状态,别重复敲命令。
  6. Debian + AppArmor 的 deny ... w 坑:snapshot-delete 报 Could not open '...': Permission denied 时,查 /etc/apparmor.d/libvirt/libvirt-<uuid>.files,临时 apparmor_parser -R / -r 绕过(complain 模式无效)。
  7. 删除失败的收尾:元数据里会出现 <snapshotDeleteInProgress/>,重新执行那次删除并跑完即可清除。
  8. 链别攒太长:及时合并(本文场景就是攒了 4~5 层后的一次清理),合并前预留充足磁盘空间。
  9. 查看删除/合并进度:virsh blockjob <domain> <disk> --info 看百分比;任务在后台执行,终端断开不影响,但别 kill 进程;判定完成的依据是"无 block job + 快照元数据消失 + 文件消失"。
  10. 大链合并很慢:实测有效速率约 90MB/s(簇级随机 IO + qcow2 元数据写放大),能关机就离线合并,或者别攒链、定期小批量清理。