1. 为什么选择用 Python 来管理 KVM 虚拟机搞过虚拟化的朋友都知道KVM 本身只是 Linux 内核里的一个模块真正干活的是它上面那套工具链——QEMU 负责模拟硬件libvirt 负责统一调度和管理。日常我们敲virsh命令开机关机、看状态、改配置爽是爽但一旦机器数量上来了或者要把虚拟化能力嵌进自己的运维平台里纯靠手敲命令就不现实了。这时候用 Python 调 libvirt 接口就成了一个特别顺手的方案。我最早接触这套东西是因为一个内部测试环境的需求几十台 KVM 虚拟机需要按需创建、批量快照、定时销毁还要把状态汇总到一个看板上。一开始想的是写 shell 脚本包一层virsh但很快就发现两个问题。第一virsh的输出是给人看的解析起来各种坑字段对齐、换行、不同版本格式还不一样第二很多操作比如拿到虚拟机的实时 CPU 占用、内存气球balloon当前值、磁盘 IO 统计virsh要么给不了要么得拼好几个命令。而 libvirt 的 Python 绑定直接暴露了底层 API拿到的是结构化对象该有的属性都有省去了大量文本解析的脏活。所以这篇文章我想把“用 Python 调 libvirt 管理 KVM”这件事从头到尾讲透。不管你是刚接触虚拟化的运维新人还是已经会敲virsh但想进一步做自动化的老手都能从里面找到能直接抄的代码和踩过的坑。核心会围绕libvirt这个 Python 模块展开讲清楚连接管理、生命周期操作、状态采集、快照、存储和网络这几块最后再聊聊实际项目里怎么组织代码、怎么处理异常。1.1 libvirt 到底是个什么东西先把概念理清楚不然后面容易懵。libvirt 是一个开源的虚拟化管理库它提供了一套统一的 API用来管理 KVM、Xen、LXC 等多种虚拟化后端。你可以把它理解成一个“翻译层”上层是你的 Python 代码下层是具体的 hypervisor对 KVM 来说就是 QEMU 进程libvirt 在中间做适配。它有几个关键组件。libvirtd是一个常驻的系统服务守护进程真正执行管理动作的是它客户端通过 socket 或者 TCP 连上它发指令、收结果。Python 这边用的libvirt模块本质上是对 C 语言版 libvirt API 的绑定所以你会发现它的很多方法名和 C 接口一一对应比如domain.create()、domain.destroy()。这里有个容易混淆的点libvirt的 Python 模块和libvirtd服务是两码事。前者是你pip install或者系统包管理器装进来的库后者是系统服务。代码跑不起来很多时候不是库的问题而是服务没起、权限不对、或者 socket 路径不对。1.2 相比 virsh 和 shell 脚本Python 方案强在哪我列个表对比一下这样更直观维度virsh 命令shell 脚本包 virshPython 调 libvirt输出格式面向人的文本需要 grep/awk 解析结构化对象直接取属性错误处理看退出码和 stderr判断麻烦异常机制可精确捕获实时指标支持有限拼命令效率低原生 API一次调用拿全批量操作循环调用慢循环调用慢连接复用效率高嵌入平台难难天然适合可封装成服务学习成本低中中高需懂 API从表里能看出来Python 方案的优势主要在“结构化”和“可编程性”上。当你需要把虚拟化能力做成一个 API 服务、一个定时任务、或者一个带界面的管理工具时Python 几乎是绕不开的选择。1.3 适合哪些人看这篇内容这篇内容适合三类人。第一类是运维工程师手头有一批 KVM 机器想从手动管理升级到自动化第二类是平台开发者要把虚拟化能力集成进自己的系统里需要一套可靠的调用方式第三类是想了解虚拟化底层原理的技术爱好者通过代码去理解 libvirt 的工作机制比看文档更直观。前提是你得对 Linux 基本操作、KVM 和 libvirt 有个初步认识至少知道虚拟机、镜像、存储池、网络这些概念。如果完全没接触过建议先花半天时间用virt-install装一台虚拟机把virsh list、virsh start这些命令跑一遍再来看这篇会顺畅很多。2. 环境准备与连接管理代码能不能跑起来八成取决于环境配得对不对。我见过太多人卡在“连不上 libvirtd”这一步然后开始怀疑人生。所以这部分我讲细一点把常见的坑都点出来。2.1 安装 Python 的 libvirt 绑定安装方式分两种取决于你的系统。用系统包管理器装是最省事的因为版本和系统里的 libvirtd 匹配不容易出兼容问题。以常见的发行版为例# Debian/Ubuntu 系 sudo apt-get install python3-libvirt libvirt-daemon-system # RHEL/CentOS 系 sudo yum install python3-libvirt libvirt-daemon如果你用的是虚拟环境或者系统源里的版本太老也可以用 pip 装pip install libvirt-python但这里有个大坑libvirt-python在编译安装时需要系统里有libvirt-dev或者叫libvirt-devel和对应的 C 编译器否则会报找不到头文件的错。而且 pip 装的版本如果和系统 libvirtd 版本差太多运行时可能报符号找不到。我的建议是能用系统包就用系统包实在要用 pip先确认pkg-config --modversion libvirt能输出一个合理的版本号。装完之后验证一下import libvirt print(libvirt.__version__)能打印出版本号说明库本身没问题了。2.2 连接 libvirtd 的几种方式连接是第一步也是最容易出问题的一步。libvirt 支持多种连接 URI格式是驱动传输方式://路径。管理本机 KVM最常用的是这几种qemu:///system连接本机的系统级 libvirtd管理的是 root 权限下的虚拟机。这是生产环境最常用的。qemu:///session连接当前用户的会话级 libvirtd权限受限一般只用于测试。qemussh://userhost/system通过 SSH 连到远程主机的系统级 libvirtd。代码里这样写import libvirt # 连接本机系统级 conn libvirt.open(qemu:///system) if conn is None: print(连接失败) exit(1) print(连接成功libvirt 版本:, conn.getLibVersion())open方法在失败时会返回None或者抛异常取决于具体版本和参数。稳妥的写法是用open加异常捕获或者用openReadOnly做只读连接测试。注意用qemu:///system时你的 Python 进程需要有访问 libvirtd socket 的权限。普通用户直接连通常会报Permission denied因为 socket 默认只有 root 和 libvirt 组能访问。解决办法是把用户加进libvirt组或者用 sudo 跑或者配置 polkit 授权。2.3 连接失败的排查思路连不上是高频问题我整理了一个排查顺序照着走基本能定位服务起没起systemctl status libvirtd没起就systemctl start libvirtd。socket 在不在ls -l /var/run/libvirt/libvirt-sock看文件是否存在、权限对不对。权限够不够当前用户是否在libvirt组里groups命令看一眼。改完组要重新登录才生效。URI 写没写对qemu:///system是三个斜杠别写成两个。SELinux/AppArmor有些系统上安全模块会拦截临时setenforce 0测试一下确认是这个问题再针对性放行。我踩过最坑的一次是容器里跑 Python 代码连宿主机的 libvirtdsocket 路径挂载对了但权限没给够折腾半天才发现是容器内用户 UID 和宿主机 socket 属主对不上。所以如果你在容器环境里用权限这块要格外留心。2.4 连接对象的管理与复用conn这个连接对象是宝贵的资源建立连接本身有开销频繁开关很浪费。正确的做法是在程序启动时建一个连接全局复用程序退出时再关掉。import libvirt import atexit conn libvirt.open(qemu:///system) def close_conn(): if conn: conn.close() atexit.register(close_conn)另外libvirt 连接支持事件循环如果你要做实时监控比如监听虚拟机的开关机事件需要注册事件回调并跑一个事件循环。这块稍微复杂后面讲状态监控的时候再展开。对于大多数“查询操作”型的任务同步调用就够了不用碰事件循环。3. 虚拟机的生命周期操作生命周期操作是管理虚拟机的核心创建、启动、暂停、恢复、关机、销毁。这些操作在 libvirt 里都有对应的方法但每个方法的语义和“坑点”值得好好说说因为名字起得太容易让人误解了。3.1 查询虚拟机列表与状态动手操作之前先得知道有哪些虚拟机、它们什么状态。libvirt 提供了几个查询方法# 所有虚拟机含未运行的 all_domains conn.listAllDomains() for d in all_domains: print(d.name(), d.ID(), d.state()) # 只查运行中的 active conn.listDomainsID() for did in active: d conn.lookupByID(did) print(d.name())d.state()返回一个元组第一个元素是状态码第二个是原因码。状态码对应关系如下状态码含义说明0NOSTATE无状态1RUNNING运行中2BLOCKED阻塞通常在等 IO3PAUSED已暂停4SHUTDOWN正在关闭5SHUTOFF已关闭6CRASHED崩溃7PMSUSPENDED挂起到内存实际写代码时别去记数字用libvirt.VIR_DOMAIN_RUNNING这种常量可读性好还不容易错。实操心得listAllDomains在虚拟机数量多的时候几百台以上会明显变慢因为它要逐个去查状态。如果只是要个名字列表可以用conn.listDefinedDomains()拿未运行的conn.listDomainsID()拿运行中的拼起来更快。3.2 启动、关机与强制销毁启动很简单dom conn.lookupByName(my-vm) if dom.isActive(): print(已经在运行了) else: dom.create()create()是启动一台已定义但未运行的虚拟机。注意别和createXML()搞混后者是用 XML 定义并创建一台全新的虚拟机。关机有两个方法语义完全不同这是新手最容易踩的坑dom.shutdown()发送 ACPI 关机信号相当于按了电源键让虚拟机里的操作系统自己优雅关闭。这需要虚拟机里装了 ACPI 支持并且响应信号。dom.destroy()直接拔电源强制终止 QEMU 进程。数据可能丢失非必要不用。# 优雅关机 dom.shutdown() # 等它关最多等 60 秒 import time for _ in range(60): if not dom.isActive(): break time.sleep(1) else: # 超时了还没关强制销毁 dom.destroy()这段“先优雅后强制”的逻辑在实际运维里非常实用。很多脚本直接destroy()结果虚拟机文件系统损坏重启后要 fsck得不偿失。3.3 暂停、恢复与重启暂停和恢复对应的是“挂起”和“唤醒”dom.suspend() # 暂停 dom.resume() # 恢复暂停状态下虚拟机不消耗 CPU但内存还占着适合临时让出资源。重启用dom.reboot()同样是发 ACPI 信号需要系统配合。这里有个细节suspend和resume只对运行中的虚拟机有效对已关闭的调用会抛异常。所以操作前最好判断一下状态或者用 try/except 包起来。3.4 用 XML 定义并创建新虚拟机创建新虚拟机是重头戏。libvirt 用 XML 描述虚拟机的全部配置你要做的就是拼出这段 XML然后调createXML。xml domain typekvm nametest-vm-01/name memory unitMiB2048/memory vcpu2/vcpu os type archx86_64hvm/type boot devhd/ /os devices disk typefile devicedisk driver nameqemu typeqcow2/ source file/var/lib/libvirt/images/test-vm-01.qcow2/ target devvda busvirtio/ /disk interface typenetwork source networkdefault/ model typevirtio/ /interface graphics typevnc port-1 autoportyes/ /devices /domain dom conn.createXML(xml, 0)createXML的第二个参数是标志位0 表示默认行为创建并启动。如果只想定义不启动可以用defineXMLdom conn.defineXML(xml) # 之后再手动启动 dom.create()拼 XML 的时候最容易出错的是磁盘路径和网络配置。磁盘文件必须提前存在可以用qemu-img create建否则创建会失败。网络名要对应已存在的 libvirt 网络用conn.listNetworks()查一下。注意createXML创建的虚拟机是“临时”的一旦销毁destroy就没了不会保留定义。要持久化得用defineXML。这个区别很多人第一次用会懵。4. 状态采集与性能监控光能开关机还不够真正做运维平台得能实时看到虚拟机的运行指标CPU 用了多少、内存剩多少、磁盘 IO 快不快、网络流量大不大。libvirt 提供了丰富的统计接口这块是 Python 方案相比 virsh 优势最明显的地方。4.1 获取 CPU 和内存实时数据# CPU 时间统计单位纳秒 cpu_stats dom.getCPUStats(True) for stat in cpu_stats: print(CPU 时间:, stat[cpu_time]) # 内存统计 mem_stats dom.memoryStats() print(实际使用内存:, mem_stats.get(actual, 0)) print(可用内存:, mem_stats.get(available, 0)) print(常驻内存:, mem_stats.get(rss, 0))getCPUStats(True)里的True表示返回所有 CPU 的汇总。要算 CPU 使用率得采两次数据做差import time def get_cpu_usage(dom, interval1): stats1 dom.getCPUStats(True)[0][cpu_time] time.sleep(interval) stats2 dom.getCPUStats(True)[0][cpu_time] # 纳秒转秒除以时间间隔和 CPU 核数 vcpus dom.maxVCPUs() usage (stats2 - stats1) / 1e9 / interval / vcpus * 100 return round(usage, 2)这个计算逻辑要解释一下cpu_time是虚拟机消耗的 CPU 时间总和两次采样的差值除以时间间隔得到的是“平均占用了几个核”再除以虚拟机的核数就是百分比。比如 2 核虚拟机差值算出来是 1.0说明平均占用了 1 个核使用率就是 50%。4.2 磁盘和网络 IO 统计# 块设备统计 block_stats dom.blockStats(vda) # 返回元组(读请求数, 读字节, 写请求数, 写字节, 错误数) print(读字节:, block_stats[1], 写字节:, block_stats[3]) # 网络接口统计 net_stats dom.interfaceStats(vnet0) # 返回元组(接收字节, 接收包, 接收错误, 接收丢包, 发送字节, 发送包, 发送错误, 发送丢包) print(接收:, net_stats[0], 发送:, net_stats[4])同样要算速率也得采两次做差。这里有个坑blockStats的参数是磁盘的目标设备名比如vda不是宿主机上的文件路径。你得先通过dom.XMLDesc()解析出设备名或者提前约定好命名规范。4.3 用 domain 对象拿配置信息除了实时指标虚拟机的静态配置也经常要读比如核数、内存上限、磁盘列表。这些信息藏在 XML 里可以用dom.XMLDesc()拿到完整 XML再用xml.etree.ElementTree解析import xml.etree.ElementTree as ET xml_str dom.XMLDesc() root ET.fromstring(xml_str) name root.find(name).text memory root.find(memory).text vcpu root.find(vcpu).text disks [] for disk in root.findall(.//disk[devicedisk]): source disk.find(source).get(file) target disk.find(target).get(dev) disks.append({source: source, target: target}) print(f名称: {name}, 内存: {memory} KiB, 核数: {vcpu}) for d in disks: print(f磁盘: {d[target]} - {d[source]})这套解析逻辑是写管理平台的基础。你可以把它封装成一个函数输入 domain 对象输出一个字典后面展示、判断、生成报表都靠它。4.4 监控数据的采集频率与存储做监控绕不开一个问题多久采一次、数据存哪。我的经验是CPU 和内存这类变化快的指标15 秒采一次比较合适磁盘和网络 IO 可以 30 秒到 1 分钟。采太密对宿主机有压力采太疏又看不出毛刺。存储方面短期数据比如最近几小时放内存或者 Redis长期趋势数据落时序数据库。如果只是内部用写个 CSV 或者 SQLite 也够。关键是别把原始数据一股脑塞进关系库量大了查询会很痛苦。实操心得采集脚本一定要做异常隔离。某台虚拟机的统计接口偶尔会超时或者返回空不能让一个异常把整个采集循环搞崩。每台机器的采集用独立的 try/except 包起来失败的记个日志跳过下个周期再试。5. 快照、存储与网络管理虚拟机的“周边”管理同样重要。快照用于备份和回滚存储池管理磁盘文件网络管理虚拟机的连通性。这三块在 Python 里都有对应的 API。5.1 快照的创建、查询与回滚快照是运维的救命稻草升级前打个快照出问题一键回滚。libvirt 支持磁盘快照和内存快照# 创建磁盘快照 snap_xml domainsnapshot namesnap-before-upgrade/name description升级前的快照/description /domainsnapshot snap dom.snapshotCreateXML(snap_xml, 0) # 列出所有快照 for name in dom.snapshotListNames(): print(name) # 回滚到某个快照 snap dom.snapshotLookupByName(snap-before-upgrade) dom.revertToSnapshot(snap) # 删除快照 snap.delete()快照这块有几个坑要提醒。第一快照不是万能的备份它依赖原始磁盘文件原始文件损坏快照也废了。第二快照链太长会影响性能定期合并blockcommit是必要的。第三带内存的快照VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY不加这个标志需要虚拟机处于运行或暂停状态且占用空间大。5.2 存储池与卷的操作存储池是 libvirt 对存储资源的抽象可以基于目录、LVM、NFS 等。管理磁盘文件本质是管理存储池里的卷# 查找存储池 pool conn.storagePoolLookupByName(default) # 列出所有卷 for vol in pool.listAllVolumes(): print(vol.name(), vol.info()) # 创建新卷 vol_xml volume namenew-disk.qcow2/name capacity unitGiB20/capacity target format typeqcow2/ /target /volume vol pool.createXML(vol_xml, 0)vol.info()返回一个元组包含容量、分配量、实际使用量。做容量规划的时候这个数据很有用。创建卷时capacity是逻辑大小allocation是实际分配qcow2 格式支持精简置备不写满就不占空间。5.3 虚拟网络的查询与配置网络管理相对独立常用的操作是查网络、看 DHCP 租约、改配置# 列出所有网络 for net in conn.listAllNetworks(): print(net.name(), net.isActive()) # 查某个网络的 DHCP 租约 net conn.networkLookupByName(default) leases net.DHCPLeases() for lease in leases: print(lease[ipaddr], lease[mac], lease[hostname])DHCPLeases返回的租约信息在排查“虚拟机拿不到 IP”这类问题时特别有用。你可以直接看到哪个 MAC 拿了哪个 IP省得进虚拟机里ip addr。创建新网络用networkCreateXMLXML 里定义网段、DHCP 范围、转发模式。这块配置和直接写 libvirt 网络 XML 一样Python 只是把它传进去而已。6. 常见问题与排查技巧实录前面讲的都是“正常流程”但实际干活大部分时间花在排错上。这部分我把这些年踩过的坑整理成速查表再补充几个典型场景的排查思路。6.1 高频问题速查表问题现象可能原因排查方法解决方式连接返回 Nonelibvirtd 没起systemctl status libvirtd启动服务Permission denied用户不在 libvirt 组groups查看加组并重新登录找不到虚拟机名称拼错或不在该连接listAllDomains确认核对名称和 URIcreateXML 失败XML 格式错或磁盘不存在看异常信息校验 XML先建磁盘关机关不掉虚拟机没装 ACPI看虚拟机系统用 destroy 兜底快照创建失败磁盘格式不支持看磁盘 driver 类型用 qcow2 格式统计返回空虚拟机已关闭判断 isActive跳过或标记离线中文名乱码编码问题检查 XML 编码声明统一用 UTF-86.2 异常处理的最佳实践libvirt 的 Python 绑定在出错时抛的是libvirt.libvirtError这个异常对象里包含了错误码和错误信息善用它能省很多事import libvirt try: dom conn.lookupByName(not-exist-vm) except libvirt.libvirtError as e: print(错误码:, e.get_error_code()) print(错误域:, e.get_error_domain()) print(错误信息:, e.get_error_message())错误码可以用来做精确判断比如VIR_ERR_NO_DOMAIN表示虚拟机不存在VIR_ERR_OPERATION_INVALID表示当前状态不允许该操作。根据错误码走不同的分支比字符串匹配靠谱得多。实操心得写管理脚本时我习惯把 libvirt 的异常统一包装成自己的异常类把错误码映射成业务语义比如“虚拟机不存在”“状态冲突”“权限不足”这样上层调用者不用关心 libvirt 的细节代码也更好维护。6.3 批量操作的性能优化管理几十上百台虚拟机时性能问题会冒出来。几个优化点第一连接复用。前面说过别每次操作都开新连接。第二批量查询用listAllDomains一次拿全别在循环里反复lookupByName。第三统计采集可以并发。用concurrent.futures.ThreadPoolExecutor开几个线程并行采集比串行快很多。但注意别开太多线程libvirt 连接本身不是完全线程安全的稳妥的做法是每个线程用自己的连接或者加锁串行化。from concurrent.futures import ThreadPoolExecutor def collect(dom_name): conn libvirt.open(qemu:///system) try: dom conn.lookupByName(dom_name) return dom_name, get_cpu_usage(dom) finally: conn.close() with ThreadPoolExecutor(max_workers5) as executor: results executor.map(collect, vm_names)6.4 权限与安全相关的注意事项最后强调几点安全相关的。第一qemu:///system连接权限很大能操作所有虚拟机代码里要做好鉴权别把接口裸奔在公网上。第二虚拟机 XML 里如果包含敏感信息比如磁盘加密密钥注意别打到日志里。第三用 SSH 连远程 libvirtd 时密钥管理要规范别用弱密码或者共享账号。我在实际项目里会把管理接口封装成一个内部服务前面加一层认证和审计所有操作记日志。这样既方便追溯也避免了直接暴露 libvirt 权限带来的风险。这套东西用下来最大的感受是libvirt 的 Python API 虽然学习曲线有点陡但一旦摸清套路能做的事情比敲命令多得多。从简单的开关机到复杂的监控采集、快照管理、批量编排都能用一套代码搞定。真正花时间的往往不是写代码而是理解每个 API 的语义和边界以及处理各种异常情况。把上面这些坑都趟过一遍你基本就能搭出一个靠谱的虚拟机管理工具了。