GoSuda

História do Cilium: a notável melhoria na estabilidade de rede obtida com uma pequena alteração de código

By iwanhae views ...

Introdução

Há algum tempo, dei uma olhada no PR de um antigo colega de trabalho sobre o projeto Cilium.

bpf:nat: Restore ORG NAT entry if it's not found

(Excluindo o código de teste) a alteração em si é pequena, correspondendo apenas à adição de um bloco if. No entanto, o impacto que essa alteração trouxe é enorme, e achei pessoalmente interessante que uma ideia simples possa contribuir de forma muito significativa para a estabilidade do sistema. Por isso, decidi contar essa história de modo que mesmo pessoas sem conhecimento especializado em redes possam compreender este caso com facilidade.

Conhecimento de Base

Se há algo tão essencial para as pessoas modernas quanto um smartphone, talvez seja o roteador Wi-Fi. O roteador Wi-Fi se comunica com os dispositivos por meio do padrão de comunicação Wi-Fi e desempenha o papel de compartilhar o endereço IP público que possui para que vários dispositivos possam utilizá-lo. O ponto tecnicamente singular que surge aqui é: como ocorre esse "compartilhamento"?

A tecnologia utilizada nesse caso é a Network Address Translation (NAT). A NAT é uma tecnologia que, considerando que a comunicação TCP ou UDP se baseia na combinação de informações de endereço IP e porta, mapeia a comunicação interna no formato IP privado:Porta para um IP público:Porta que não está em uso, permitindo assim a comunicação com o exterior.

NAT

Quando um dispositivo interno tenta acessar a Internet externa, o dispositivo NAT converte a combinação de endereço IP privado e número de porta desse dispositivo para o seu próprio endereço IP público e um número de porta arbitrário que não esteja em uso. Essas informações de conversão são registradas em uma estrutura chamada tabela NAT, dentro do dispositivo NAT.

Por exemplo, suponhamos que um smartphone dentro de casa (IP privado: a.a.a.a, porta: 50000) tente se conectar a 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 verá um pacote TCP como o seguinte.

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 esse pacote fosse enviado diretamente ao servidor web (c.c.c.c), a resposta não retornaria ao smartphone com o endereço IP privado (a.a.a.a); por isso, o roteador primeiro identifica uma porta arbitrária que não esteja envolvida na comunicação atual (por exemplo, 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 a nova entrada na tabela NAT, o roteador altera o endereço IP e o número de porta de origem do pacote TCP recebido do smartphone para o seu próprio endereço IP público (b.b.b.b) e para a porta recém-alocada (60000), e então o envia ao servidor web.

1# Pacote TCP enviado pelo roteador, roteador => servidor web
2# SNAT executado
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 de volta 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 esse pacote de resposta, o roteador localiza na tabela NAT o endereço IP privado original (a.a.a.a) e a porta original (50000) correspondentes ao endereço IP de destino (b.b.b.b) e à porta (60000), e altera o destino do pacote para o smartphone.

1# Pacote TCP enviado pelo roteador, roteador => smartphone
2# DNAT executado
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Por meio desse processo, o smartphone passa a ter a sensação de que se comunica com o servidor web como se possuísse diretamente um endereço IP público. Graças à NAT, vários dispositivos internos podem utilizar a Internet simultaneamente com um único endereço IP público.

Kubernetes

O Kubernetes possui uma estrutura de rede extremamente sofisticada e complexa entre as tecnologias surgidas recentemente. E, naturalmente, a NAT mencionada anteriormente também é amplamente utilizada nele. Os dois casos 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 só permitem comunicação dentro da rede do cluster. Portanto, para que um pod se comunique com a Internet externa, é necessária NAT para o tráfego que sai do cluster. Nesse caso, a NAT é realizada principalmente no nó Kubernetes (cada servidor do cluster) em que o pod está em execução. Quando o pod envia um pacote destinado ao exterior, esse pacote é primeiro encaminhado ao nó ao qual o pod pertence. O nó substitui o endereço IP de origem do pacote (o IP privado do pod) pelo seu próprio endereço IP público, altera adequadamente a porta de origem e encaminha o pacote para o exterior. Esse processo é semelhante ao processo de NAT descrito anteriormente no roteador Wi-Fi.

Por exemplo, suponhamos que um pod dentro de um cluster Kubernetes (10.0.1.10, porta: 40000) se conecte a um servidor de API externo (203.0.113.45, porta: 443). Nesse caso, o nó Kubernetes recebe do pod o seguinte pacote.

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ó registra as seguintes informações

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, executa SNAT da seguinte forma antes de enviar o pacote ao exterior.

1# Pacote TCP enviado pelo nó, nó => servidor de API
2# SNAT executado
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Depois disso, o processo segue as mesmas etapas do caso anterior do roteador Wi-Fi.

Quando há comunicação com um Pod a partir do exterior do cluster por meio de NodePort

Um dos métodos para expor um serviço para o exterior no Kubernetes é utilizar um serviço do tipo NodePort. O serviço NodePort mantém abertas 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 pertencentes ao serviço. Usuários externos podem acessar o serviço por meio do endereço IP do nó do cluster e do NodePort.

Nesse caso, a NAT desempenha um papel importante, em especial porque DNAT (Destination NAT) e SNAT (Source NAT) ocorrem simultaneamente. Quando o tráfego externo chega ao NodePort de um determinado nó, a rede do Kubernetes deve encaminhar esse tráfego, em última instância, ao pod que fornece o serviço. Nesse processo, primeiro ocorre o DNAT, alterando o endereço IP e o número de porta de destino do pacote para o endereço IP e o número de porta do pod.

Por exemplo, suponhamos que um usuário externo ao cluster (203.0.113.10, porta: 30000) acesse um serviço por meio do NodePort (30001) de um nó do cluster Kubernetes (192.168.1.5). Suponhamos também que esse serviço aponte internamente 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)

