Pular para o conteúdo
, , ,

Snapshots no Firecracker: de 7 segundos para 240ms [pt.4]

De 7s para 240ms: elimine a lentidão do boot usando snapshots no Firecracker

Avatar de DK
DKTrabalha com Linux e Unix a mais de 23 anos e possui as certificações LPI 3, RHCE, AIX e VIO.

09 dez, 2025
12 min de leitura

Série Firecracker:


Nos artigos anteriores, construímos um nano-Lambda que gera QR codes e valida URLs. Funciona bem, roda em menos de 2 segundos. Mas e se a gente quisesse algo mais pesado? Um classificador de spam com machine learning, por exemplo?

Aí a coisa complica. Bibliotecas como scikit-learn demoram segundos só pra carregar. Cada execução do nano-Lambda vira uma espera de 7-8 segundos olhando pro terminal. Tenta ficar oito segundos olhando pra uma tela sem piscar. É uma eternidade. Se você rodar isso 10 vezes pra debugar, já perdeu mais de um minuto de vida olhando pro nada.

O problema é que a gente tá “ligando o computador” toda vez. Boot do kernel, init do sistema, import de bibliotecas pesadas… tudo do zero, a cada requisição. É como se você desligasse e ligasse seu notebook toda vez que quisesse abrir uma nova aba do Chrome.

A AWS não faz isso. Ela usa uma técnica chamada snapshot: tira uma “foto” da VM já pronta e restaura essa foto instantaneamente quando precisa. É hibernação turbinada — você clona um computador que já está ligado, com todos os programas abertos, e ele acorda sem saber que foi copiado.

Hoje a gente implementa isso.

TL;DR — O que você vai conseguir:

Métrica Antes Depois
Tempo de inicialização ~7-8s ~300ms
Speedup ~25x
Custo CPU (boot completo) Storage (512MB)

O problema: cold start pesado

Olha só onde o tempo tá indo. Quando a gente roda o classificador de spam com scikit-learn, dá pra ver exatamente o que tá demorando:

Etapa Tempo
Preparar ambiente (copiar rootfs 512MB) ~2.5s
Iniciar Firecracker + socket ~0.6s
Boot do kernel ~0.1s
Init do Alpine ~0.2s
Import do scikit-learn ~3.5s
Treinar modelo ~0.01s
Classificar texto ~0.001s

Olha o culpado aí. O import sklearn. Sozinho, ele come mais de 3 segundos. É um gordinho: carrega numpy, scipy, binários em C… É muita coisa pra arrastar do disco pra memória toda vez que a gente quer classificar uma frase simples.

A sacada é: e se a gente tirasse uma foto da VM depois que o scikit-learn já tá carregado? Economizaria esses 3+ segundos em toda execução subsequente.

A solução: snapshots

É exatamente o que acontece quando você fecha a tampa do notebook e ele hiberna. O sistema salva tudo no disco e desliga. Quando você abre, o Word e o Chrome estão lá, na mesma página, sem ter que carregar o Windows do zero. A VM nem sabe que foi “desligada” — pra ela, foi um cochilo instantâneo.

Um snapshot do Firecracker captura dois arquivos:

  1. Memória da VM (vm_mem): Todo o conteúdo da RAM — código carregado, variáveis, estado dos processos
  2. Estado da CPU (vm_state): Registradores, program counter, estado da MMU

Com esses dois arquivos, você pode restaurar a VM exatamente no ponto onde ela estava. Não precisa fazer boot, não precisa carregar bibliotecas, não precisa inicializar nada. A VM simplesmente “acorda” pronta pra trabalhar.

O fluxo

diagrama_snapshot_restore

A primeira execução ainda é lenta (precisa criar o snapshot). Mas todas as seguintes pulam direto pro ponto onde a VM está pronta.

Nota: No nosso exemplo didático, a VM acorda num loop infinito de sleep. Numa aplicação real, ela acordaria pronta pra ouvir um socket e processar requisições imediatamente.

Preparando o terreno

Se você acompanhou os artigos anteriores, já deve ter:

  • Firecracker funcionando (artigo 1)
  • nano-Lambda rodando (artigo 2)
  • Python 3 com requests-unixsocket instalado

Agora a gente precisa de um rootfs com scikit-learn pra ter um cold start bem gordo e visível.

