A Saga do Cilium: Melhorias Notáveis na Estabilidade da Rede Através de Pequenas Alterações no Código
Introdução
Recentemente, tive a oportunidade de examinar uma Pull Request (PR) no projeto Cilium de um antigo colega de trabalho.
bpf:nat: Restore ORG NAT entry if it's not found
A quantidade de modificações (excluindo o código de teste) é mínima, consistindo na adição de um único bloco if. No entanto, o impacto dessa modificação é considerável, e a ideia de que uma simples concepção pode contribuir significativamente para a estabilidade do sistema me pareceu particularmente interessante. Por isso, decidi expor este caso de forma que seja facilmente compreendido por pessoas sem conhecimento especializado em redes.
Conhecimentos Preliminares
Se há um item essencial para a vida moderna, tanto quanto o smartphone, é provavelmente o roteador Wi-Fi. O roteador Wi-Fi estabelece comunicação com dispositivos através do padrão de comunicação Wi-Fi e desempenha a função de compartilhar seu endereço IP público para uso por múltiplos dispositivos. A peculiaridade técnica que surge aqui é: como essa "compartilhamento" é realizado?
A tecnologia utilizada para isso é a Network Address Translation (NAT). A NAT é uma técnica que, dado que a comunicação TCP ou UDP é composta pela combinação de endereço IP e informações de porta, permite que a comunicação interna, que ocorre via IP Privado:Porta, seja mapeada para um IP Público:Porta que não esteja em uso no momento, possibilitando a comunicação com o exterior.
NAT
Quando um dispositivo interno tenta acessar a internet externa, o equipamento NAT converte a combinação de endereço IP privado e número de porta desse dispositivo para seu próprio endereço IP público e um número de porta aleatório não utilizado. Essa informação de conversão é registrada em uma tabela NAT dentro do equipamento NAT.
Por exemplo, suponhamos que um smartphone em casa (IP privado: a.a.a.a, porta: 50000) tente acessar um servidor web (IP público: c.c.c.c, porta: 80).
1Smartphone (a.a.a.a:50000) ==> Roteador (b.b.b.b) ==> Servidor Web (c.c.c.c:80)
Ao receber a requisição do smartphone, o roteador observará o seguinte pacote TCP:
1# Pacote TCP recebido pelo roteador, smartphone => roteador
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Se este pacote fosse enviado diretamente para o servidor web (c.c.c.c), a resposta não retornaria para o smartphone (a.a.a.a) que possui um endereço IP privado. Portanto, o roteador primeiro encontra uma porta aleatória que não esteja envolvida na comunicação atual (ex: 60000) e a registra na tabela NAT interna.
1# Tabela NAT interna do roteador
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
Após registrar uma nova entrada na tabela NAT, o roteador modifica o endereço IP de origem e o número da porta do pacote TCP recebido do smartphone para seu próprio endereço IP público (b.b.b.b) e o número da porta recém-alocada (60000), enviando-o para o servidor web.
1# Pacote TCP enviado pelo roteador, roteador => servidor web
2# Realiza SNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Agora, o servidor web (c.c.c.c) reconhece a requisição como proveniente da porta 60000 do roteador (b.b.b.b) e envia o pacote de resposta ao roteador da seguinte forma:
1# Pacote TCP recebido pelo roteador, servidor web => roteador
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Ao receber este pacote de resposta, o roteador consulta a tabela NAT para encontrar o endereço IP privado original (a.a.a.a) e o número da porta (50000) correspondentes ao endereço IP de destino (b.b.b.b) e ao número da porta (60000), alterando o destino do pacote para o smartphone.
1# Pacote TCP enviado pelo roteador, roteador => smartphone
2# Realiza DNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Através deste processo, o smartphone tem a impressão de estar se comunicando diretamente com o servidor web, como se possuísse um endereço IP público. Graças ao NAT, múltiplos dispositivos internos podem usar a internet simultaneamente com um único endereço IP público.
Kubernetes
Kubernetes possui uma das arquiteturas de rede mais sofisticadas e complexas dentre as tecnologias recentes. E, naturalmente, o NAT, mencionado anteriormente, é empregado em diversas situações. Os dois casos mais representativos são os seguintes:
Quando um Pod se comunica com o exterior do cluster
Os Pods dentro de um cluster Kubernetes geralmente recebem endereços IP privados que permitem a comunicação apenas dentro da rede do cluster. Portanto, se um Pod precisa se comunicar com a internet externa, é necessário o NAT para o tráfego de saída do cluster. Neste caso, o NAT é geralmente realizado no nó Kubernetes (cada servidor do cluster) onde o Pod está sendo executado. Quando um Pod envia um pacote para o exterior, este pacote é primeiro encaminhado para o nó ao qual o Pod pertence. O nó então altera o endereço IP de origem deste pacote (o IP privado do Pod) para seu próprio endereço IP público e modifica a porta de origem conforme apropriado, encaminhando-o para o exterior. Este processo é similar ao processo de NAT descrito anteriormente para o roteador Wi-Fi.
Por exemplo, assumindo que um Pod dentro de um cluster Kubernetes (10.0.1.10, porta: 40000) se conecta a um servidor API externo (203.0.113.45, porta: 443), o nó Kubernetes receberia o seguinte pacote do Pod:
1# Pacote TCP recebido pelo nó, Pod => Nó
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
O nó registraria o seguinte conteúdo:
1# Tabela NAT interna do nó (exemplo)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
E, em seguida, realizaria o SNAT e enviaria o pacote para o exterior, da seguinte forma:
1# Pacote TCP enviado pelo nó, nó => servidor API
2# Realiza SNAT
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
O processo subsequente segue o mesmo caminho que o exemplo do roteador de smartphone.
Quando a comunicação com um Pod ocorre do exterior do cluster através de um NodePort
No Kubernetes, uma das maneiras de expor serviços externamente é usando serviços NodePort. Um serviço NodePort abre uma porta específica (NodePort) em todos os nós do cluster e encaminha o tráfego que chega a essa porta para os Pods associados ao serviço. Usuários externos podem acessar o serviço através do endereço IP do nó do cluster e do NodePort.
Nesse contexto, o NAT desempenha um papel crucial, e, em particular, o DNAT (Destination NAT) e o SNAT (Source NAT) ocorrem simultaneamente. Quando o tráfego chega a um NodePort de um nó específico vindo do exterior, a rede Kubernetes precisa encaminhar esse tráfego para o Pod que fornece o serviço. Nesse processo, primeiramente ocorre o DNAT, onde o endereço IP de destino e o número da porta do pacote são alterados para o endereço IP e o número da porta do Pod.
Por exemplo, vamos supor que um usuário externo (203.0.113.10, porta: 30000) acessa um serviço através do NodePort (30001) de um nó Kubernetes (192.168.1.5). Este serviço, internamente, aponta para um Pod com endereço IP 10.0.2.15 e porta 8080.
1Usuário Externo (203.0.113.10:30000) ==> Nó Kubernetes (Externo:192.168.1.5:30001 / Interno: 10.0.1.1:42132) ==> Pod Kubernetes (10.0.2.15:8080)
Neste caso, o nó Kubernetes possui tanto o endereço IP do nó acessível externamente, 192.168.1.5, quanto o endereço IP 10.0.1.1, válido na rede interna do Kubernetes. (A política relacionada a isso varia dependendo do tipo de CNI utilizado, mas neste artigo a explicação será baseada no Cilium).
Quando a solicitação do usuário externo chega ao nó, o nó precisa encaminhá-la para o Pod que a processará. Nesse momento, o nó aplica a seguinte regra de DNAT para alterar o endereço IP de destino e o número da porta do pacote:
1# Pacote TCP sendo preparado para ser enviado ao Pod pelo nó
2# Após aplicação do DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
O ponto crucial aqui é que, quando o Pod envia uma resposta a esta requisição, o endereço IP de origem será o seu próprio endereço IP (10.0.2.15) e o endereço IP de destino será o endereço IP do usuário externo que enviou a requisição (203.0.113.10). Nesta situação, o usuário externo receberia uma resposta de um endereço IP inexistente que ele nunca solicitou, e simplesmente descartaria o pacote. Portanto, o nó Kubernetes realiza um SNAT adicional quando o Pod envia pacotes de resposta para o exterior, alterando o endereço IP de origem do pacote para o endereço IP do nó (192.168.1.5 ou o IP da rede interna 10.0.1.1, neste caso 10.0.1.1).
1# Pacote TCP sendo preparado para ser enviado ao Pod pelo nó
2# Após aplicação de DNAT, SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Agora, o Pod que recebeu o pacote responde ao nó que inicialmente recebeu a solicitação via NodePort, e o nó reverte os processos de DNAT e SNAT para retornar as informações ao usuário externo. Nesse processo, cada nó armazenará as seguintes informações:
1# Tabela DNAT interna do nó
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Tabela SNAT interna do nó
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Discussão Principal
Geralmente, no Linux, esses processos de NAT são gerenciados e operados pelo subsistema conntrack através de iptables. De fato, outros projetos CNI como flannel ou calico utilizam isso para resolver os problemas mencionados. O problema, no entanto, é que o Cilium, ao usar a tecnologia eBPF, ignora completamente essa pilha de rede tradicional do Linux. 🤣

