KVM: agora de verdade

KVM: agora de verdade

Se você leu o post anterior (KVM mora no seu kernel) e já tem VMs funcionando, parabéns: você passou da fase do "instalei e funcionou". Agora começa a parte interessante: fazer o KVM trabalhar de verdade.

VMs rodando com configurações padrão deixam muito desempenho na mesa. A diferença entre uma VM com CPU genérica e uma com CPU pinning + hugepages pode ser de 20-40% em workloads intensivos. Esse post cobre os ajustes que separam "máquina virtual que funciona" de "máquina virtual que performa".

💡 Esse post assume que você já tem o stack KVM/QEMU/libvirt instalado e funcionando. Se não chegou lá ainda, comece pelo KVM mora no seu kernel.

CPU pinning: cada vCPU no seu lugar

Por padrão, o libvirt deixa o scheduler do kernel Linux decidir em qual core físico cada vCPU da VM vai rodar. Isso é flexível, mas problemático em cargas intensas: a VM compete com processos do host, migra entre cores e perde cache L1/L2 toda vez que isso acontece.

CPU pinning fixa cada vCPU em um core físico específico. A VM tem aquele recurso exclusivo, o cache fica quente e o desempenho fica previsível.

Primeiro, entender a topologia do host:

# Ver a estrutura de cores/threads/NUMA nodes
lscpu -e
# ou mais detalhado:
lstopo --of txt
# (instalar: apt install hwloc)

Exemplo de saída do lscpu -e num host com 6 cores/12 threads:

CPU NODE SOCKET CORE L1d:L1i:L2:L3 ONLINE
  0    0      0    0 0:0:0:0          sim
  1    0      0    1 1:1:1:0          sim
  2    0      0    2 2:2:2:0          sim
  ...
  6    0      0    0 0:0:0:0          sim  ← hyperthreading do core 0
  7    0      0    1 1:1:1:0          sim  ← hyperthreading do core 1

Com isso em mãos, edite a VM:

virsh edit minha-vm

Adicione dentro de <domain>:

<vcpu placement='static'>4</vcpu>
<cputune>
  <!-- Fixar cada vCPU num core físico específico -->
  <vcpupin vcpu='0' cpuset='2'/>
  <vcpupin vcpu='1' cpuset='3'/>
  <vcpupin vcpu='2' cpuset='4'/>
  <vcpupin vcpu='3' cpuset='5'/>
  <!-- Fixar o thread de emulação (processo qemu em si) num core separado -->
  <emulatorpin cpuset='0-1'/>
</cputune>
<cpu mode='host-passthrough' check='none' migratable='off'>
  <topology sockets='1' dies='1' cores='4' threads='1'/>
</cpu>

⚠️ host-passthrough com migratable='off' expõe as flags reais do CPU do host pra VM. Isso dá mais performance, mas a VM não consegue migrar pra um host com CPU diferente. Em homelab com hardware homogêneo, não é problema. Em produção heterogênea, use host-model.

Depois de salvar e reiniciar a VM, confirmar que o pinning está ativo:

virsh vcpuinfo minha-vm
# Deve mostrar "CPU Affinity" com os cores que você definiu

Hugepages: memória que não fragmenta

Memória normal usa páginas de 4KB. Com hugepages, você usa páginas de 2MB (ou 1GB em hardware que suporta). Menos entradas na TLB, menos cache miss, menos trabalho pro MMU. Em VMs com muita memória RAM, a diferença é sentida.

Configurar hugepages no host:

# Ver suporte
grep Huge /proc/meminfo

# Calcular quantas páginas de 2MB você precisa
# (RAM da VM / 2MB) + margem de ~10%
# Exemplo: VM com 8GB = 8192MB / 2MB = 4096 páginas + 10% ≈ 4500

# Configurar hugepages (temporário — some no reboot)
echo 4500 > /proc/sys/vm/nr_hugepages

# Configurar hugepages (persistente)
echo "vm.nr_hugepages = 4500" >> /etc/sysctl.conf
sysctl -p

# Confirmar que foram alocadas
grep HugePages_Total /proc/meminfo

⚠️ Hugepages precisam ser alocadas ANTES de subir a VM. O kernel precisa de memória contígua e não fragmentada. Em sistemas que ficam ligados há muito tempo, às vezes é preciso reiniciar o host pra conseguir alocar todas as páginas necessárias.

Habilitar na VM:

<memoryBacking>
  <hugepages/>
  <nosharepages/>  <!-- desabilita KSM para essa VM — mais performance, menos compartilhamento -->
</memoryBacking>

Para hugepages de 1GB (se o hardware suportar, verifique com grep pdpe1gb /proc/cpuinfo):

<memoryBacking>
  <hugepages>
    <page size='1' unit='GiB'/>
  </hugepages>
</memoryBacking>

NUMA: não ignore a topologia

