AWS Lambda와 Fargate를 구동하는 마이크로VM VMM인 Firecracker의 프로세스 구성, 부트 시퀀스, VirtIO 디바이스 모델, 네트워크·스토리지·vsock·ballooning, MMDS, 보안 샌드박싱(Jailer/Seccomp/cgroup)까지 내부 구현을 살펴본다.
AWS Lambda와 Fargate — Amazon의 서버리스 컴퓨팅 엔진 — 를 이미 알고 있을 가능성이 큽니다. 본질적으로 서버리스 컴퓨팅은 강력한 보안과 뛰어난 성능을 모두 요구하는 꽤 도전적인 과제를 제시합니다. 바로 그 요구를 해결하기 위해 Amazon은 Firecracker라는 마이크로VM 솔루션을 내놓았습니다.
마이크로VM은 그저 최소화된, 가벼운 가상 머신을 가리키는 멋진 말일 뿐입니다. 이는 경량 Virtual Machine Monitor(VMM)가 생성하며, 불필요하거나 있으면 좋은(하지만 꼭 필요하지 않은) 기능을 과감히 제거했습니다. 전통적인 VM과 마찬가지로, 격리와 보안을 위해 하드웨어 수준의 가상화를 제공합니다.
이 글에서 말하는 마이크로VM은 기본적으로 컨테이너 워크로드를 위해 맞춤 제작된 가상화 기술입니다.
Firecracker는 Linux Kernel-based Virtual Machine(KVM)을 활용하는 VMM입니다. Amazon이 자사의 컨테이너 워크로드 요구를 해결하기 위해 만들었습니다. 오픈 소스이며, (엄청나게 멋진) Rust로 작성되었고, 2018년부터 프로덕션에서 사용되고 있습니다.
최근까지 Lambda는 별도의 가상 머신 내부에서 분리된 일반 Linux 컨테이너 위에서 실행되고 있었습니다. 각 컨테이너는 서로 다른 Lambda 함수를 제공했고, 각 VM은 서로 다른 테넌트를 제공했습니다. 보안 측면에서는 매우 효과적이었지만, 이 구성은 성능에 한계가 있었고 크기가 가변적인 워크로드를 고정 크기 VM에 촘촘히 담는 것이 어렵다는 점이 드러났습니다.
Amazon은 서버리스 워크로드를 위해 더 나은 솔루션을 만들기로 했고, 다음을 요구했습니다:
그리고 실제로 이를 달성하여 “최소 125ms”의 인상적인 부트 타임과 “호스트당 초당 최대 150 microVM”의 생성률을 달성했습니다(출처: https://firecracker-microvm.github.io/).
각 Firecracker 프로세스는 단일 MicroVM에 바인딩되며, 다음 스레드로 구성됩니다: API Server, VMM, 그리고 vCPU 스레드(게스트 CPU 코어당 1개).
Firecracker는 현재 커널 버전 4.14 이상에서 동작하는 x86_64 및 aarch64 아키텍처를 지원합니다. aarch64 지원은 아직 기능이 완전하지 않으며 알파 단계 릴리스로 간주됩니다. 이 글에서의 아키텍처 관련 정보는 x86_64 구현을 기준으로 합니다.
API Server는 각 Firecracker 프로세스의 컨트롤 플레인입니다. 공식 문서에 따르면 “가상 머신의 빠른 경로(fast path)에는 절대 포함되지 않으며”, config-file을 대신 제공하는 조건에서 no-api 플래그로 비활성화할 수 있습니다.
전용 스레드에서 ApiServerAdapter에 의해 시작되며, 유닉스 소켓 위에서 동작하는 REST API를 노출합니다. 게스트 커널 설정, 부트 인자, 네트워크 설정, 블록 디바이스 설정, 게스트 머신 설정 및 cpuid, 로깅, 메트릭, 레이트 리미팅, 메타데이터 서비스 구성을 위한 엔드포인트가 존재합니다. API 서버에는 부트 전(pre-boot)과 부트 후(post-boot) 모두에서 작업을 보낼 수 있습니다.
API 서버 스레드와(후술할) 실제 VM을 실행·제어하는 VMM 스레드 간 통신은 Rust 채널을 사용해 이루어집니다.
채널은 API 서버에 API 요청이 도착했음을 epoll 이벤트 루프로 통지받습니다. Firecracker는 다양한 위치에서 이벤트 처리를 위해 이를 사용합니다:
// FD to notify of API events. This is a blocking eventfd by design.
// It is used in the config/pre-boot loop which is a simple blocking loop
// which only consumes API events.
let api_event_fd = EventFd::new(0).expect("Cannot create API Eventfd.");
// Channels for both directions between Vmm and Api threads.
let (to_vmm, from_api) = channel();
let (to_api, from_vmm) = channel();
thread::Builder::new()
.name("fc_api".to_owned())
.spawn(move || {
match ApiServer::new(mmds_info, to_vmm, from_vmm, to_vmm_event_fd).bind_and_run(
bind_path,
process_time_reporter,
&api_seccomp_filter,
) {
// ...
}
}).expect("API thread spawn failed.");
Source: firecracker/src/firecracker/src/api_server_adapter.rs
API 서버가 생성되면 ApiServerAdapter는 이어서 build_microvm_from_requests()를 호출합니다. 이 함수는 연속된 API 호출을 이용해 루프를 돌며 VM을 부트 전 단계로 구성합니다:
pub fn build_microvm_from_requests<F, G>(
seccomp_filters: &BpfThreadMap,
event_manager: &mut EventManager,
instance_info: InstanceInfo,
recv_req: F,
respond: G,
boot_timer_enabled: bool,
) -> result::Result<(VmResources, Arc<Mutex<Vmm>>), ExitCode>
where
F: Fn() -> VmmAction,
G: Fn(ActionResult),
{
//...
// Configure and start microVM through successive API calls.
// Iterate through API calls to configure microVm.
// The loop breaks when a microVM is successfully started, and a running Vmm is built.
while preboot_controller.built_vmm.is_none() {
// Get request, process it, send back the response.
respond(preboot_controller.handle_preboot_request(recv_req()));
// If any fatal errors were encountered, break the loop.
if let Some(exit_code) = preboot_controller.fatal_error {
return Err(exit_code);
}
}
// ...
}
Source: firecracker/src/vmm/src/rpc_interface.rs
VM의 부트 전 구성이 성공적으로 끝나면 ApiServerAdapter는 ApiServerAdapter::run_microvm()을 호출하여 VM을 실행합니다.
Firecracker의 API Server 명세는 여기에서 확인할 수 있습니다.
BIOS를 사용하는 전통적인 PC 부트 시퀀스는 다음 단계로 구성됩니다:
시작 시 CPU는 리얼 모드에서 실행되며, 하드웨어 리셋 벡터에 위치한 명령을 실행해 ROM 위치로 점프합니다. 해당 펌웨어 코드는 다시 시작 프로그램(이 경우 BIOS)을 로드합니다. 시작 프로그램은 자신이 의존하는 하드웨어 장치들이 정상 동작하는지 확인하기 위해 POST(power-on self test) 무결성 검사를 수행합니다.
그 뒤 부팅 가능한 장치(CD 드라이브, HDD, NIC)를 찾기 시작하며, 아무것도 찾지 못하면 부팅에 실패합니다. HDD의 경우 부팅 가능한 장치는 MBR(Master Boot Record)이며, 이는 활성 파티션을 찾아 그 부트 섹터 코드를 실행하는 역할을 합니다. 부트 섹터 코드는 기본적으로 1단계 부트 로더로서, 커널을 물리 메모리에 로드하고 OS로 제어를 넘깁니다.
부트 로더 시스템은 다양한 형태가 있습니다. 부트 로더마다 단계 수가 다르고, 이는 1단계 부트 로더의 512바이트 크기 제한처럼 다양한 자원 제약을 다루도록 설계됩니다. 예를 들어 Grub는 3단계 부트 로더입니다.
하지만 Linux 커널은 반드시 BIOS와 부트 로더를 통해 로드될 필요는 없습니다. 대신 Firecracker는 커널 이미지가 어떻게 로드되고 실행되어야 하는지를 규정하는 64-bit Linux Boot Protocol을 활용합니다. Firecracker는 16-bit real mode에서 시작하는 대신, 커널을 protected-mode 엔트리 포인트에서 직접 부팅합니다.
Linux Boot Protocol 공식 문서에 따르면 protected-mode 엔트리는 0x100000에 위치하며, 다음 스키마에서 확인할 수 있습니다:
For a modern bzImage kernel with boot protocol version >= 2.02, a
memory layout like the following is suggested:
~ ~
| Protected-mode kernel |
100000 +------------------------+
| I/O memory hole |
0A0000 +------------------------+
| Reserved for BIOS | Leave as much as possible unused
~ ~
| Command line | (Can also be below the X+10000 mark)
X+10000 +------------------------+
| Stack/heap | For use by the kernel real-mode code.
X+08000 +------------------------+
| Kernel setup | The kernel real-mode code.
| Kernel boot sector | The kernel legacy boot sector.
X +------------------------+
| Boot loader | <- Boot sector entry point 0000:7C00
001000 +------------------------+
| Reserved for MBR/BIOS |
000800 +------------------------+
| Typically used by MBR |
000600 +------------------------+
| BIOS use only |
000000 +------------------------+
... where the address X is as low as the design of the boot loader
permits.
따라서 Firecracker는 HIMEM_START를 0x0010_0000으로 설정하고, load_kernel()을 호출할 때 start_address로 최종 전달합니다. load_kernel()은 제공된 이미지에 대해 정상성 검사(sanity check)를 수행하고, 세그먼트를 읽어들인 뒤, 게스트 메모리의 엔트리 포인트를 반환합니다.
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub fn load_kernel<F>(
guest_mem: &GuestMemoryMmap,
kernel_image: &mut F,
start_address: u64,
) -> Result<GuestAddress>
where
F: Read + Seek,
{
kernel_image
.seek(SeekFrom::Start(0))
.map_err(|_| Error::SeekKernelImage)?;
let mut ehdr = elf::Elf64_Ehdr::default();
ehdr.as_bytes()
.read_from(0, kernel_image, mem::size_of::<elf::Elf64_Ehdr>())
.map_err(|_| Error::ReadKernelDataStruct("Failed to read ELF header"))?;
// Sanity checks
// ...
kernel_image
.seek(SeekFrom::Start(ehdr.e_phoff))
.map_err(|_| Error::SeekProgramHeader)?;
let phdr_sz = mem::size_of::<elf::Elf64_Phdr>();
let mut phdrs: Vec<elf::Elf64_Phdr> = vec![];
for _ in 0usize..ehdr.e_phnum as usize {
let mut phdr = elf::Elf64_Phdr::default();
phdr.as_bytes()
.read_from(0, kernel_image, phdr_sz)
.map_err(|_| Error::ReadKernelDataStruct("Failed to read ELF program header"))?;
phdrs.push(phdr);
}
// Read in each section pointed to by the program headers.
for phdr in &phdrs {
if (phdr.p_type & elf::PT_LOAD) == 0 || phdr.p_filesz == 0 {
continue;
}
kernel_image
.seek(SeekFrom::Start(phdr.p_offset))
.map_err(|_| Error::SeekKernelStart)?;
let mem_offset = GuestAddress(phdr.p_paddr);
if mem_offset.raw_value() < start_address {
return Err(Error::InvalidProgramHeaderAddress);
}
guest_mem
.read_from(mem_offset, kernel_image, phdr.p_filesz as usize)
.map_err(|_| Error::ReadKernelImage)?;
}
Ok(GuestAddress(ehdr.e_entry))
}
Source: firecracker/src/kernel/src/loader/mod.rs
Firecracker는 압축되지 않은 커널 이미지 vmlinux를 직접 사용하여, 커널이 시작 시 스스로 압축을 푸는 전통적인 부트 시퀀스를 거치는 추가 비용을 절감합니다. 위에서 설명한 이 특수한 Firecracker 부트 시퀀스 전체는 큰 성능 향상을 가능케 하며, 궁극적으로 AWS Lambda 고객이 경험하는 빠른 콜드 스타트로 이어집니다.
Virtio는 Rusty Russell(같은 천재가 iptables도 작성했습니다!)이 x86 Linux 패러가상화 하이퍼바이저 lguest 작업의 일부로 만든 디바이스 가상화 표준입니다. 게스트가 자신이 다른 호스트에서 실행된다는 사실을 모르는 완전 가상화(full virtualization)와 달리, 패러가상화(para-virtualization)는 게스트가 직접 드라이버를 구현하고 호스트와 협력해야 합니다. 그 결과 게스트는 트랩(trap)과 하드웨어 에뮬레이션 드라이버의 중재를 거치지 않고 호스트와 직접 대화하므로 더 좋은 성능을 얻을 수 있습니다. 물론 이를 위해서는 게스트 OS에 수정이 필요합니다. Firecracker는 패러가상화된 KVM으로 구현되어 일반 VM보다 더 좋은 성능을 제공합니다.
이를 외국인과 그/그녀의 모국어로 직접 대화하는 것(패러가상화)과, 통역사의 도움을 받아 대화하는 것(완전 가상화)의 차이로 생각해 볼 수 있습니다.

Virtio의 목적은 (게스트의) 프런트엔드 드라이버, (호스트의) 백엔드 디바이스 드라이버(이하 “디바이스”), 그리고 양 끝단 사이의 전송 계층에 대한 추상화 및 통합 표준을 제공하는 것입니다.
명세는 virtio 호환 시스템 구현에 필요한 요구사항을 정의합니다. 프런트엔드 드라이버는 Linux >= 2.6.25에 기본 포함되어 있는 반면, 백엔드 드라이버(“디바이스”)는 문서에 따라 구현해야 합니다.
데이터 플레인 접근을 위해 게스트와 호스트가 상호작용하는 방식은, 게스트가 할당한 버퍼를 담는 virtqueue라는 링 버퍼 구조를 기반으로 합니다. 호스트는 해당 게스트 메모리 영역에 읽기/쓰기를 수행합니다. 각 디바이스는 하나 이상의 virtqueue를 가질 수 있으며, 각 버퍼는 읽기 전용 또는 쓰기 전용 중 하나일 수 있지만 둘 다일 수는 없습니다. 각 디바이스는 실제로 해당 디바이스가 읽고 쓰는 데이터 외에도 status 필드, feature bit, 구성 공간(configuration space)을 가집니다.
양쪽 간 알림(notification)은 다음을 상대에게 알리기 위해 사용됩니다:
패러가상화가 완전 가상화보다 더 나은 성능을 제공하는 좋은 사례로 Virtio의 ‘available buffer’ 알림 메커니즘을 들 수 있는데, 이는 비싼 VMExit를 많이 절약해 줍니다. 예를 들어 완전 가상화 솔루션에서 NIC 에뮬레이션을 한다면, 에뮬레이션된 디바이스에 1바이트를 쓸 때마다 VMExit가 발생합니다. 반면 virtio에서는 전체 버퍼를 먼저 쓰고, 그 다음 사용 가능 버퍼가 생겼음을 호스트에 알리기 위해 단 한 번의 VMExit만 발생합니다.
참고로 vhost라는 더 나은 virtio 백엔드 구현도 있습니다. 이는 KVM용 커널 내(in-kernel) virtio 디바이스를 도입하여 게스트 커널에서 호스트 커널로 직접 데이터 플레인을 연결하고, 중복되는 호스트 유저스페이스↔커널스페이스 시스템 콜을 절약합니다. Firecracker는 현재 이 구현을 사용하지 않습니다.
Virtio는 드라이버/디바이스의 레이아웃과 구현이 조금씩 다른 3가지 전송 계층을 정의합니다:
PCI 기반 전송과 MMIO 기반 전송의 차이 중 하나는, PCI와 달리 MMIO는 범용적인 디바이스 디스커버리 메커니즘을 제공하지 않는다는 점입니다. 즉, 각 디바이스마다 게스트 OS가 사용되는 레지스터와 인터럽트의 위치를 알고 있어야 합니다.
일반적으로 게스트에서 호스트로 가는 알림은 특수 레지스터에 대한 쓰기이며, 이는 하이퍼바이저가 잡는 시그널을 트리거합니다(ioeventfd와 VMExit). 반면 호스트에서 게스트로 가는 알림은 기본 irqfd 인터럽트입니다. ‘used buffer’ 알림과 ‘available buffer’ 알림은 일반적으로 매우 비싼 연산일 수 있기 때문에 억제(suppress)할 수 있습니다.
Firecracker로 돌아가면, 각 부착된 디바이스(net, block 등)는 각자 자신의 MMIO 전송 인스턴스로 등록됩니다. 이는 기본적으로 MMIO 명세를 구현한 구조체 + 임의의 주소 공간에서의 읽기/쓰기에 응답하는 BusDevice 트레이트로 구성됩니다.
디바이스를 부착할 때 Firecracker는 이를 일반 이벤트 루프에 구독(subscribe)시킵니다. 각 디바이스는 MutEventSubscriber 트레이트를 구현하며, 이는 디바이스의 queue_evts(즉 ‘available buffer’ 알림)에 대한 이벤트 처리를 구현합니다. 이 queue 이벤트는 관련 virtqueue 버퍼의 인덱스를 담고 있으므로, 예를 들어 balloon 드라이버라면 inflateq, deflateq, statsq 큐가 될 수 있습니다.
Firecracker는 디바이스의 queue_evts에 있는 각 파일 디스크립터(해당 디바이스에 특화된 것들)를, 게스트 내부에서 주소 0x050(virtio::NOTIFY_REG_OFFSET)에 쓰기가 발생할 때마다 KVM 자체가 시그널하도록 KVM_IOEVENTFD ioctl을 이용해 등록합니다. virtio::NOTIFY_REG_OFFSET는 Queue Notifier라고 불립니다. 공식 MMIO 명세에 따르면 “이 레지스터에 값을 쓰면 큐에 처리할 새 버퍼가 있음을 디바이스에 알립니다.” KVM_IOEVENTFD ioctl로 등록되지 않은 MMIO/PMIO 게스트 주소에 대한 쓰기는 일반 VMexit를 트리거합니다.
KVM_IOEVENTFD
이 ioctl은 게스트 내부의 합법적인 pio/mmio 주소에 ioeventfd를 연결하거나 분리합니다. 등록된 주소에 대한 게스트 쓰기는 exit를 트리거하는 대신 제공된 이벤트를 시그널합니다.
전반적으로 MMIO 기반 디바이스를 등록하는 흐름은 다음과 같습니다:
pub fn register_mmio_virtio_for_boot(
&mut self,
vm: &VmFd,
device_id: String,
mmio_device: MmioTransport,
_cmdline: &mut kernel_cmdline::Cmdline,
) -> Result {
let mmio_slot = self.allocate_new_slot(1)?;
self.register_mmio_virtio(vm, device_id, mmio_device, &mmio_slot)?;
#[cfg(target_arch = "x86_64")]
Self::add_virtio_device_to_cmdline(_cmdline, &mmio_slot)?;
Ok(mmio_slot)
}
Source: firecracker/src/vmm/src/device_manager/mmio.rs
최소 VMM인 Firecracker는 에뮬레이트하는 드라이버 집합이 꽤 제한적입니다: 블록 스토리지(virtio-blk), 네트워크(virtio-net), vsock(virtio-vsock), balloon 드라이버(virtio-balloon), 시리얼 콘솔, 그리고 VM을 멈추는 용도로만 쓰이는 부분 I8042 키보드 컨트롤러.
위 디바이스들 외에도, Firecracker 게스트는 Programmable Interrupt Controllers(PIC) 및 I/O Advanced Programmable Interrupt Controller(IOAPIC), 그리고 KVM의 Programmable Interval Timer(PIT)도 보게 됩니다.
시리얼 콘솔과 I8042 컨트롤러 같은 레거시 디바이스는 Port Mapped IO를 기반으로 합니다. 시작된 각 vcpu는 virtio 디바이스용 MMIO 버스와 레거시 디바이스용 PMIO 버스를 설정합니다:
pub fn start_vcpus(
&mut self,
mut vcpus: Vec<Vcpu>,
vcpu_seccomp_filter: Arc<BpfProgram>,
) -> Result<()> {
// ... redacted
for mut vcpu in vcpus.drain(..) {
vcpu.set_mmio_bus(self.mmio_device_manager.bus.clone());
#[cfg(target_arch = "x86_64")]
vcpu.kvm_vcpu
.set_pio_bus(self.pio_device_manager.io_bus.clone());
// … redacted
}
// ... redacted
Ok(())
}
Source: firecracker/src/vmm/src/lib.rs
MMIO 읽기/쓰기는 VMExit를 트리거하며, 이는 vCPU를 실행하는(후술할) run_emulation()이라는 함수에서 처리되는 것들 중 하나입니다. 이 VMExit들은 디바이스의 컨트롤 플레인(즉 구성 공간)에 접근하기 위해 사용됩니다:
/// Runs the vCPU in KVM context and handles the kvm exit reason.
///
/// Returns error or enum specifying whether emulation was handled or interrupted.
pub fn run_emulation(&self) -> Result<VcpuEmulation> {
match self.emulate() {
VcpuExit::MmioRead(addr, data) => {
if let Some(mmio_bus) = &self.kvm_vcpu.mmio_bus {
mmio_bus.read(addr, data);
METRICS.vcpu.exit_mmio_read.inc();
}
Ok(VcpuEmulation::Handled)
}
VcpuExit::MmioWrite(addr, data) => {
if let Some(mmio_bus) = &self.kvm_vcpu.mmio_bus {
mmio_bus.write(addr, data);
METRICS.vcpu.exit_mmio_write.inc();
}
Ok(VcpuEmulation::Handled)
}
// ... redacted
arch_specific_reason => {
// run specific architecture emulation.
self.kvm_vcpu.run_arch_emulation(arch_specific_reason)
}
// ... redacted
}
}
Source: firecracker/src/vmm/src/vstate/vcpu/mod.rs
PMIO 읽기/쓰기는 아키텍처별이며 별도로 처리됩니다:
/// Runs the vCPU in KVM context and handles the kvm exit reason.
///
/// Returns error or enum specifying whether emulation was handled or interrupted.
pub fn run_arch_emulation(&self, exit: VcpuExit) -> super::Result<VcpuEmulation> {
match exit {
VcpuExit::IoIn(addr, data) => {
if let Some(pio_bus) = &self.pio_bus {
pio_bus.read(u64::from(addr), data);
METRICS.vcpu.exit_io_in.inc();
}
Ok(VcpuEmulation::Handled)
}
VcpuExit::IoOut(addr, data) => {
if let Some(pio_bus) = &self.pio_bus {
pio_bus.write(u64::from(addr), data);
METRICS.vcpu.exit_io_out.inc();
}
Ok(VcpuEmulation::Handled)
}
// ... redacted
}
Source: firecracker/src/vmm/src/vstate/vcpu/x86_64.rs
게스트의 네트워크 디바이스는 호스트의 tap 디바이스로 백업됩니다:
impl Net {
/// Create a new virtio network device with the given TAP interface.
pub fn new_with_tap(
id: String,
tap_if_name: String,
guest_mac: Option<&MacAddr>,
rx_rate_limiter: RateLimiter,
tx_rate_limiter: RateLimiter,
allow_mmds_requests: bool,
) -> Result<Self> {
let tap = Tap::open_named(&tap_if_name).map_err(Error::TapOpen)?;
// Set offload flags to match the virtio features below.
tap.set_offload(
net_gen::TUN_F_CSUM | net_gen::TUN_F_UFO | net_gen::TUN_F_TSO4 | net_gen::TUN_F_TSO6,
)
.map_err(Error::TapSetOffload)?;
let vnet_hdr_size = vnet_hdr_len() as i32;
tap.set_vnet_hdr_size(vnet_hdr_size)
.map_err(Error::TapSetVnetHdrSize)?;
let mut avail_features = 1 << VIRTIO_NET_F_GUEST_CSUM
| 1 << VIRTIO_NET_F_CSUM
| 1 << VIRTIO_NET_F_GUEST_TSO4
| 1 << VIRTIO_NET_F_GUEST_UFO
| 1 << VIRTIO_NET_F_HOST_TSO4
| 1 << VIRTIO_NET_F_HOST_UFO
| 1 << VIRTIO_F_VERSION_1;
// … redacted
}
// … redacted
}
Source: firecracker/src/devices/src/virtio/net/device.rs
Vsock은 호스트/게스트 간 양방향 통신 방법으로 도입되었습니다. 대안으로 virtio-console을 사용할 수도 있는데, 이는 호스트/게스트 상호작용을 제공하지만 다소 제한적입니다. 우선 1:1 시리얼 포트를 통해 N:1 연결을 멀티플렉싱하는 것은 어렵고 애플리케이션 레벨에서 처리되어야 합니다. 또한 API가 소켓 API가 아니라 문자 디바이스(character device)에 기반하고, 의미론(semantics)이 스트림 중심이라 데이터그램 프로토콜과 잘 맞지 않습니다. 그 위에 호스트 머신에는 약 512 정도의 작고 하드코딩된 포트 제한도 존재합니다.
반면 Vsock은 일반 유닉스 도메인 소켓 API(connect(), bind(), accept(), read(), write(), 등)를 제공하므로 데이터그램 및 스트림 의미론을 모두 지원합니다. 이를 위한 전용 주소 패밀리 AF_VSOCK이 있습니다. 소스/목적지 주소는 호스트 바이트 오더의 32-bit 컨텍스트 id(cid)와 32-bit 포트의 튜플로 구성됩니다.
Firecracker는 MicroVM이 vsock 드라이버를 구성한 상태로 시작되어야 하는 호스트 시작(host-initiated) vsock 연결을 지원합니다. 또한 게스트 시작(guest-initiated) 연결도 지원하는데, 이 경우 호스트는 목적지 포트에서 리스닝 중이어야 하며, 그렇지 않으면 게스트에 VIRTIO_VSOCK_OP_RST 메시지를 보냅니다.
스토리지를 위해 Firecracker는 호스트의 파일로 백업되는 virtio-block 디바이스를 구현합니다. 현재로서는 파일시스템 패스스루 솔루션(virtio-fs)을 사용하지 않습니다(보안 우려 때문일 수도 있겠죠?). 또한 Firecracker에는 핫플러그가 없기 때문에 VM의 모든 블록 디바이스는 VM 실행 이전에 부착되어야 합니다. 아울러 VM에서 성공적으로 마운트하려면 해당 디바이스들이 게스트 커널이 지원하는 파일시스템으로 미리 포맷되어 있어야 합니다.
모든 읽기/쓰기 작업은 단일 requestq virtio 큐를 통해 처리됩니다. virtio 명세가 공식적으로 지원하는 오퍼레이션은 다음과 같습니다:
#define VIRTIO_BLK_T_IN 0
#define VIRTIO_BLK_T_OUT 1
#define VIRTIO_BLK_T_FLUSH 4
#define VIRTIO_BLK_T_DISCARD 11
#define VIRTIO_BLK_T_WRITE_ZEROES 13
Firecracker는 IN, OUT, FLUSH만 지원합니다:
pub enum RequestType {
In,
Out,
Flush,
GetDeviceID,
Unsupported(u32),
}
VM을 부팅하기 전에 rootfs 블록 디바이스를 다음과 같이 구성해야 합니다:
rootfs_path=$(pwd)"/your-rootfs.ext4"
curl --unix-socket /tmp/firecracker.socket -i \
-X PUT 'http://localhost/drives/rootfs' \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d "{
\"drive_id\": \"rootfs\",
\"path_on_host\": \"${rootfs_path}\",
\"is_root_device\": true,
\"is_read_only\": false
}"
최소 rootfs 이미지를 만드는 예시는 Firecracker 공식 문서에서 확인할 수 있습니다.
Ballooning은 메모리 오버커밋을 위한 솔루션을 제공하려는 개념입니다. 이는 호스트가 제어하는 온디맨드 방식의 게스트 메모리 할당 및 회수를 가능하게 합니다.
virtio-balloon 디바이스는 다음과 같이 동작합니다. balloon 게스트 드라이버는 호스트가 지정한 타깃에 도달할 때까지 메모리를 할당하고, 그 새 메모리 주소들을 호스트 디바이스에 보고합니다. 반대로 balloon 드라이버가 호스트 디바이스가 요구하는 것보다 더 많은 메모리를 가지고 있다면, 메모리를 게스트 자체에 반환합니다. 이는 게스트 커널 내부의 독립적인 “메모리 소비자/할당자”로서, 다른 프로세스들과 메모리를 두고 경쟁하며, VM의 부트 전 RAM 제한 내에서 동작합니다.
호스트는 원할 때 balloon 메모리 페이지를 제거해 다른 게스트에게 넘길 수 있습니다. 이를 통해 호스트는 자신이 가진 가용 자원에 따라 각 게스트의 메모리 자원을 제어하고 미세 조정할 수 있으며, 그 결과 오버커밋이 가능해집니다.

virtio-balloon은 inflateq, deflateq, statsq의 세 가지 virtio 큐를 가집니다. inflateq는 게스트 드라이버가 호스트 디바이스에 제공한 주소를 보고할 때 사용되며(“balloon”이 부풀려짐), deflateq는 게스트가 사용하는 메모리 주소를 보고할 때 사용됩니다(“balloon”이 줄어듦). statsq는 선택 사항이며, 게스트가 메모리 통계를 전송하는 데 사용할 수 있습니다.
Firecracker의 구현은 best-effort 기반으로 동작하며, 어떤 VM이 추가 메모리 페이지 할당에 실패하면 에러를 기록하고 200ms 동안 잠든 뒤 다시 시도합니다.
Firecracker는 공식 virtio 명세에 명시된 세 가지 feature bit 중 두 가지를 지원합니다:
deflate_on_oom(또는 VIRTIO_BALLOON_F_DEFLATE_ON_OOM) - 커널 활동에 필요하지 않은 프로세스가 OOM에 빠질 때 OOM killer로 죽이는 대신 balloon에서 메모리를 디플레이트stats_polling_interval_s(또는 VIRTIO_BALLOON_F_STATS_VQ) - 몇 초마다 통계를 전송할지 지정; 0이면 비활성화Firecracker가 활성화하지 않는 세 번째(또는 첫 번째) feature bit는 VIRTIO_BALLOON_F_MUST_TELL_HOST로, balloon의 페이지가 사용되기 전에 반드시 호스트에 알려야 한다는 것을 드라이버에 지시하기 위한 것입니다.
호스트 측에서도 메모리 압박을 모니터링하고 그에 따라 balloon을 조작해야 한다는 점에 유의하세요. 이는 수동으로 하기엔 현실적이지 않으며, 자동화하되 신중하게 처리되어야 합니다.
Firecracker의 Firecracker Ballooning 문서에는 추가적인 주의가 필요한 보안 우려와 함정들이 문서화되어 있습니다.
게스트 balloon 드라이버 구현에 관심이 있다면, 꽤 직관적인 편이니 여기와 여기를 참고해 보세요.
Firecracker는 virtio-net 및 virtio-block 디바이스에 대해 I/O 레이트 리미팅을 제공하며, 대역폭(bytes/sec)과 초당 작업 수(ops/sec) 모두에 대해 스로틀링할 수 있습니다. 구현은 토큰 버킷(token bucket)에 기반하며, 레이트 리미터 타입마다 하나씩 존재합니다.
API 서버를 통해 구성할 수 있으며, (선택적으로) 드라이브 및 네트워크 인터페이스별로 설정할 수 있습니다. 구성 가능한 값은 리필 시간, 버킷 크기, 그리고 선택적인 1회성 버스트입니다.
참고: src/api_server/swagger/firecracker.yaml#L1086
이미 언급했듯이 Firecracker는 PIO 버스 위에서 몇 가지 레거시 디바이스를 에뮬레이션합니다. 예를 들어 x86에서 흔히 보이는 시리얼 COM 포트를 I/O 포트 0x3f8/0x2f8/0x3e8/0x2e8로 에뮬레이션합니다. 더 구체적으로는 0x3f8 포트를 사용하고, 0x2f8, 0x3e8, 0x2e8는 어디에도 연결되지 않은 싱크(sink)로 사용합니다. 또한 포트 0x060에 등록된 I8052 키보드 컨트롤러도 노출하며, 이는 Firecracker가 셧다운을 제어하고 해당 목적에 쓰이는 ctrl+alt+delete 시퀀스를 발행하는 데 사용합니다.
pub fn register_devices(&mut self, vm_fd: &VmFd) -> Result<()> {
self.io_bus
.insert(self.stdio_serial.clone(), 0x3f8, 0x8)
.map_err(Error::BusError)?;
self.io_bus
.insert(
Arc::new(Mutex::new(devices::legacy::Serial::new_sink(
self.com_evt_2_4.try_clone().map_err(Error::EventFd)?,
))),
0x2f8,
0x8,
)
.map_err(Error::BusError)?;
self.io_bus
.insert(
Arc::new(Mutex::new(devices::legacy::Serial::new_sink(
self.com_evt_1_3.try_clone().map_err(Error::EventFd)?,
))),
0x3e8,
0x8,
)
.map_err(Error::BusError)?;
self.io_bus
.insert(
Arc::new(Mutex::new(devices::legacy::Serial::new_sink(
self.com_evt_2_4.try_clone().map_err(Error::EventFd)?,
))),
0x2e8,
0x8,
)
.map_err(Error::BusError)?;
self.io_bus
.insert(self.i8042.clone(), 0x060, 0x5)
.map_err(Error::BusError)?;
vm_fd
.register_irqfd(&self.com_evt_1_3, 4)
.map_err(|e| Error::EventFd(std::io::Error::from_raw_os_error(e.errno())))?;
vm_fd
.register_irqfd(&self.com_evt_2_4, 3)
.map_err(|e| Error::EventFd(std::io::Error::from_raw_os_error(e.errno())))?;
vm_fd
.register_irqfd(&self.kbd_evt, 1)
.map_err(|e| Error::EventFd(std::io::Error::from_raw_os_error(e.errno())))?;
Ok(())
}
Source: firecracker/src/vmm/src/device_manager/legacy.rs
Firecracker는 각 KVM vCPU 에뮬레이션을 별도의 POSIX 스레드에서 스폰하고 관리합니다. 각 vCPU는 OS 스레드나 프로세스가 아니라, 하드웨어가 지원하는 실행 모드입니다. 예를 들어 Intel VT-x는 소프트웨어 에뮬레이션 없이 가상화된 게스트를 네이티브에 가깝게 실행하도록 돕기 위한 기술입니다. Intel 기술은 두 가지 실행 모드를 제공합니다: a. 호스트 VMM을 위한 VMX root 모드, b. 게스트 명령 실행을 위한 VMX non-root 모드. 이는 Virtual Machine Control Structure라는 게스트별 구조체의 도움을 받는데, 이 구조체는 호스트 및 게스트 모드 모두가 필요로 하는 컨텍스트 정보를 저장하는 역할을 합니다. 이 기술은 KVM이 사용하며, 따라서 Firecracker도 vCPU를 실행하기 위해 이를 사용합니다.

Firecracker는 각 vCPU 상태를(VMExit 및 ioeventfd 인터럽트 포함) 모니터링하고, 상태 머신(state machine)에서 적절히 처리합니다.
여기를 확인해 보세요: https://github.com/firecracker-microvm/firecracker/blob/HEAD/src/vmm/src/vstate/vcpu/mod.rs.
Firecracker가 제공하는 또 다른 기능은 CPUID feature 마스킹입니다. x86에서 CPUID 명령은 프로세서의 기능을 질의하는데, 일부 워크로드에는 매우 필요한 기능입니다. VM 내부에서 실행할 때 이 명령은 잘 동작하지 않으며 에뮬레이션이 필요합니다. KVM은 KVM_SET_CPUID2 ioctl을 통해 CPUID 에뮬레이션을 지원하며, Firecracker는 이를 활용합니다.
MMDS는 Firecracker의 변경 가능한(mutable) 데이터 저장소로, 게스트가 호스트가 제공하는 JSON 메타데이터에 접근할 수 있게 합니다. 가능한 사용 사례로는 호스트가 제어하는 게스트 내부의 크리덴셜 로테이션이 있습니다.
이 기능은 세 가지 구성 요소로 이루어집니다:
게스트에서 virtio-net 디바이스로 들어오는 각 프레임은 목적지를 검사합니다. 목적지가 Metadata Service로 지정되어 있고(그리고 켜져 있다면) Dumbo로 포워딩됩니다. 이후 응답이 있는지 확인하고, 디바이스 링 버퍼에 충분한 공간이 있을 경우 게스트로 응답을 보냅니다. MMDS로 지정된 것이 아니라면 tap 디바이스로 보냅니다.
// Tries to detour the frame to MMDS and if MMDS doesn't accept it, sends it on the host TAP.
//
// `frame_buf` should contain the frame bytes in a slice of exact length.
// Returns whether MMDS consumed the frame.
fn write_to_mmds_or_tap(
mmds_ns: Option<&mut MmdsNetworkStack>,
rate_limiter: &mut RateLimiter,
frame_buf: &[u8],
tap: &mut Tap,
guest_mac: Option<MacAddr>,
) -> Result<bool> {
let checked_frame = |frame_buf| {
frame_bytes_from_buf(frame_buf).map_err(|e| {
error!("VNET header missing in the TX frame.");
METRICS.net.tx_malformed_frames.inc();
e
})
};
if let Some(ns) = mmds_ns {
if ns.detour_frame(checked_frame(frame_buf)?) {
METRICS.mmds.rx_accepted.inc();
// MMDS frames are not accounted by the rate limiter.
rate_limiter.manual_replenish(frame_buf.len() as u64, TokenType::Bytes);
rate_limiter.manual_replenish(1, TokenType::Ops);
// MMDS consumed the frame.
return Ok(true);
}
}
// This frame goes to the TAP.
// Check for guest MAC spoofing.
if let Some(mac) = guest_mac {
let _ = EthernetFrame::from_bytes(checked_frame(frame_buf)?).map(|eth_frame| {
if mac != eth_frame.src_mac() {
METRICS.net.tx_spoofed_mac_count.inc();
}
});
}
match tap.write(frame_buf) {
Ok(_) => {
METRICS.net.tx_bytes_count.add(frame_buf.len());
METRICS.net.tx_packets_count.inc();
METRICS.net.tx_count.inc();
}
Err(e) => {
error!("Failed to write to tap: {:?}", e);
METRICS.net.tap_write_fails.inc();
}
};
Ok(false)
}
Source: firecracker/src/devices/src/virtio/net/device.rs#L395
MMDS와 Dumbo의 설계에 대한 더 자세한 내용은 이 design docs를 확인하세요.
더 나은 보안 및 성능 보장을 위해 Firecracker는 추가적인 샌드박싱을 제공합니다:
pivot_root() 및 chroot() 호출, cgroup 적용, jail 내부에 /dev/kvm 같은 특수 경로를 mknod()로 생성하는 작업 등. 이후 권한을 낮추고 Firecracker 이미지로 exec()합니다.--cgroup 플래그로 cgroups 사용을 지원합니다.자, 여기까지입니다.
이 놀라운 프로젝트의 소스 코드를 직접 읽고 스스로 탐험해 보는 것을 강력히 추천합니다 - https://github1s.com/firecracker-microvm/firecracker.