virt-report虚拟化社区动态

QEMU 外部快照与在线块操作

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、在线块操作与磁盘视图管理

一台虚拟机持续写盘时,管理程序可能需要留下快照、合并增长的镜像链,或者把磁盘搬到另一个存储。直接复制正在变化的镜像会混合不同时刻的数据;直接改 backing 关系,又可能让同一偏移突然读到不同内容。QEMU 块层需要在客户机继续 I/O 的同时保持其可见数据一致,再安全地改变节点关系。

本文以 A ← B 为起点:A 是基础镜像,B 的 backing 指向 A,客户机当前写 B。沿外部快照、stream、commit、mirror 与 backup 比较数据去向和完成条件。qcow2 的地址映射与写时复制解释单个镜像内部,本篇分析多个块节点组成的链和在线作业。

块图保存在宿主 QEMU 进程中。客户机看到的 /dev/vda 可以连接到 QEMU 的 virtio-blk 设备,设备再通过块后端访问活动节点 node-B;节点背后才是宿主上的 b.qcow2 文件。客户机设备名、QMP 节点名和宿主文件路径分别由不同层使用,在线切换节点可以保持客户机磁盘身份不变。

操作目标采用的机制最重要的区别
保留当前磁盘视图,后续写入另存外部快照切到新 overlay;历史视图仍依赖旧链
消除活动层对下层的依赖stream把所需数据向上填入,不修改旧基础层
把上层内容合入基础层commit向下写入,会改变基础层及依赖它的历史视图
把当前磁盘搬到新存储mirror持续追赶新写入,确认完成后切换活动入口
保存作业开始时的副本backup保护时间点内容,客户机仍使用原盘

源码版本:QEMU 10.2.0。

二、外部快照的时间点与新写入层

给 B 做外部快照时,QEMU 接入新 overlay C,把客户机后续写入转到 C,形成 A ← B ← C。C 未覆盖的偏移仍经 B 向下读取。快照时刻的磁盘视图由 A + B 共同组成;新建 C 保存此后的变化。单独留下一个初始为空的 C,不能恢复快照时刻的数据。

外部快照前后的 backing 链与数据视图
初始活动层 B 读取基础镜像 A;外部快照后活动层切到 C,A 与 B 构成快照时刻的磁盘视图。C 初始未分配同一偏移,读取继续落到 B;客户机新写入只改变 C 中的当前视图。
初始活动层 B 读取基础镜像 A;外部快照后活动层切到 C,A 与 B 构成快照时刻的磁盘视图。C 初始未分配同一偏移,读取继续落到 B;客户机新写入只改变 C 中的当前视图。 打开原图
Mermaid 源码
flowchart TB
  accTitle: 外部快照前后的 backing 链与数据视图
  accDescr: 快照前客户机入口是 B,B 的 backing 是 A。快照后入口切到 C,C 的 backing 是 B,B 仍指向 A。十六进制偏移 0x1000 在 A 中为 v1、B 中为 v2,新 C 最初未分配,后来客户机写为 v3。A 加 B 保留快照时刻视图,A 加 B 加 C 表示当前视图。
  subgraph before[快照前]
    direction LR
    G0[客户机 I/O] --> B0["B:活动层<br/>0x1000 = v2"]
    B0 -->|backing| A0["A:基础镜像<br/>0x1000 = v1"]
  end
  subgraph after[快照后]
    direction LR
    G1[客户机 I/O] --> C1["C:新活动层<br/>未分配 → 写入 v3"]
    C1 -->|backing| B1["B:保留 v2"]
    B1 -->|backing| A1["A:保留 v1"]
  end
  before -->|接入新 overlay| after
  B1 -.-> S[快照时刻视图:A + B]
  C1 -.-> N[当前视图:A + B + C]
时刻偏移 0x1000 的内容客户机读到什么
快照前A 中为 v1,B 中为 v2从活动层 B 读到 v2
刚接入 CC 尚未分配该区域沿 C → B 读到 v2;快照视图仍是 A + B
客户机写入 v3C 中为 v3,B 仍为 v2当前读到 v3;从保留的 A + B 视图读到 v2

