1. 项目缘起与整体设计思路
1.1 为什么选择 libvirt 而不是直接调 virsh
很多刚接触 KVM 虚拟化的朋友,第一反应是用virsh命令行工具来管理虚拟机。启动、关闭、查看状态,敲几条命令确实方便。但当你需要批量管理几十台甚至上百台虚拟机,或者要把虚拟机的生命周期管理集成到自己的运维平台、自动化脚本里时,virsh的局限性就暴露出来了:解析文本输出不稳定、错误处理麻烦、并发控制困难。
libvirt是一套开源的虚拟化管理 API,它抽象了底层不同的虚拟化技术(KVM、Xen、LXC 等),对外提供统一的编程接口。Python 生态里有libvirt-python这个官方绑定库,可以直接调用 libvirt 的 C 接口,性能好、功能全。相比virsh,用 Python 调 libvirt 有几个明显优势:
- 结构化返回:拿到的是对象和字典,不用去正则匹配命令行输出。
- 异常机制:出错时抛
libvirt.libvirtError,可以精确捕获和处理。 - 事务与并发:可以在一个连接里做多步操作,配合线程池做并发管理。
- 可编程性:能根据业务逻辑动态决定创建、迁移、销毁等动作。
我当初做这个项目的背景是:手头有一批测试机需要频繁地创建、快照、回滚、销毁,用virsh写 shell 脚本越写越乱,后来干脆用 Python 重写了一套管理模块,稳定性和可维护性都上了一个台阶。下面就把这套东西的完整思路和实操细节拆开讲。
1.2 整体架构分层
这套管理模块我分成了三层,逻辑上比较清晰:
- 连接层:负责与 libvirt 守护进程建立连接,处理认证、超时、重连。这一层封装成一个单例或者连接池,避免每次操作都重新连接。
- 资源层:把虚拟机、存储池、网络、快照等抽象成 Python 类,每个类封装对应的 libvirt 对象操作。
- 业务层:面向具体场景的编排,比如“创建一台带指定磁盘和网络的虚拟机”“批量快照并保留最近 N 个”等。
这样分层的好处是,底层连接和资源操作可以复用,业务层只关心流程。后面我会按这个结构逐一展开。
1.3 环境依赖与版本选择
在动手之前,先把环境理清楚。我用的组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| 操作系统 | 主流 Linux 发行版 | 内核需支持 KVM |
| libvirt | 6.x 及以上 | 提供稳定的 API |
| Python | 3.8+ | 语法和库支持较好 |
| libvirt-python | 与 libvirt 版本匹配 | 通过 pip 或系统包安装 |
安装libvirt-python时有个坑:它需要编译,依赖libvirt-devel(或libvirt-dev)头文件。如果直接用 pip 装报错,先装开发包:
# Debian/Ubuntu 系 sudo apt-get install -y libvirt-dev pkg-config # RHEL/CentOS 系 sudo yum install -y libvirt-devel pip install libvirt-python注意:pip 安装的 libvirt-python 版本要和系统 libvirt 版本大致匹配,版本差太多可能出现符号找不到的问题。稳妥做法是用系统包管理器装
python3-libvirt。
2. 连接层:稳定地与 libvirt 对话
2.1 建立连接的几种方式
libvirt 支持多种连接 URI,常见的有:
qemu:///system:连接本机系统级 libvirt,管理需要 root 权限的虚拟机。qemu:///session:连接当前用户的会话级 libvirt,权限低但不需要 root。qemu+ssh://user@host/system:通过 SSH 连接远程主机的 libvirt。
我大部分场景用的是qemu:///system,因为生产环境的虚拟机通常由系统级守护进程管理。连接代码很简单:
import libvirt conn = libvirt.open('qemu:///system') if conn is None: raise RuntimeError('无法连接到 libvirt')但实际用的时候不能这么裸奔,得加上错误处理和重连逻辑。
2.2 连接封装与自动重连
libvirt 连接可能因为守护进程重启、网络抖动等原因断开。我封装了一个LibvirtConnection类,核心是维护一个连接对象,并在操作失败时尝试重连:
import libvirt import time class LibvirtConnection: def __init__(self, uri='qemu:///system', retry=3): self.uri = uri self.retry = retry self.conn = None self._connect() def _connect(self): for i in range(self.retry): try: self.conn = libvirt.open(self.uri) if self.conn: return except libvirt.libvirtError as e: time.sleep(1) raise RuntimeError(f'连接 {self.uri} 失败') def get(self): if self.conn is None or not self.conn.isAlive(): self._connect() return self.conn这里isAlive()是 libvirt 提供的探活方法,比每次操作都 try/except 更轻量。重连时加个短暂 sleep,避免守护进程刚重启还没就绪就疯狂重试。
2.3 连接池的取舍
如果你的程序是多线程的,每个线程共用一个连接对象会有并发问题。libvirt 的连接对象本身不是线程安全的。我的做法是每个线程独立连接,或者用一个简单的连接池:
import threading class ConnectionPool: def __init__(self, uri, size=5): self.uri = uri self.pool = [LibvirtConnection(uri) for _ in range(size)] self.lock = threading.Lock() self.index = 0 def acquire(self): with self.lock: conn = self.pool[self.index] self.index = (self.index + 1) % len(self.pool) return conn.get()实操心得:连接池大小不要设太大,libvirt 守护进程对并发连接数有一定限制,一般 5 到 10 个足够。连接用完不需要显式归还,因为我们是轮询复用,但要注意每个连接对象内部的重连逻辑。
3. 资源层:虚拟机对象的封装与操作
3.1 查找与列举虚拟机
拿到连接后,第一步通常是找到目标虚拟机。libvirt 提供了几种查找方式:
conn.listAllDomains():返回所有域(虚拟机)对象列表。conn.lookupByName(name):按名称查找,找不到抛异常。conn.lookupByUUIDString(uuid):按 UUID 查找。
我一般封装一个find_domain方法,支持按名称或 UUID 查找,并统一异常:
def find_domain(conn, name=None, uuid=None): try: if uuid: return conn.lookupByUUIDString(uuid) return conn.lookupByName(name) except libvirt.libvirtError: return None返回None而不是抛异常,业务层判断更直观。
3.2 虚拟机状态与生命周期
虚拟机的状态用整数表示,常见的有:
| 状态常量 | 值 | 含义 |
|---|---|---|
| VIR_DOMAIN_RUNNING | 1 | 运行中 |
| VIR_DOMAIN_SHUTOFF | 5 | 已关闭 |
| VIR_DOMAIN_PAUSED | 3 | 暂停 |
| VIR_DOMAIN_CRASHED | 6 | 崩溃 |
启动、关闭、重启、强制断电这些操作对应的方法:
dom.create() # 启动 dom.shutdown() # 优雅关闭(发 ACPI 信号) dom.destroy() # 强制断电 dom.reboot() # 重启 dom.suspend() # 暂停 dom.resume() # 恢复注意:
shutdown()是发信号给客户机,需要客户机内安装并运行 acpid 之类的服务才能生效。如果客户机没响应,得用destroy()强制断电,但这样有数据丢失风险。我一般先shutdown(),等 30 秒还没关再destroy()。
3.3 获取虚拟机详细信息
虚拟机的配置信息可以通过dom.info()拿到一个元组,包含状态、最大内存、当前内存、CPU 数、CPU 时间。更详细的信息用dom.XMLDesc()拿 XML 描述,然后解析:
import xml.etree.ElementTree as ET def get_domain_info(dom): info = dom.info() xml = dom.XMLDesc() root = ET.fromstring(xml) return { 'name': dom.name(), 'uuid': dom.UUIDString(), 'state': info[0], 'max_memory': info[1], 'memory': info[2], 'vcpu': info[3], 'cpu_time': info[4], 'os_type': root.findtext('os/type'), }XML 里还能拿到磁盘、网卡、显卡等设备信息,按需解析即可。
4. 实操过程:从零创建一台虚拟机
4.1 准备磁盘镜像
创建虚拟机前得先有磁盘。我一般用qemu-img创建 qcow2 格式的镜像,支持稀疏分配和快照:
qemu-img create -f qcow2 /var/lib/libvirt/images/test-vm.qcow2 20G20G 是逻辑大小,实际占用取决于写入量。qcow2 的好处是支持快照、压缩、加密,缺点是性能比 raw 略低。测试环境用 qcow2 完全够用。
4.2 构造虚拟机 XML 定义
libvirt 创建虚拟机需要一段 XML 描述。我写了一个模板函数,根据参数动态生成:
def build_domain_xml(name, memory_mb, vcpu, disk_path, mac=None): return f""" <domain type='kvm'> <name>{name}</name> <memory unit='MiB'>{memory_mb}</memory> <vcpu>{vcpu}</vcpu> <os> <type arch='x86_64'>hvm</type> <boot dev='hd'/> </os> <features> <acpi/> </features> <devices> <disk type='file' device='disk'> <driver name='qemu' type='qcow2'/> <source file='{disk_path}'/> <target dev='vda' bus='virtio'/> </disk> <interface type='network'> <source network='default'/> {'<mac address="' + mac + '"/>' if mac else ''} <model type='virtio'/> </interface> <graphics type='vnc' port='-1' autoport='yes'/> </devices> </domain> """这里几个关键点:
type='kvm'表示用 KVM 加速,性能比纯 QEMU 好很多。- 磁盘总线用
virtio,网卡模型也用virtio,这是 KVM 下性能最好的组合。 - VNC 端口用
-1让 libvirt 自动分配,避免端口冲突。
4.3 定义并启动虚拟机
有了 XML 就可以定义虚拟机了:
conn = libvirt.open('qemu:///system') xml = build_domain_xml('test-vm', 2048, 2, '/var/lib/libvirt/images/test-vm.qcow2') dom = conn.defineXML(xml) if dom is None: raise RuntimeError('定义虚拟机失败') dom.create() print(f'虚拟机 {dom.name()} 已启动,UUID: {dom.UUIDString()}')defineXML是持久化定义,重启 libvirt 后虚拟机还在。如果只想临时创建,用createXML,但一般不推荐,因为重启后配置就丢了。
4.4 参数计算与选择依据
内存和 CPU 的分配不是拍脑袋定的。我的经验是:
- 内存:客户机操作系统本身占用 + 应用需求 + 20% 余量。比如跑一个轻量 Web 服务,系统占 512M,应用占 1G,那就给 2G。
- CPU:按并发线程数估算,一般 2 到 4 核够用。注意不要超过物理机核心数太多,否则调度开销会拖慢整体性能。
- 磁盘:qcow2 稀疏分配,逻辑大小给足,实际占用按需增长。但要注意宿主机磁盘空间监控,别让镜像把盘撑爆。
实操心得:创建虚拟机时给磁盘加
discard='unmap'选项,配合客户机内的 fstrim,可以回收删除文件占用的空间,qcow2 镜像不会只涨不缩。
5. 快照管理:批量与自动化
5.1 创建与回滚快照
快照是测试环境的神器,回滚一下就能回到干净状态。libvirt 的快照 API:
# 创建快照 snap_xml = """ <domainsnapshot> <name>snap-001</name> <description>before test</description> </domainsnapshot> """ dom.snapshotCreateXML(snap_xml, 0) # 回滚 snap = dom.snapshotLookupByName('snap-001') dom.revertToSnapshot(snap) # 删除快照 snap.delete()快照分内部快照和外部快照。内部快照存在 qcow2 镜像里,简单但性能差;外部快照单独存文件,性能好但管理复杂。测试环境用内部快照足够。
5.2 批量快照与保留策略
批量场景下,我写了一个函数,给所有运行中的虚拟机打快照,并只保留最近 N 个:
def batch_snapshot(conn, keep=3): for dom in conn.listAllDomains(): if dom.state()[0] != libvirt.VIR_DOMAIN_RUNNING: continue name = f"auto-{int(time.time())}" snap_xml = f"<domainsnapshot><name>{name}</name></domainsnapshot>" dom.snapshotCreateXML(snap_xml, 0) # 清理旧快照 snaps = dom.listAllSnapshots() if len(snaps) > keep: snaps.sort(key=lambda s: s.getXMLDesc().find('creationTime')) for s in snaps[:-keep]: s.delete()注意:快照不是备份。内部快照和原镜像在同一个文件里,镜像损坏快照也一起丢。重要数据还是要用
blockcopy或外部工具做真正的备份。
6. 常见问题与排查技巧实录
6.1 连接失败排查
| 现象 | 可能原因 | 解决 |
|---|---|---|
libvirt.open返回 None | 守护进程未启动 | systemctl start libvirtd |
| 权限拒绝 | 当前用户不在 libvirt 组 | 加入组并重新登录 |
| 远程连接超时 | 防火墙或 SSH 配置 | 检查端口和密钥 |
6.2 虚拟机启动失败
启动失败最常见的原因是磁盘路径不对或权限不足。libvirt 以qemu用户运行,如果镜像文件属主是 root 且权限 600,qemu 读不了。解决:
chown qemu:qemu /var/lib/libvirt/images/test-vm.qcow2或者把镜像放在 libvirt 默认的 images 目录,权限一般已经配好。
6.3 性能问题排查
虚拟机卡顿,先看宿主机负载,再看虚拟机内部。用virsh domstats或 Python 的dom.blockStats()拿磁盘 IO,dom.interfaceStats()拿网络流量。如果 CPU 占用高但业务量不大,可能是磁盘 IO 瓶颈,考虑换 virtio-scsi 或加缓存策略。
6.4 独家避坑技巧
- UUID 不要手动指定:让 libvirt 自动生成,手动指定容易冲突。
- XML 转义:如果虚拟机名称或路径里有特殊字符,记得转义,否则 XML 解析失败。
- 优雅关闭超时:
shutdown()后轮询状态,别死等,设个超时兜底。 - 日志位置:libvirt 日志在
/var/log/libvirt/,虚拟机日志在/var/log/libvirt/qemu/,排查问题先看这里。
7. 后续扩展方向
这套模块跑稳之后,可以往几个方向扩展。一是接入监控,定时采集虚拟机的 CPU、内存、磁盘、网络指标,推到监控系统。二是做自动伸缩,根据负载动态调整虚拟机数量。三是集成到 CI/CD 流程,测试用例跑完自动销毁环境。每个方向都可以在现有资源层之上加业务逻辑,不用动底层封装。
我个人在实际操作中的体会是,libvirt 的 Python 绑定虽然文档不算特别友好,但 API 设计比较一致,摸清几个核心对象(连接、域、快照、存储池)之后,剩下的就是查文档和试错。把连接层和资源层封装好,后面写业务代码会轻松很多。