← Todos os posts
15 de ago. de 2026

Por Que uma VM KVM em NAT Perde Internet Ao Instalar o Docker

TL;DR: O Docker instala uma tabela nftables ip filter com chain FORWARD { policy drop; jump DOCKER-USER; jump DOCKER-FORWARD; }. A DOCKER-USER começa vazia. Qualquer tráfego que o kernel encaminha e que não seja explicitamente do Docker — incluindo a própria rede NAT virbr0 do libvirt — cai nessa policy DROP e desaparece sem erro ICMP, sem log, sem nada. Isso acontece mesmo com a zona libvirt do firewalld configurada com masquerade: yes e forward: yes, e mesmo com a tabela nftables própria do libvirt, libvirt_network, totalmente correta. Correção: sudo iptables -I DOCKER-USER -i virbr0 -j ACCEPT e o mesmo com -o virbr0, que persiste entre restarts do docker porque DOCKER-USER é a única chain que o Docker nunca reescreve.

Uma VM Windows 11 na rede NAT padrão do libvirt recebe um lease DHCP, tem gateway padrão funcionando, e ainda assim não alcança a internet — sem rota, sem resposta, nada nos logs da própria VM que explique o motivo. O instinto é depurar a pilha de rede da VM ou a configuração NAT do libvirt, e ambas podem estar inteiramente corretas. A falha real está uma camada abaixo, numa segunda pilha de firewall independente que o Docker instala no host e que sobrepõe a decisão de encaminhamento do kernel antes mesmo das regras do próprio libvirt serem consultadas.

Você já fez tudo que os manuais mandam. virsh net-list mostra default ativa. A VM pegou um lease. ip_forward é 1. O dnsmasq está rodando. Cada peça da rede NAT do libvirt parece correta isoladamente — e a VM ainda não consegue pingar 8.8.8.8.

Este post é para quem roda libvirt/KVM (via virt-manager ou virsh puro) junto com Docker num host Linux com firewalld ativo — a combinação mais comum em desktops Fedora, RHEL e derivados de Debian atuais. Assume que você já conhece iptables/nft, leases DHCP e conceitos básicos de bridge, e que o sistema operacional convidado (este post usou Windows 11, mas o mecanismo é agnóstico ao guest) não é o problema.

Sumário

Por que as checagens óbvias passam e a VM continua sem alcançar nada

Resposta direta: Toda camada da qual o libvirt é diretamente responsável — a rede NAT default, o lease DHCP, a bridge virbr0, ip_forward=1, e até uma zona libvirt do firewalld corretamente configurada com masquerade e forward habilitados — pode estar totalmente correta enquanto a VM ainda tem zero conectividade. Isso porque nenhuma dessas camadas é a camada que está descartando o tráfego. O hook FORWARD do kernel executa múltiplas tabelas nftables registradas independentemente em sequência, e uma tabela completamente separada instalada pelo Docker pode vetar o pacote antes ou independentemente do que a tabela do próprio libvirt decide. Checar a configuração do lado do libvirt e do firewalld isoladamente nunca vai revelar isso; é preciso olhar o que o kernel de fato faz com o pacote.

Comece pelas checagens básicas, porque elas descartam rápido as causas comuns e aqui estavam genuinamente corretas:

$ virsh net-list --all
 Name      State    Autostart   Persistent
--------------------------------------------
 default   active   yes         yes

$ virsh net-dhcp-leases default
 Expiry Time           MAC address         Protocol   IP address           Hostname
 2026-08-15 20:45:07   52:54:00:82:41:ce   ipv4       192.168.122.129/24   DESKTOP-GV9NH7P

$ ip addr show virbr0
54: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... state UP
    inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0

$ cat /proc/sys/net/ipv4/ip_forward
1

A VM tem endereço, o gateway está de pé, o kernel está configurado para encaminhar. Num sistema moderno com firewalld rodando, o próximo instinto — checar iptables -t nat -L POSTROUTING bruto atrás de uma regra MASQUERADE — é um beco sem saída. O firewalld no libvirt atual (≥ 7.x no Fedora) não gerencia NAT via regras iptables legadas diretamente; ele opera uma tabela nftables própria, e o libvirt se integra a isso através da zona libvirt do firewalld em vez de escrever iptables bruto. Checar iptables -t nat -L aqui só mostra as regras MASQUERADE do Docker para docker0 e qualquer bridge customizada — nada para virbr0 — e é tentador concluir que a regra NAT do libvirt simplesmente não existe. Ela não está faltando; está em outra tabela, endereçada de outro jeito.

