8 分钟阅读

简介

虚拟化技术的发展

  1. 主机虚拟化,一台物理机可以被划分为若干个小的机器,每个机器的硬件互不共享,各自安装操作系统
  2. 硬件虚拟化,同一个物理机上隔离多个操作系统实例
  3. 操作系统虚拟化,由操作系统创建虚拟的系统环境,使应用感知不到其他应用的存在

在数据中心里面用的虚拟机,我们通常叫作 Type-1 型的虚拟机,客户机的指令交给虚拟机监视器之后,可以直接由虚拟机监视器去调用硬件,指令不需要做什么翻译工作,可以直接往下传递执行就好了。因为虚拟机监视器需要直接和硬件打交道,所以它也需要包含能够直接操作硬件的驱动程序。所以 Type-1 的虚拟机监视器更大一些,同时兼容性也不能像 Type-2 型那么好。不过,因为它一般都是部署在我们的数据中心里面,硬件完全是统一可控的,这倒不是一个问题了。

在 Type-2 虚拟机里,我们上面说的虚拟机监视器好像一个运行在操作系统上的软件。你的客户机的操作系统呢,把最终到硬件的所有指令,都发送给虚拟机监视器。而虚拟机监视器,又会把这些指令再交给宿主机的操作系统去执行。

cpu 虚拟化

KVM 的「基于内核的虚拟机」是什么意思?早期比较常见的虚拟化解决方案,它的工作原理很简单:把CPU的所有寄存器都写在一组变量中(这组变量我们称为CPUFile),然后用一片内存当作被模拟CPU的内存(这片内存这里称为vMEM),然后在用一些数据结构表示IO设备的状态(这里称为vIO),三者的数据结构综合在一起,就是代表一个虚拟化的环境了(这里称之为VM),之后按顺序读出一条条的指令,根据这个指令的语义,更改VM的数据结构的状态(如果模拟了硬件,还要模拟硬件的行为,比如发现显存被写了一个值,可以在虚拟屏幕上显示一个点等),这样,实施虚拟的那个程序就相当于给被虚拟的程序模拟了一台计算机,这种技术,我称它为“解释型虚拟化技术”。指令是被一条一条解释执行的。随着技术的发展,有人就开始取巧了:很多时候,我们仅仅是在x86上模拟x86,这些指令何必要一条条解释执行?我们可以用CPU直接执行这些指令啊,执行到特权指令的时候,我们直接异常,然后在异常中把当前CPU的状态保存到CPUFile中,然后再解释执行这个特权指令,这样不是省去了很多”解释“的时间了?

KVM 介绍(二):CPU 和内存虚拟化 - Avaten的文章 - 知乎因为宿主操作系统是工作在 ring0 的,客户操作系统就不能也在ring0 了,但是它不知道这一点,以前执行什么指令,现在还是执行什么指令,但是没有执行权限是会出错的。所以这时候虚拟机管理程序(VMM)需要避免这件事情发生。虚机怎么通过 VMM 实现 Guest CPU 对硬件的访问,根据其原理不同有三种实现技术:

  1. 基于二进制翻译的全虚拟化。客户操作系统运行在 Ring 1,它在执行特权指令时,会触发异常(CPU的机制,没权限的指令会触发异常),然后 VMM 捕获这个异常,在异常里面做翻译,模拟,最后返回到客户操作系统内,客户操作系统认为自己的特权指令工作正常,继续运行。但是这个性能损耗,就非常的大,简单的一条指令,执行完,了事,现在却要通过复杂的异常处理过程。
  2. 半虚拟化/操作系统辅助虚拟化。修改操作系统内核,替换掉不能虚拟化的指令,通过超级调用(hypercall)直接和底层的虚拟化层hypervisor来通讯,hypervisor 同时也提供了超级调用接口来满足其他关键内核操作,比如内存管理、中断和时间保持。所以像XEN这种半虚拟化技术,客户机操作系统都是有一个专门的定制内核版本,这也是为什么XEN只支持虚拟化Linux,无法虚拟化windows原因,微软不改代码啊。
  3. 硬件辅助的全虚拟化。2005年后,CPU厂商Intel 和 AMD 开始支持虚拟化了,这种 CPU,有 VMX root operation 和 VMX non-root operation两种模式,VMM 可以运行在 VMX root operation模式下,客户 OS 运行在VMX non-root operation模式下。因为 CPU 中的虚拟化功能的支持,并不存在虚拟的 CPU,KVM Guest 代码是运行在物理 CPU 之上。对 KVM 虚机来说,运行在 VMX Root Mode 下的 VMM 在需要执行 Guest OS 指令时执行 VMLAUNCH 指令将 CPU 转换到 VMX non-root mode,开始执行客户机代码 ==> 运行某些指令或遇到某些事件时 ==> 硬件自动挂起 Guest OS ==> 切换到 VMX root operation 模式 ,恢复VMM 的运行 (而不是cpu 直接报错) ==> VMM 将操作转到 guest os 对应的内存、设备驱动等。