Um conselho de quem já travou a máquina fazendo isso: libera espaço. Sério. O rootfs tem 512MB, o snapshot mais 512MB… quando você vê, seu /tmp lotou e o Linux começa a reclamar. Garante pelo menos 1GB livre antes de rodar o script pra não passar raiva.

# build-rootfs-sklearn.sh

#!/bin/bash
set -e

ROOTFS="rootfs-sklearn.ext4"
SIZE_MB=512
MOUNT_POINT="/tmp/rootfs-mount"

echo "Criando rootfs com scikit-learn"

dd if=/dev/zero of=$ROOTFS bs=1M count=$SIZE_MB
mkfs.ext4 -F $ROOTFS

mkdir -p $MOUNT_POINT
sudo mount $ROOTFS $MOUNT_POINT

# Instala Alpine com sklearn
sudo podman run --rm -v $MOUNT_POINT:/rootfs alpine:3.21 sh -c '
    apk add --root /rootfs --initdb \
        alpine-base openrc \
        python3 py3-pip py3-numpy py3-scikit-learn py3-joblib
'

sudo mkdir -p $MOUNT_POINT/functions
sudo mkdir -p $MOUNT_POINT/model

# Cria init.sh que será usado para snapshot
sudo tee $MOUNT_POINT/init.sh > /dev/null << 'INIT'
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true

# Carrega sklearn e treina modelo
python3 << 'PYEOF'
import time
start = time.time()

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline

print(f"[TIMING] sklearn importado em {time.time()-start:.3f}s")

# Dados de treino
texts = [
    "ganhe dinheiro rapido agora",
    "voce ganhou um premio clique aqui",
    "oferta imperdivel so hoje",
    "reuniao amanha as 10h",
    "relatorio do projeto em anexo",
    "ola tudo bem com voce"
] * 10

labels = [1, 1, 1, 0, 0, 0] * 10  # 1=spam, 0=ham

# Treina
model = Pipeline([
    ("tfidf", TfidfVectorizer()),
    ("clf", MultinomialNB())
])
model.fit(texts, labels)

print("[READY] Modelo treinado - VM pronta para snapshot")
PYEOF

# Sinaliza que está pronto e aguarda
echo "SNAPSHOT_READY"
while true; do sleep 1; done
INIT

sudo chmod +x $MOUNT_POINT/init.sh

sudo umount $MOUNT_POINT
rmdir $MOUNT_POINT

echo "=== Rootfs criado: $ROOTFS ==="

Execute o script (precisa de root por causa do mount):

chmod +x build-rootfs-sklearn.sh
sudo ./build-rootfs-sklearn.sh

O momento certo do snapshot

Aqui tem um “pulo do gato” que se você errar, nada funciona. O timing.

Se pausar cedo demais, o scikit-learn não carregou. O snapshot vai capturar a VM no meio do import, e você não ganha nada. Se pausar tarde, perdeu tempo. A gente precisa que a VM grite “Tô pronta!” antes de congelar.

A solução é um “aperto de mão” entre a VM e o host:

  1. A VM carrega tudo que precisa
  2. A VM imprime um marcador: SNAPSHOT_READY
  3. O host monitora o output da VM
  4. Quando vê o marcador, pausa e tira o snapshot

Esse padrão é tão comum que vale destacar:

# Dentro da VM (init.sh)
print("SNAPSHOT_READY")  # Sinaliza pro host

# No host (orquestrador)
while True:
    output = read_vm_console()
    if "SNAPSHOT_READY" in output:
        pause_vm()
        create_snapshot()
        break

Sem esse handshake, você fica no escuro — pode acabar capturando uma VM ainda em inicialização.

Implementando o teste

Vamos criar um script que:

  1. Mede o cold start completo
  2. Cria um snapshot
  3. Mede o tempo de restore
#!/usr/bin/env python3
"""
test-snapshot.py - Compara cold start vs restore no Firecracker
"""

import subprocess
import requests_unixsocket
import time
import shutil
import tempfile
import os
import sys

FIRECRACKER_BIN = "./firecracker"
KERNEL_PATH = "./vmlinux.bin"
ROOTFS_TEMPLATE = "./rootfs-sklearn.ext4"
SOCKET_PATH = "/tmp/fc-snapshot.socket"
SNAPSHOT_PATH = "/tmp/fc-snapshot"
MEM_FILE = f"{SNAPSHOT_PATH}/vm_mem"
STATE_FILE = f"{SNAPSHOT_PATH}/vm_state"