Como resultado, o Cilium optou por implementar diretamente apenas as funcionalidades necessárias para o Kubernetes dentre as tarefas que a pilha de rede Linux existente realizava, conforme ilustrado na imagem acima. Consequentemente, para o processo de SNAT mencionado anteriormente, o Cilium gerencia diretamente uma tabela SNAT na forma de um LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Tabela SNAT do Cilium
2# !Exemplo para facilitar a compreensão. A definição real é: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip | src port | dst ip | dst port | protocol, conntrack e outros metadados
4----------------------------------------------
5| | | | |
E, como se trata de uma Hash Table, para uma consulta rápida, existe uma chave, que utiliza a combinação de src ip, src port, dst ip, dst port como valor da chave.
Identificação do Problema
Fenômeno - 1: Consulta
Isso gera um problema: para verificar se um pacote que passa pelo eBPF precisa passar por um processo de SNAT ou DNAT, é necessário consultar a Hash Table mencionada. Como visto anteriormente, existem dois tipos de pacotes no processo de SNAT: 1. Pacotes que saem do interior para o exterior, e 2. Pacotes que entram do exterior para o interior como resposta. Ambos os pacotes exigem uma transformação no processo NAT e se caracterizam pela inversão dos valores de src ip, port e dst ip, port.
Portanto, para uma consulta rápida, é necessário adicionar mais um valor à Hash Table com a chave invertida (src e dst), ou, para todos os pacotes, mesmo aqueles não relacionados ao SNAT, consultar a mesma Hash Table duas vezes. Naturalmente, o Cilium, para obter melhor desempenho, adotou a abordagem de inserir os mesmos dados duas vezes, sob o nome de RevSNAT.
Fenômeno - 2: LRU
Além do problema anterior, nenhum hardware possui recursos infinitos, e, especialmente em lógicas de hardware que exigem alto desempenho, onde estruturas de dados dinâmicas não podem ser usadas, é necessário remover dados existentes quando os recursos são escassos. O Cilium resolveu isso utilizando o LRU Hash Map, uma estrutura de dados básica fornecida nativamente pelo Linux.
Fenômeno 1 + Fenômeno 2 = Perda de Conexão
https://github.com/cilium/cilium/issues/31643
Em outras palavras, para uma conexão TCP (ou UDP) com SNAT:
- A mesma informação está registrada duas vezes em uma Hash Table para pacotes de saída e de entrada.
- Dada a lógica LRU, um dos dois dados pode ser perdido a qualquer momento.
Se uma das entradas NAT (doravante denominada entrada) para pacotes de saída ou de entrada for removida pelo LRU, a NAT não poderá ser realizada corretamente, o que pode levar à perda de toda a conexão.
Solução
Neste ponto, surgem as seguintes PRs mencionadas anteriormente:
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Anteriormente, quando um pacote passava pelo eBPF, ele tentava uma consulta na tabela SNAT usando uma chave composta por src ip, src port, dst ip e dst port. Se a chave não existisse, novas informações NAT seriam geradas de acordo com as regras SNAT e registradas na tabela. Se fosse uma nova conexão, isso levaria a uma comunicação normal; no entanto, se a chave tivesse sido removida inadvertidamente pelo LRU, um novo NAT seria realizado com uma porta diferente daquela usada na comunicação anterior, fazendo com que o receptor recusasse o pacote e a conexão fosse encerrada com um pacote RST.
A abordagem adotada pelas PRs acima é simples:
Se um pacote for observado em qualquer direção, a entrada correspondente à direção inversa também deve ser atualizada.
Quando a comunicação é observada em qualquer direção, ambas as entradas são atualizadas, afastando-as da prioridade de remoção da lógica LRU. Isso reduz a probabilidade de que apenas uma das entradas seja excluída, levando ao colapso de toda a comunicação.
Embora esta seja uma abordagem muito simples e possa parecer uma ideia trivial, ela resolveu eficazmente o problema de interrupção da conexão devido à expiração prematura das informações NAT para pacotes de resposta, e melhorou significativamente a estabilidade do sistema. Pode-se dizer que é uma melhoria importante que alcançou os seguintes resultados em termos de estabilidade da rede:

