在第1章中,我们讨论了什么是虚拟化,并介绍了两种虚拟化类型——基于虚拟机的虚拟化和基于容器的虚拟化。对于基于虚拟机的虚拟化,我们简要讨论了虚拟机管理程序(Hypervisor)的作用及其重要性,它有助于创建虚拟机。
在本章中,我们将深入探讨虚拟机管理程序。大部分内容将使用 Linux 内核虚拟机(KVM)和 Quick Emulator (QEMU) 等组件来解释虚拟化。基于这些组件,我们将了解如何创建虚拟机以及如何促进客户机与主机之间的数据流动。
Linux 通过在用户空间使用 QEMU 以及一个名为 KVM(Linux 内核虚拟机)的专用内核模块来提供虚拟机管理程序的功能。KVM 使用 Intel vt-x 扩展指令集在硬件层面隔离资源。由于 QEMU 是一个用户空间进程,因此从调度的角度来看,内核将其视为其他进程。
在讨论 QEMU 和 KVM 之前,让我们先简要了解一下 Intel 的 vt-x 及其特定指令集。
Intel Vt-x 指令集
Intel 的虚拟化技术 (VT) 有两种形式:
- Vt-x(适用于 Intel x86 IA-32 和 64 位架构)
- Vt-i(适用于 Itanium 处理器系列)
它们在功能上是相似的。为了理解在 CPU 级别上支持虚拟化的必要性,我们快速回顾一下程序与操作系统如何与 CPU 交互,以及虚拟机中的程序如何与 CPU 交互。
在主机上运行的常规程序中,操作系统将程序指令翻译为 CPU 指令,由 CPU 执行。
在虚拟机的情况下,为了在虚拟机内运行程序,客户操作系统将程序指令翻译为虚拟 CPU 指令,然后虚拟机管理程序将这些指令转换为物理 CPU 的指令。
如你所见,对于虚拟机来说,程序指令被翻译了两次——先翻译为虚拟 CPU 指令,然后再翻译为物理 CPU 指令。
这会导致巨大的性能开销,降低虚拟机的运行速度。像 vt-x 这样的 CPU 虚拟化功能使得 CPU 的全部能力能够完全抽象给虚拟机,从而使虚拟机中的所有软件都能无性能损失地运行,仿佛它们是在专用 CPU 上运行一样。
vt-x 还解决了 x86 指令架构无法虚拟化的问题。根据 Popek 和 Goldberg 的虚拟化原则(在第1章中介绍过的),所有敏感指令也必须是特权指令。特权指令在用户模式下会触发陷阱。而在 x86 中,一些指令是敏感的,但不是特权指令。这意味着在用户空间运行这些指令不会触发陷阱。实际上,这意味着它们是不可虚拟化的。POPF 指令就是这样的一个例子。
vt-x 通过设计简化了虚拟机监控器(VMM)软件,解决了虚拟化中的漏洞:
- 环压缩:没有 vt-x 时,客户操作系统运行在 Ring 1,客户操作系统应用程序运行在 Ring 3。为了在客户操作系统中执行特权指令,我们需要更高的权限,而这些权限默认情况下对客户机不可用(出于安全原因)。因此,为了执行这些指令,我们需要捕获到虚拟机管理程序(在 Ring 0 中运行,具有更多权限),由它代表客户机执行特权指令。这被称为环压缩或去特权化。vt-x 通过直接在 Ring 0 中运行客户操作系统来避免这一问题。
- 非捕获指令:像 x86 上的 POPF 这样的指令,本应因为它们是敏感指令而捕获到虚拟机管理程序,但实际上并不会捕获。这是一个问题,因为我们需要将程序控制转移到虚拟机管理程序来处理所有敏感指令。vt-x 通过在 Ring 0 中运行客户操作系统来解决这个问题,其中像 POPF 这样的指令可以捕获到运行在 Ring -1 中的虚拟机管理程序。
- 过度捕获:没有 vt-x 时,所有敏感和特权指令都会捕获到虚拟机管理程序的 Ring 0 中。有了 vt-x 之后,这变得可配置,取决于 VMM 哪些指令会触发捕获,哪些指令可以在 Ring 0 中安全处理。本书的范围之外详细探讨这些细节。
vt-x 增加了两种模式:非根模式(Ring -1)是 VMM 运行的地方,而根模式(Ring 0)是客户操作系统运行的地方。
为了理解这些模式如何参与程序执行,让我们来看一个示例。假设一个程序正在虚拟机中执行,在执行过程中,它发出了一个 I/O 系统调用。正如在第1章中讨论的那样,客户机中的用户空间程序在 Ring 3 中执行。当程序发出 I/O 调用(即系统调用)时,这些指令在客户操作系统内核级别(Ring 0)执行。客户操作系统本身无法处理 I/O 调用,因此它将这些调用委派给 VMM(Ring -1)。当执行从 Ring 0 转移到 Ring -1 时,这称为 VMExit,当执行从 Ring -1 返回到 Ring 0 时,这称为 VMEntry。图 2-1 显示了这一过程。
注意 在我们深入探讨 QEMU 之前,我想顺便提一下虚拟化领域的一些有趣项目,例如 Dune,它在虚拟机环境中运行一个进程,而不是完整的操作系统。在根模式下,运行的是虚拟机管理程序(VMM)。KVM 就是在这种模式下运行的。
Quick Emulator(QEMU)
QEMU 作为用户进程运行,并处理 KVM 内核模块。它使用 vt-x 扩展,从内存和 CPU 的角度为客户机提供一个隔离的环境。QEMU 进程拥有客户机的 RAM,RAM 可以通过文件或匿名方式进行内存映射。虚拟 CPU 在物理 CPU 上进行调度。
普通进程和 QEMU 进程之间的主要区别在于这些线程上执行的代码。在客户机的情况下,由于它是虚拟化的机器,代码执行的是软件 BIOS 和操作系统。
图 2-2 显示了 QEMU 如何与虚拟机管理程序交互。
QEMU 还为 I/O 分配了一个单独的线程。这个线程运行一个事件循环,并基于非阻塞机制。它为 I/O 注册文件描述符。QEMU 可以使用诸如 Virtio 这样的准虚拟化驱动程序,为客户机提供 Virtio 设备,例如用于块设备的 Virtio-blk 和用于网络设备的 Virtio-net。图 2-3 显示了促进客户机与主机(虚拟机管理程序)之间通信的特定组件。
在图 2-3 中,你可以看到 QEMU 进程内的客户机实现了前端驱动程序,而主机实现了后端驱动程序。前端和后端驱动程序之间的通信通过称为 virtqueue 的专用数据结构进行。来自客户机的任何数据包首先被放入 virtqueue 中,然后通过超调用通知主机端的驱动程序,将数据包提取出来并实际处理到设备。这个数据包流可以有两种不同的形式,如下所示:
- 来自客户机的数据包由 QEMU 接收,然后推送到主机上的后端驱动程序。例如,virtio-net 就是这种情况。
- 来自客户机的数据包通过称为 vhost 驱动程序的机制直接到达主机。这绕过了 QEMU 层,相对更快。
使用 KVM 模块创建虚拟机
要创建一个虚拟机,需要对内核 KVM 模块进行一系列 ioctl 调用,该模块向客户机暴露了一个 /dev/kvm 设备。简单来说,这些是从用户空间创建并启动虚拟机的调用步骤:
- KVM CREATE VM:此命令创建一个新的虚拟机,该虚拟机没有虚拟 CPU 也没有内存。
- KVM SET USER MEMORY REGION:此命令将用户空间的内存映射到虚拟机中。
- KVM CREATE IRQCHIP / KVM CREATE VCPU:此命令创建一个硬件组件,如虚拟 CPU,并将其与 vt-x 功能映射。
- KVM SET REGS / SREGS / KVM SET FPU / KVM SET CPUID / KVM SET MSRS / KVM SET VCPU EVENTS / KVM SET LAPIC:这些命令是硬件配置。
- KVM RUN:此命令启动虚拟机。
KVM RUN 命令启动虚拟机,内部由 KVM 内核模块调用 VMLaunch 指令,该指令将虚拟机的代码执行置于非根模式。然后将指令指针更改为客户机内存中的代码位置。这是一个稍微简化的描述,因为该模块在设置虚拟机时还做了很多其他工作,包括设置 VMCS(虚拟机控制结构)等。
基于 Vhost 的数据通信
在讨论虚拟机管理程序时,如果没有具体的示例,讨论将是不完整的。我们将以 vhost-net 设备驱动程序为背景,来看看网络数据包流的示例(如图 2-4 所示)。当我们使用 vhost 机制时,QEMU 不再位于数据平面上,客户机和主机之间通过 virtqueue 直接通信。QEMU 仍然在控制平面上,通过 ioctl 命令在内核上设置 vhost 设备:
/dev/vhost-net 设备。
当设备初始化时,会为特定的 QEMU 进程创建一个内核线程。该线程处理特定客户机的 I/O 操作。线程在主机端的 virtqueue 上监听事件。当有事件到达需要提取数据时(在 Virtio 术语中,这称为 "kick" 或超调用),I/O 线程从客户机的 tx(传输)队列中提取数据包。然后,线程将这些数据传输到 tap 设备,使其可用于底层的桥接器/交换机,以便将数据传输到下游的覆盖或路由机制。
KVM 内核模块为客户机注册了 eventfd。这是一个文件描述符(FD),由 QEMU 为客户机在 KVM 内核模块中注册。该文件描述符针对客户机的 I/O 退出事件(即 "kick")进行注册,用于提取数据。
什么是 eventfd?
eventfd 是一种进程间通信(IPC)机制,提供了在用户空间程序之间或内核与用户空间之间的等待-通知功能。其基本思想很简单。就像我们为文件创建文件描述符(FD)一样,我们也可以为事件创建文件描述符。这样做的好处是,这些文件描述符可以像其他文件描述符一样被处理,并可以通过 poll、select 和 epoll 等机制进行注册。当这些文件描述符被写入时,这些机制可以实现通知系统。
消费者线程可以通过 epoll_wait 在 epoll 对象上等待。一旦生产者线程写入该文件描述符,epoll 机制就会通知消费者(具体取决于边沿触发或电平触发)发生了事件。
- 边沿触发(Edge-triggered)意味着你只会在事件被检测到时(如瞬间发生)收到通知,而电平触发(Level-triggered)意味着只要事件存在,你就会收到通知(这将在一段时间内成立)。
例如,在边沿触发系统中,如果你希望收到数据可读时的通知,那么只有当数据之前不可读但现在可读时,你才会收到通知。如果你读取了一部分可用数据(仍有部分数据可读),你不会再收到通知。如果你读取了所有可用数据,当有新数据再次可读时,你会收到另一条通知。在电平触发系统中,只要数据可读,你就会收到通知。
主机使用 eventfd 通过 ioeventfd 将数据从客户机发送到主机,并通过 irqfd 接收从主机到客户机的中断。
另一个 eventfd 的使用场景是内存不足(OOM)控制组(cgroup)。其工作方式是:每当进程超过 memcg 限制时,OOM 杀手可以决定是否杀死它,或者如果此行为被禁用,内核可以执行以下操作:
- 创建 eventfd。
- 将 OOM 事件写入 eventfd。
进程线程会阻塞,直到事件生成。一旦事件生成,线程就会被唤醒以响应 OOM 通知。
eventfd 与 Linux 管道的区别在于,管道需要两个文件描述符,而 eventfd 只需要一个。
vhost I/O 线程监视 eventfd。每当客户机发生 I/O 事件时,该客户机的 I/O 线程会收到通知,表明它需要从 tx 队列中清空缓冲区。
类似于 ioeventfd,还有一个 irqfd。QEMU 用户空间也为客户机注册了这个(irqfd)文件描述符。客户机驱动程序监听这些文件描述符的变化。使用它的原因是为了将中断传递回客户机,以通知客户机端的驱动程序处理数据包。以之前的示例为例,当需要将数据包发送回客户机时,I/O 线程会填充客户机的 rx 队列(接收队列)缓冲区,并通过 irqfd 向客户机注入中断。在数据包流的反向路径中,通过物理接口接收到的主机上的数据包被发送到 tap 设备。与 tap 设备交互的线程接收这些数据包,以填充客户机的 rx 缓冲区。然后它通过 irqfd 通知客户机驱动程序。图 2-4 显示了这个过程。
替代虚拟化机制
在介绍了基于虚拟机的虚拟化机制后,现在是时候简要了解一下其他虚拟化手段了,这些手段不同于容器隔离,如 Docker 使用的基于命名空间/cgroups 的机制。本节的目的是了解以下几点:
- 减少不同软件层(如 VMM)暴露的接口,以减少攻击向量。攻击向量可以以漏洞的形式出现,如通过内存漏洞安装恶意软件或通过提升权限来控制系统。
- 使用硬件隔离来隔离我们运行的不同容器/进程。
总而言之,我们可以在获得类似于虚拟机的隔离级别的同时,减少或最小化暴露的机器接口,并达到与容器类似的配置速度。
我们已经讨论了虚拟机如何在 VMM 的帮助下隔离这些工作负载。VMM 暴露了机器模型(x86 接口),而容器暴露了 POSIX 接口。VMM 通过硬件虚拟化可以隔离 CPU、内存和 I/O(如 vt-d、SRIOV 和 IOMMU)。共享内核的容器通过命名空间和 cgroups 提供此功能,但仍被认为是比硬件隔离技术更弱的替代方案。
那么,有没有办法让这两个世界更接近?其中一个目标是通过采用极简接口方法来减少攻击向量。这意味着,与其向应用程序暴露完整的 POSIX API 或向客户操作系统暴露完整的机器接口,我们只提供应用程序/操作系统所需的部分。这就是我们开始看到 unikernel 和 library OS 如何演变的起点。
Unikernels
Unikernels 提供了一种通过工具链准备极简操作系统的机制。这意味着如果应用程序只需要网络 API,那么键盘、鼠标设备及其驱动程序就不会被打包。这大大减少了攻击向量。
Unikernels 的早期问题之一是它们必须跨不同的设备驱动程序模型构建。随着 I/O 虚拟化和 Virtio 驱动程序的出现,这个问题在一定程度上得到了缓解,因为 Unikernels 现在可以使用精确的 Virtio 设备和客户机上应用程序所需的驱动程序来构建。这意味着客户机可以是一个运行在虚拟机管理程序(如 KVM)之上的 Unikernel(library OS)。这仍然有一些限制,因为 QEMU 或用户空间部分仍然包含大量代码,所有这些代码都可能受到漏洞攻击。
为了进一步实现极简化,一种解决方案是将 VMM 与 Unikernel 打包在一起,这意味着 VMM 现在扮演每个实例的 QEMU 角色。VMM 代码仅限于所需的功能,并促进客户机与 VMM 之间基于内存的通信。使用此模型,可以在虚拟机管理程序上运行多个 VMM。VMM 的角色是促进 I/O,并利用硬件隔离功能创建客户机的 Unikernel。
Unikernel 本身是一个单一进程,不具有多线程能力,如图 2-5 所示。
在图 2-5 中,左侧的图像展示了一个运行 VMM 和 QEMU 组合以在其上运行 Unikernel 的示例,而右侧的图像展示了将 VMM(监控程序)如 UKVM 与 Unikernel 一起打包的示例。因此,我们基本上减少了代码量(QEMU),从而消除了一个重要的攻击向量。这与我们之前讨论的极简接口方法是一致的。
Project Dune
细心的读者可以很容易地发现,vt-x 在内存和 CPU 上的隔离并不局限于仅在客户机的内存中运行客户操作系统代码。从技术上讲,我们可以在这种硬件隔离的基础上提供不同的沙盒机制。这正是 Project Dune 所做的。Dune 在 vt-x 的硬件隔离基础上,不运行客户操作系统,而是运行一个 Linux 进程。这意味着该进程在 CPU 的 Ring 0 中运行,并且暴露给它的是机器接口。这个进程可以通过以下方式实现沙盒化:
- 在 Ring 0 中运行进程的可信代码。这基本上是 Dune 称为 libdune 的库。
- 在 Ring 3 中运行不可信代码。
Dune 架构如图 2-6 所示。
Dune 创建了一个操作环境,设置了页表(CR3 寄存器指向根页表)。它还为硬件异常设置了中断描述符表(IDT)。可信代码和不可信代码运行在相同的地址空间内,其中可信代码的内存页通过页表项中的监督位受到保护。系统调用会陷入同一进程,并通过超调用与 VMM 交互。有关 Dune 的更多详细信息,请访问 dune.scs.stanford.edu/。
novm
novm 是另一种类型的硬件容器,也是一种替代形式的虚拟化。(它也使用 KVM API 通过 /dev/kvm 设备文件创建虚拟机。)novm 不向虚拟机提供磁盘接口,而是向虚拟机提供文件系统(9p)接口。这允许将我们想要作为容器部署的软件进行打包。novm 没有 BIOS,VMM 只是将虚拟机直接置于 32 位保护模式。这使得配置过程更快,因为不需要执行设备探测等步骤。
替代虚拟化方法的总结
总而言之,本节介绍了三种替代的虚拟化方法:第一种方法将 Unikernel 与极简操作系统接口打包,第二种方法去除了操作系统接口,直接在 Ring 0 中运行进程,第三种方法为虚拟机提供文件系统而不是直接提供块设备,并优化启动过程。
这些方法在硬件级别提供了良好的隔离性和非常快的启动时间,可能非常适合运行无服务器工作负载和其他云工作负载。
是否还有其他方法?当然有。像 Cloudflare 和 Fastly 这样的公司正在尝试通过在进程内提供隔离来解决虚拟化问题。其目的是利用某些语言的能力来实现以下目标:
- 通过控制流完整性实现代码流隔离
- 内存隔离
- 基于能力的安全性
然后我们可以使用这些原语在每个进程内构建沙盒。通过这种方式,我们可以获得我们希望执行代码的更快启动时间。
WebAssembly 在这个领域引领创新。其基本思想是在同一进程中运行 WebAssembly(即 Wasm 模块),每个模块彼此隔离,因此我们可以为每个租户提供一个沙盒。这非常适合无服务器计算范式,并且可能防止冷启动问题。
顺便提一下,还有一种称为热插拔功能的新功能,使设备可以动态地在客户机中使用。热插拔功能允许开发人员动态调整块设备的大小,例如,无需重新启动客户机。此外,还有 hotplug-dimm 模块,允许开发人员调整客户机可用的 RAM 大小。
总结
本章重点介绍了 Linux 的虚拟机管理程序功能,特别是 QEMU 和 KVM。KVM 利用 Intel 的 vt-x 扩展指令集实现硬件级别的资源隔离。通过在用户空间运行 QEMU,内核将其视为任何其他进程对待,从调度的角度来看。Intel vt-x 指令集有两种形式:适用于 Intel x86 IA-32 和 64 位架构的 Vt-x,以及适用于 Itanium 处理器系列的 Vt-i。支持 CPU 虚拟化的需求源于虚拟机需要两次翻译程序指令,导致性能开销。Vt-x 通过直接在 Ring 0 中运行客户操作系统,避免了环压缩和过度捕获,从而简化了 VMM 软件。
QEMU 作为用户进程运行,并与 KVM 内核模块交互,利用 vt-x 扩展在内存和 CPU 方面隔离客户机环境。QEMU 拥有客户机的 RAM,可以通过文件或匿名方式进行内存映射。QEMU 还通过一个单独的线程处理 I/O,使用 Virtio 等准虚拟化驱动程序为客户机提供虚拟设备。客户操作系统和主机之间的通信通过称为 virtqueue 的专用数据结构进行。
使用 KVM 模块创建虚拟机涉及一系列从用户空间到内核的 ioctl 调用来创建和启动虚拟机。KVM RUN 命令启动虚拟机,通过 VMLaunch 指令将其置于非根模式,并开始执行客户机代码。
本章还简要介绍了替代虚拟化机制,例如打包极简操作系统接口的 Unikernels,运行在 Ring 0 中而无需启动客户操作系统的 Project Dune,以及通过文件系统接口提供硬件容器并优化启动过程的 novm。这些方法提供了良好的硬件级隔离和快速的启动时间,非常适合云工作负载和无服务器环境。最后,章节还提到了 Cloudflare 和 Fastly 等公司使用控制流完整性、内存隔离和基于能力的安全性在进程内部构建沙盒的方法,这些方法可能在代码执行时实现更快的启动时间,尤其是在 WebAssembly (Wasm) 模块兴起的背景下。