def api_url(path):
    encoded = SOCKET_PATH.replace("/", "%2F")
    return f"http+unix://{encoded}{path}"

def call_api(method, path, data=None):
    session = requests_unixsocket.Session()
    url = api_url(path)

    if method == "PUT":
        resp = session.put(url, json=data)
    elif method == "PATCH":
        resp = session.patch(url, json=data)
    elif method == "GET":
        resp = session.get(url)

    if resp.status_code >= 400:
        raise Exception(f"API error: {resp.status_code} - {resp.text}")
    return resp

def cleanup():
    """Limpa processos e arquivos anteriores."""
    subprocess.run(["pkill", "-9", "firecracker"], capture_output=True)
    time.sleep(0.5)

    for path in [SOCKET_PATH]:
        if os.path.exists(path):
            os.remove(path)

    if os.path.exists(SNAPSHOT_PATH):
        shutil.rmtree(SNAPSHOT_PATH)

def start_firecracker(log_file=None):
    """Inicia o processo Firecracker."""
    if os.path.exists(SOCKET_PATH):
        os.remove(SOCKET_PATH)

    stdout = open(log_file, "w") if log_file else subprocess.PIPE

    proc = subprocess.Popen(
        [FIRECRACKER_BIN, "--api-sock", SOCKET_PATH],
        stdout=stdout,
        stderr=subprocess.STDOUT
    )

    for _ in range(50):
        if os.path.exists(SOCKET_PATH):
            break
        time.sleep(0.1)

    time.sleep(0.2)
    return proc

def configure_vm(rootfs_path):
    """Configura kernel, rootfs e recursos."""
    call_api("PUT", "/boot-source", {
        "kernel_image_path": KERNEL_PATH,
        "boot_args": "console=ttyS0 reboot=k panic=1 pci=off init=/init.sh"
    })

    call_api("PUT", "/drives/rootfs", {
        "drive_id": "rootfs",
        "path_on_host": rootfs_path,
        "is_root_device": True,
        "is_read_only": False
    })

    call_api("PUT", "/machine-config", {
        "vcpu_count": 1,
        "mem_size_mib": 256
    })

def wait_for_ready(log_file, timeout=30):
    """Aguarda VM sinalizar que está pronta."""
    start = time.time()
    while time.time() - start < timeout:
        if os.path.exists(log_file):
            with open(log_file, "r") as f:
                if "SNAPSHOT_READY" in f.read():
                    return True
        time.sleep(0.1)
    return False