Em CPUs modernas com múltiplos sockets ou CPUs "grandes" (Threadripper, EPYC, alguns Xeon), a memória RAM não é igualmente rápida pra todos os cores: cada "NUMA node" tem sua memória local e acessa a memória do outro node com latência maior.

Se você não configura NUMA na VM, o QEMU pode alocar memória num node e rodar a vCPU em outro. O resultado é latência de memória alta e desempenho imprevisível.

# Ver topologia NUMA do host
numactl --hardware
# ou
lstopo

Numa VM numa máquina NUMA, fixar tudo no mesmo node:

<numatune>
  <memory mode='strict' nodeset='0'/>
</numatune>
<cputune>
  <vcpupin vcpu='0' cpuset='0-3'/>   <!-- cores do NUMA node 0 -->
  <vcpupin vcpu='1' cpuset='0-3'/>
  <vcpupin vcpu='2' cpuset='0-3'/>
  <vcpupin vcpu='3' cpuset='0-3'/>
</cputune>

mode='strict' garante que a VM só use memória do node 0. Se não tiver memória suficiente lá, a VM não sobe. Prefira isso a ter performance degradada silenciosamente.

PCIe passthrough: hardware real dentro da VM

Passthrough permite passar um dispositivo PCIe físico (GPU, placa de rede, NVMe) diretamente pra VM, sem emulação. A VM enxerga o hardware como se ele estivesse conectado diretamente. Performance de metal, dentro de uma VM.

Caso de uso clássico no homelab: GPU passthrough, com uma VM Windows recebendo acesso direto à GPU pra gaming ou workstation, enquanto o host Linux continua rodando o resto.

Pré-requisitos

# Verificar suporte a IOMMU
dmesg | grep -i iommu
# Deve aparecer algo como "DMAR: IOMMU enabled" ou "AMD-Vi: enabled"

# Se não aparecer, habilitar no bootloader
# Para Intel — adicionar em GRUB_CMDLINE_LINUX:
#   intel_iommu=on iommu=pt
# Para AMD:
#   amd_iommu=on iommu=pt
# Editar /etc/default/grub e rodar update-grub

Identificar o dispositivo

# Listar dispositivos PCIe com endereços IOMMU
lspci -nn | grep -i nvidia    # ou amd, ou o dispositivo que quiser

