Firecracker 源码深读:AWS 怎样用"减法"造出 125ms 启动的虚拟机

从 12 万行 Rust 源码读懂 microVM 的精髓:无 BIOS 直接引导、virtio-mmio 极简设备面、嵌套信任区安全模型、UFFD 惰性快照恢复,以及它为什么成了 AI agent 沙箱的底座。

传统观点认为你只能二选一:要么选硬件虚拟化的强隔离——代价是重和慢;要么选容器的轻和快——代价是共享宿主内核的弱隔离。Firecracker 的回答是”我全都要”,而它拿到手的方式不是发明更多东西,而是一场系统性的减法:删掉 BIOS,删掉 PCI(后来又有分寸地加回来),删掉 USB、显卡和 QEMU 的上百万行遗产,让”这里没有代码”本身成为最强的安全特性和最快的启动路径。

每一次 AWS Lambda 函数调用的背后,都有一台真正的虚拟机:独立内核、独立页表、硬件级隔离。它启动最快约 125ms,每台开销不到 5MiB 内存,单宿主机每秒能创建最多 150 台(官方口径)。这套东西叫 microVM,实现它的 VMM(Virtual Machine Monitor,虚拟机监控器)叫 Firecracker,2018 年开源,论文 Firecracker: Lightweight Virtualization for Serverless Applications 发表在 NSDI 2020。

本文基于 firecracker-microvm/firecracker commit f2f43b6(版本 1.17.0-dev,约 12 万行 Rust,Apache-2.0)做源码解读,回答几个实现层面的问题:

  • 一台”没有 BIOS 的虚拟机”是怎么被引导起来的?
  • vCPU 线程里那个 KVM_RUN 循环到底长什么样?
  • 设备模型砍到只剩什么?virtio 队列怎样直接读写 guest 内存?
  • “假设 vCPU 线程一启动就在跑恶意代码”,这个威胁模型如何落进 jailer 和 seccomp?
  • 快照恢复为什么能到百毫秒级,和 AI agent 沙箱(E2B 等)有什么关系?

先给一句话总结:

Firecracker = KVM 之上的一层极薄用户态设备模型。

它把「虚拟机为什么慢」拆到根因——慢在模拟了一台完整 PC——
然后把这台 PC 里 serverless 用不到的一切全部删掉,
再用 Rust + seccomp + jailer 把剩下的部分层层锁死。

1. 全局架构:一个进程就是一台虚拟机

Firecracker 最反直觉的设计是它的进程模型:一个 Firecracker 进程只管一台 microVM,进程死了虚拟机就死了。没有中心守护进程,没有共享的设备模拟服务。宿主机上跑一千台 microVM,就是一千个彼此独立的普通 Linux 进程——于是 Linux 内核已有的一切进程级安全设施(cgroups、namespaces、seccomp、调度器)都自动适用于”虚拟机”这个粒度。

Cargo.toml 看,仓库是一个 workspace,职责拆得很清楚:

Crate路径职责
firecrackersrc/firecracker主进程:参数解析、API server、事件循环装配
vmmsrc/vmm核心:KVM 封装、vCPU、设备模型、快照、内存
jailersrc/jailer安全外壳:chroot/namespace/cgroup/降权后 exec 进 firecracker
seccompilersrc/seccompiler把 JSON 系统调用白名单编译成 BPF 过滤器
snapshot-editorsrc/snapshot-editor快照文件离线编辑工具
rebase-snapsrc/rebase-snap增量快照合并工具

运行时的线程模型在官方 docs/design.md 里写得很直白:每个进程只有三类线程——API 线程(处理 REST 控制面,永远不在虚拟机快速路径上)、VMM 线程(事件循环,跑所有设备模拟)、每 guest 核一个 vCPU 线程(跑 KVM_RUN)。

flowchart TB
    subgraph host[宿主 Linux]
        subgraph jail[jailer: chroot + namespaces + cgroups]
            subgraph fc[Firecracker 进程(每 microVM 一个)]
                API[API 线程<br/>UNIX socket REST] -->|mpsc channel + eventfd| VMM[VMM 事件循环线程<br/>virtio 设备模拟 / 限速器]
                VMM --- V1[vCPU 线程 0<br/>KVM_RUN 循环]
                VMM --- V2[vCPU 线程 N<br/>KVM_RUN 循环]
            end
        end
        KVM[/dev/kvm/] --- V1
        KVM --- V2
        TAP[TAP 设备] --- VMM
    end