Conclusão
Considero esta PR um excelente exemplo que demonstra como um conhecimento básico de Ciência da Computação sobre o funcionamento do NAT, combinado com uma ideia simples, pode gerar uma transformação significativa mesmo dentro de um sistema complexo.
Ah, é claro, eu não apresentei diretamente exemplos de sistemas complexos neste artigo. No entanto, para compreender adequadamente esta PR, eu implorei ao DeepSeek V3 0324 por quase 3 horas, inclusive com a palavra "Please", e como resultado, obtive conhecimento sobre Cilium +1 e o diagrama abaixo. 😇
E, lendo os problemas e as PRs, escrevo este artigo como uma forma de compensar as minhas pressentimentos de que os problemas poderiam ter sido causados por algo que eu havia criado anteriormente.
Pós-escrito - 1
A propósito, existe uma forma muito eficaz de evitar este problema. Como a causa fundamental do problema é a falta de espaço na tabela NAT, basta aumentar o tamanho da tabela NAT. :-D
Enquanto alguém, ao encontrar o mesmo problema, poderia ter aumentado o tamanho da tabela NAT e fugido sem registrar o problema, eu admiro e respeito a paixão de gyutaeb por analisar e compreender minuciosamente, mesmo que não fosse um problema diretamente relacionado a ele, e por contribuir para o ecossistema Cilium com dados objetivos como evidência.
Foi isso que me motivou a escrever este artigo.
Pós-escrito - 2
Esta história, de fato, não se alinha diretamente com Gosuda, que se concentra profissionalmente na linguagem Go. No entanto, como a linguagem Go e o ecossistema da nuvem estão intimamente relacionados, e os contribuidores do Cilium possuem algum conhecimento de Go, decidi trazer para Gosuda um conteúdo que poderia ser publicado em um blog pessoal.
Como um dos administradores (eu mesmo) deu permissão, presumo que esteja tudo bem.
Se você acha que não está tudo bem, salve rapidamente em PDF, pois não se sabe quando poderá ser excluído. ;)
Pós-escrito - 3
A elaboração deste artigo contou com a significativa ajuda de Cline e Llama 4 Maveric. Embora a análise tenha começado com Gemini e eu tenha implorado ao DeepSeek, a ajuda real veio do Llama 4. O Llama 4 é excelente. Recomendo que o experimentem.