def main():
    if os.geteuid() != 0:
        print("Execute como root: sudo python3 test-snapshot.py")
        sys.exit(1)

    print("=" * 60)
    print("Teste de Snapshot/Restore do Firecracker")
    print("=" * 60)

    cleanup()

    # PARTE 1: Cold Start
    print("\n[1] COLD START")
    print("-" * 40)

    cold_start = time.time()

    temp_rootfs = tempfile.NamedTemporaryFile(suffix=".ext4", delete=False).name
    shutil.copy(ROOTFS_TEMPLATE, temp_rootfs)
    print(f"    Rootfs copiado ({time.time() - cold_start:.3f}s)")

    # Inicia Firecracker
    log_file = "/tmp/fc-boot.log"
    fc_proc = start_firecracker(log_file)
    print(f"    Firecracker iniciado ({time.time() - cold_start:.3f}s)")

    # Configura VM
    configure_vm(temp_rootfs)
    print(f"    VM configurada ({time.time() - cold_start:.3f}s)")

    # Inicia VM
    call_api("PUT", "/actions", {"action_type": "InstanceStart"})
    print("    VM iniciada - aguardando SNAPSHOT_READY...")

    # Aguarda sklearn carregar
    if not wait_for_ready(log_file):
        print("    ERRO: Timeout esperando VM ficar pronta")
        return

    cold_time = time.time() - cold_start
    print(f"\n    >>> COLD START: {cold_time:.3f}s")

    # Mostra timing do sklearn
    with open(log_file, "r") as f:
        for line in f:
            if "[TIMING]" in line or "[READY]" in line:
                print(f"    {line.strip()}")

    # PARTE 2: Criar Snapshot
    print("\n[2] CRIANDO SNAPSHOT")
    print("-" * 40)

    snap_start = time.time()

    # Pausa VM
    call_api("PATCH", "/vm", {"state": "Paused"})
    print(f"    VM pausada ({time.time() - snap_start:.3f}s)")

    # Cria snapshot
    os.makedirs(SNAPSHOT_PATH, exist_ok=True)
    call_api("PUT", "/snapshot/create", {
        "snapshot_type": "Full",
        "snapshot_path": STATE_FILE,
        "mem_file_path": MEM_FILE
    })

    snap_time = time.time() - snap_start
    print(f"    Snapshot criado ({snap_time:.3f}s)")

    # Mostra tamanhos
    mem_size = os.path.getsize(MEM_FILE) / (1024 * 1024)
    state_size = os.path.getsize(STATE_FILE) / 1024
    print(f"    Arquivos: memoria={mem_size:.0f}MB, estado={state_size:.0f}KB")

    # Para VM original
    fc_proc.terminate()
    fc_proc.wait()
    if os.path.exists(SOCKET_PATH):
        os.remove(SOCKET_PATH)

    # PARTE 3: Restore
    print("\n[3] RESTORE")
    print("-" * 40)

    restore_start = time.time()

    # Novo Firecracker
    fc_proc2 = start_firecracker()
    print(f"    Firecracker iniciado ({time.time() - restore_start:.3f}s)")

    # Carrega snapshot
    call_api("PUT", "/snapshot/load", {
        "snapshot_path": STATE_FILE,
        "mem_backend": {
            "backend_type": "File",
            "backend_path": MEM_FILE
        },
        "enable_diff_snapshots": False,
        "resume_vm": True
    })

    restore_time = time.time() - restore_start
    print(f"\n    >>> RESTORE: {restore_time:.3f}s")

    # RESUMO
    print("\n" + "=" * 60)
    print("RESULTADOS")
    print("=" * 60)
    print(f"  Cold Start:     {cold_time:.3f}s")
    print(f"  Criar Snapshot: {snap_time:.3f}s")
    print(f"  Restore:        {restore_time:.3f}s")
    print()
    print(f"  Speedup:        {cold_time/restore_time:.1f}x mais rapido")
    print(f"  Economia:       {cold_time - restore_time:.3f}s por execucao")
    print("=" * 60)

    # Cleanup
    fc_proc2.terminate()
    fc_proc2.wait()
    os.remove(temp_rootfs)

if __name__ == "__main__":
    main()

Executando o teste

sudo python3 test-snapshot.py

Resultado no meu ambiente:

============================================================
Teste de Snapshot/Restore do Firecracker
============================================================

NOTA: Esta VM esta isolada (sem rede).
      Para acesso a internet, veja o artigo sobre networking.

[1] COLD START (boot + sklearn)
----------------------------------------
    Rootfs preparado (1.048s)
    Firecracker iniciado (1.355s)
    VM configurada (1.401s)
    VM iniciada - aguardando SNAPSHOT_READY...

    >>> COLD START TOTAL: 7.796s

[2] CRIANDO SNAPSHOT
----------------------------------------
    VM pausada (0.005s)
    Snapshot criado (0.645s)
    Memoria: 512.0 MB | Estado: 14.6 KB
    VM original terminada

[3] RESTORE DO SNAPSHOT
----------------------------------------
    Firecracker iniciado (0.301s)

    >>> RESTORE TOTAL: 0.309s

============================================================
RESULTADOS
============================================================
  Cold Start:     7.796s
  Criar Snapshot: 0.645s
  Restore:        0.309s

  Speedup:        25.2x mais rapido
  Economia:       7.488s por execucao
============================================================

~300 milissegundos. Piscou, perdeu. Saímos de uma espera de cafézinho (~8s) para algo instantâneo. É mais de 25x mais rápido. A sensação de usar muda da água pro vinho.

Por que isso importa

Não parece muito no papel. Mas na prática, a diferença é brutal:

  • ~8 segundos: O usuário clica, vê uma tela carregando, tem tempo de pensar “travou?”, considera fechar a aba. É uma eternidade em interações web.
  • ~300 milissegundos: Imperceptível. Qualquer coisa abaixo de 300-400ms é sentida como “instantânea”. O usuário nem percebe que esperou.