控制面和数据面的通信也刻意保持原始:API 线程解析 HTTP 请求后,通过 Rust 标准库的 mpsc channel 把请求递给 VMM 线程,再用一个 eventfd 把事件循环唤醒(src/firecracker/src/api_server_adapter.rs)。没有 RPC 框架,没有序列化协议,就是进程内两根管道。HTTP 请求体上限被硬编码为 50KiB(HTTP_MAX_PAYLOAD_SIZEsrc/vmm/src/lib.rs)——控制面连”收大请求”的能力都被删掉了。

2. 启动路径:没有 BIOS 的虚拟机怎么开机

一台物理 x86 机器开机要走 BIOS/UEFI → 实模式 → 保护模式 → bootloader → 内核,这条链路是 microVM 启动时间的头号敌人。Firecracker 的做法:全部跳过。VMM 在用户态直接把内核镜像写进 guest 内存、手工填好所有 CPU 寄存器和引导数据结构,然后让 vCPU 从内核入口点直接开跑。

主装配函数是 build_microvm_for_boot()src/vmm/src/builder.rs:143),把它的骨架抄下来,就是一张 microVM 的”出生流程图”:

pub fn build_microvm_for_boot(...) -> Result<Arc<Mutex<Vmm>>, StartMicrovmError> {
    let guest_memory = vm_resources.allocate_guest_memory()?;     // 1. 分配 guest 内存
    let kvm = Kvm::new(...)?;                                     // 2. 打开 /dev/kvm
    let mut vm = KvmVm::new(kvm)?;                                // 3. KVM_CREATE_VM
    let mut vcpus = vm.create_vcpus(vcpu_count)?;                 // 4. KVM_CREATE_VCPU
    vm.register_dram_memory_regions(guest_memory)?;               // 5. 内存区域注册进 KVM

    let mut device_manager = DeviceManager::new(...)?;            // 6. 设备总线
    let entry_point = load_kernel(&kernel_file, guest_memory)?;   // 7. 内核写入内存
    // ... attach balloon/block/net/vsock/entropy 等 virtio 设备

    configure_system_for_boot(...)?;                              // 8. 寄存器 + 引导数据结构

    kvm_vm.start_vcpus(vcpus, seccomp_filters.get("vcpu")...)?;   // 9. vCPU 线程启动(带 seccomp)
    // vCPU 初始为 Paused,resume 后 guest 才开跑
}

第 7 步 load_kernel()src/vmm/src/arch/x86_64/mod.rs:454)揭示了”无 bootloader 引导”的三条路径:

  • ELF vmlinux:直接按 ELF 段加载进 guest 内存,从 ELF 入口进;
  • PVH:如果 ELF 带 PVH note(Xen 传统的直接引导协议),优先用 PVH 入口——比传统协议更干净;
  • bzImage:从 boot protocol ≥ 2.12 的 64 位入口(加载地址 + 0x200)直接进入,跳过全部实模式 setup 代码。

第 8 步做的事情,在物理机上属于 BIOS 和 bootloader 的职责,这里全由 VMM 用普通内存写操作代劳。看 src/vmm/src/arch/x86_64/layout.rs 里的常量表,guest 物理内存的每一处布局都是编译期定死的:

0x7000        zero page(Linux 引导参数页,boot_params 结构)
0x20000       内核命令行(上限 2048 字节)
0x9fc00       系统保留区起点
0xe0000       ACPI RSDP 表
0x100000      内核本体(1MiB,HIMEM_START)
3GiB..4GiB    32 位 MMIO 设备窗口

连 e820 内存映射表(物理机上由 BIOS 探测生成、告诉内核”哪些内存能用”的表)都是 VMM 手工逐条填的(add_e820_entrysrc/vmm/src/arch/x86_64/mod.rs:428)。

这里值得停一下,把”为什么快”想透:Firecracker 的启动快不是优化出来的,而是删出来的。BIOS 初始化、探测硬件、bootloader 解压跳转——这些步骤不是被加速了,而是不存在了。启动时间里剩下的大头是 guest 内核自身的初始化,所以官方还配套维护了裁剪过的 guest 内核配置(resources/guest_configs/),把内核里用不到的驱动也删掉。减法贯穿始终。

3. vCPU 线程:一个 200 行的状态机

vCPU 是虚拟化的心脏,但 Firecracker 的 vCPU 线程主循环出奇地小。每个 vCPU 线程启动后先给自己上 seccomp 过滤器,然后进入一个三态状态机(src/vmm/src/vstate/vcpu.rs:217):