总结一下:cpu 虚拟化的核心难点是特权指令,对于特权指令,guest os 要么改自己不执行特权指令,要么VMM 为cpu 报错擦屁股,要么cpu 不报错。

《编程高手必学的内存知识》

在虚拟化技术中涉及的有三个核心角色,分别是宿主机Host,客户机Guest和虚拟机监控器 (Virtual Machine Monitor , VMM)。VMM负责为客户机准备虚拟 CPU,虚拟内存等虚拟资源,并同时对客户机进行管理。

  1. 我们可以很容易想到一个实现虚拟化技术的方案是:通过纯软件模拟 CPU 执行过程。也就是说,这里需要对完整的底层硬件进行模拟,包括处理器、物理内存和外部设备等等。这样的话,Guest 的所有程序都相当于运行在 Host 的一个解释器里,来一条指令就解释一条指令,资源限制以及运行等价的要求都很容易满足。不过这个方案的缺陷也非常明显,因为你是用软件来对 CPU 的指令进行了翻译,通常一条指令最终会被翻译成非常多的指令,那效率自然也是非常低的。既然对指令进行翻译的效率是如此低下,那我们为什么不能让 Guest 程序的代码直接运行在 Host 的 CPU 上呢?我们本来翻译指令的目的,是为了让 VMM 能够对 Guest 执行的指令进行监管,防止 Guest 对计算资源的滥用,那如果又让 Guest 的执行直接运行在 CPU 上,VMM 又哪里有机会能够对 Guest 进行监管呢?
  2. 为了解决这个问题。人们提出一个重要的模型,这就是陷入模拟(Trap-and-Emulate)模型。将 Guest 运行的指令进行分类,一类是安全的指令,也就是说这些指令可以让 Host 的 CPU 正常执行而不会产生任何副作用,例如普通的数学运算或者逻辑运算,或者普通的控制流跳转指令等;另一类则是一些“不安全”的指令,又称为“Trap”指令,也就是说,这些指令需要经过 VMM 进行模拟执行,例如中断、IO 等特权指令等。接下来,我们来看一下它的具体实现过程:
    1. 对于“安全”的指令,Guest 在执行时可以交由 Host 的 CPU 正常运行,这样可以保证大部分场景的性能。
    2. 不过,当 Guest 执行一些特权指令时就需要发出 Trap,通知 VMM 来接管 Guest 的控制流。VMM 会对特权指令进行模拟 (Emulate),从而达到资源控制的效果。当然在进行模拟的过程中需要保证执行结果的等价性。比如”int 0x80”这条指令就是一个特权指令,它会导致当前进程切入内核态执行。在虚拟化场景下遇到这种特权指令,我们不能直接交给宿主机的真实 CPU 去执行,因为宿主机 CPU 会使用宿主机的 IDT 来处理这次中断请求。而我们真正希望的是,使用客户机的 IDT 去查找相应的中断服务程序。这就需要 Guest 退回到 VMM,让 VMM 模拟 CPU 的动作去解析 IDT 中的中断描述符,找到 Guest 的中断服务程序并调用它。在这个例子中,Geust 退回 VMM 的操作就是 Trap,VMM 模拟 CPU 的动作去调用 Guest 的中断服务程序就是 Emulate。
  3. 不过,这里仍然存在一个问题:当 Guest 的内核代码在 Host 的 CPU 上执行的时候,Guest 没有办法区分“安全”指令和“非安全”指令,也就是说 Guest 不知道哪条指令应该触发 Trap。幸好,现代的芯片对这种情况做了硬件上的支持。现代的 X86 芯片提供了 VMX 指令来支持虚拟化,并且在 CPU 的执行模式上提供了两种模式:root mode 和 non-root mode,这两种模式都支持 ring 0 ~ ring 3 三种特权级别。VMM 会运行在 root mode 下,而 Guest 操作系统则运行在 non-root mode 下。所以,对于 Guest 的系统来讲,它也和物理机一样,可以让 kernel 运行在 ring 0 的内核态,让用户程序运行在 ring 3 的用户态, 只不过整个 Guest 都是运行在 non-root 模式下。有了 VMX 硬件的支持,Trap-and-Emulate 就很好实现了。Guest 可以在 non-root 模式下正常执行指令,就如同在执行物理机的指令一样。当遇到“不安全”指令时,例如 I/O 或者中断等操作,就会触发 CPU 的 trap 动作,使得 CPU 从 non-root 模式退出到 root 模式,之后便交由 VMM 进行接管,负责对 Guest 请求的敏感指令进行模拟执行。这个过程称为 VM Exit。而处于 root 模式下的 VMM,在一开始准备好 Guest 的相关环境,准备进入 Guest 时,或者在 VM Exit 之后执行完 Trap 指令的模拟准备,再次进入 Guest 的时候,可以继续通过 VMX 提供的相关指令 VMLAUNCH 以及 VMResume,来切换到 non-root 模式中由 Guest 继续执行。 这个过程也被称为 VM Entry。