Aqui, o nó Kubernetes possui tanto o endereço IP 192.168.1.5, acessível a partir do exterior, quanto o endereço IP 10.0.1.1, válido na rede interna do Kubernetes. (Dependendo do tipo de CNI utilizado, as políticas relacionadas a isso podem variar, mas neste texto a explicação é feita com base no Cilium.)

Quando a requisição do usuário externo chega ao nó, o nó deve encaminhá-la ao pod que irá processá-la. Nesse momento, o nó aplica a seguinte regra de DNAT para alterar o endereço IP e o número de porta de destino do pacote.

1# Pacote TCP que o nó está preparando para enviar ao pod
2# Após a 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 importante aqui é que, quando o pod envia a resposta a essa requisição, o endereço IP de origem é o próprio endereço IP do pod (10.0.2.15) e o endereço IP de destino é o endereço IP do usuário externo que fez a requisição (203.0.113.10). Nesse caso, o usuário externo receberia uma resposta de um endereço IP que ele nunca requisitou e que não existe para ele, e simplesmente descartaria o pacote (DROP). Por isso, ao enviar o pacote de resposta do pod para o exterior, o nó do Kubernetes executa adicionalmente SNAT para alterar 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, prosseguimos com 10.0.1.1).

1# Pacote TCP que o nó está preparando para enviar ao pod
2# Após a aplicação de DNAT e 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 esse pacote responde ao nó que originalmente recebeu a requisição via NodePort, e o nó aplica de forma inversa o mesmo processo de DNAT e SNAT para devolver as informações ao usuário externo. Durante esse processo, cada nó armazena 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            |

Desenvolvimento

Em geral, no Linux, esses processos de NAT são gerenciados e executados pelo subsistema chamado conntrack, por meio do iptables. De fato, outros projetos de CNI como flannel e calico utilizam esse mecanismo para resolver problemas semelhantes aos descritos acima. O problema, porém, é que o Cilium utiliza uma tecnologia chamada eBPF e, com isso, ignora completamente essa pilha tradicional de rede do Linux. 🤣

https://cilium.io/blog/2021/05/11/cni-benchmark/

Como resultado, o Cilium optou por implementar diretamente apenas as funções necessárias em cenários de Kubernetes, entre as tarefas que a pilha tradicional de rede do Linux realizava, conforme mostrado na figura acima. Assim, para o SNAT mencionado anteriormente, o Cilium gerencia diretamente sua 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. Para a definição real, veja: 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 tabela hash, para permitir consultas rápidas, utiliza-se uma chave, e a combinação de src ip, src port, dst ip e dst port é usada como chave.

Reconhecimento do Problema

Fenômeno 1: Consulta

Assim surge um problema: para que um pacote que passa pelo eBPF determine se precisa passar por SNAT ou DNAT, é necessário consultar a tabela hash acima. Como vimos, existem dois tipos de pacotes no processo de SNAT: 1. o pacote que sai da rede interna para a externa, e 2. o pacote de resposta que entra da externa para a interna. Esses dois pacotes precisam de conversão NAT, mas possuem a característica de que os valores de src ip, src port e dst ip, dst port são invertidos entre si.

Assim, para uma consulta rápida, seria necessário adicionar mais uma entrada à tabela hash usando como chave os valores de src e dst invertidos, ou então consultar a mesma tabela hash duas vezes para todos os pacotes, mesmo aqueles não relacionados ao SNAT. Naturalmente, para obter melhor desempenho, o Cilium adotou a abordagem de inserir os mesmos dados duas vezes, por meio de um mecanismo chamado RevSNAT.

Fenômeno 2: LRU