pub fn run(&mut self, seccomp_filter: BpfProgramRef) {
    crate::seccomp::apply_filter(seccomp_filter)  // 先锁死自己能用的系统调用
        .unwrap_or_else(|err| panic!(...));
    let mut state = VcpuRunState::Paused;
    loop {
        state = match state {
            VcpuRunState::Running => self.running(),
            VcpuRunState::Paused => self.paused(),
            VcpuRunState::Finished => break,
        };
    }
}

Running 态内层就是 KVM 虚拟化的经典循环:run_emulation()KVM_RUN ioctl 让物理 CPU 直接执行 guest 指令,直到 guest 碰了需要 VMM 介入的东西才退出来(VM Exit)。退出原因的处理是一个大 match(src/vmm/src/vstate/vcpu.rs:407):

match self.kvm_vcpu.fd.run() {
    Ok(VcpuExit::MmioRead(addr, data))  => // guest 读了设备寄存器 → 查总线转发给设备
    Ok(VcpuExit::MmioWrite(addr, data)) => // guest 写了设备寄存器 → 同上
    Ok(VcpuExit::Hlt) | Ok(VcpuExit::Shutdown) => // guest 停机
    ...
}

Paused 态则阻塞在一个 channel 上等控制命令(Resume/SaveState/DumpCpuConfig)——快照保存只允许在 Paused 态做,状态机从类型层面保证了”不会边跑边存”。

值得注意的是这个设计里的分工:vCPU 线程处理同步 I/O(MMIO 读写必须当场答复,guest 在等),VMM 事件循环线程处理异步 I/O(网络包到了、限速令牌回来了)。两类线程各自有各自的 seccomp 白名单——vCPU 线程能用 49 个系统调用,事件循环线程 76 个,API 线程 36 个(数字来自 resources/seccomp/x86_64-unknown-linux-musl.json 实测统计,默认动作 trap)。

4. 设备模型:五种 virtio 设备加一根串口

QEMU 能模拟软驱、声卡、USB 控制器、VGA 显卡和几十种网卡——每一个都是攻击面(NSDI 论文点名的案例是 2015 年的软驱控制器逃逸漏洞 VENOM)。Firecracker 的设备清单短到可以背下来:

  • virtio-block / virtio-net / virtio-vsock:存储、网络、宿主通信三件套
  • virtio-balloon / virtio-rng / virtio-pmem / virtio-mem:内存回收、熵源、持久内存、内存热插拔
  • legacy:一根串口 + 一个只保留 CPU reset 功能的 i8042 键盘控制器残桩
  • MMDS:instance metadata 服务(见下文 dumbo)

没有 USB,没有显卡,没有声卡。这不是”还没来得及支持”,而是论文里明说的立场:这些设备在 serverless 工作负载里没有存在理由,不存在的代码没有漏洞。

4.1 virtio-mmio:设备”总线”退化成一段内存地址

virtio 设备需要一个 transport 让 guest 发现并配置它。标准做法是挂在模拟的 PCI 总线上,但 PCI 枚举本身要花启动时间、要模拟配置空间。Firecracker 默认用 virtio-mmiosrc/vmm/src/devices/virtio/transport/mmio.rs):每个设备就是 4KiB 一段的 MMIO 寄存器窗口(MMIO_LEN = 0x1000),guest 读偏移 0 会得到魔数 0x74726976(ASCII 的 “virt”)。设备地址通过内核命令行直接告诉 guest:

virtio_mmio.device=4K@0xd0000000:5

guest 内核照单全收,无需任何探测。“总线枚举”这个概念被删掉了,代价是设备不能动态发现——对”配置一次、跑完即弃”的 serverless 场景这不是代价。

4.2 virtio 队列:零拷贝读 guest 内存

virtio 的数据面是共享内存环形队列(src/vmm/src/devices/virtio/queue.rs)。Firecracker 的 Queue 结构直接持有指向 guest 内存中 avail ring / used ring 的裸指针,用 volatile 读写访问:

pub struct Queue {
    pub avail_ring_ptr: *mut u16,   // 指向 guest 内存中的 available ring
    pub used_ring_ptr: *mut u8,     // 指向 guest 内存中的 used ring
    ...
}
pub fn avail_ring_idx_get(&self) -> u16 {
    unsafe { self.avail_ring_ptr.add(1).read_volatile() }
}