这是磁盘视图的时间边界,RAM 和应用事务并未一起保存。多盘原子操作需要组合事务,应用一致性还需要客户机文件系统冻结、数据库检查点等协调。快照链依赖原有文件,宿主存储损坏时链上多个时间点可能一起丢失,因此仍要把备份副本与活动存储分开。

以下 QMP 命令发送到承载测试磁盘的 QEMU,要求已完成能力协商,已存在活动节点 node-B,其 backing 为 node-A;/path/to/test/c.qcow2 是预先创建、未被其他进程使用、与 B 虚拟容量相同的空 qcow2 文件。这些操作会修改块图,各节应分别从满足条件的测试链开始:

{"execute":"blockdev-add","arguments":{
  "driver":"qcow2","node-name":"node-C","backing":null,
  "file":{"driver":"file","filename":"/path/to/test/c.qcow2"}
}}
{"execute":"blockdev-snapshot","arguments":{"node":"node-B","overlay":"node-C"}}

blockdev-add 打开已存在的文件并建立节点,不负责创建镜像内容。backing:null 让 C 先以无 backing 的节点接入,再由 snapshot 命令设置 B 并替换客户机入口。接入前文件必须为空且没有需保留的数据。overlay 前置条件、snapshot 接口

还要区分运行时块图与镜像头部:这里的 blockdev-snapshot 通过 bdrv_append() 接入既有节点,并不会自动把 B 的路径写进预创建 C 的文件头。若 C 没有正确的 backing 元数据,下次仅按文件名打开 C 就会丢失这层依赖。管理程序必须保存完整的启动块图,或预先准备正确的镜像 backing 元数据;后者仍可用 backing:null 打开,再接到已有 B 节点。创建镜像与接入节点的两条分支

三、stream 把下层仍需读取的数据补入上层

以下几种操作分别以快照后的 A ← B ← C 为起点,比较不同管理目标,彼此不接续执行。stream 的目标是让当前磁盘独立于历史链:把 A、B 中仍被 C 引用的数据复制进 C,完成后解除 C 的 backing。客户机原本就写 C,复制过程只填充缺失的逻辑区域,不能用下层旧数据覆盖客户机已经写入的 v3。

{"execute":"block-stream","arguments":{
  "job-id":"flatten-C","device":"node-C","auto-dismiss":false
}}

这个命令没有指定 base,目标是把 C 变成独立镜像。若增加 "base-node":"node-A",则只补入需要从 B 读取的部分,成功后成为 A ← C;A 本身的内容不在本次复制范围内。两种目标对应不同的存储占用和依赖关系。

对已经在 C 写成 v3 的偏移 0x1000,stream 保留 C 中的内容;只有 C 尚未覆盖的区域才需要向下查找并补入。完成前后客户机读到的内容相同,改变的是这些内容还依赖哪些下层节点。

StreamBlockJob 保存目标节点、要保留的 base 边界和 copy-on-read 过滤节点。stream_run() 先检查当前层是否已经分配,再查下层是否存在需要带入的数据,通过 copy-on-read 填充。作业成功进入收尾阶段后,stream_prepare() 排空相关 I/O,修改块图和持久化 backing 信息。结构与改链、复制循环

收益是减少链依赖,同时保留 A、B 的原内容;代价是 C 分配更多空间,并与客户机争用读写带宽。复制完成不会自动删除旧文件,其他快照或虚拟机仍可能依赖它们。

四、commit 向下合并与历史内容更新

仍从未经 stream 的 A ← B ← C 出发,commit 选择相反的数据流向:把 B 的有效数据写进 A,随后让 C 直接引用 A,得到 A′ ← C。在偏移 0x1000,A 从 v1 改为 v2;C 仍保存 v3,当前客户机视图保持 v3,但原来只含 A 的历史视图已经改变。中间镜像及依赖旧 A 的其他分支也可能失去原语义,不能把 base 当作可随意覆盖的公共模板。

{"execute":"block-commit","arguments":{
  "job-id":"merge-B","device":"node-C",
  "top-node":"node-B","base-node":"node-A","auto-dismiss":false
}}

此例的 top 是非活动层 B,客户机仍写 C;A 必须允许作业取得写权限,且不存在需要保持原内容的其他使用者。CommitBlockJob 记录 top、base 和被冻结的链范围,按 top 的逻辑视图读取,在 base 中写数据或显式零。成功收尾用 bdrv_drop_intermediate() 跳过已合并的中间节点。结构与收尾、数据与零区间复制