PS:这段把Host、Guest和VMM的协作方式说清楚了,但是VMM 如何帮助Guest 操作外设没提。

Guest 内存管理 未读。

KVM + QEMU

kvm两大组件

  1. 内核中实现的一个KVM模块
  2. 用户态的QEMU工具 KVM(Kernel-based Virtual Machine)利用修改的QEMU提供BIOS、显卡、网络、磁盘控制器等的仿真,但对于I/O设备(主要指网卡和磁盘控制器)来说,则必然带来性能低下的问题。因此,KVM也引入了半虚拟化的设备驱动,通过虚拟机操作系统中的虚拟驱动与主机Linux内核中的物理驱动相配合,提供近似原生设备的性能。从此可以看出,KVM支持的物理设备也即是Linux所支持的物理设备。

为了简化代码,KVM 在 QEMU 的基础上做了修改。VM 运行期间,QEMU 会通过 KVM 模块提供的系统调用进入内核,由 KVM 负责将虚拟机置于处理的特殊模式运行。遇到虚机进行 I/O 操作,KVM 会从上次的系统调用出口处返回 QEMU,由 QEMU 来负责解析和模拟这些设备。从 QEMU 的角度看,也可以说是 QEMU 使用了 KVM 模块的虚拟化功能,为自己的虚机提供了硬件虚拟化加速。除此以外,虚机的配置和创建、虚机运行说依赖的虚拟设备、虚机运行时的用户环境和交互,以及一些虚机的特定技术比如动态迁移,都是 QEMU 自己实现的。

guest os 与 物理机cpu 之间vmm 如何发挥作用的,还没找到直观感觉(未完成)。vm 是一个进程,vCpu 是其中的一个线程,由VMM 管理,这个味道咋跟docker 这么一样呢?

