GoSuda

A História do Cilium: Como uma Pequena Mudança de Código Levou a Melhorias Notáveis na Estabilidade da Rede

By iwanhae
views ...

Introdução

Há pouco tempo, observei um PR de um ex-colega de trabalho sobre o projeto Cilium.

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

A quantidade de modificações (excluindo o código de teste) é pequena, adicionando apenas um bloco de instrução if. No entanto, o impacto dessa modificação é enorme, e pessoalmente achei interessante como uma ideia simples pode contribuir significativamente para a estabilidade do sistema. Por isso, gostaria de compartilhar essa história de forma que mesmo pessoas sem conhecimento especializado em redes possam entendê-la facilmente.

Conhecimento Básico

Se há um item essencial para os seres humanos modernos tão importante quanto o smartphone, provavelmente é o roteador Wi-Fi. O roteador Wi-Fi se comunica com dispositivos por meio do padrão de comunicação Wi-Fi e compartilha seu endereço IP público para que vários dispositivos possam usá-lo. A singularidade técnica que surge aqui é: como ele "compartilha"?

A tecnologia utilizada para isso é o Network Address Translation (NAT). O NAT é uma tecnologia que permite que a comunicação interna, realizada por IP privado:Porta, seja mapeada para um IP público:Porta não utilizado no momento, permitindo a comunicação com o exterior, dado que a comunicação TCP ou UDP é composta por uma combinação de endereço IP e informações de porta.

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 do dispositivo para seu próprio endereço IP público e um número de porta arbitrário não utilizado. Essa informação de conversão é registrada em uma tabela NAT dentro do dispositivo NAT.

Por exemplo, suponha 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 solicitação do smartphone, o roteador verá 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 for enviado diretamente para o servidor web (c.c.c.c), a resposta não retornará para o smartphone com endereço IP privado (a.a.a.a). Portanto, o roteador primeiro encontra uma porta arbitrária que não está 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-alocado (60000), e o envia para o 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 solicitação como vinda da porta 60000 do roteador (b.b.b.b) e envia o pacote de resposta para o 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 pesquisa na tabela NAT o endereço IP público (b.b.b.b) e o número da porta (60000) correspondentes ao endereço IP privado original (a.a.a.a) e ao número da porta (50000), 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    |

Através deste processo, o smartphone sente como se estivesse se comunicando diretamente com o servidor web usando seu próprio 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 estruturas de rede mais sofisticadas e complexas entre as tecnologias recentes. E, claro, o NAT, mencionado anteriormente, é utilizado em vários lugares. Os exemplos representativos são os dois seguintes:

Ao realizar comunicação com o exterior do cluster a partir do interior do Pod

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, o NAT é necessário para o tráfego de saída do cluster. Nesse caso, o NAT é realizado principalmente no nó do Kubernetes (cada servidor do cluster) onde o Pod está sendo executado. Quando um Pod envia um pacote para o exterior, esse pacote é primeiro transmitido para o nó ao qual o Pod pertence. O nó altera o endereço IP de origem desse pacote (IP privado do Pod) para seu próprio endereço IP público e modifica adequadamente a porta de origem antes de transmiti-lo para o exterior. Esse processo é semelhante ao processo de NAT explicado anteriormente para roteadores Wi-Fi.

Por exemplo, suponha que um Pod dentro de um cluster Kubernetes (10.0.1.10, porta: 40000) se conecte a um servidor API externo (203.0.113.45, porta: 443). O nó do Kubernetes receberá 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ó registrará 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       |

Em seguida, ele executará o SNAT e enviará o pacote para o exterior da seguinte forma:

1# Pacote TCP enviado pelo nó, Nó => Servidor 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      |

O processo subsequente segue o mesmo padrão do exemplo do roteador de smartphone.

Ao se comunicar com um Pod a partir do exterior do cluster através de um NodePort