若 top 改为活动层 C,客户机仍会不断写它,复制一遍无法完成交接。这种 active commit 使用 mirror 路径持续同步到 base,进入 READY 后等待显式完成,再把客户机入口切到 base。其关键成本包括同步写入、改链时的 I/O 排空,以及 base 已被修改后失去原有历史视图。普通 commit 与 active commit 分流

五、mirror 增量追赶与活动磁盘切换

stream 和 commit 调整原有链的依赖;迁往另一个存储则要建立新的活动入口。这里重新以 A ← B ← C 为源链,准备目标节点 T,将当前 A + B + C 的完整逻辑视图复制过去,并追踪复制期间的写入。sync:full 复制完整可见磁盘,sync:top 与 none 则有不同的下层依赖,不能拿局部同步目标当作独立完整磁盘。

假设已通过 blockdev-add 打开一个可写、容量匹配、没有其他使用者也未连接客户机的目标 node-T,并正确配置其底层文件,发起独立的 mirror 作业:

{"execute":"blockdev-mirror","arguments":{
  "job-id":"move-disk","device":"node-C","target":"node-T",
  "sync":"full","auto-dismiss":false
}}
{"execute":"query-block-jobs"}
{"execute":"query-jobs"}

后台模式在块图中放入 mirror 过滤节点,使用脏位图追踪还需同步的范围。假设 T 已复制了偏移 0x1000 的 v3,客户机又在 C 写入 v4,这个范围就需要重新同步。位图保存待处理范围,在途请求与区间冲突管理协调同时发生的写入。第一次清空待同步数据并 flush 后,作业报告 READY;此后仍继续追踪写入,客户机入口此时还在 C。追赶、READY 与最终排空

mirror 从追赶到活动盘切换
RUNNING 复制当前视图并追踪写入;READY 表示可以请求完成,但客户机仍使用原盘。job-complete 后排空 I/O、再次确认同步并 flush,成功改图后客户机改用目标。auto-dismiss=false 保留终态供查询,确认后才 dismiss。
RUNNING 复制当前视图并追踪写入;READY 表示可以请求完成,但客户机仍使用原盘。job-complete 后排空 I/O、再次确认同步并 flush,成功改图后客户机改用目标。auto-dismiss=false 保留终态供查询,确认后才 dismiss。 打开原图
Mermaid 源码
flowchart TB
  accTitle: mirror 从追赶到活动盘切换
  accDescr: RUNNING 复制源盘并跟踪新增写入,第一次同步完成进入 READY,客户机仍使用原盘。管理程序请求 job-complete,QEMU 排空 I/O、复查同步和 flush,在成功收尾时替换活动节点。auto-dismiss 为 false 时保留 CONCLUDED 供查询;确认无 error 且活动图正确,再 dismiss。
  R["RUNNING<br/>复制与脏位图追赶"] --> E["READY<br/>继续跟踪写入,客户机仍用 C"]
  E -->|job-complete| D[排空 I/O,复查脏数据,flush]
  D -->|成功收尾,auto-finalize 为 true| P["pivot<br/>客户机入口 C → T"]
  P --> C["CONCLUDED<br/>检查 error 与实际块图"]
  C -->|确认后 job-dismiss| X[清理作业记录]
  R -->|错误或取消| A[收束复制,保留原盘入口]
  E -->|取消,不切换入口| A
  A --> C

观察到 READY 后,再执行下面的请求。这里保留默认 auto-finalize:true,由 QEMU 自动执行收尾改图;auto-dismiss:false 只让已结束作业继续可查询:

{"execute":"job-complete","arguments":{"id":"move-disk"}}
{"execute":"query-jobs"}
{"execute":"query-block"}
{"execute":"query-named-block-nodes"}

完成路径会排空相关 I/O,再次检查脏数据并 flush,最后用 bdrv_replace_node() 改变入口,通常称为 pivot。此后新写入落到 T,旧链不再跟随更新。QMP 命令返回与完成事件没有固定先后顺序;应核对作业终态和活动节点,不能只看到一次进度 100% 就删除旧盘。节点替换、完成接口

