虚拟化扫盲笔记:Container、KVM、QEMU 到 Firecracker
/ 31 min read
Table of Contents
常年折腾各种OS、VPS小鸡,用Docker折腾各种self-hosted服务,最近看到一些Agent沙箱,今天探索下虚拟化该怎么搞。
核心不是扣细节,接受LLM的部分幻觉,主要是建立心智模型。
我之前的理解是KVM/Vmware这类VM虚拟化模拟底层硬件,LXC/Docker这类Container只是隔离资源。所以前者还是比较好理解的,后者怎么利用Linux的一些特性隔离的。
前者需要保证硬件的独占,后者可以共享资源,例如CPU/GPU,一般各类实验室都是后者,但是我强烈不建议Docker用来跑实验(不模拟整个系统),希望各个实验室的sysadmin研究研究LXD,没有Systemd,不能Privileged的是硬伤。(而且Docker是针对单一应用的,一个docker Container跑一个application/打包的进程,而不是一篮子应用,缺乏很多核心功能,而且非常容易逃逸)
总的看下来,虚拟化的核心就是欺骗/经典加一层/MITM。BTW,协程就是绕过系统调用,自己维护一套消息循环,加速I/O密集型的操作。
简单学学Container虚拟化——“骗”进程
一句话:进程进行系统调用的时候返回一个修改后的内容,让进程不知道自己被隔离。
Claude: 容器(LXC/Docker)和 VM(VMware/KVM/Firecracker)的根本区别是:
容器里跑的进程,和你宿主机上的进程,本质上是同一个内核管的同一批进程——只是内核给它们戴了”VR 眼镜”,让它们看到的世界变样了。
VM 里跑的进程是另一个内核管的另一批进程,两个内核之间隔着 CPU 硬件的一道墙。
简单学学KVM虚拟化——“骗”内核
VT-x、 AMD-V之后,CPU有两种执行模式:VMX root、VMX non-root/guest
机制:
mock 指令:guest 内核要执行特权指令,CPU 自动触发 VM Exit,陷入到 hypervisor。hypervisor 看是什么操作,模拟一下结果,然后 VM Entry 把控制权还回去,guest 完全感知不到中间这一跳。这就是 trap-and-emulate,只不过 trap 由硬件免费提供。
mock 内存:Guest 内核以为自己在管理物理内存,它维护自己的页表把 GVA(guest virtual)翻译到 GPA(guest physical)。但 GPA 不是真物理地址,hypervisor 还要把 GPA 翻译到 HPA(host physical)。Intel 用 EPT(Extended Page Tables)、AMD 用 NPT,本质都是给 MMU 加了第二级页表。MMU 一次走两层:GVA →GPA 用 guest 自己的页表,GPA→HPA 用 hypervisor 的影子页表。整个过程对 guest 透明。这就是为什么 VM 的内存访问只比裸机慢 5% 左右——MMU 干掉了 99% 的开销。
mock I/O: 只看最佳实践,virtio:guest 知道自己是虚拟的,装一个 virtio-net 驱动,通过共享内存的环形队列(virtqueue)和 hypervisor 通信,少走很多 VM Exit。再往上 SR-IOV / VFIO 是直接把物理网卡的一部分(VF)透传给 guest,完全不经过 hypervisor。
MicroVM/Firecracker
QEMU 太重,要模拟一整PC硬件,导致会有很多CVE 的风险。
Firecracker 用 Rust 重写,只保留 serverless 场景的最小集:virtio-net、virtio-block、virtio-vsock、串口、一个用来发 ctrl-alt-del 的伪 PS/2 控制器。没了。没有 BIOS——直接 boot 内核(用 Linux 的 boot protocol,jump 到 kernel entry point)。没有 PCI——virtio 设备挂在 MMIO 上。没有 USB、没有 GPU、没有声音。每个 Firecracker 沙箱全球 200ms 内启动,拿到自己独立的 kernel、根文件系统和完全隔离的 network namespace,内存开销大概 5MB(QEMU 是 50MB+)。
Further Reading
Seven Years of Firecracker - Marc’s Blog
【从零造容器】容器 vs microVM:Firecracker 凭什么 125ms 启动 | 土法炼钢兴趣小组的算法知识备份
Firecracker 與 Docker:微型虛擬機 (MicroVM) 與容器之間的技術邊界 - Hugging Face 文件
AI Agent 的代码执行沙箱:从容器到微虚拟机的隔离之道
递归知识:看不懂的再问问
系统调用
系统调用是用户态代码请求内核做事的唯一合法接口。
用户进程在 ring 3,做不了打开文件、申请内存、发网络包这些动作——这些都涉及操作硬件,硬件只有内核(ring 0)能直接操作。
Linux Namespace
Namespace 是内核对自己管理的”全局资源”做切片的机制——让进程以为自己看到的是全世界,实际只是一个子集。
目前有 8 种:
- mount (mnt):挂载点视图
。每个 namespace 有独立的挂载表,容器看到的
/是自己的 rootfs。 - UTS:hostname
和 domainname。让容器有自己的
hostname。 - IPC:System V IPC(共享内存、信号量、消息队列)和 POSIX 消息队列的命名空间。
- PID:进程 ID。每个 namespace 有自己的 PID 1,内层 namespace 看不到外层的进程。
- network (net):网络栈整套
——网卡、路由表、iptables、socket、
/proc/net/*。这是最重的一个,创建一个 net namespace 等于复制了一整套 TCP/IP 协议栈状态。 - user:UID /GID 映射。容器里的 root(UID 0)映射到宿主的某个 UID(比如 100000)。让容器内的”root”在宿主看来是普通用户。
- cgroup:cgroup 文件系统的视图。
- time(2020 年才加的):
CLOCK_BOOTTIME和CLOCK_MONOTONIC时钟。让 checkpoint/restore 工具能保持时间连续性。
三个操作namespace 的syscall
clone(CLONE_NEWPID | CLONE_NEWNET | ...):创建子进程的同时把它放进新 namespaceunshare():当前进程脱离自己当前 namespace,进新的(docker exec启动逻辑会用)setns(fd):加入一个已存在的 namespace(nsenter命令就是这个)
task_struct
Linux 内核里”一个进程”的全部信息,task_struct 不是进程的描述,task_struct 就是进程本身——拷贝走这个结构,理论上就能恢复整个进程。
为什么需要它?操作系统要做调度、信号、内存管理、文件操作,每一项都需要知道”是谁在做”、“它的状态是什么”。需要一个集中存储的地方。每个 CPU 核都有一个 current 宏指向当前正在运行的 task_struct,几乎所有内核代码都靠这个跳板。
关键字段(简化):
pid_t pid/tgid:进程 ID 和线程组 ID(Linux 里线程是共享地址空间的 task_struct,有自己的 pid 但共享 tgid)volatile long state:RUNNING / INTERRUPTIBLE / UNINTERRUPTIBLE / ZOMBIE 等struct mm_struct *mm:地址空间——页表、VMA 列表、堆栈位置。线程间共享这个,进程间不共享struct files_struct *files:打开的文件描述符表,fd 号到 struct file 的映射struct nsproxy *nsproxy:8 个 namespace 的引用集合——这就是 namespace 隔离的物理实现位置struct cred *cred:UID 、GID、capabilitiesstruct css_set *cgroups:cgroup 归属struct sched_entity se:调度器需要的数据 ,vruntime 之类struct task_struct *parent/struct list_head children:进程树结构struct signal_struct *signal:信号处理表
fork() 本质就是:复制一份 task_struct(再加 mm_struct、files_struct 等,有些通过 copy-on-write 共享)。exec() 是:保留 task_struct,把里面的 mm 换成新程序的。clone() 是 fork 的通用版,你可以精细控制哪些资源共享、哪些复制——线程就是 clone() 时设了 CLONE_VM | CLONE_FILES | CLONE_SIGHAND 等等。
pivot_root
它是把当前 mount namespace 的根目录换成另一个目录的 syscall。
它和 chroot 经常一起被提到但有本质区别:
chroot只是让进程的/看起来变了。原根目录还在内核挂载表里,理论上(尤其有CAP_SYS_CHROOT时)能”反向 chroot”逃出去——这是 chroot 经典的安全漏洞,所以从来没人把 chroot 当真正的隔离边界。pivot_root(new_root, put_old)真正把新目录变成根,把老根挪到指定位置。容器一般紧接着umount("/old_root"),老根直接从挂载表里消失,进程在内核数据结构里就完全没有指向宿主文件系统的引用了。
cgroups 的执行过程
cgroups 是内核的”资源会计 + 限流”机制,和 namespace 是垂直关系——namespace 管”能看到什么”,cgroup 管”能用多少”。两者合起来才叫容器。
它在 /sys/fs/cgroup 是一棵目录树,每个目录就是一个 cgroup。每个 cgroup 里有控制文件:
cgroup.procs:写进去的 PID 加入这个 cgroupmemory.max:内存硬上限 (字节)memory.high:内存软限 (超了开始 throttle 但不杀)cpu.max:"quota period",比如"200000 1000000"表示每 1 秒最多用 0.2 秒 CPU(20%)cpu.weight:同级 cgroup 间的 CPU 权重分配io.max:每设备的 IOPS / 带宽限制pids.max:最大进程数 (防 fork bomb)
case
- 走一遍完整例子。假设建了一个 cgroup,memory.max=1G,然后一个 Python 进程被放进去,执行
data = [0] * (10**9): # 申请约 8GB 内存 - Python 调
malloc,glibc 内部走mmap(NULL, 8GB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)。 mmap此时只分配虚拟地址,不给物理内存(lazy allocation)。所以这步成功返回。- Python 开始往这块内存写零,触发 page fault(虚拟页没对应物理页)。
- 内核的 page fault handler 跑起来,调用
alloc_pages想拿一个物理页。 - 分配前先走
mem_cgroup_try_charge():“这个进程所在的 memcg,还能再吃 4KB 吗?” 内核检查usage + 4096 <= memory.max。 - 前几十万页都通过——每次都给 charge 上去。memory.max 是 1G,大概 26 万页之后用完。
- 第 26 万零 1 次 charge 失败。内核先尝试
try_to_free_pages回收(扫 LRU 列表,把 cgroup 内的页换出/回收),如果回收不到足够空间…… - 触发 cgroup OOM killer。它只在这个 cgroup 内挑进程杀,而不是动整个系统。SIGKILL 送给 Python 进程,它死了,不影响系统上的其他东西。
Copy-on-Write(Cow)
中文一般叫”写时复制”,核心思想是把”复制”这个动作推迟到真正发生写入的那一刻——在那之前所有人共享同一份数据,只有要改的人才被迫去做真正的物理复制。
这是个非常基础的技术,贯穿计算机系统的每一层。先看最经典的例子。
内存页层面:fork () 的真相
你之前应该写过 fork()。教科书说”fork 复制一份父进程”。问题是父进程可能占了 8GB 内存,fork 一下真的要花几秒钟拷 8GB 吗?显然不是,fork 通常几毫秒就回来。
真实流程:
fork()创建子进程的 task_struct,但不拷贝任何物理内存页- 复制父进程的页表,让子进程的页表指向和父进程完全相同的物理页
- 把父子两边所有页表项的”可写”位清掉,标记为只读
- fork 返回——此刻父子共享所有物理页,内存占用没变,只多了一份页表
然后父进程或子进程任意一方尝试写某个页:
- CPU 写一个只读页 → 触发 page fault(写保护错误)
- 内核的 fault handler 检查这页的 VMA flags,看到设了
VM_MAYWRITE但 PTE 被标了只读——“哦,是 CoW 页” - 内核分配一个新物理页,把原页内容拷贝过去,更新触发 fault 的那一方的页表项指向新页、设可写
- 另一方继续用原页(也设回可写,因为现在没人和它共享了)
- 重新执行那条写指令,这次成功
结果:只有真正被写过的页才被实际复制
。如果子进程 fork 完立刻 exec()(shell 启动命令、daemon spawn worker 的标准模式),整个地址空间会被替换掉,前面那次 fork 几乎没复制任何东西就被丢了——你拿到了”复制”的语义,付了零成本。这就是为什么 Unix 这套 fork+exec 模型能存活 50 年。
文件系统层面:Docker overlay2
Docker 镜像是分层的——每个 RUN 指令产生一层。一个常见 Python 应用容器可能是:ubuntu base + apt install python + pip install requirements + COPY app.py,四层。
如果你启动 10 个相同镜像的容器,这 10 个容器共用同一份镜像的所有层,在磁盘上只有一份。每个容器只有自己独立的最上面那一层(writable layer)。当容器内某个进程修改 /etc/hosts:
- overlay 文件系统查找:在 writable layer 没找到,在下层 image layer 找到了
- **把这个文件从下层”复制上来”**到 writable layer(这步叫 “copy_up”)
- 在 writable layer 上做修改
- 之后这个容器对
/etc/hosts的所有访问都走 writable layer,不再碰下层
这就是为什么 Docker 拉一个 500MB 镜像跑 10 个容器只占 500MB + 一点点 delta,而不是 5GB。和 fork() 的 CoW 在概念上完全一样,只是粒度从内存页变成了文件。
VM 磁盘层面:qcow2
QEMU 的 qcow2 格式天生支持 CoW。可以在一个基础镜像之上创建一个 overlay:
qemu-img create -f qcow2 -b base.qcow2 -F qcow2 instance.qcow2
instance.qcow2 开始是空的,所有读取都穿透到 base.qcow2。任何写入都记录在 instance.qcow2 里。你可以从一个 base 派生几百个 overlay,base 在磁盘上只有一份。
E2B / Firecracker 的快照系统底层就是这套——之前那篇 RisingWave 的文章里讲的”每个快照是一个 overlay 层,快照成本随变更量而非总大小线性增长”,说的就是这个。
把前面的几条线连起来:Firecracker 的 150ms 冷启动,根本就是 CoW 在内存页上的复刻。流程是这样的:
- 预先启动一台 Firecracker VM,装好 Python、curl、各种工具,等到 ready 状态
- 打个完整快照:把 guest 内存全部 dump 成文件,把 guest 磁盘当作 base.qcow2 冻住
- 来一个新请求,要起一台沙箱:
- 磁盘:
qemu-img create -b base.qcow2 ...,瞬间完成 - 内存:新 VM 的 EPT 页表指向 base 的内存页,全部标只读,这就是 KVM 的
MAP_PRIVATE加 userfaultfd 或者直接的 page sharing
- 磁盘:
- 新 VM 启动,绝大部分内存页和 base 共享。它一开始干的事(读 Python 解释器、读标准库)全是读,完全没有任何复制
- 只有 agent 真正写入的内存(它的 dataframe、它跑的程序输出)才触发 page fault,才真正分配新页
Ring 0
Ring 是 x86 CPU 的特权级。CPU 在执行指令时,当前特权级(CPL,Current Privilege Level)存在 CS 段寄存器最低两位,所以一共 4 个级别:0 、1、2、3。
Ring 0:最高权限
,内核。可以执行所有指令,包括 HLT、LGDT/LIDT(改全局/中断描述符表)、CLI/STI(关/开中断)、IN/OUT(端口 I/O)、MOV CR0-4(改控制寄存器)、WRMSR(写 MSR)等。
Ring 1、2:历史上 OS/2 用过,Xen 半虚拟化用过,现代 Linux/Windows 都不用。
Ring 3:用户态
。上面那些指令一执行就 #GP(General Protection Fault),内核会送你一个 SIGSEGV。
内存保护也绑在 ring 上:页表项每页有个 U/S 位(User/Supervisor)。Ring 3 访问内存时,MMU 检查目标页的 U/S 位,如果是 supervisor-only,直接 page fault。这就是为什么用户进程读不到内核地址——MMU 在硬件层面挡了。
Ring 之间不能随便跳。从 ring 3 到 ring 0 只有几个受控入口:syscall 指令、中断、异常。这几个入口都由内核启动时通过 IDT 和 MSR 注册好,CPU 自动跳到指定地址。所以恶意用户代码没办法 “改一下 CS 然后跑内核代码”——硬件不让。
虚拟化加了一个正交维度。VT-x 引入 VMX root 和 VMX non-root 两种状态。Guest 操作系统跑在 VMX non-root 里,guest 内部仍然有 ring 0~3(guest 内核以为自己是 ring 0,实际是 VMX non-root 的 ring 0)。当 guest 的 ring 0 执行某些操作(由 VMCS 配置决定哪些),硬件触发 VM Exit,跳到 VMX root 里运行的 hypervisor。所以 hypervisor 比 guest 内核还低一层,在 guest “够不到”的位置。
ARM 上类似的概念叫 Exception Level:EL0 (用户)、EL1(内核)、EL2(hypervisor)、EL3(secure monitor / TrustZone)。
Hypervisor
Hypervisor(也叫 VMM,Virtual Machine Monitor)就是创建和管理 VM 的软件。
按部署形态分两类:
- Type 1(bare metal):直接跑在硬件上 ,不依赖宿主 OS。代表:VMware ESXi、Xen、Hyper-V。这种通常自带一个极简内核(ESXi 的 vmkernel,Xen 的 hypervisor 本体),只做虚拟化相关的事,不跑应用。
- Type 2(hosted):作为宿主 OS 上的一个应用跑。代表:VMware Workstation、VirtualBox、Parallels。
KVM + QEMU 是个有点混合的形态:KVM 是 Linux 内核模块,加载之后 Linux 自己就变成了一个 hypervisor(因为 Linux 已经直接跑在硬件上,加上 KVM 提供 VT-x 的封装就齐了)。QEMU 是用户态进程,负责设备模拟和 VM 生命周期。所以 KVM+QEMU 整体算 Type 1-ish。Firecracker 就是把 QEMU 这部分换成了一个更轻的 Rust 实现,KVM 部分不动。Firecracker 就是把 QEMU 这部分换成了一个更轻的 Rust 实现,KVM 部分不动。
Hypervisor 实际干的事,在硬件虚拟化时代基本是一个事件循环:
设置 VMCS(描述这台虚拟机的 CPU 状态);设置 EPT(GPA→HPA 页表);while (running) { VM Entry; // 一条指令,把 CPU 交给 guest // 硬件让 guest 跑,直到触发 VM Exit reason = read_vmcs_exit_info(); switch (reason) { case EXIT_REASON_IO_INSTRUCTION: emulate_io(); // guest 写了某个 IO 端口,我来处理 break; case EXIT_REASON_EPT_VIOLATION: fix_up_page(); // guest 访问了未映射的 GPA break; case EXIT_REASON_HLT: schedule_other(); // guest 空闲,让出物理 CPU break; // ... 几十种 exit reason }}其他职责:VM 生命周期(start、pause、resume)、快照、迁移、设备模拟(或者更现代的 virtio 半虚拟化接口)、给 guest 注入中断。
核心要点:有了硬件虚拟化之后 ,hypervisor 本身可以做得非常薄——VT-x 和 EPT 帮你做了 99% 的脏活。Firecracker 之所以能压到 5 万行代码,就是因为它砍掉了 QEMU 里那些为了”模拟一台完整 PC”而存在的设备代码,只留下虚拟化必需的最小集。这也是为什么 microVM 在 agent 沙箱场景这么流行——隔离强度等于完整 VM,代码量和内存开销接近容器。
理解内核模块
内核模块是 Linux 内核的一个可加载扩展。内核不是一坨编译进单一二进制的代码,它是一个核心(vmlinuz)加一堆 .ko 文件(kernel object)。核心提供基础设施,模块在运行时按需加载。
网卡驱动是模块,文件系统(ext4、xfs、btrfs)是模块,蓝牙协议栈是模块,KVM 也是模块。lsmod 看一下系统正在用的所有模块,通常有一两百个。
加载 KVM:
sudo modprobe kvmsudo modprobe kvm_intel # 或 kvm_amdls /dev/kvm # 加载后内核暴露这个字符设备/dev/kvm 是 KVM 给用户态的入口。任何想跑 VM 的程序(QEMU、Firecracker、crosvm、cloud-hypervisor、Lima)都是 open 这个设备文件,然后通过 ioctl() 系统调用和它通信。
一个最小化的 KVM 使用流程长这样(伪代码):
int kvm = open("/dev/kvm", O_RDWR);int vm = ioctl(kvm, KVM_CREATE_VM, 0); // 创建一台虚拟机ioctl(vm, KVM_SET_USER_MEMORY_REGION, &mem); // 给它分配内存int vcpu = ioctl(vm, KVM_CREATE_VCPU, 0); // 创建一个虚拟 CPUioctl(vcpu, KVM_SET_REGS, ®s); // 设置寄存器ioctl(vcpu, KVM_SET_SREGS, &sregs);while (1) { ioctl(vcpu, KVM_RUN, 0); // 跑!这一行返回时,guest 已经触发了 VM Exit handle_exit(&kvm_run); // 处理:IO?中断?HLT?}理解KVM
KVM 全称 Kernel-based Virtual Machine,它是 Linux 内核里的一个模块,文件叫 kvm.ko(加上 kvm-intel.ko 或 kvm-amd.ko,针对不同 CPU 厂商)。
把 Intel VT-x / AMD-V 这套硬件虚拟化能力,封装成 Linux 用户态进程能用的 API。
VT-x 那套指令(VMXON、VMLAUNCH、VMRESUME、读写 VMCS 等)全部是特权指令,只能在 ring 0 执行。用户态写一个 QEMU,想跑 VM,但 QEMU 是 ring 3 的应用,没法直接执行这些指令。所以 Linux 内核里必须有一段代码代你执行——这就是 KVM。
KVM 和 QEMU 的关系
这两个名字经常一起出现,职责得分清楚:
KVM 负责”虚拟 CPU + 虚拟内存”。它把每个 vCPU 绑到一个宿主线程上,执行 ioctl(vcpu, KVM_RUN) 时,这个线程通过 VMLAUNCH 把控制权交给 guest,guest 跑,直到 VM Exit 后控制权回到宿主线程。内存上 KVM 维护 EPT(GPA→HPA 的二级页表),让 guest 以为自己在管理真物理内存。这两件事 KVM 全包了——但仅此而已。
QEMU 负责其他所有事:虚拟磁盘 、虚拟网卡、BIOS/UEFI、PCI 总线、键盘鼠标、串口、显卡、USB、声音……另外还负责 VM 的生命周期(start/stop/snapshot/migrate)和给 guest 注入中断。
具象的看一次磁盘读:
- Guest 内核里的磁盘驱动写了一下虚拟磁盘控制器的某个寄存器
- VM Exit 触发(EXIT_REASON_IO_INSTRUCTION 或 MMIO 访问)
- KVM 拿到控制权,看一眼”这是 IO 操作,我不管”,把 exit reason 和相关信息塞到
kvm_run共享结构里 - KVM 让
ioctl(KVM_RUN)返回到 QEMU 那个线程 - QEMU 在用户态读出 exit reason,模拟这块磁盘的行为(从一个 qcow2 文件读一段数据,放进 guest 看到的”DMA”内存区域)
- QEMU 再
ioctl(KVM_RUN),KVM 通过 VMRESUME 把控制权还回 guest - Guest 内核以为磁盘读完了,继续执行
所以 KVM 是个”硬件代理人”,QEMU 是个”假主板”,两者配合才是一台完整 VM。
Firecracker 把 QEMU 替换掉了,自己用 Rust 写了一个极简版本——只支持 virtio-net、virtio-block、virtio-vsock、串口、一个简陋 PS/2,没了。KVM 那一头完全不变,Firecracker 照样 open /dev/kvm、照样 ioctl,只是用户态那一半更轻。这就是 Firecracker 能做到 ~50K 行代码、~5MB 内存开销的根本原因——硬活全是 KVM 在内核里干,用户态只剩薄薄一层。
拓展:嵌套虚拟化 KVM in KVM
其实这个很常见啊,裸金属L0开多个VDS L1,VDS再分多个VPS L2,VPS里再套多个Container Container严格来说不算L3, Container 不是一层完整的硬件虚拟化 VM,它没有自己的 guest kernel,也没有独立的 GVA → GPA → HPA 这套二阶段地址翻译模型。
nested virtualization(嵌套虚拟化),有时也叫 nested KVM
L0 是宿主机,L0 上跑一台 KVM VM 叫 L1,L1 里面再跑一台 VM 叫 L2。
L1 自己也想用 VT-x(因为不用 VT-x 跑 VM 慢得没法用),但 VT-x 这套指令本来只在物理 ring 0 能用,L1 内核已经被 L0 的 VMX 关在 VMX non-root 里了——它执行 VMLAUNCH,硬件不让它执行。
要让 L1 也能跑 VM,有两种思路:
软件路线(早期、Xen 那套):让 L1 hypervisor 知道自己是被虚拟化的,不去碰真 VT-x 指令,而是用 hypercall 求 L0 替它做。这叫 paravirtualization,L1 必须改造代码。
硬件辅助 nested(KVM 用的):L0 KVM 模拟一套”虚拟的 VT-x”给 L1 看。L1 KVM 不知道自己在虚拟环境,该 VMLAUNCH 就 VMLAUNCH——这条指令在 L1 ring 0 执行时,硬件触发 VM Exit 到 L0,L0 KVM 看到”哦,L1 想跑一个嵌套 guest”。
L0 干什么?它把 L1 的 VMCS(虚拟的)和自己的 VMCS(真的)合并,合并出一份 L2-on-physical 的 VMCS,然后真正 VMLAUNCH 让 L2 跑在物理 CPU 上。所以 L2 实际上是直接跑在物理 CPU 的 VMX non-root 里,只不过 L1 以为是它在管 L2。VM Exit 时 L0 截获,该报给 L1 的就翻译成”L2 对你的虚拟 VT-x 触发了 exit”,转交给 L1 处理;该自己处理的就处理掉,L1 不知道。
这套机制对 EPT 影响很大。L2 有 GVA→GPA(L2 的 guest physical)的页表,L1 有 GPA→L1PA(L1 的 guest physical = L0 的 guest physical)的 EPT,L0 有 L1PA→HPA 的 EPT——理论上 MMU 一次访存要走三层翻译。但硬件只支持两级 EPT,所以 L0 把 L1 的 EPT 和自己的 EPT 合并成一张影子 EPT(shadow EPT),让硬件直接走 GVA → GPA → HPA。每次 L1 改自己的 EPT,都要触发 exit 到 L0,L0 同步更新影子 EPT。这部分开销不小,所以嵌套虚拟化比单层 VM 慢一截,大概是裸机性能的 60-80%(取决于 workload)。
怎么开
宿主上检查:
cat /sys/module/kvm_intel/parameters/nested # Y/1 表示开了# 或 AMD:cat /sys/module/kvm_amd/parameters/nested没开的话:
sudo modprobe -r kvm_intelsudo modprobe kvm_intel nested=1然后启动 L1 VM 时给它的 vCPU 暴露 VMX 特性。QEMU 加 -cpu host 或显式 -cpu host,+vmx;libvirt 在 XML 里设 <cpu mode='host-passthrough'/>。这样 L1 内核就能加载 kvm_intel 模块了。
谁在用 nested
- 云上跑 VM:AWS bare metal 实例、GCP 的 nested VM 支持、Azure 部分实例,本质都是给你一台 L1,你在里面跑 L2(比如 GitLab Runner 跑 KVM 测试,或者你想在云 VM 里跑 OpenStack)。
- Kata Containers / gVisor / Firecracker in Kubernetes:K8s 节点本身可能是云 VM(L1),你在节点里跑 Kata 给每个 pod 一个 microVM(L2)。整个 agent 沙箱叠在公有云上时基本都这个拓扑。
- 本地开发:macOS
的 Docker Desktop 在 Lima/Apple Virtualization Framework 里跑一个 Linux VM(L1),L1 里跑容器——还没到嵌套 KVM,但思路类似。如果你在那台 L1 里再
modprobe kvm,就成了真嵌套。 - CI:Anthropic 、各家 CI 系统经常给 runner 一个 VM 隔离,runner 里又起 VM 跑测试。
嵌套能套多深
理论上可以一直套——L0 给 L1 模拟 VT-x,L1 也可以给 L2 模拟 VT-x,L2 再给 L3,如此类推。每多一层,exit 处理就多一层翻译,性能掉得很厉害。实际工程里基本就两层(L0/L1/L2),三层以上没人认真用,L3 的性能基本能拖垮一切。
性能损失的根源是 VM Exit 放大:L2 里一次普通的 IO 操作,在 L0/L1/L2 单层 VM 里只需要 1 次 exit + 1 次 emulate;到了嵌套里变成 L2 exit 到 L0,L0 转给 L1,L1 emulate,L1 想 VMRESUME 又是一次 exit 到 L0,L0 再 VMRESUME 给 L2——一次 IO 路径上的 exit 数量 2-3 倍上去了。对 IO 密集 workload 这就是几倍的延迟差。
声明
文章通过Claude和ChatGPT联合询问和润色。