背景

对业务虚拟机的系统盘做一个快照,每次开机之前或关机之后自动还原快照,就可以避免 Windows 异常之后虚拟机无法修复的情况。

需求分析

  • -snapshot 参数做不到只对 vda 加快照
  • 加了 -snapshot 参数的机器,如果要更新 vda 需要至少两次重启虚拟机(关机->解快照->开机->更新->关机->加快照->开机)
  • 尝试单纯使用 libvirt hooks 来实现,发现其有两个限制:
  • libvirt hooks 不能回调 libvirt API,会无限递归
  • 不像 VDSM hooks,libvirt hooks 无法修改 libvirt XML

潜在的解决方案有几种

  • 对 qemu-kvm 进行包装(wrap)替换原生的程序
  • 自行修改 libvirt 源代码,重新编译
  • 使用外部配置文件进行控制是否加快照,使用 libvirt hooks 进行快照自动还原

解决方案

最终选择最后一种方案,简单有效。

基本逻辑

  • 假设虚拟机有两个虚拟磁盘设备 vda 和 vdb,现在需要令 vda 自动还原快照,vdb 不做快照
  • 假设 vda 的虚拟磁盘文件为 os.img
  • 虚拟机开机之前,将 os.img 重命名为 os.img.backing_file (如果该文件已存在,则不进行重命名)
  • 以 os.img.backing_file 作为 backing_file 创建 os.img
  • 虚拟机开机,会根据其 libvirt xml 文件查找其虚拟磁盘文件,即 os.img
  • 虚拟机运行,增量内容写入到 os.img
  • 虚拟机关机,查询外部配置文件,是否要做「单次提交」
  • 如果需要,做「单次提交」并修改配置文件,将「单次提交」标记为否
  • 「提交」的意思是将一个 os.img 中的内容写入到其 backing_file 中
  • 将 os.img.backing_file 重命名回 os.img(完成对增量内容的覆盖)

示例说明

#!/usr/bin/env python3

import json
import logging
import shlex
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path


class Config:
    def __init__(self, configFile):
        self.configFile = configFile
        with open(self.configFile, "r") as f:
            self.config = json.load(f)
        self.disks = self.config["disks"]

    def report(self):
        """ Do a one-shot-commit, then change the config file. """
        with open(self.configFile, "w") as f:
            json.dump(self.config, f, indent=2)


def getHookDetails():
    raw_xml = "".join((sys.stdin.readlines()))
    logging.info(str(sys.argv))
    return raw_xml


def getFilePath(target, domXML):
    XML = ET.fromstring(domXML)
    pattern = f"./devices/disk/target[@dev='{target}']..[@device='disk']"
    disk = XML.find(pattern)
    diskXML = ET.tostring(disk).decode()
    target_path = disk.find("source").get("file")
    logging.debug(f"Disk XML Dump:\n{diskXML}")
    return target_path


class SnapshotAutoRevert:
    def __init__(self, domName, qcow2_file):
        """ Take qcow2 file path as argument, which will be auto reverted in every VM life cycle. """
        self.domName = domName
        self.qcow2_file = qcow2_file

    def renameToBackingFile(self):
        if not Path(f"{self.qcow2_file}.backing_file").is_file():
            Path(self.qcow2_file).rename(f"{self.qcow2_file}.backing_file")
            logging.info(f"{self.domName} Rename origin image to backing_file")

    def createSnapshot(self):
        logging.info(f"{self.domName} Create a snapshot based on origin image")
        cmd = (
            f"qemu-img create -f qcow2 {self.qcow2_file} "
            f"-b {self.qcow2_file}.backing_file -F qcow2"
        )
        args = shlex.split(cmd)
        with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
            logging.info(proc.stdout.read().decode())

    def commitSnapshot(self):
        logging.info(f"{self.domName} Commit the snapshot")
        cmd = f"qemu-img commit {self.qcow2_file}"
        args = shlex.split(cmd)
        with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
            logging.info(proc.stdout.read().decode())

    def revertSnapshot(self):
        Path(f"{self.qcow2_file}.backing_file").rename(self.qcow2_file)
        logging.info(f"{self.domName} Revert to origin image, delete snapshot")


def main():
    hookPath = Path(sys.argv[0])
    domName = sys.argv[1]
    domAction = sys.argv[2]
    configFile = Path.joinpath(hookPath.parent, "config", f"{domName}.json")
    if not configFile.exists():
        exit(0)
    vmConfig = Config(configFile)
    domXML = getHookDetails()
    logging.debug(f"Config before: {vmConfig.config}")

    index = -1
    reportFlag = False
    for disk in vmConfig.disks:
        index += 1
        logging.info(disk["target"])
        target_file = getFilePath(disk["target"], domXML)
        commitFlag = disk["one-shot-commit"]
        AutoRevert_target = SnapshotAutoRevert(domName, target_file)

        if domAction == "prepare":
            AutoRevert_target.renameToBackingFile()
            AutoRevert_target.createSnapshot()
        elif domAction == "release":
            if commitFlag:
                AutoRevert_target.commitSnapshot()
                vmConfig.config["disks"][index]["one-shot-commit"] = False
                reportFlag = True
            AutoRevert_target.revertSnapshot()

    if reportFlag:
        vmConfig.report()
        logging.debug(f"Config after: {vmConfig.config}")


if __name__ == "__main__":
    logging.basicConfig(
        filename="/var/log/libvirt/qemu/hooks.log",
        format="%(asctime)s %(message)s",
        level=logging.INFO,
    )

    main()

hook 需要命名为 /etc/libvirt/hooks/qemu,因此在有快照的机器上查看该文件即可读取源代码。

配置文件位置 /etc/libvirt/hooks/config/{vm_name}.json (每个虚拟机使用各自的配置文件)。

{
  "disks": [
    {
      "target": "vda",
      "one-shot-commit": true
    },
    {
      "target": "sdb",
      "one-shot-commit": false
    }
  ]
}

该配置文件的含义是对 {vm_name} 这个虚拟机设置针对 vda 和 sdb 快照自动还原,当该虚拟机的此次生命周期结束之后,对其 vda 进行「单次提交」(维护操作),提交之后该配置文件会被更新为如下内容:

{
  "disks": [
    {
      "target": "vda",
      "one-shot-commit": false
    },
    {
      "target": "sdb",
      "one-shot-commit": false
    }
  ]
}

在一个典型的业务宿主机上,常见的配置文件如下所示:

{
  "disks": [
    {
      "target": "vda",
      "one-shot-commit": false
    }
  ]
}

意思就是「只针对 vda 做快照自动还原,正常工作,不做单次提交」,没有 vdb 或 sdb,就不会对这些虚拟磁盘做快照。

如果找不到虚拟机的配置文件,就不会对虚拟机做快照。

如果配置文件是非法的 json 格式,该虚拟机不会启动。

相关日志会输出到 /var/log/libvirt/qemu/hooks.log

想查看一台虚拟机是否运行在「快照自动还原」模式下,可以在虚拟机运行过程中执行 virsh dumpxml vm_name | grep backing_file ,如果有结果,就代表该机器当前运行在「快照自动还原」模式下。此时,如果想使虚拟机中的更新内容固化下来,可以直接去修改其 hook 配置文件 /etc/libvirt/hooks/config/{vm_name}.json,将 "one-shot-commit" 改为 true 即可。虚拟机生命周期结束时,会由 hook 脚本来执行「单次提交」并自动将 "one-shot-commit" 改为 false