Além disso, independentemente do problema anterior, nenhum hardware possui recursos infinitos e, especialmente em uma lógica no nível de hardware que exige alto desempenho, não é possível usar estruturas de dados dinâmicas. Assim, quando os recursos são insuficientes, é necessário remover (evict) dados existentes. O Cilium resolveu isso utilizando o LRU Hash Map, uma estrutura de dados básica fornecida por padrão pelo Linux.

Fenômeno 1 + Fenômeno 2 = Perda de Conexão

https://github.com/cilium/cilium/issues/31643

Ou seja, para uma única conexão TCP (ou UDP) sujeita a SNAT:

  1. Os dados de um pacote de saída e de um pacote de entrada são gravados duas vezes na mesma tabela hash, e
  2. De acordo com a lógica do LRU, a qualquer momento um dos dois dados pode ser perdido,

se qualquer uma das informações de NAT (doravante entrada) referentes aos pacotes que saem ou entram for removida pelo LRU, a conversão NAT não poderá ser realizada corretamente, o que pode levar à perda de toda a conexão.

Solução

Neste ponto, surgem os PRs mencionados 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, a tabela SNAT era consultada com uma chave formada pela combinação de src ip, src port, dst ip e dst port. Se a chave não existisse, uma nova informação de NAT era criada de acordo com a regra de SNAT e registrada na tabela. Em caso de nova conexão, isso levaria a uma comunicação normal; contudo, se a chave tivesse sido removida involuntariamente pelo LRU, a NAT seria realizada com uma porta diferente da que era usada na comunicação existente, e a parte receptora rejeitaria o pacote, encerrando a conexão com um pacote RST.

A abordagem adotada pelo PR acima é simples:

Sempre que um pacote for observado em qualquer direção, atualizemos também a entrada correspondente à direção oposta.

Quando a comunicação é observada em qualquer direção, ambas as entradas são atualizadas, afastando-as da prioridade de remoção (Eviction) do LRU; dessa forma, reduz-se a possibilidade de que apenas uma das entradas seja excluída e toda a comunicação entre em colapso.

Embora seja uma abordagem muito simples e possa parecer uma ideia trivial, esse método resolveu de forma eficaz o problema em que as informações de NAT das respostas expiravam primeiro e a conexão era interrompida, aumentando significativamente a estabilidade do sistema. Além disso, trata-se de uma melhoria importante que alcançou os seguintes resultados em termos de estabilidade de rede.

benchmark

Conclusão

Considero que este PR é um excelente exemplo de como uma ideia simples pode provocar grandes mudanças mesmo dentro de sistemas complexos, começando pelo conhecimento fundamental de ciência da computação sobre como a NAT funciona.

Ah, claro, não mostrei diretamente um exemplo de sistema complexo neste texto. No entanto, para compreender bem este PR, eu implorei ao DeepSeek V3 0324 por quase três horas, acrescentando até a palavra Please, e como resultado obtive mais um pouco de conhecimento sobre Cilium e o diagrama abaixo. 😇

diagram

E, ao ler as issues e os PRs, senti um mau pressentimento de que algo que eu havia criado no passado poderia ter causado esses problemas; como uma forma de compensação psicológica por esse sentimento, decidi escrever este texto.

Pós-escrito - 1

A propósito, este problema possui um método de contorno muito eficaz. Como a causa raiz do problema é a falta de espaço na tabela NAT, basta aumentar o tamanho da tabela NAT. :-D

Admiro e respeito profundamente o entusiasmo de gyutaeb, que, mesmo sem que o problema estivesse diretamente relacionado a ele, analisou e compreendeu tudo minuciosamente, contribuiu para o ecossistema do Cilium com dados objetivos e evidências, em vez de apenas aumentar o tamanho da tabela NAT e ir embora sem registrar a issue quando encontrou o mesmo problema.

Foi isso que me levou a decidir escrever este texto.

Pós-escrito - 2

Esta história não está diretamente alinhada com a Gosuda, que se dedica profissionalmente à linguagem Go. No entanto, a linguagem Go e o ecossistema de nuvem estão intimamente relacionados, e como os contribuidores do Cilium possuem certo domínio da linguagem Go, trouxe para a Gosuda um conteúdo que poderia ser publicado em um blog pessoal.

Acredito que está tudo bem, pois contei com a permissão de um dos administradores (eu mesmo).

Se você achar que não está tudo bem, salve logo este conteúdo em PDF, pois ele pode ser excluído a qualquer momento. ;)

Pós-escrito - 3

Na redação deste texto, recebi grande ajuda do Cline e do Llama 4 Maverick. Embora eu tenha começado a análise com o Gemini e implorado ao DeepSeek, a ajuda real veio do Llama 4. O Llama 4 é muito bom, recomendo que você o experimente.