# Ver o grupo IOMMU (TODOS os dispositivos do mesmo grupo precisam ser passados juntos)
for d in /sys/kernel/iommu_groups/*/devices/*; do
  echo "$(basename $(dirname $(dirname $d))): $(lspci -nns $(basename $d))"
done | sort -V | grep -v "00:0"

⚠️ IOMMU groups são importantes. Se sua GPU está no mesmo grupo IOMMU que outro dispositivo crítico do host (placa de rede onboard, por exemplo), você precisa passar os dois juntos, o que pode não ser possível. Isso é uma limitação de hardware, não de software.

Isolar o dispositivo do host

# Adicionar ao /etc/modprobe.d/vfio.conf
# (substituir 10de:1b81 pelo vendor:device ID do seu dispositivo)
echo "options vfio-pci ids=10de:1b81,10de:10f0" > /etc/modprobe.d/vfio.conf

# Garantir que vfio-pci carrega antes do driver nativo (nvidia, amdgpu, etc.)
echo "softdep nvidia pre: vfio-pci" >> /etc/modprobe.d/vfio.conf

# Atualizar initramfs
update-initramfs -u

# Reiniciar e confirmar que vfio-pci assumiu o dispositivo
lspci -k | grep -A3 "VGA\|3D"
# Deve mostrar "Kernel driver in use: vfio-pci"

Adicionar à VM

<hostdev mode='subsystem' type='pci' managed='yes'>
  <source>
    <address domain='0x0000' bus='0x01' slot='0x00' function='0x0'/>
  </source>
</hostdev>

O endereço bus/slot/function vem do lspci. Por exemplo, 01:00.0 = bus 0x01, slot 0x00, function 0x0.

Tuning de I/O de disco

Disco é geralmente o gargalo que mais aparece em VMs mal configuradas. Alguns ajustes que fazem diferença real:

Driver e cache

<disk type='file' device='disk'>
  <driver name='qemu' type='qcow2'
          cache='none'          <!-- sem cache do host — mais seguro, mais performance -->
          io='native'           <!-- I/O nativo do kernel, não userspace -->
          discard='unmap'       <!-- propaga TRIM/discard pra imagem qcow2 -->
          detect_zeroes='unmap'/>
  <source file='/var/lib/libvirt/images/minha-vm.qcow2'/>
  <target dev='vda' bus='virtio'/>
</disk>

Modos de cache, do mais seguro ao mais rápido:

  • writethrough: escreve no cache e no disco imediatamente. Seguro, lento.
  • writeback: escreve no cache, manda pro disco depois. Rápido, risco em crash.
  • none: bypassa cache do host. Bom equilíbrio; a VM gerencia seu próprio cache.
  • unsafe: ignora tudo e mente pra VM. Nunca use em produção.

Para a maioria dos casos, cache='none' é o certo.

iothread: processar I/O num thread dedicado

<domain>
  <iothreads>1</iothreads>
  <iothreadids>
    <iothread id='1'/>
  </iothreadids>
  ...
  <disk ...>
    <driver ... iothread='1'/>
    ...
  </disk>
</domain>

Com iothread, o processamento de I/O de disco sai do thread principal do QEMU e vai pra um thread dedicado. Em VMs com disco intensivo, a diferença é mensurável: o CPU da VM não compete com o I/O pelo mesmo thread.

Tuning de rede

Rede virtual padrão do libvirt funciona, mas tem overhead. Para VMs com tráfego intenso:

virtio-net com multi-queue

<interface type='bridge'>
  <source bridge='br0'/>
  <model type='virtio'/>
  <driver name='vhost' queues='4'/>  <!-- uma fila por vCPU -->
</interface>

Multi-queue divide o processamento de rede entre múltiplas filas, uma por vCPU. Dentro da VM, habilitar:

ethtool -L eth0 combined 4   # ajustar pro número de queues configurado

Para performance extrema: SR-IOV

SR-IOV (Single Root I/O Virtualization) cria funções virtuais diretamente na placa de rede física; cada VM recebe uma "fatia" do hardware real, sem emulação. Latência próxima de metal, throughput completo da placa.

Requer placa de rede com suporte (Intel X710, Mellanox ConnectX, etc.) e IOMMU habilitado. A configuração é parecida com PCIe passthrough, mas passando uma VF (virtual function) em vez da PF (physical function) inteira.

Clonagem correta

O virt-clone do post anterior fazia uma cópia simples. Pro uso em templates:

# Clonar VM (desligada)
virt-clone \
  --original minha-vm \
  --name clone-001 \
  --file /var/lib/libvirt/images/clone-001.qcow2

# Depois do clone, limpar identidade da nova VM
virt-sysprep -d clone-001 \
  --operations defaults,-ssh-userdir \
  --hostname clone-001

O virt-sysprep remove SSH host keys, limpa logs, reseta machine-id: itens que precisam ser únicos em cada instância. Sem isso, dois clones na mesma rede podem ter conflitos sutis e difíceis de debugar.

💡 Na prática, prefiro o fluxo com virt-customize + cloud-init descrito no post Para de configurar VM na mão. Mas pra VMs que não usam cloud-init (Windows, sistemas legados), virt-clone + virt-sysprep é o caminho certo.

Storage pools

Organizar discos em pools torna o gerenciamento muito mais limpo, especialmente quando você tem múltiplos discos físicos ou LVs:

# Pool de diretório (o padrão)
virsh pool-define-as pool-ssd dir --target /mnt/ssd/vms
virsh pool-build pool-ssd
virsh pool-start pool-ssd
virsh pool-autostart pool-ssd

# Pool LVM (melhor performance em disco dedicado)
virsh pool-define-as pool-lvm logical \
  --source-dev /dev/sdb \
  --target /dev/vg-vms
virsh pool-build pool-lvm
virsh pool-start pool-lvm

# Criar volume dentro do pool
virsh vol-create-as pool-ssd minha-vm.qcow2 50G --format qcow2

# Listar volumes
virsh vol-list pool-ssd

Monitorando o que está acontecendo

# Monitor de VMs em tempo real (como htop, mas pro libvirt)
virt-top

# Stats detalhadas de uma VM específica
virsh domstats minha-vm

# CPU steal e balão de memória
virsh dommemstat minha-vm
virsh vcpuinfo minha-vm

# I/O de disco
virsh domblkstat minha-vm vda

# I/O de rede
virsh domifstat minha-vm vnet0

Os logs de cada VM ficam em /var/log/libvirt/qemu/nome-da-vm.log, o primeiro lugar pra olhar quando algo não sobe ou se comporta estranhamente.

Conclusão

CPU pinning, hugepages, NUMA tuning e iothread são ajustes que você configura uma vez e sente o diferença pelo tempo que a VM existir. PCIe passthrough abre possibilidades que fazem a virtualização parecer mágica: hardware físico, dentro de uma VM, sem perda de desempnho.

Não precisa aplicar tudo de uma vez. Começa com host-passthrough no CPU e cache='none' no disco; já são dois ajustes simples que melhoram qualquer VM. O resto você vai adicionando conforme a necessidade aparecer.

O próximo passo natural depois daqui é o Proxmox, que faz a maioria dessas configurações via interface web, mas agora você sabe o que está acontecendo por baixo quando você clica em "CPU Type: host".