guest 把请求描述符写进环,敲一下 MMIO 的 doorbell 寄存器触发 VM Exit;VMM 直接顺着描述符链读 guest 内存,处理完把结果挂回 used ring,再通过 KVM irqfd 给 guest 注入中断。数据本体从头到尾不拷贝进 VMM 私有缓冲。块设备后端还有 io_uring 异步引擎(src/vmm/src/io_uring/,要求宿主内核 ≥ 5.10.51),网络后端就是一个 TAP 文件描述符。

4.3 内建限速器与 dumbo:多租户的自我修养

每个块设备和网卡都内嵌一个令牌桶限速器(src/vmm/src/rate_limiter/),带宽和 ops/s 双桶独立记账,桶空了设备暂停处理并挂一个 timerfd 到事件循环等令牌回填。这个设计的动机写在论文的多租户约束里:一千台 microVM 挤在一台宿主机上,任何一台都不能用 I/O 洪水拖垮邻居——限速不是外挂策略,是设备本身的属性

更有意思的是 src/vmm/src/dumbo/——一个名副其实”极简”(dumb)的用户态 TCP/IPv4 协议栈,只服务一个用途:MMDS(instance metadata 服务)。guest 访问 metadata IP 的流量在 VMM 里被就地拦截、解析、应答,根本不出宿主网卡。为一个 HTTP GET 写整个 TCP 栈看似over-engineering,实则是威胁模型推出来的必然:metadata 里有凭证,这条路径绝不能和真实网络共享任何代码。

4.4 灰度看”删 PCI”:2025 年后它又回来了

有意思的转折:当年为启动速度删掉的 PCI,在近期版本里以 --enable-pci 可选开关的形式回来了(src/vmm/src/pci/docs/kernel-policy.md)。启用后所有 virtio 设备改走 PCI transport,官方给出的理由是更高吞吐、更低延迟,而且它是设备热插拔(Developer Preview,docs/device-hotplug.md)的前提。

这是一个很好的”设计权衡随约束漂移”的样本:2018 年的 Firecracker 只服务 Lambda 这一种负载,PCI 纯属成本;2026 年的 microVM 生态要跑 AI agent 沙箱、要热插设备、要为异构硬件留口子,PCI 的收益侧变重了。注意它的处理方式——默认仍然关闭,MMIO 路径原封不动。减法哲学没有被推翻,只是从”删掉”进化成”默认不存在,按需显式打开”。

5. 安全模型:假设 vCPU 一启动就在跑恶意代码

docs/design.md 的威胁模型开门见山:

From a security perspective, all vCPU threads are considered to be running malicious code as soon as they have been started.

从这个假设出发,Firecracker 叠了四层互相独立的防线:

flowchart LR
    G[恶意 guest 代码] -->|第一层| KVM[KVM 硬件虚拟化边界<br/>EPT 页表 + VT-x]
    KVM -->|第二层| RUST[Rust 内存安全<br/>设备模拟代码无缓冲区溢出]
    RUST -->|第三层| SEC[seccomp 白名单<br/>vCPU 线程仅 49 个系统调用]
    SEC -->|第四层| JAIL[jailer 沙箱<br/>chroot + 5 种 namespace + cgroup + 降权]
    JAIL --> HOST[宿主内核]

第一层,KVM。guest 代码跑在硬件虚拟化里,对内存的访问被 EPT 页表钉死在分给它的区域。要突破这层需要 CPU 或 KVM 本身的漏洞。

第二层,Rust。突破 KVM 层的经典路径是打设备模拟代码的内存破坏漏洞(QEMU 时代的主旋律)。Firecracker 的设备模拟全部是 safe-by-default 的 Rust,unsafe 块被 clippy 规则强制要求逐个写注释说明安全性论证(Cargo.toml 里的 undocumented_unsafe_blocks = "warn")。这个技术选型有血统渊源:Firecracker 起家于 Google Chrome OS 的 crosvm(仓库 CREDITS.md 明确致谢),后来大部分重写,两者又共同孵化了 rust-vmm 社区组件库。

第三层,seccomp。就算攻击者在 Firecracker 进程里拿到了任意代码执行,他能对宿主内核发起的系统调用也被 BPF 过滤器锁死。白名单按线程角色最小化,越靠近 guest 的线程权限越小;名单不是手写在代码里,而是 JSON 声明(resources/seccomp/)由 seccompiler 编译成 BPF——审计攻击面时看 JSON 就够了。

第四层,jailer。生产环境里 Firecracker 不直接启动,由 jailer 拉起。Env::run()src/jailer/src/env.rs:646)的执行序列就是一张沙箱搭建清单:把 firecracker 二进制拷进 chroot → 加入指定网络 namespace → 设 rlimits → 配 cgroups → chroot 进空目录 → 用 mknod 在监狱里手工创建仅有的三个设备节点(/dev/kvm/dev/net/tun/dev/urandom)→ 降权到非特权 uid/gid → exec 进 firecracker。从此这个进程看到的文件系统里只有被显式放进来的东西。