KVM原理简介 写的更好一些。KVM是作为一个内核模块出现的,所以它还得借助用户空间的程序QEMU 来和用户进行交互。QEMU是一套由法布里斯·贝拉(Fabrice Bellard)所编写的以GPL许可证分发源码的模拟处理器,在GNU/Linux平台上使用广泛。其本身是一个纯软件的支持CPU虚拟化、内存虚拟化及I/O虚拟化等功能的用户空间程序。借助KVM提供的虚拟化支持可以将CPU、内存等虚拟化工作交由KVM处理,自己则处理大多数I/O虚拟化的功能,可以实现极高的虚拟化效率。

  1. 正常我们在执行WFI指令时会使CPU进入一个低功耗的状态,但是对于HOST OS来说,如果让CPU真正进入低功耗状态,显然会影响其他VM的运行。如果我们配置了HCR_EL2.TWI==1时,那么Guest OS在执行WFI时就会触发EL2的异常,然后陷入(PS: 感觉跟异常调用一样)Hypervisor,那么此时Hypervisor就可以将对应VCPU所处的线程调出出去,将CPU让给其他的VCPU线程使用。PS: Hypervisor 感觉就是提供了 系统调用处理这个场景
  2. 内存虚拟化的目的是给虚拟客户机操作系统提供一个从0开始的连续的地址空间,同时在多个客户机之间实现隔离与调度。arm主要通过Stage 2转换来提供对内存虚拟化的支持,其允许Hypervisor控制虚拟机的内存视图,而在这之前则是使用及其复杂的影子页表技术来实现。Stage 2转换可以控制虚拟机是否可以访问特定的某一块物理内存,以及该内存块出现在虚拟机内存空间的位置。这种能力对于虚拟机的隔离和沙箱功能来说至关重要。这使得虚拟机只能看到分配给它自己的物理内存。为了支持Stage 2 转换, 需要增加一个页表,我们称之为Stage 2页表。操作系统控制的页表转换称之为stage 1转换,负责将虚拟机视角的虚拟地址转换为虚拟机视角的物理地址。而stage 2页表由Hypervisor控制,负责将虚拟机视角的物理地址转换为真实的物理地址。虚拟机视角的物理地址在Armv8中有特定的词描述,叫中间物理地址(intermediate Physical Address, IPA)。
  3. I/O虚拟化,有四种方案:设备模拟,比如qemu 模拟具体的I/O设备的特性;前后端驱动接口;设备直接分配,将一个物理设备直接分配给Guest OS使用;设备共享分配,其主要就是让一个物理设备可以支持多个虚拟机功能接口,将不同的接口地址独立分配给不同的Guest OS使用。如SR-IOV协议。

汇总下

硬件辅助虚拟化(Intel VT-x)给 CPU 两个模式:root(跑 VMM)和 non-root(跑整个 guest)。guest 的普通指令、以及大部分特权指令都直接在物理 CPU 上原生执行,只有”敏感”操作(真实 I/O、访问未映射内存等)才触发 VM Exit 陷出到 VMM 处理。cpu/mem/io(虚拟化)操作入口都是指令执行

  1. guest 普通指令。 non-root mode + ring3, 物理 CPU 直接跑,add/mov/jmp、算术逻辑、用户态访存等
  2. guest 非敏感特权指令。 non-root + ring0, 物理 CPU 直接跑。 cli/sti(开关本地中断)、lidt/lgdt/ltr(装自己的 IDT/GDT)等。
  3. guest 敏感特权指令。 non-root + ring0, 触发 VM Exit → 切到 root,会触及宿主/别的 VM/真实硬件/全局状态,必须让 VMM 接管。
    1. 2和3的边界是 VMCS 配的,不是写死的。除少数无条件陷出(cpuid/vmcall),”哪些算敏感”由 VMM 通过 VMCS 的 I/O bitmap / MSR bitmap / 各种 exec controls 决定。 业界的演进就是把第3类指令转为第2类。

用 KVM 跑一个函数,再跑一个 Linux

前面《编程高手必学的内存知识》那段一直没找到“VMM 到底怎么发挥作用”的直观感觉。其实把 KVM 当成一个“能让物理 CPU 去跑任意机器码”的设备来用,一下就通了。

/dev/kvm:用户态进内核的唯一入口

KVM 是一个内核模块,它对外只暴露一个字符设备 /dev/kvm。所有用户态程序(QEMU、Firecracker,以及下面这个最小 VMM)想用上 CPU 的硬件虚拟化能力,唯一的办法就是 open("/dev/kvm") 拿到一个 fd,然后全程用 ioctl 跟它打交道——没有别的接口。

这些 ioctl 按作用域分三层,每层对应一个 fd(拿到上一层 fd 才能开下一层):

  1. system fdopen("/dev/kvm") 得到):全局能力。KVM_GET_API_VERSIONKVM_CREATE_VMKVM_GET_VCPU_MMAP_SIZE 等。
  2. vm fd(对 system fd 发 KVM_CREATE_VM 得到):一台虚机级别。KVM_SET_USER_MEMORY_REGION(给 guest 配内存)、KVM_CREATE_VCPU、建虚拟中断控制器等。
  3. vcpu fd(对 vm fd 发 KVM_CREATE_VCPU 得到):一个 vCPU 级别。KVM_RUN(让它跑)、KVM_GET/SET_REGS/SREGS(读写寄存器)等。

用法范式就一句话:ioctl(某层 fd, KVM_XXX, 参数)。下面骨架里 open → CREATE_VM → SET_USER_MEMORY_REGION → CREATE_VCPU → KVM_RUN,正好把这三层 fd 顺着走了一遍。

