虚拟机启动:从 Libvirt 到客户机引导
一、虚拟机冷启动的三个阶段
virsh start demo 将已保存的虚拟机定义转换为一个运行实例。Libvirt 根据 XML 准备磁盘、网络和进程隔离,启动 QEMU,再通过 QMP 设置运行参数并启动客户机 CPU。QEMU 负责创建虚拟硬件;客户机随后执行固件、从启动盘装载操作系统,并初始化自己的设备驱动与服务。
本文跟踪 qemu:///system 下一个没有 managed save 的持久虚拟机:4 个 vCPU、8 GiB 内存、一块 qcow2 系统盘和一张连接宿主桥的 virtio 网卡。主线是定义解析、资源准备、进程创建、QMP 配置和客户机引导。调用端通信见Libvirt RPC 的编码与调用分发;QEMU 内部的机器创建见QEMU 虚拟机启动流程。
源码版本:Libvirt 12.0.0、QEMU 10.2.0。
Mermaid 源码
flowchart TB
accTitle: 虚拟机启动的分工与放行顺序
accDescr: 启动请求进入 Libvirt,完成资源准备和进程隔离,QEMU 创建暂停的机器与 KVM 对象;Libvirt 配置 QMP 后放行 vCPU,客户机依次执行固件、内核与服务。
A["启动请求与虚拟机定义"] --> B["Libvirt:资源、权限与进程隔离"]
B --> C["QEMU:机器、RAM、设备与 KVM 对象<br/>-S 暂停客户机执行"]
C --> D["Libvirt:QMP 查询与运行前设置"]
D -->|cont| E["vCPU 开始执行:KVM_RUN"]
E --> F["客户机:固件 → 内核 → 服务"]
F --> G["业务健康检查:确认服务就绪"]
classDef manager fill:#e4f1ec,stroke:#438575,color:#173e34;
classDef machine fill:#eaf0f6,stroke:#8198ac,color:#263c4c;
classDef guest fill:#fff4db,stroke:#b89a55,color:#59491e;
class A,B,D manager;
class C,E machine;
class F,G guest;
先区分这三个阶段:磁盘无访问权限属于宿主准备问题;QEMU 无法创建所选设备属于建机问题;固件找不到启动盘、客户机缺少磁盘驱动,则属于引导阶段。下面用暂停启动分开观察这些阶段,再沿源码展开资源准备和状态协调。
二、暂停启动 demo,观察执行前后的区别
以下操作在宿主执行,用于一台已定义、当前关闭且没有 managed save 的测试虚拟机。显式指定 qemu:///system,保证查询和启动使用同一个管理实例;--paused 让机器完成创建,但暂不执行客户机代码:
virsh -c qemu:///system start demo --paused
virsh -c qemu:///system domstate demo
virsh -c qemu:///system dominfo demo
virsh -c qemu:///system resume demo
暂停时,QEMU 进程、RAM、磁盘与网卡已经存在,Libvirt 也已连接 QMP;domstate 应返回 paused。resume 后,vCPU 开始从复位状态执行固件,随后进入系统引导。RUNNING 只说明 CPU 获准运行,服务是否可用还要看客户机控制台和业务健康检查。
| 定义中的内容 | 启动时建立的关系 | 客户机开始执行后的工作 |
|---|---|---|
| 4 个 vCPU、8 GiB RAM | CPU 对象与执行线程、RAM 后端及 KVM 内存槽 | 固件提供内存布局,操作系统建立自己的页表与调度状态 |
| qcow2 系统盘,virtio 总线 | 文件与格式节点连接 virtio-blk 前端 | 固件读取引导数据,内核加载驱动并挂载根文件系统 |
| 桥接的 virtio 网卡 | TAP 接入宿主桥,网络后端连接 virtio-net 前端 | 客户机初始化队列、配置接口与 IP |
配置声明了资源需求,启动过程逐步把它们变成可用对象。若 resume 成功但没有登录提示,可从客户机控制台或串口日志定位最后完成的阶段:固件是否找到了启动盘,内核是否挂载了根文件系统,登录服务是否已经启动。
三、启动前的资源准备
qemuDomainObjStart() 先检查 managed save。存在保存状态时,默认恢复该状态;--force-boot 才要求放弃它并重新引导。本例没有保存状态,进入 qemuProcessStart() 的冷启动路径。冷启动与恢复分支
| 阶段 | 主要操作 | 此时尚未发生的事 |
|---|---|---|
qemuProcessInit() | 取得 QEMU 能力、校验配置,分离运行定义与持久定义,分配运行时 ID,设为 PAUSED / STARTING_UP | 尚未创建 QEMU 进程 |
qemuProcessPrepareDomain() | 补全设备地址与别名,解析存储、网络、固件,生成 monitor 配置与运行路径 | 这些定义仍需转成 QEMU 参数和宿主资源 |
qemuProcessPrepareHost() | 创建目录、准备 NVRAM 与字符设备,检查存储链,准备所需外部后端和 hostdev | 桥接网络的 TAP 连接等操作还会在命令构造阶段继续完成 |
qemuProcessLaunch() | 构造命令并连接网络资源、启动子进程、设置隔离与权限、连接 QMP、完成设备与线程配置 | QEMU 已运行时,客户机 CPU 仍可保持暂停 |
到 Launch 阶段,定义中的磁盘路径、设备类型和网络选择逐步转成可访问的存储、打开的文件描述符和 QEMU 参数。Libvirt 结合 QEMU capabilities、宿主条件和启动模式决定具体配置;前一步留下的设备地址、别名与资源句柄,供后面的命令构造和进程创建使用。初始化、定义准备、宿主准备
例如 8 GiB、4 vCPU 的虚拟机,持久 XML 只声明所需配置;运行时还会有设备别名、monitor socket、进程 PID 和线程 ID。将这些临时值直接写回持久定义,会让下次启动受上次实例的资源分配影响。virDomainObjSetDefTransient() 保留定义边界,关闭后再移除本次运行定义。
系统盘与网络资源的具体准备
本例系统盘的准备分为两层。Libvirt 检查镜像及 backing chain、存储访问条件和锁管理要求,生成块后端与 virtio-blk 前端参数;QEMU 启动后才据此建立块图、打开后端并创建客户机可见的磁盘设备。文件存在并不等于 QEMU 可读写:安全标签、进程身份和锁状态也必须满足要求。宿主存储准备、块后端与磁盘设备参数
桥接网卡同样需要宿主和客户机两部分。Libvirt 在构造网络命令时通过 qemuBuildInterfaceConnect() 连接 TAP 与宿主桥,并将需要继承的 TAP FD 纳入启动参数;QEMU 使用这些 FD 创建网络后端,再把 virtio-net 前端连接到后端。配置启用 vhost-net 时还要准备相应的 vhost FD。此时已建立数据通路,但客户机尚未加载网卡驱动或配置 IP。桥接网络连接与命令构造
存储检查、TAP 连接和隔离设置分布在上述不同阶段。失败清理据实际进度关闭 FD、移除本次创建的接口并归还存储与隔离资源;尚未创建的资源无需回收。
四、fork、exec 与两道启动屏障
Mermaid 源码
sequenceDiagram
accTitle: 从 virsh start 到客户机引导的完整时序
accDescr: virsh 经 RPC 请求 Libvirt 启动虚拟机。Libvirt 读取定义,准备存储与桥接网络,构造命令并创建子进程,在 exec 前完成宿主隔离。QEMU 创建机器、CPU、内存、磁盘和网卡,QMP 配置完成后 cont 允许客户机执行;API 返回与固件、操作系统、业务就绪分别进行。
participant A as virsh / 客户端
participant L as Libvirt QEMU 驱动
participant H as 宿主资源
participant P as 子进程 / QEMU
participant G as 客户机固件 / OS
A->>L: start demo → Create API / RPC
L->>L: 查找定义、检查 ACL<br/>启动作业、确认未运行
L->>L: 生成运行定义<br/>分配设备地址与别名
L->>H: 检查镜像 / backing chain、准备目录与后端
H-->>L: 可用路径、存储资源与配置
L->>H: 构造网络参数时连接 TAP / 宿主桥
H-->>L: TAP FD,按配置准备 vhost FD
L->>L: 构造启动参数<br/>块图、磁盘、网卡<br/>QMP、-S
L->>P: fork,传递参数与所需 FD
P->>P: pre-exec 初始化
P-->>L: 握手就绪,等待父进程确认
Note over L,P: 第一屏障:尚未 exec QEMU
L->>H: namespace、cgroup、亲和性、资源标签
L->>P: HandshakeNotify:允许 exec
P->>P: exec QEMU<br/>后端、KVM、CPU<br/>RAM、总线与设备<br/>完成整机复位
Note over L,P: 第二屏障:QEMU 已运行,-S 暂停客户机 CPU
P-->>L: QMP greeting
L->>P: qmp_capabilities
P-->>L: 协商完成
L->>P: 查询 vCPU / IOThread、配置调度 / 链路 / 磁盘限速
L->>H: 启用网络设备、恢复锁状态
L->>P: cont
par 管理侧完成启动
P-->>L: 命令结果与异步 RESUME 事件
Note over L,P: 事件更新 RUNNING;不依赖响应与事件的固定先后
L->>L: 保存状态与启动事件<br/>结束作业
L-->>A: API 成功
and 客户机继续引导
P->>G: 从 CPU 复位状态开始执行固件
G->>P: 枚举设备,经 virtio-blk 读取系统盘
P-->>G: 块后端返回引导数据
G->>G: 装载内核与驱动<br/>挂载文件系统
G->>G: 配置网络<br/>启动业务服务
end
Note over A,G: Libvirt API 不等待客户机操作系统或业务就绪
qemuBuildCommandLine() 构造 argv,其中包含 -S,要求 QEMU 完成初始化后先暂停客户机 CPU。Libvirt 还为进程创建设置执行前钩子、PID 文件、输出日志和握手管道。命令行构造
执行 virCommandRun() 后,子进程完成自己的 pre-exec 初始化,通知父进程,然后等待确认。父进程读到 PID,保存早期状态,等待握手,再设置 mount namespace、cgroup、CPU 亲和性及资源权限等。virCommandHandshakeNotify() 发出确认后,子进程才被允许 exec QEMU。父进程顺序、子进程握手
这个顺序让宿主约束在 QEMU 真正使用资源前生效。例如进程应先进入目标 cgroup,再开始分配大块内存或创建工作线程。拿到 PID 只证明子进程已出现,exec 仍可能失败;后面还需要确认 monitor 可连接、能力协商与查询成功。
QEMU exec 成功后创建机器、内存、设备和 vCPU 线程,-S 使客户机 CPU 暂不执行。Libvirt 连接 monitor,接收 greeting,再发送 qmp_capabilities 进入命令模式,随后刷新 vCPU / IOThread 信息并设置调度、网络链路、内存气球和磁盘限速等。这些设置有些依赖 QEMU 实际创建的线程和设备,因此必须在进程创建之后完成。monitor 初始化、运行前配置
两道屏障解决不同问题:pre-exec 握手保护宿主资源准备顺序,-S 保护客户机开始执行的时机。QMP 连接属于 Libvirt 与 QEMU 之间的管理通道,也不同于前面的 Libvirt RPC。
五、cont、运行状态与生命周期事件
qemuProcessFinishStartup() 决定是否启动 CPU。普通启动调用 qemuProcessStartCPUs(),先启用网络设备并恢复锁管理状态,保存 VIR_DOMAIN_RUNNING_BOOTED 原因,再通过 monitor 发送 cont。--paused 路径不发送 cont,而是把暂停原因改为用户要求。启动 CPU、完成启动
Mermaid 源码
flowchart TB
accTitle: 进程状态与客户机运行状态
accDescr: 关闭的持久虚拟机先进入启动准备并取得运行时 ID,随后创建 QEMU 进程。普通启动经 cont 和 RESUME 事件进入运行状态,暂停启动保持 PAUSED。准备或运行前配置失败则清理进程和资源,回到关闭失败状态。
O["SHUTOFF<br/>持久定义存在<br/>id = -1,pid = 0"] --> I["PAUSED / STARTING_UP<br/>已分配运行时 ID<br/>QEMU 尚未创建"]
I --> Q["PAUSED / STARTING_UP<br/>QEMU 进程已存在<br/>-S 保持客户机 CPU 暂停"]
Q --> C{"启动模式"}
C -->|普通启动| N["保存 runningReason = BOOTED<br/>发送 cont"]
N -->|QMP RESUME 事件| R["RUNNING / BOOTED<br/>客户机 CPU 开始运行<br/>操作系统可能仍在引导"]
C -->|--paused| P["PAUSED / USER<br/>进程与管理通道存在<br/>客户机 CPU 保持暂停"]
I -.准备失败.-> F["清理本次资源与进程<br/>SHUTOFF / FAILED<br/>id = -1,pid = 0"]
Q -.monitor 或配置失败.-> F
N -.启动 CPU 失败.-> F
R -.完成阶段保存或钩子失败.-> F
F --> D["保留持久定义<br/>可修正后重新启动"]
classDef idle fill:#f2f1ed,stroke:#989d95,color:#37433c;
classDef paused fill:#edf3f8,stroke:#7591ac,color:#233b50;
classDef running fill:#e4f2ef,stroke:#5e8f87,color:#203e3b;
classDef failure fill:#fbf0e6,stroke:#b58a60,color:#5f432b;
class O,D idle;
class I,Q,C,N,P paused;
class R running;
class F failure;
在本文版本中,qemuProcessStartCPUs() 不直接设置 RUNNING;QMP RESUME 事件进入 qemuProcessHandleResume() 后,处理器使用保存的运行原因更新 virDomainObj,保存状态并排队生命周期事件。命令响应与异步事件经过不同处理路径,不能依赖“先收到 cont 响应,再收到 RESUME”这种固定顺序。RESUME 处理器
冷启动成功后,qemuDomainObjStart() 另行排队 STARTED / BOOTED 事件;显式暂停启动还会排队 SUSPENDED / PAUSED。启动作业结束,API 返回成功。运行状态、原因和事件分别描述当前状态及其变化,不能把收到某个事件简单等同于客户机服务已经可用。
| 观察到的信号 | 已确认状态 | 后续检查 |
|---|---|---|
| 运行时 ID 已分配 | Libvirt 已进入启动准备 | QEMU 是否成功 exec |
| PID 存在 | 对应宿主进程存在 | QMP 是否完成协商、客户机 CPU 是否执行 |
| QMP 查询成功 | QEMU 管理通道可工作 | 客户机是否已经开始引导 |
| RUNNING / BOOTED | 客户机 CPU 已进入运行状态 | 客户机内核、网络和应用是否就绪 |
客户机在 cont 后从复位状态执行固件。固件枚举虚拟 PCI 设备,按启动顺序从系统盘加载引导程序;操作系统随后接管磁盘、网卡与中断控制器,初始化驱动、挂载文件系统,再启动网络和服务。这些工作都发生在客户机内部,通常与管理侧返回 API 结果交错进行。应用就绪需要客户机代理或业务健康检查提供证据;启动 API 不等待这些条件。暂停中的 QEMU 同样拥有进程、设备和管理通道。
六、用运行定义与启动作业管理状态
持久虚拟机关闭后仍保留定义。启动时,virsh 先按名称或 UUID 查找句柄,再发起创建调用。没有额外标志时使用 virDomainCreate();携带 --paused 等标志时使用 virDomainCreateWithFlags()。服务端 QEMU 驱动最终进入同一套带 flags 的启动逻辑。命令入口
| 数据结构 | 关键字段 | 启动中的作用 |
|---|---|---|
virDomainObj | def、newDef、pid、state、persistent、job | 关联定义、进程与生命周期;newDef 保存需要在关闭后使用的定义 |
virDomainDef | uuid、name、id、CPU、内存和设备配置 | 启动中的 def 可补入设备地址、别名、监听路径等运行信息 |
qemuDomainObjPrivate | qemuCaps、mon、monConfig、pidfile、cgroup、runningReason | 保存 QEMU 能力、monitor 通道、宿主资源及状态转换原因 |
virCommand | argv、环境变量、继承 FD、执行前钩子、握手管道 | 组织新进程的启动条件,协调 fork 与 exec 之间的准备 |
这些结构保存在 Libvirt 服务端进程中:通用对象记录生命周期,QEMU 驱动私有对象记录管理连接和宿主资源。它们通过 PID、monitor 与外部 QEMU 进程关联。pid 是宿主进程号,def->id 是 Libvirt 分配的运行时 ID,两者由不同分配器生成。
qemuDomainCreateWithFlags() 取得对象、检查 ACL,再通过 qemuProcessBeginJob() 建立 VIR_ASYNC_JOB_START 作业,取得作业后重新检查虚拟机是否已经 active。这里的 async job 是内部并发管理机制,调用 API 仍会等待启动操作返回。
对象锁保护字段的一致性,作业则保护跨多次等待与 QMP 调用的操作。等待 monitor 响应时会释放对象锁,使事件线程能够更新状态;仅靠一把全程持有的对象锁,反而可能把完成这次操作所需的事件阻塞住。启动入口、QEMU 驱动锁与作业规则
七、失败清理与管理连接恢复
资源准备、exec、monitor 连接、设备设置或最终状态保存都可能失败。qemuProcessStart() 的失败分支调用 qemuProcessStop(..., VIR_DOMAIN_SHUTOFF_FAILED, ...),依据安全标签是否已设置等信息选择清理标志。尚未开始资源准备的校验失败则直接返回,不会销毁一个并不存在的进程。失败分支
停止路径保留最初错误,终止 QEMU,收束事件线程,关闭 monitor 和 agent,停止外部设备,清理网络、cgroup、标签与运行文件,最后复原定义。其间将 def->id 设为 -1、pid 清零,写入 SHUTOFF 状态。清理顺序需要配合对象锁与事件线程,防止其他线程把正在销毁的虚拟机再次视为可操作对象。进程与事件收束、状态复原
持久定义通常仍保留,便于修正配置后再次启动;运行状态文件另用于守护进程恢复。qemuProcessReconnect() 重新连接已有 QEMU 的 monitor,恢复资源管理关系并查询真实状态,不必重新创建客户机。重连发现实例仍停留在 STARTING_UP 等未完成状态时会进入清理分支,因此“守护进程可以重启”也不意味着任意启动中断都能无条件续接。重连与状态核对
八、QMP 状态协调的收益与启动成本
早期人类可读的 monitor 适合人工交互,自动管理需要解析文本输出。QMP 提供结构化 JSON 命令、错误和异步事件,减少命令输出格式变化带来的歧义。Libvirt 在 2010 年加入连接时的 qmp_capabilities 协商;如今启动路径依赖 QMP 查询线程、设置运行参数和同步生命周期。
同年,QEMU 驱动接入锁管理基础设施,把磁盘锁、标签修改和 fork / exec 时序联系起来。随着直通设备、外部后端、NUMA 和 cgroup 等能力增加,创建进程逐渐成为更大资源事务中的一步。
收益是资源隔离、并发控制与故障恢复可以统一处理,成本则是更多阶段、状态和失败出口。排查启动慢或启动失败,最有效的起点是定位最后一个完成的阶段:配置校验、宿主资源准备、exec、QMP 协商、运行前设置,还是客户机自己的引导。将这些耗时全部归入“QEMU 启动”会掩盖实际瓶颈。
| 优化方向 | 减少的等待 | 需要承担的代价 |
|---|---|---|
| 提前准备网络和存储 | 请求中的宿主准备 | 资源提前占用,还需处理失效与清理 |
| 裁减无用设备和引导服务 | 设备初始化、固件枚举与客户机服务等待 | 验证驱动与业务依赖,避免遗漏必需设备 |
| 预分配 RAM | 运行初期的按需分配抖动 | 通常把工作移到建机阶段,并提前占用内存 |
| 从保存状态恢复 | 重新引导操作系统的工作 | 需要验证状态兼容、恢复 I/O 及外部连接一致性 |
对比优化结果时,以同一业务的服务可用时间为终点,并分别记录宿主准备、建机和客户机引导耗时;只测 API 返回时间,会遗漏最后一段。