Como são firewalld e a tabela nftables do libvirt quando estão corretos

Resposta direta: O libvirt registra sua própria tabela nftables nativa, libvirt_network, com chains dedicadas para NAT (nat_POST_libvirt), encaminhamento (filter_FWD_libvirt) e regras de entrada DHCP/DNS — tudo independente das tabelas iptables legadas que o Docker usa. Separadamente, o firewalld expõe uma zona libvirt vinculada à virbr0 que controla se o tráfego dessa zona é mascarado e encaminhado. Ambos podem reportar um atestado de saúde limpo (masquerade: yes, forward: yes, chains de NAT/forward populadas com contadores de pacotes não-zero) enquanto a VM ainda não tem conectividade nenhuma, porque nenhum dos dois é a palavra final sobre se o kernel encaminha o pacote.

sudo nft list ruleset | grep -A5 libvirt mostra o quadro real:

table ip libvirt_network {
    chain forward {
        type filter hook forward priority filter; policy accept;
        counter packets 2211 bytes 130420 jump guest_cross
        counter packets 2211 bytes 130420 jump guest_input
        counter packets 2211 bytes 130420 jump guest_output
    }
    ...
    chain nat_POST_libvirt { ... }
}

Contadores incrementando, policy accept, chains ligadas corretamente. E firewall-cmd --zone=libvirt --list-all inicialmente mostrou o problema real neste host — forward: no e masquerade: no, ambos desligados por padrão:

$ sudo firewall-cmd --zone=libvirt --add-masquerade --permanent
$ sudo firewall-cmd --zone=libvirt --add-forward --permanent
$ sudo firewall-cmd --reload

Essa é uma correção legítima para um problema legítimo — numa instalação nova do Fedora Workstation com libvirt e firewalld presentes, a zona libvirt nem sempre vem com masquerade e forward pré-habilitados, e sem eles nada mais importa. Corrigir isso é necessário. Neste host não foi suficiente — pings da VM continuaram dando timeout após o reload, o que é o sinal de que existe um segundo ponto de aplicação, independente, mais adiante.

Achando o ponto real de descarte com tcpdump e nft, não virsh

Resposta direta: Quando toda configuração voltada para libvirt e firewalld está correta e os pacotes ainda somem, pare de confiar na saída de configuração e confie na captura de pacotes. Rode tcpdump -i virbr0 -n icmp enquanto pinga a partir da VM: se as requisições saem da bridge sem nenhuma resposta chegando, o descarte está acontecendo depois da virbr0, em algum lugar no caminho de encaminhamento do host. A partir daí, nft list ruleset (não iptables -L, que só mostra a visão de compatibilidade legada) em todas as tabelas — não só as que você espera — é o que revela uma segunda tabela que você nem sabia que estava no hook de encaminhamento.

$ sudo tcpdump -i virbr0 -n icmp -c 4
19:47:04 IP 192.168.122.129 > 8.8.8.8: ICMP echo request, id 1, seq 5
19:47:09 IP 192.168.122.129 > 8.8.8.8: ICMP echo request, id 1, seq 6

Requisições saindo, nenhuma resposta, nunca. Isso descarta a pilha de rede da própria VM — o pacote sai da bridge — e aponta para o caminho de encaminhamento do host. A tabela que explica isso:

$ sudo nft list table ip filter
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        counter packets 485 bytes 32608 jump DOCKER-USER
        counter packets 485 bytes 32608 jump DOCKER-FORWARD
    }
    chain DOCKER-USER {
    }
    ...
}

policy drop. DOCKER-USER está vazia — zero regras, então o pacote passa direto. DOCKER-FORWARD só carrega lógica para as próprias bridges do Docker (docker0, redes customizadas). Nada nessa tabela jamais ouviu falar de virbr0. Essa tabela filter é registrada no mesmo hook forward e na mesma priority filter que a tabela libvirt_network do libvirt — as duas são legítimas, as duas rodam, e a policy default-deny desta é o que um pacote da virbr0 encontra na saída, independente do que a policy accept da libvirt_network decidiu antes.

Esse é um comportamento documentado e antigo da integração do Docker com iptables, não um bug específico deste host: o Docker gerencia a policy padrão da chain FORWARD porque precisa evitar encaminhamento arbitrário de host para rede de container por padrão, e expõe DOCKER-USER especificamente como a chain que administradores devem popular com suas próprias exceções de encaminhamento — é a única chain que o dockerd garante nunca vai limpar ou reescrever num restart.

