本章解释了虚拟化的基础知识,这些知识在你后续创建类似 Docker 的精简版容器框架时会非常有帮助。在进入这一过程之前,你需要了解 Linux 内核如何支持虚拟化,以及 Linux 内核和 CPU 的发展如何在性能方面推动了虚拟机的进步,进而促成了容器化技术的诞生。
本章还解释了什么是虚拟机,以及它的内部工作原理。我们还将探讨一些关于虚拟机管理程序(Hypervisor)的基础知识,它们使得在系统中运行虚拟机成为可能。
虚拟化的历史
在虚拟化时代之前,唯一能够配置完整物理服务器的方式是通过 IT 部门进行操作。这是一个昂贵且耗时的过程。这种方法的一个主要缺点是,机器的资源——如 CPU、内存和磁盘——往往未得到充分利用。为了克服这一问题,虚拟化的概念开始逐渐兴起。
虚拟化的历史可以追溯到20世纪60年代,当时 IBM 的程序员 Jim Rymarczyk 开始对 IBM 主机进行虚拟化。IBM 设计了 CP-40 主机用于内部使用。这个系统逐步演变为 CP-67,使用分区技术同时运行多个应用程序。最终,UNIX 操作系统出现了,它允许在 x86 硬件上运行多个程序。然而,移植性问题依然存在。1990年代初,Sun Microsystems 推出了 Java,促成了“写一次,到处运行”的范式普及。用户现在可以编写一个 Java 程序,该程序可以在各种硬件架构上运行。Java 通过引入中间代码(称为字节码),使其能够在不同硬件架构上的 Java 运行时环境中执行。这标志着进程级虚拟化的开端,即 Java 运行时环境对 POSIX 层进行了虚拟化。
在1990年代末,VMware 介入并推出了其虚拟化模型。这种模型涉及对实际硬件的虚拟化,如 CPU、内存、磁盘等。这意味着在 VMware 软件(也称为虚拟机管理程序)之上,可以运行操作系统本身(称为客户机)。这反过来意味着开发人员不仅限于运行 Java 程序,还可以运行任何适用于客户操作系统的程序。
大约在2001年,VMware 推出了 ESX 和 GSX 服务器。GSX 是一种类型2虚拟机管理程序,这意味着它需要一个操作系统(如 Windows)来运行客户机。ESX 是一种类型1虚拟机管理程序(后来被 VMware ESXi 取代),它允许客户操作系统直接在虚拟机管理程序上运行。
什么是虚拟化?
虚拟化在我们想要虚拟化的实际资源之上提供了抽象。应用此抽象的层次会影响不同虚拟化技术的表现形式。
从更高的层次来看,根据抽象层次的不同,主要有两种虚拟化技术:
- 基于虚拟机(VM)的虚拟化
- 基于容器的虚拟化
除了这两种虚拟化技术外,还有其他技术,例如轻量级、单一用途的虚拟机——unikernels。IBM 目前正通过像 Nabla 这样的项目尝试将 unikernels 作为进程来运行。在本书中,我们主要关注基于虚拟机的虚拟化和基于容器的虚拟化。
基于虚拟机的虚拟化
基于虚拟机的方法虚拟化了完整的操作系统。它向虚拟机呈现的抽象形式是虚拟设备,如虚拟磁盘、虚拟 CPU 和虚拟网络接口卡(NIC)。换句话说,基于虚拟机的方法虚拟化了完整的指令集架构(ISA),例如 x86 ISA。
通过虚拟机,多个操作系统可以共享相同的硬件资源,每个虚拟机都可以访问虚拟化后的资源。例如,虚拟机上的操作系统(也称为客户机)可以继续对磁盘执行 I/O 操作(在这种情况下,它是一个虚拟磁盘),认为它是唯一运行在物理硬件(也称为主机)上的操作系统,尽管实际上这些资源是由多个虚拟机以及主机操作系统共享的。
虚拟机与主机操作系统中的其他进程非常相似。虚拟机在硬件隔离的虚拟地址空间中执行,且其权限级别低于主机操作系统。进程与虚拟机之间的主要区别在于主机向虚拟机暴露的应用程序二进制接口(ABI)。对于进程,暴露的 ABI 具有诸如网络套接字、文件描述符(FD)等结构,而在完整的操作系统虚拟化中,ABI 包括虚拟磁盘、虚拟 CPU、虚拟网络卡等。
基于容器的虚拟化
这种形式的虚拟化不抽象硬件,而是利用 Linux 内核中的技术来隔离对不同资源的访问路径。它在同一操作系统内划分出一个逻辑边界。例如,基于容器的虚拟化提供了一个独立的根文件系统、独立的进程树、独立的网络子系统等。
虚拟机管理程序(Hypervisors)
一种用于虚拟化操作系统的特殊软件被称为虚拟机管理程序(Hypervisor)。虚拟机管理程序本身包括两个部分:
- 虚拟机监控器(VMM):用于捕获和模拟特权指令集(只有操作系统内核才能执行的指令)
- 设备模型:用于虚拟化 I/O 设备
虚拟机监控器(VMM)
由于虚拟机上不能直接访问硬件(尽管在某些情况下可以),VMM 会捕获访问硬件的特权指令(如磁盘/网络卡)并代表虚拟机执行这些指令。
VMM 必须满足以下三个属性(Popek 和 Goldberg,1973年):
- 隔离性:应隔离各个客户机(VM),使其互不干扰。
- 等效性:在有或没有虚拟化的情况下,表现应相同。这意味着大部分(几乎全部)指令直接在物理硬件上运行,无需翻译等处理。
- 性能:应在没有虚拟化的情况下表现得一样好。这同样意味着运行虚拟机的开销应是最小的。
VMM 的一些常见功能如下:
- 不允许虚拟机访问特权状态;例如,不允许虚拟机操作某些主机寄存器的状态。VMM 总是会捕获并模拟这些调用。
- 处理异常和中断。如果从虚拟机内发出网络调用(即请求),它将在 VMM 中被捕获并模拟。在收到来自物理网络/网络接口卡(NIC)的响应后,CPU 会生成一个中断并将其传递给目标虚拟机。
- 处理 CPU 虚拟化,通过在虚拟机的虚拟 CPU 内本地运行大部分指令,并仅在某些特权指令时进行捕获。这意味着其性能几乎与直接在硬件上运行的本地代码一样好。
- 通过将调用映射到客户机中的虚拟设备映射内存,处理内存映射 I/O,并映射到实际的物理设备映射内存。为此,VMM 应控制物理内存映射(从客户机物理内存到主机物理内存)。更多细节将在本章后面提供。
设备模型
虚拟机管理程序的设备模型通过捕获和模拟 I/O 虚拟化,然后将中断返回到特定的虚拟机。
内存虚拟化
虚拟化面临的一个关键挑战是如何虚拟化内存。客户操作系统(Guest OS)的行为应与非虚拟化操作系统相同。这意味着客户操作系统至少应感知到它控制了内存。
在虚拟化的情况下,客户操作系统不能直接访问物理内存。这意味着客户操作系统不应能够操作硬件页表,因为这可能导致客户操作系统控制物理系统。
在深入探讨如何解决这一问题之前,即使在正常的操作系统和硬件交互的背景下,也需要对内存虚拟化有一个基本的了解。
操作系统为其进程提供了内存的虚拟视图;任何对物理内存的访问都会被硬件组件——称为内存管理单元(MMU)——拦截并处理。操作系统通过特权指令设置 CR3 寄存器,而 MMU 使用该入口遍历页表以确定物理映射。操作系统还负责在物理内存分配和释放时更改这些映射。
现在,对于虚拟化的客户机来说,行为应该是类似的。客户机不应直接访问物理内存,而是应由 VMM 拦截并处理。
基本上,在运行客户操作系统时涉及三种内存抽象:
- 客户虚拟内存:这是在客户操作系统上运行的进程所看到的内存。
- 客户物理内存:这是客户操作系统看到的内存。
- 系统物理内存:这是 VMM 看到的内存。
有两种可能的方式来处理这个问题:
- 影子页表
- 硬件支持的嵌套页表
影子页表
在影子页表的情况下,客户虚拟内存通过 VMM 直接映射到系统物理内存。这通过避免额外的一层翻译来提高性能。但这种方法有一个缺点:当客户页表发生变化时,影子页表需要更新。这意味着必须捕获并模拟到 VMM 中来处理这一情况。VMM 可以通过将客户页表标记为只读来实现这一点。这样,客户操作系统试图写入它们时会触发捕获,VMM 然后可以更新影子表。
硬件支持的嵌套页表
英特尔和 AMD 通过硬件扩展提供了一个解决方案。英特尔提供了一种称为扩展页表(EPT)的技术,允许 MMU 遍历两个页表。
第一次遍历是从客户虚拟内存到客户物理内存,第二次遍历是从客户物理内存到系统物理内存。由于所有这些翻译现在都在硬件中进行,因此不需要维护影子页表。客户页表由客户操作系统维护,而另一个页表由 VMM 维护。
在使用影子页表的情况下,转换旁路缓冲区(TLB,属于 MMU 的一部分)缓存需要在上下文切换时刷新,即切换到另一个虚拟机时。相比之下,在扩展页表的情况下,硬件通过地址空间标识符引入了一个虚拟机标识符,这意味着 TLB 缓存可以同时保存不同虚拟机的映射,从而提高了性能。
CPU 虚拟化
在探讨 CPU 虚拟化之前,你需要了解 x86 架构中的保护环概念。这些环允许 CPU 保护内存和控制特权,并确定哪些代码在什么特权级别下执行。
内核在最高特权模式,即 Ring 0 中运行,而用于运行进程的用户空间则在 Ring 3 中运行。
硬件要求所有特权指令必须在 Ring 0 中执行。如果尝试在 Ring 3 中运行特权指令,CPU 会产生故障。内核已经注册了故障处理程序,并根据故障类型调用相应的故障处理程序。对应的故障处理程序会对故障进行合理性检查并处理它。如果检查通过,故障处理程序代表进程执行操作。在基于虚拟机的虚拟化中,虚拟机作为主机操作系统上的一个进程运行,因此如果故障没有被处理,整个虚拟机可能会被终止。
从更高层次来看,来自 Ring 3 的特权指令执行是通过代码段寄存器的代码特权级别(CPL)位来控制的。所有来自 Ring 3 的调用都会被门控到 Ring 0。例如,可以通过类似 syscall 的指令(从用户空间)发出系统调用,这会设置正确的 CPL 级别,并以较高的特权级别执行内核代码。任何试图直接从上层环调用高特权代码的行为都会导致硬件故障。
同样的概念适用于虚拟化操作系统。在这种情况下,客户机被降级到 Ring 1,而客户机的进程运行在 Ring 3 中。VMM 本身运行在 Ring 0 中。对于完全虚拟化的客户机,任何特权指令都必须被捕获并模拟。VMM 模拟被捕获的指令。除了特权指令外,敏感指令也需要被 VMM 捕获并模拟。
旧版本的 x86 CPU 是不可虚拟化的,这意味着并非所有敏感指令都是特权指令。像 SGDT、SIDT 等指令可以在 Ring 1 中执行而不被捕获。当运行客户操作系统时,这可能是有害的,因为这可能允许客户机窥探主机内核的数据结构。这个问题可以通过两种方式解决:
- 完全虚拟化中的二进制翻译
- 在 XEN 中通过超调用实现的准虚拟化
完全虚拟化中的二进制翻译
在这种情况下,客户操作系统无需进行任何更改。指令被捕获并在目标环境中模拟。这会导致较大的性能开销,因为许多指令必须被捕获到主机/虚拟机管理程序中并进行模拟。
在 XEN 中通过超调用实现的准虚拟化
为了避免完全虚拟化中与二进制翻译相关的性能问题,我们使用准虚拟化,其中客户机知道它运行在虚拟化环境中,并且它与主机的交互经过优化,以避免过度捕获。例如,设备驱动代码被更改并分为两个部分。一个是后端,与虚拟机管理程序一起工作,另一个是前端,与客户机一起工作。客户机和主机驱动程序现在通过环形缓冲区进行通信。环形缓冲区从客户机内存中分配。现在客户机可以在环形缓冲区内积累/聚合数据,并通过一次超调用(即对虚拟机管理程序的调用,也称为“踢”)来通知数据已准备好被读取。这避免了客户机到主机的过度捕获,是一个性能上的提升。
2005年,x86 终于实现了虚拟化。英特尔引入了一个额外的环,称为 Ring -1,也称为虚拟机扩展(VMX)根模式。VMM 运行在 VMX 根模式中,而客户机运行在非根模式中。
这意味着客户机可以在 Ring 0 中运行,对于大多数指令,没有捕获。客户机需要的特权/敏感指令由 VMM 在根模式下通过捕获执行。这些切换被称为虚拟机退出(即 VMM 接管客户机的指令执行)和虚拟机进入(即虚拟机从 VMM 中获得控制)。
除此之外,虚拟化 CPU 管理着一个称为虚拟机控制结构(VMCS)的数据结构,它包含虚拟机的状态和寄存器信息。CPU 在虚拟机进入和退出期间使用这些信息。VMCS 结构类似于表示进程的 task_struct 数据结构。一个 VMCS 指针指向当前活动的 VMCS。当捕获到 VMM 时,VMCS 提供所有客户机寄存器的状态,例如退出的原因等。
硬件辅助虚拟化的优点有两方面:
- 无需二进制翻译
- 无需操作系统修改
问题在于虚拟机进入和退出仍然是繁重的调用,涉及大量的 CPU 周期,因为必须保存和恢复完整的虚拟机状态。大量工作已经投入到减少这些进入和退出的周期中。使用准虚拟化驱动程序有助于缓解一些性能问题。详细信息在下一节中解释。
I/O 虚拟化
通常有两种 I/O 虚拟化模式:
- 完全虚拟化
- 准虚拟化
完全虚拟化
在完全虚拟化中,客户操作系统不知道它运行在虚拟机管理程序(Hypervisor)上,并且无需进行任何更改即可在该虚拟机管理程序上运行。每当客户机发出 I/O 调用时,这些调用会被虚拟机管理程序捕获,并由虚拟机管理程序在物理设备上执行 I/O 操作。
准虚拟化
在这种情况下,客户操作系统知道它运行在虚拟化环境中,并在客户机中加载了专门的驱动程序来处理 I/O 操作。I/O 的系统调用被替换为超调用(Hypercalls)。
图 1-1 显示了准虚拟化和完全虚拟化之间的区别。
在准虚拟化场景中,客户机端的驱动程序被称为前端驱动程序,而主机端的驱动程序被称为后端驱动程序。Virtio 是实现准虚拟化驱动程序的虚拟化标准。客户机的前端网络或 I/O 驱动程序基于 Virtio 标准实现,前端驱动程序知道它们运行在虚拟环境中。它们与虚拟机管理程序的后端 Virtio 驱动程序协同工作。这种前端和后端驱动程序的工作机制有助于实现高性能的网络和磁盘操作,这也是准虚拟化带来大部分性能提升的原因。
如前所述,客户机上的前端驱动程序实现了一组通用接口,这些接口由 Virtio 标准描述。当客户机中的进程需要发出 I/O 调用时,进程会调用前端驱动程序 API,驱动程序通过 virtqueue(虚拟队列)将数据包传递给相应的后端驱动程序。
后端驱动程序可以通过两种方式工作:
- 它们可以使用 QEMU 仿真,这意味着 Quick Emulator (QEMU) 通过用户空间的系统调用模拟设备调用。这意味着虚拟机管理程序允许用户空间的 QEMU 程序执行实际的设备调用。
- 它们可以使用类似 vhost 的机制,从而避免 QEMU 仿真,由虚拟机管理程序内核执行实际的设备调用。
如前所述,前端和后端 Virtio 驱动程序之间的通信是通过 virtqueue 抽象来完成的。virtqueue 提供了一个用于交互的 API,允许它入队和出队缓冲区。根据驱动程序的类型,驱动程序可以使用零个或多个队列。以网络驱动程序为例,它使用两个 virtqueue——一个队列用于请求,另一个用于接收数据包。另一方面,Virtio 块设备驱动程序只使用一个 virtqueue。
考虑以下网络数据包流的示例,其中客户机希望通过网络发送一些数据:
- 客户机通过客户机内核发起网络数据包写入。
- 客户机中的准虚拟化驱动程序(Virtio)将这些缓冲区放入 virtqueue(tx)。
- virtqueue 的后端是工作线程,它接收这些缓冲区。
- 然后缓冲区被写入 tap 设备文件描述符。tap 设备可以连接到类似 OVS 或 Linux 桥接器的软件桥接器。
- 桥接器的另一端有一个物理接口,然后通过物理层传输数据。
在此示例中,当客户机将数据包放置在 tx 队列中时,它需要一种机制来通知主机端有数据包需要处理。Linux 中有一个有趣的机制称为 eventfd,用于通知主机端有事件发生。主机监控 eventfd 的变化。
类似的机制用于将数据包发送回客户机。
正如前几节所述,硬件行业正在虚拟化领域迎头赶上,并提供越来越多的硬件虚拟化支持,无论是 CPU 虚拟化(引入新的环和 vt-x 指令)还是内存虚拟化(扩展页表)。
同样,对于 I/O 虚拟化,硬件有一种机制称为 I/O 内存管理单元(IOMMU),它类似于 CPU 内存管理单元(前面介绍过),但只是针对 I/O 的内存管理。借助 CPU MMU 概念,设备内存访问被拦截并映射,以允许不同的客户机访问。客户机被物理映射到不同的物理内存,并且访问由 IOMMU 硬件控制。这为设备访问提供了所需的隔离。
此功能可以与一种称为单根 I/O 虚拟化(SRIOV)的技术结合使用,SRIOV 允许将兼容 SRIOV 的设备划分为多个虚拟功能。基本思想是绕过数据路径中的虚拟机管理程序,使用直通机制,使虚拟机直接与设备通信。SRIOV 的详细信息超出了本书的范围。对此感兴趣的读者可以访问以下链接以了解更多关于 SRIOV 的信息:
总结
在本章中,我们首先深入探讨了虚拟化的历史和演变。我们从1960年代 IBM 主机的虚拟化开始,然后介绍了 UNIX 和 Java 的引入,这为进程级虚拟化铺平了道路。1990年代末,VMware 进入虚拟化领域,实现了实际硬件的虚拟化。
接下来,我们讨论了两种主要的虚拟化技术:基于虚拟机的虚拟化,它虚拟化了整个操作系统;以及基于容器的虚拟化,它在 Linux 内核中创建逻辑边界。
本章还解释了虚拟机管理程序(Hypervisor)的作用,它由虚拟机监控器(VMM)和设备模型组成,用于管理虚拟化环境。我们还研究了内存和 CPU 虚拟化技术,如影子页表和硬件支持的嵌套页表。此外,我们还探讨了准虚拟化和 Virtio 驱动程序在优化 I/O 操作中的优势。
总体而言,本章提供了对虚拟化关键概念的全面理解,并说明了它在现代计算环境中的重要性。