前提:宿主 CPU 支持并开启了硬件虚拟化(Intel VT-x / AMD-V),内核也加载了 kvm + kvm_intel/kvm_amd 模块,系统里才会有 /dev/kvm 这个设备节点。很多云主机/容器里没透传虚拟化、压根没有这个节点,就用不了 KVM 加速,只能退回纯软件模拟(前面说的二进制翻译那条慢路)——所以判断“这台机器能不能跑 microVM”,第一道关就是 ls /dev/kvm

有了这层认识,一个最小的用户态 VMM 骨架就这么几步(QEMU 本质也是这套,只是把设备模拟做全了):

int kvm  = open("/dev/kvm", O_RDWR);
int vmfd = ioctl(kvm, KVM_CREATE_VM, 0);
// 给 guest 一块“物理内存” = host 上 mmap 的一段;guest 看到的 GPA → host 的 HPA 由 EPT 翻译
void *mem = mmap(...);
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region);   // region.guest_phys_addr = 0x1000
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);        // 一个 vCPU = 后面一个线程在跑它
struct kvm_run *run = mmap(..., vcpufd, 0);          // VM Exit 的信息就写在这块共享内存里
while (1) {
    ioctl(vcpufd, KVM_RUN, NULL);          // CPU 进 non-root 跑 guest;返回 = 一次 VM Exit
    switch (run->exit_reason) { ... }      // 读 exit_reason,VMM 来“擦屁股”
}

ioctl(KVM_RUN) 返回的那一刻就是一次 VM Exit;exit_reason 告诉你 guest 撞了哪条敏感(第 3 类)指令;switch 里写的就是 VMM 该做的事。第 1/2 类指令根本不返回、对性能免费。“vm 是进程、vCPU 是线程”就落在这里:某个线程在 ioctl(KVM_RUN)

例子:用 KVM 跑一个 sum(n) 函数

int sum(int n){ int s=0; while(n){ s+=n; n--; } return s; } 编成 16 位机器码,正好 8 字节:

; 入参 bx=n   出参 ax=sum
31 C0   xor ax,ax     ; s=0
01 D8   add ax,bx     ; s+=n   ┐
4B      dec bx        ; n--    │ 循环体,全是第 1/2 类指令
75 FB   jnz -5        ; n!=0   ┘
F4      hlt           ; 完事,叫 VMM 来取结果   ← 唯一的第 3 类

host 侧跟上面骨架一样,只多“传参 / 跑 / 取返回值”三步:

const uint8_t func[] = {0x31,0xC0, 0x01,0xD8, 0x4B, 0x75,0xFB, 0xF4};
memcpy(mem, func, sizeof(func));

struct kvm_regs regs = { .rip=0x1000, .rbx=5, .rflags=0x2 };   // ① 传参:n=5
ioctl(vcpufd, KVM_SET_REGS, &regs);

while (1) { ioctl(vcpufd, KVM_RUN, NULL);                      // ② 跑
            if (run->exit_reason == KVM_EXIT_HLT) break; }     //    跑到 hlt = 返回

ioctl(vcpufd, KVM_GET_REGS, &regs);                           // ③ 取返回值
printf("sum(5)=%d\n", (int)(regs.rax & 0xffff));              //    → 15

n=5 要跑 5 轮 add/dec/jnz,这 15 条指令全程零 VM Exit——它们在 non-root 由物理 CPU 原生执行,KVM 根本不插手;整个函数只有最后 hlt 把 VMM 叫醒一次。所以“用 KVM 跑一个函数”就是一套调用约定:

函数概念 KVM 对应
传参(走寄存器) KVM_SET_REGS 设 rbx
函数体执行 guest 字节在 non-root 原生跑,无 VM Exit
return hltKVM_EXIT_HLT(约定“我跑完了”)
取返回值 KVM_GET_REGS 读 rax
“调用”这个动作 ioctl(KVM_RUN)

hlt 本是第 3 类敏感指令,这里被借用成了“函数返回”的信号——这就是 VMM 和 guest 之间最朴素的一次握手。

从一个 func 到一个 Linux main

把这个例子放大,就是 KVM 跑 Linux——机制完全一样,区别只在“字节多大、参数多复杂、跑完会不会停”:

  上面的 sum 函数 Linux 内核