O trade-off: CPU por Storage

Olha os tamanhos dos arquivos de snapshot:

  • Memória: 512MB (toda a RAM da VM)
  • Estado: ~15KB (registradores, program counter)

Você está trocando ~8 segundos de CPU por 512MB de disco. Em nuvem, esse trade-off quase sempre compensa — armazenamento é barato, tempo de computação e paciência do usuário são caros.

Nota técnica: Os ~300ms incluem o tempo de iniciar o processo Firecracker do zero (~300ms). O restore da memória em si leva poucos milissegundos. Orquestradores de alta performance (como o Lambda por baixo dos panos) mantêm processos “pré-aquecidos”, reduzindo o tempo total para 20-50ms.

O que está acontecendo por baixo

Quando você chama /snapshot/create, o Firecracker:

  1. Pausa a vCPU — para a execução do guest no ponto exato
  2. Serializa registradores — salva PC, SP, flags, todos os registradores
  3. Dumpa a memória — escreve toda a RAM guest num arquivo
  4. Salva estado de dispositivos — serial, block devices, etc

No restore:

  1. Aloca memória — reserva RAM pro guest
  2. Carrega dump — mapeia o arquivo de memória
  3. Restaura registradores — coloca CPU virtual no estado salvo
  4. Resume — execução continua exatamente onde parou

A VM literalmente “acorda” no meio de um sleep(1). Ela não sabe que foi pausada, serializada pra disco, e restaurada numa instância nova do Firecracker. Pra ela, foi um cochilo instantâneo.

Escala horizontal: múltiplas VMs do mesmo snapshot

E aqui que a coisa fica interessante de verdade. Um snapshot pode ser usado pra criar múltiplas VMs ao mesmo tempo. Cada uma começa no mesmo estado, mas evolui independentemente.

diagrama_snapshot_horizontal

Isso é exatamente o que serviços como Lambda fazem pra escalar. Um snapshot “dourado” com o runtime pronto, e dezenas de VMs sendo restauradas dele conforme as requisições chegam.

O custo de inicialização pesado (carregar bibliotecas, treinar modelo) é pago uma vez. Todas as execuções subsequentes pagam só o custo do restore.

Dica de ouro pra produção: Copy-on-Write. Sabe aquele arquivo de 512MB da memória? Você não precisa copiar ele pra cada nova VM. Você pode fazer 100 VMs lerem o mesmo arquivo ao mesmo tempo. O Linux cuida da mágica — só copia as páginas que cada VM modifica. Resultado? Você sobe um exército de Lambdas gastando quase a mesma RAM de um só.

Quando usar snapshots

Snapshots fazem mais sentido quando:

  • Inicialização é cara: Carregar ML models, conectar em bancos, aquecer caches
  • Execuções são frequentes: O custo do snapshot se dilui em muitas restaurações
  • Estado inicial é previsível: Você pode criar um snapshot “genérico” que serve pra várias requisições

Não faz sentido quando:

  • Inicialização é rápida: Se sua função carrega em 50ms, o overhead do snapshot não compensa
  • Estado varia muito: Se cada execução precisa de configuração diferente, um snapshot genérico não ajuda
  • Execuções são raras: O snapshot fica obsoleto, você gasta tempo criando algo que mal usa

Conclusão

Pronto. Agora você sabe como o Lambda consegue responder em milissegundos mesmo rodando VMs completas por baixo. Não é mágica — é engenharia cuidadosa com snapshots.

Se você notou que essa VM está isolada (sem rede), a Parte 03 já cobre isso: TAP devices, NAT e como dar acesso à internet pra sua microVM.

Com snapshots + networking, você tem quase todas as peças. Falta uma: em produção, ninguém fica rodando scripts Python na mão. No próximo artigo, a gente daemoniza as microVMs com systemd — pra elas iniciarem no boot, reiniciarem se morrerem, e se comportarem como serviços de verdade.


Arquivos deste artigo:

  • build-rootfs-sklearn.sh — Script pra criar rootfs com scikit-learn
  • test-snapshot.py — Teste comparando cold start vs restore
Números reais medidos: Métrica Valor
Cold Start ~8s
Restore ~300ms
Speedup ~25x
Tamanho memória 512MB
Tamanho estado ~15KB
Avatar de DK

Comentários

Deixe um comentário

Seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Ir para