Uma das maneiras de expor serviços externamente no Kubernetes é usar um serviço NodePort. Um serviço NodePort abre uma porta específica (NodePort) em todos os nós do cluster e encaminha o tráfego de entrada para essa porta para os Pods pertencentes ao serviço. Usuários externos podem acessar o serviço através do endereço IP do nó do cluster e do NodePort.

Neste ponto, o NAT desempenha um papel importante, e especificamente, o DNAT (Destination NAT) e o SNAT (Source NAT) ocorrem simultaneamente. Quando o tráfego externo chega a um NodePort específico de um nó, a rede do Kubernetes deve, em última análise, encaminhar esse tráfego para o Pod que fornece o serviço. Neste processo, o DNAT ocorre primeiro, alterando o endereço IP de destino e o número da porta do pacote para o endereço IP e o número da porta do Pod.

Por exemplo, suponha que um usuário externo (203.0.113.10, porta: 30000) acesse um serviço através de um NodePort (30001) de um nó do cluster Kubernetes (192.168.1.5). Suponha que este 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)

Neste caso, o nó do Kubernetes possui tanto o endereço IP do nó 192.168.1.5, acessível externamente, quanto o endereço IP 10.0.1.1, válido na rede interna do Kubernetes. (Embora as políticas relacionadas a isso variem dependendo do tipo de CNI usado, este artigo explica com base 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 modificar o endereço IP de destino e o número da porta 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 uma resposta a essa solicitação, o endereço IP de origem é o seu próprio endereço IP (10.0.2.15) e o endereço IP de destino é o endereço IP do usuário externo que enviou a solicitação (203.0.113.10). Nesse caso, o usuário externo receberá uma resposta de um endereço IP inexistente que ele nunca solicitou e simplesmente descartará o pacote. Por isso, o nó do Kubernetes executa 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 que o nó está preparando para enviar ao Pod
2# Após a 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ó inverte os processos de DNAT e SNAT para retornar a informação ao usuário externo. Durante esse 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            |

Corpo Principal

Geralmente, no Linux, esses processos de NAT são gerenciados e operados pelo subsistema conntrack através do iptables. De fato, outros projetos CNI como flannel ou calico utilizam isso para lidar com os problemas mencionados acima. No entanto, o problema é que o Cilium, ao usar a tecnologia eBPF, ignora completamente essa pilha de rede tradicional do Linux. 🤣

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

Como resultado, o Cilium escolheu implementar diretamente apenas as funcionalidades necessárias para o Kubernetes, dentre as tarefas que a pilha de rede Linux existente executava, conforme ilustrado no diagrama acima. Portanto, para o processo de SNAT mencionado anteriormente, o Cilium gerencia diretamente a 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. 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 chave.

Identificação do Problema

Fenômeno - 1: Consulta

Um problema que surge é que, para que um pacote que passa pelo eBPF verifique se precisa executar o processo SNAT ou DNAT, ele precisa consultar a Hash Table mencionada acima. Como vimos anteriormente, existem dois tipos de pacotes no processo SNAT: 1. Pacotes que saem de dentro para fora, e 2. Pacotes que entram de fora para dentro como resposta. Esses dois pacotes precisam de conversão no processo NAT e têm a característica de que os valores de src ip, port e dst ip, port são invertidos.

Portanto, para uma consulta rápida, é necessário adicionar uma entrada extra na Hash Table com a chave de src e dst invertidos, ou consultar a mesma Hash Table duas vezes para todos os pacotes, mesmo que não estejam relacionados ao SNAT. Naturalmente, para um melhor desempenho, o Cilium adotou o método de inserir os mesmos dados duas vezes, sob o nome RevSNAT.

Fenômeno - 2: LRU

Independentemente do problema acima, não pode haver recursos infinitos em nenhum hardware, e especialmente em lógica de hardware que exige alto desempenho, onde estruturas de dados dinâmicas não podem ser usadas, é necessário remover dados existentes quando os recursos são insuficientes. O Cilium resolveu isso usando 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

Ou seja, para uma conexão TCP (ou UDP) SNATed:

  1. Os mesmos dados são registrados duas vezes em uma única Hash Table para pacotes de saída e entrada, e
  2. Em uma situação onde qualquer um dos dois dados pode ser perdido a qualquer momento devido à lógica LRU,

Se uma das informações NAT (doravante, entrada) para um pacote de saída ou entrada for removida pelo LRU, o NAT não poderá ser executado corretamente, levando à perda de toda a conexão.

Solução

Aqui entram 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, ele tentava consultar a tabela SNAT criando uma chave a partir da combinação de src ip, src port, dst ip e dst port. Se a chave não existisse, ele geraria novas informações NAT de acordo com as regras SNAT e as registraria na tabela. Para uma nova conexão, isso levaria a uma comunicação normal. No entanto, se a chave fosse acidentalmente removida pelo LRU, uma nova NAT seria realizada com uma porta diferente daquela usada na comunicação existente, o que faria com que o receptor do pacote recusasse o recebimento e a conexão seria encerrada com um pacote RST.

A abordagem adotada por este PR é simples:

Se um pacote for observado em qualquer direção, a entrada na 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 Eviction da lógica LRU. Isso reduz a probabilidade de um cenário em que apenas uma entrada seja excluída, resultando no colapso de toda a comunicação.

Esta é uma abordagem muito simples e pode parecer uma ideia trivial, mas através dela, o problema da desconexão causada pela expiração prematura das informações NAT para pacotes de resposta foi efetivamente resolvido, melhorando significativamente a estabilidade do sistema. Além disso, pode-se dizer que foi uma melhoria importante que alcançou os seguintes resultados em termos de estabilidade de rede:

benchmark

Conclusão

Considero este PR um excelente exemplo que demonstra como uma ideia simples pode trazer uma grande mudança, começando pelos conhecimentos básicos de CS sobre o funcionamento do NAT, mesmo dentro de um sistema complexo.

Ah, claro, não apresentei diretamente um exemplo de sistema complexo neste artigo. No entanto, para entender corretamente este PR, implorei por quase 3 horas ao DeepSeek V3 0324, até mesmo usando a palavra Please, e como resultado, obtive conhecimento sobre o Cilium +1 e a seguinte imagem. 😇

diagram

E ao ler os problemas e os PRs, escrevo este artigo com um sentimento de compensação por uma premonição sinistra de que algo que eu havia feito no passado poderia ter causado um problema.

Comentário Final - 1

A propósito, existe uma maneira muito eficaz de evitar este problema. Como a causa raiz do problema é a falta de espaço na tabela NAT, basta aumentar o tamanho da tabela NAT. :-D

Enquanto alguém que encontrou o mesmo problema no passado pode ter aumentado o tamanho da tabela NAT e fugido sem deixar um problema, admiro e respeito a paixão de gyutaeb, que analisou e compreendeu minuciosamente o problema, mesmo não estando diretamente envolvido, e contribuiu para o ecossistema Cilium com dados objetivos.

Esta foi a razão pela qual decidi escrever este artigo.

Comentário Final - 2

Esta história, na verdade, não é diretamente relevante para a Gosuda, que lida profissionalmente com a linguagem Go. No entanto, a linguagem Go e o ecossistema de nuvem estão intimamente relacionados, e os contribuidores do Cilium têm algum conhecimento de Go, então resolvi trazer para a Gosuda um conteúdo que poderia ser publicado em um blog pessoal.

Acredito que tudo estará bem, já que tive a permissão de um dos administradores (eu mesmo).

Se você acha que não está tudo bem, salve rapidamente em PDF, pois não se sabe quando será excluído. ;)

Comentário Final - 3

A criação deste artigo contou com grande auxílio de Cline e Llama 4 Maveric. Embora a análise tenha sido iniciada com Gemini e eu tenha implorado ao DeepSeek, a ajuda de fato veio do Llama 4. O Llama 4 é excelente. Experimente-o.