guest 字节 8 字节机器码 几 MB 的 bzImage(VMM 自己 load 进内存,不走 BIOS/GRUB
“参数” rbx=n rsi → boot_params(内存布局 / initramfs 位置 / cmdline)
函数体 一个循环 一个永不返回的 main()(调度、跑进程……)
return hlt 不 hlt——一直跑
取结果 KVM_GET_REGS 读 ax 不断 KVM_EXIT_IO/MMIO:它要读写设备(串口、virtio 磁盘/网卡)

所以 Linux 不过是一个“大号、不返回、还会不停做 I/O 的 main()”:VMM 把内核字节放进 guest 内存、用 boot_params 当“参数”、KVM_RUN 让 CPU 去跑;guest 自己的普通/特权指令在 non-root 原生跑,只有遇到 I/O 等敏感操作才陷出来让 VMM(KVM + QEMU)模拟。microVM(如 Firecracker)就是把这套做到极致:内核直接引导(省掉 BIOS/GRUB)、设备模型砍到只剩 virtio——本质仍是这个 KVM_RUN 循环。

启动一个容器就是启动一个 ENTRYPOINT/CMD 进程(数据 在mount namespace 挂好了),linux 启动一个进程(fork + execve)是创建一个task struct,各种信息配好,然后把task struct.pc 拨到一个合适位置,等着linux 调度task struct。启动一个vm = 内核建 struct kvm + 每 vCPU 一个 struct kvm_vcpu。VMM 进程里跑着 1 个管理/IO 线程(跑 VMM 那段ioctl 管理程序)+ 每 vCPU 一个 vCPU 线程(都是 task_struct,被 Linux 正常调度)。vCPU 线程一被调度,就 KVM_RUN → VMENTER → CPU 在 non-root 从 guest rip(指向guest os内核入口)开始跑 guest。

从“跑 Linux”到 microVM:直引导 + virtio

上一节的 KVM_RUN 循环已经是 VMM 的心脏。要变成一个真能用的 microVM(如 Firecracker),只差两件事:内核怎么进来、设备怎么来。

直引导:跳过 BIOS 和 GRUB

真机启动 = 上电 → BIOS(自检、枚举硬件)→ GRUB(读盘、菜单)→ 内核,几秒起。microVM 让 VMM 自己当 bootloader:把内核 bzImage 直接 load 进 guest 内存、填好 boot_params(内存布局 / initramfs 位置 / cmdline)、rip 指向内核 64 位入口——BIOS、GRUB 全省掉。这套约定叫 Linux/x86 boot protocol,RustVMM 的 linux-loader 替你干。

initramfs 里的 /init 就是 guest 的 PID 1,要跑的 init / agent 程序塞这。再加上下面只有 virtio、不枚举 PCI 的设备模型,整个冷启压到 ~125ms。

virtio:填上那个空的 KVM_EXIT_MMIO 分支

Virtqueues and virtio ring: How the data travels 内核要磁盘/网卡/控制台。模拟真硬件的话,guest 每戳一次寄存器就 VM Exit,太慢;virtio 改成 host 和 guest 共用一块内存里的环批量传请求:

virtio virtqueue:描述符表 / avail 环 / used 环

一次读盘:guest 把请求填进描述符表、把头索引塞进 avail 环 → 写 QueueNotify 寄存器敲门铃(落到上一节那个空的 KVM_EXIT_MMIO 分支)→ host 读环、pread 镜像文件、把数据直接写进 guest 缓冲(guest 内存就是 host 的 mmap,host_addr = mmap基 + (GPA-基))→ 把结果塞进 used 环KVM_IRQ_LINE 注入完成中断。门铃进、中断出——正好一个指令入口加一个事件入口。再用 ioeventfd / irqfd 把门铃和中断挪进内核、设备后端独立线程跑,vCPU 就能一直满速。

microVM 的设备就这么几个 virtio:blk(盘)/ net(网)/ vsock(host↔agent,见下)/ 串口。没有 PCI、没有 GPU——既是启动快(不枚举 PCI),也是攻击面小的来源。

vsock:host↔guest 的控制通道(不走网络)

有盘有网之后还差一件事:host 上的控制面要能指挥 guest 里的 agent 跑命令、读写文件、管生命周期。用网络(virtio-net + TCP)也行,但要给每个 sandbox 分 IP、配路由、设防火墙,重且脏;而且你想把 sandbox 网络完全封死时,控制通道也跟着断了。于是 microVM 普遍另开一条专用通道:vsock

vsock 是一个专给 host↔guest 用的 socket 协议族 AF_VSOCK,用法跟 TCP 一样,只是寻址不用 IP 而用 (CID, port):host 永远是 CID 2,每个 guest 分一个 CID(≥3)。它落地为第 4 个 virtio 设备 virtio-vsock,机制和上面 virtio 一模一样(virtqueue + 门铃 + 中断),只是搬运的是 socket 字节流。host 侧怎么接?以 Firecracker 为例,host 开一个 Unix domain socket 做多路复用,写 CONNECT <port> 就接到 guest 里 listen 该 port 的进程——全程不碰网络

        host (控制面)                              guest (microVM)
   控制进程                                    agent (PID 1,即 initramfs 的 /init)
        │ 普通 Unix socket                          │ listen vsock:<port>,跑 ttrpc
        └────── virtio-vsock(队列+门铃+中断)────────┘
                                              收到 exec → 在 guest 里 fork+exec → stdout 回流 vsock
        业务流量另走 virtio-net

落到实现:guest 里那个 agent 是 PID 1(就是直引导那节 initramfs 里的 /init),listen 一个 vsock 端口、上面常跑 ttrpc(containerd 那套轻量 RPC,kata-agent 等也用);host 控制面经 vsock 给它发 exec/文件/生命周期请求。两个平面分开:控制面走 vsock,业务面走 virtio-net(容器里业务服务的端口)。

这正好和容器的 kubectl exec 形成对照:容器共享内核,host 用 runc exec(setns)直接钻进去执行,不需要 pod 内有 agent;microVM 是独立内核,host 跨不进 guest 边界、setns 没用,必须在里面预先放一个 agent,经 vsock 喊它替你 fork+exec。一个“从外面伸手进去”,一个“在里面安插内应”。

启动快 + 省内存:snapshot + CoW

冷启压到 ~125ms 之后还能更快:干脆不 boot。一台 VM 的完整状态就两块——vCPU 寄存器(KVM_GET_REGS/SREGS/...,KB 级)+ guest 内存(就是那块 host mmap,dump 成文件)+ 一点设备状态。存下来就是 snapshot;新实例直接 KVM_SET_* 灌回寄存器、把内存文件映射回来、KVM_RUN,就恢复到拍快照那一刻、跳过整个 boot。(其实就是前面 sum 函数里 KVM_GET_REGS 取返回值的放大版:那次读 1 个寄存器,snapshot 读全部状态。)

光这样 restore 还是慢(拷整份内存)又费(每实例一份)。两个机制把它做到又快又省:

  • CoW:把快照内存文件用 mmap(..., MAP_PRIVATE) 映射成 guest 内存。所有实例共享同一份基底页,某实例写某页时内核才复制那一页(copy-on-write)。→ N 个实例 = 一份共享基底 + 各自脏页增量,内存只占一份,restore 也不用拷整份。
  • UFFD(userfaultfd):注册 guest 内存后 VM 立即 resume,页按需 fault-in——guest 摸到未加载的页才从快照文件读进来。→ restore 几 ms 返回,没摸到的页永不加载(“申请 2G、实际用几百 M”的画像正合适)。
  • 拍快照本身用脏页追踪(KVM dirty log / soft-dirty)做增量,只 dump 变化的页。CRIU、Firecracker diff snapshot 都靠它。

于是一个“热模板”玩法:boot 一次模板 VM 到就绪 → 拍快照 → 每个新实例从快照 fork(restore + CoW + UFFD),几 ms 起、共享基底内存;空闲实例还能 pause-to-disk(拍快照 + 释放 RAM)。

为什么能同时压启动和省内存?因为是同一件事——不在运行时构建状态,而是预烤成快照、再 CoW 共享、按需懒加载:

看时间轴(快) 看空间轴(省)
restore 代替 boot(跳过 kernel boot / init / 应用 warmup) 一份基底被多实例共享
CoW 立即返回,不等整份内存拷完 实例只付脏页增量
UFFD 页按需 fault,不阻塞 resume 没摸的页不占 RAM

所以“几 ms 热启”和“MB 级边际开销”本是同一个 CoW 引擎的两面。代价是冷页长尾(首访从快照 fault,微小延迟)和内存超卖的赌注(实例同时摸满工作集就 CoW 塌缩、可能 OOM),收益高低取决于真实工作集和空闲比。

小结:进程 / 容器 / 虚拟机 三方对比

把前面两件事——“VM 是一个进程、vCPU 是线程”和“VMM 不是 dockerd”——并起来,横向对比“直接跑在 host 的进程 / 容器 / 虚拟机”三种执行模型:

维度 进程(直接跑 host) 容器(docker) 虚拟机(VM)
隔离机制 namespace + cgroup(共享 host 内核 KVM / VT-x(独立 guest 内核
在 host 上是什么 一个 host 进程 runc 拉起的 host 进程(套 namespace) 一个 VMM 进程,guest 跑在它内部(vCPU=线程、guest 内存=它 mmap 的一段)
host 看得到内部进程吗 —(它本身就是) 看得到,容器进程就是 host 进程 看不到,只见 1 个 VMM 进程 + vCPU 线程
怎么“进去”执行命令 直接 fork/exec host setns 钻进 namespace(docker / k8s exec) 跨不进,需 guest 内 agent 经 vsock
guest 做 I/O 直接 syscall 直接 syscall(共享内核) VM Exit 后由 VMM 进程做 host syscall
管理 / 编排守护进程 init / systemd dockerd / containerd(一个 daemon 管很多容器) libvirtd / 外部编排(每个 VM fork 一个 VMM 进程)
隔离强度 / 边际开销 无 / 最低 中(可逃逸)/ 低 强 / 较高,snapshot + CoW 可压到 ms 级

一句话:容器把工作负载直接摊在 host 上(共享内核、host 直接可见、可 setns 进去);虚拟机把工作负载塞进一个 VMM 进程内部(自带内核、host 穿不透)。 所以 VMM 对应的是“runc + 容器进程”这个运行体,而不是 dockerd——dockerd 那个管家角色,在 VM 世界对应的是 libvirtd / 外部编排器。

命令行方式创建一个传统虚拟机

qemu 创建传统虚拟机流程(virtualbox是一个图形化的壳儿)

# 创建一个虚拟机镜像,大小为 8G,其中 qcow2 格式为动态分配,raw 格式为固定大小
qemu-img create -f qcow2 ubuntutest.img 8G
# 创建虚拟机(可能与下面的启动虚拟机操作重复)
qemu-system-x86_64 -enable-kvm -name ubuntutest  -m 2048 -hda ubuntutest.img -cdrom ubuntu-14.04-server-amd64.iso -boot d -vnc :19
# 在 Host 机器上创建 bridge br0
brctl addbr br0
# 将 br0 设为 up
ip link set br0 up
# 创建 tap device
tunctl -b
# 将 tap0 设为 up
ip link set tap0 up
# 将 tap0 加入到 br0 上
brctl addif br0 tap0
# 启动虚拟机, 虚拟机连接 tap0、tap0 连接 br0
qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.qcow2 -vnc :19 -net nic,model=virtio -nettap,ifname=tap0,script=no,downscript=no
# ifconfig br0 192.168.57.1/24
ifconfig br0 192.168.57.1/24
# VNC 连上虚拟机,给网卡设置地址,重启虚拟机,可 ping 通 br0
# 要想访问外网,在 Host 上设置 NAT,并且 enable ip forwarding,可以 ping 通外网网关。
# sysctl -p
net.ipv4.ip_forward = 1
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
# 如果 DNS 没配错,可以进行 apt-get update

可以看到至少在网络配置这个部分,传统虚拟机跟容器区别并不大

其它

QEMU尽管非常的强大,但也正是应为它的强大导致其对初学者非常的不友好。这里推荐大家刚开始学习KVM时可以先学习kvm tool,这是一个基于C语言开发的KVM虚拟化工具,其代码非常精简易懂,同时也可以支持完整的linux虚拟化,非常适合初学者入门使用。其项目地址为https://github.com/kvmtool/kvmtool。

阿里云张献涛:自主最强DPU神龙的秘诀

  1. 神龙架构从CPU手头接过虚拟化的重任,一路带飞存储、网络、安全等关键性能。
  2. 到今天为止,最适合做DPU的还是可编程可升级的FPGA。 从 VMWare 到阿里神龙,虚拟化技术 40 年演进史

留下评论