六、backup 与启动时刻的数据视图

mirror 的目标持续追赶源盘,backup 则固定一个时间点。这是与 mirror 并列的另一个场景,客户机仍使用活动层 C,没有发生向 T 的切换。观察偏移 0x1000:若 full backup 启动时 C 中为 v3,客户机随后要写 v4,而后台还没复制该位置,就先通过 copy-before-write 把 v3 保存到备份目标,再放行覆盖。完成后活动盘读到 v4,备份视图仍读到 v3;mirror 若完成切换,目标则需要包含最终同步后的 v4。

操作目标内容客户机入口
外部快照旧链保存切换时视图,新 overlay 保存后续变化立即改到新 overlay
mirror持续追赶,完成切换时与源视图一致READY 时不变,显式完成后切换到目标
backup普通 full 模式保持作业启动时的视图留在原盘,不因备份完成而切换

backup_job_create() 接入 copy-before-write 过滤器,后台拷贝与写前保护共用复制状态。增量备份另由 dirty bitmap 限定范围,还需要保留完整基线与位图继承关系。每份增量文件独立存在,不等于每份都能独立恢复。备份过滤器与作业创建

七、操作完成确认与可恢复数据保留

query-jobs 的 CONCLUDED 表示作业已经结束,仍需检查 error:没有该字段才代表成功。默认自动 dismiss 会让已完成或失败的作业消失,所以“列表里没有 job”无法区分成功、失败、已取消或根本没启动。保留终态时,核对完成事件、错误与实际块图后,再调用 job-dismiss 清理记录。

情况需要保留什么容易误判的地方
暂停或 I/O 错误原活动盘、作业依赖的上下层节点及错误记录PAUSED / STANDBY 仍是活作业;修正空间或后端问题后再决定恢复或取消
stream 中途取消未完成复制所需的 backing 链部分数据已进入上层,但通常尚未解除 backing;取消与自然完成也可能竞态
commit 失败或取消原活动链及所有相关文件,核对 base 的实际变化已写入 base 的数据不会自动撤销,历史分支不能按原样复用
mirror 切换前取消原盘仍承担客户机 I/OREADY 后用 block-job-cancel 且 force:false 可收束为时点副本,仍不会 pivot;强制取消不保证目标完整
连接丢失或进程异常管理日志、镜像文件、持久配置与可用旧链重新连接后先核对作业及实际图;故障可能发生在数据复制与元数据更新之间

两个取消接口的默认语义不同:本版本 job-cancel 直接按 force=true 请求取消;block-job-cancel 可以指定 force:false,且省略时默认为 false。需要在 READY 后保留最终同步副本时,应使用后者并检查实际结果;任何取消请求都可能与自然完成竞态。job-cancel 实现、block-job-cancel 语义

auto-finalize:false 可以让支持的作业停在 PENDING,等待 job-finalize 处理改图与收尾。这个阶段仍可能失败;它也不会撤回 commit 已对 base 做过的写入。源码将 run、prepare、abort、clean 分开,既协调 I/O 和权限,也暴露了数据修改与最终图变更并非一次可任意撤销的事务。作业状态、commit 取消边界

这些示例分别代表不同目标,应从各自满足前置条件的测试链开始,不能按页面顺序连续执行。Libvirt 管理的虚拟机还需同步持久定义、快照元数据和存储权限,宜通过相应管理 API 操作;绕过管理层直接改块图,可能使下一次启动仍指向旧链。

八、从文件路径到块图与作业生命周期

QEMU 1.1 提供 block-stream,1.3 提供 block-commit,2.0 扩展到活动层提交。随后 blockdev 接口将节点准备与操作分开,3.1 的 commit 支持 base-node、top-node,减少按路径字符串查找节点的歧义。统一 job 接口与 finalize、dismiss 选项,使管理程序能保留结果、协调收尾;新增能力也要求更明确的状态管理。

选择操作时先明确要保留的视图和最终入口:想独立保留当前盘,可考虑 stream;愿意改变下层内容来缩链,可考虑 commit;更换活动存储使用 mirror;保留特定时刻的副本使用 backup。再比较复制量、写放大、后端带宽和失败后仍可用的链。仅按“哪种合并更快”选择,很容易忽略历史数据与恢复条件。

九、参考资料