四层防线的组合意味着:攻击者要从 guest 打到宿主,需要同时串起 KVM 逃逸 + Rust 代码漏洞 + seccomp 白名单内的内核漏洞 + namespace 逃逸。每一层都在为”上一层被打穿”兜底。

一个持怀疑态度的读者会问:gVisor 也号称多层防御,为什么 Lambda 选了 microVM 而不是用户态内核?论文给的答案是兼容性和性能的权衡——gVisor 用 Sentry 拦截并重新实现系统调用,兼容性永远追不上真内核,且系统调用密集型负载性能损失明显;microVM 里跑的是原版 Linux 内核,guest 兼容性是 100%。代价则是每台 VM 多几 MiB 的内核内存开销。没有免费午餐,只有约束匹配。

6. 快照与 UFFD:为什么它成了 AI agent 沙箱的底座

Firecracker 的快照机制(src/vmm/src/persist.rs:169)把一台暂停的 microVM 序列化成两个文件:状态文件(vCPU 寄存器、设备状态、内存布局元数据,带 CRC 和版本号)和内存文件(guest 内存本体)。恢复时有两条路径,差别很值得品:

  • mmap 路径:内存文件直接 mmap 进来。快,但页面按访问惰性从磁盘加载,无法定制加载策略。
  • UFFD 路径guest_memory_from_uffdsrc/vmm/src/persist.rs:543):注册 userfaultfd,guest 每碰一个缺页,缺页事件被发给一个外部进程,由它决定怎么喂这页内存。

UFFD 路径把”内存怎么恢复”变成了可编程的:外部 handler 可以从网络流式拉取、可以多台 VM 共享同一份基底内存做写时复制、可以预取热页。这正是 serverless 平台”预热快照池”玩法的基础设施。

这也解释了 Firecracker 在 AI 时代的第二春。agent 要执行不可信的模型生成代码,容器隔离不够硬,传统 VM 又起不动那么快——microVM 恰好卡在这个空档上。E2B 的沙箱就是 Firecracker microVM,走”预热快照 + 恢复”路径把冷启动压到 150-200ms 量级,服务 Manus、Perplexity 等生产负载;Vercel Sandbox 同样构建在 Firecracker 上。2018 年为 Lambda 的多租户密度设计的那套减法,2026 年原封不动地成了”给每个 agent 一台一次性电脑”的答案。

7. 带走的模型:删除是一种能力

把全文收进一个可复用的心智模型——最小机制原则(minimal mechanism)

安全性 ≈ 攻击面的倒数;攻击面 ≈ 代码量 × 权限。
启动时间 ≈ 必须执行的初始化步骤之和。

两个量都单调依赖「系统里有多少东西」。
所以对特定负载做系统设计时,第一等的杠杆不是加机制,而是:
  1. 枚举目标负载真正需要的能力(Firecracker:serverless = 网/盘/串口,没了)
  2. 其余一切物理性删除(不是禁用,是不存在)
  3. 剩下的部分按「被攻破」假设逐层加锁(Rust → seccomp → jail)
  4. 当约束漂移时,以「默认关闭的显式开关」加回能力(PCI 的回归方式)

这个模型迁移性很强:裁剪 guest 内核配置、给 agent 设计工具集、收敛微服务的 API 面,都是同一道题。Firecracker 用 12 万行 Rust 给出的示范是:知道不做什么,和知道做什么同样是硬实力

对照系统设计里那条”瓶颈塑造架构”的元原理:Firecracker 的稀缺资源是”多租户宿主机上单 VM 的边际成本”(内存开销 × 启动延迟 × 攻击面),于是整个架构长成了它的形状——一进程一 VM、无 BIOS、五种设备、四层监狱。读任何基础设施源码时,先找到它的稀缺资源,架构图就会自己开口说话。

参考来源

论文与官方资料

解读与生态

  • the morning paper: Firecracker — Adrian Colyer 的论文精读(内存开销对比 QEMU/Cloud Hypervisor 数据出处)
  • google/crosvm — Firecracker 的血统来源(见仓库 CREDITS.md
  • rust-vmm — Firecracker 与 crosvm 共同孵化的 Rust 虚拟化组件社区
  • E2B — 基于 Firecracker microVM 的 AI agent 代码执行沙箱