A correção, e por que DOCKER-USER é o lugar certo

Resposta direta: Adicione regras de accept explícitas para tráfego virbr0 na DOCKER-USER, não diretamente na FORWARD e não tentando mudar a policy padrão da FORWARD do Docker de volta para ACCEPT. DOCKER-USER é o ponto de extensão suportado pelo Docker exatamente para essa situação, e regras colocadas ali sobrevivem a restarts do dockerd, mudanças em docker network e upgrades do Docker — regras colocadas direto na FORWARD ou em outras chains auto-gerenciadas pelo Docker acabam sendo apagadas na próxima vez que o Docker reconcilia seu próprio ruleset.

sudo iptables -I DOCKER-USER -i virbr0 -j ACCEPT
sudo iptables -I DOCKER-USER -o virbr0 -j ACCEPT

As duas direções importam: -i virbr0 cobre tráfego iniciado pela VM saindo da bridge em direção à interface WAN, -o virbr0 cobre tráfego de retorno sendo encaminhado de volta. Usar iptables aqui é o correto em vez de nft diretamente — as ferramentas e health checks do Docker assumem a interface de compatibilidade legada, e misturar edições nft nativas numa tabela que o Docker considera sua tende a ser revertido silenciosamente no próximo restart do dockerd, exatamente o modo de falha do qual você está tentando sair.

Verifique que a VM se recupera:

C:\> ping 8.8.8.8
Pinging 8.8.8.8 with 32 bytes of data:
Reply from 8.8.8.8: bytes=32 time=9ms TTL=116

Essas duas regras não são persistentes por padrão na maioria das distribuições — iptables -I escreve só no ruleset ao vivo. Se você está num sistema sem mecanismo de persistência de iptables já configurado a nível de distro, adicione-as a uma unit systemd ou um hook de iptables-restore que rode depois que docker.service e libvirtd.service já estejam de pé, porque a ordem importa: se as regras da DOCKER-USER forem aplicadas antes do próprio Docker iniciar e reescrever suas chains, elas podem acabar apagadas mesmo assim.

Um desvio autoinfligido que vale mencionar: não destrua/recrie uma rede em uso

Enquanto se persegue as configurações da zona do firewalld, um movimento natural é reiniciar a rede para forçar o libvirt a reaplicar suas regras:

sudo virsh net-destroy default
sudo virsh net-start default

Isso é seguro se a VM estiver desligada. Se a VM está rodando, sua interface TAP vnet* pode acabar se desconectando da virbr0 — a bridge volta a ficar de pé com NO-CARRIER e zero portas conectadas, o que parece idêntico a um problema de firewall (sem conectividade, sem erros) mas não tem nada a ver com a correção real. ip link show master virbr0 não retornar nada é o sinal. Reiniciar a VM reconecta sua interface TAP à bridge e resolve isso; um hot-unplug/replug de NIC ao vivo via virsh faria o mesmo sem reboot completo, mas um reboot é mais simples de raciocinar no meio da depuração.

O que generaliza além desse par específico de ferramentas

Docker e libvirt não são únicos — são só o par mais comum de serviços Linux que ambos assumem que são donos da chain FORWARD. Quaisquer duas ferramentas que registrem sua própria tabela nftables no hook forward podem produzir exatamente esse sintoma: configuração correta nos termos de cada ferramenta, perda silenciosa de pacotes no kernel, e nenhum log em lugar nenhum apontando para a interação real. podman com seu próprio backend de rede, kubernetes via kube-proxy ou cilium, e clientes VPN que gerenciam suas próprias regras de encaminhamento (algumas configurações WireGuard, certos agentes VPN corporativos) seguem o mesmo padrão.

O movimento de depuração generalizável é parar de confiar na saída de status de cada ferramenta individual assim que duas dessas ferramentas coexistem no mesmo host, e ir direto para sudo nft list ruleset para ler o quadro completo e em camadas que o kernel de fato aplica — cada tabela, cada chain, cada policy, em ordem de priority do hook — em vez de perguntar ao firewall-cmd, virsh ou docker individualmente se a configuração deles está correta. Cada um vai responder honestamente e cada resposta ainda pode ser irrelevante para explicar por que o pacote morreu.

Referências


Breno Zanato Detomini
Breno Zanato Detomini

Engenheiro de sistemas embarcados e redes, baseado no Brasil.

← Todos os posts