GoSuda

La storia di Cilium: Notevoli miglioramenti nella stabilità della rete ottenuti con una piccola modifica al codice

By iwanhae
views ...

Introduzione

Qualche tempo fa, ho avuto modo di osservare una PR sul progetto Cilium di un ex collega.

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

La quantità di modifiche (escludendo il codice di test) è minima, limitandosi all'aggiunta di un blocco if. Tuttavia, l'impatto di questa modifica è stato enorme e l'idea che una semplice intuizione possa contribuire in modo significativo alla stabilità del sistema mi ha personalmente affascinato. Per questo motivo, desidero raccontare questa storia in modo che sia facilmente comprensibile anche a chi non possiede conoscenze specialistiche nel campo delle reti.

Conoscenze pregresse

Se c'è un oggetto indispensabile per l'uomo moderno tanto quanto lo smartphone, probabilmente è il router Wi-Fi. Il router Wi-Fi comunica con i dispositivi tramite lo standard di comunicazione Wi-Fi e ha il compito di condividere il suo indirizzo IP pubblico con più dispositivi. La particolarità tecnica che ne deriva è: come avviene questa "condivisione"?

La tecnologia utilizzata in questo contesto è il Network Address Translation (NAT). Il NAT è una tecnica che permette la comunicazione con l'esterno mappando una comunicazione interna, composta da IP privato:Porta, a un IP pubblico:Porta attualmente non in uso, considerando che le comunicazioni TCP o UDP sono formate dalla combinazione di un indirizzo IP e un'informazione di porta.

NAT

Quando un dispositivo interno tenta di accedere a Internet, il dispositivo NAT traduce la combinazione di indirizzo IP privato e numero di porta del dispositivo in un proprio indirizzo IP pubblico e un numero di porta arbitrario non utilizzato. Questa informazione di traduzione viene registrata all'interno del dispositivo NAT, in una tabella denominata NAT table.

Ad esempio, supponiamo che uno smartphone (IP privato: a.a.a.a, porta: 50000) all'interno di una casa tenti di connettersi a un web server (IP pubblico: c.c.c.c, porta: 80).

1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Web Server (c.c.c.c:80)

Il router, ricevendo la richiesta dallo smartphone, osserverà il seguente TCP Packet:

1# Pacchetto TCP ricevuto dal router, smartphone => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Se questo pacchetto venisse inviato direttamente al web server (c.c.c.c), la risposta non tornerebbe allo smartphone (a.a.a.a) che possiede un indirizzo IP privato. Pertanto, il router individua innanzitutto una porta arbitraria (es: 60000) non attualmente coinvolta nella comunicazione e la registra nella sua NAT table interna.

1# NAT table interna del router
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Dopo aver registrato una nuova voce nella NAT table, il router modifica l'indirizzo IP di origine e il numero di porta del pacchetto TCP ricevuto dallo smartphone, sostituendoli con il proprio indirizzo IP pubblico (b.b.b.b) e il numero di porta appena assegnato (60000), e lo trasmette al web server.

1# Pacchetto TCP inviato dal router, router => web server
2# Esecuzione di SNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Ora il web server (c.c.c.c) riconosce la richiesta come proveniente dalla porta 60000 del router (b.b.b.b) e invia il pacchetto di risposta al router come segue:

1# Pacchetto TCP ricevuto dal router, web server => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Quando il router riceve questo pacchetto di risposta, individua nella NAT table l'indirizzo IP privato originale (a.a.a.a) e il numero di porta (50000) corrispondenti all'indirizzo IP di destinazione (b.b.b.b) e al numero di porta (60000), modificando la destinazione del pacchetto allo smartphone.

1# Pacchetto TCP inviato dal router, router => smartphone
2# Esecuzione di DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Attraverso questo processo, lo smartphone ha l'impressione di comunicare direttamente con il server web utilizzando un indirizzo IP pubblico. Grazie al NAT, più dispositivi interni possono utilizzare Internet contemporaneamente con un unico indirizzo IP pubblico.

Kubernetes

Kubernetes possiede una delle architetture di rete più sofisticate e complesse tra le tecnologie recenti. E naturalmente, il NAT, menzionato in precedenza, è utilizzato in vari contesti. I due casi principali sono i seguenti:

Quando un Pod all'interno del cluster comunica con l'esterno del cluster

I Pod all'interno di un cluster Kubernetes ricevono generalmente indirizzi IP privati che consentono la comunicazione solo all'interno della rete del cluster. Pertanto, se un Pod deve comunicare con Internet esterno, è necessario il NAT per il traffico in uscita dal cluster. In questo caso, il NAT viene eseguito principalmente sul nodo Kubernetes (ciascun server del cluster) su cui il Pod è in esecuzione. Quando un Pod invia un pacchetto verso l'esterno, questo viene prima inoltrato al nodo a cui appartiene il Pod. Il nodo modifica l'indirizzo IP di origine di questo pacchetto (l'IP privato del Pod) con il proprio indirizzo IP pubblico e modifica opportunamente anche la porta di origine prima di inoltrarlo all'esterno. Questo processo è simile al processo NAT descritto in precedenza per il router Wi-Fi.

Ad esempio, supponiamo che un Pod all'interno di un cluster Kubernetes (10.0.1.10, porta: 40000) tenti di connettersi a un server API esterno (203.0.113.45, porta: 443). Il nodo Kubernetes riceverà il seguente pacchetto dal Pod:

1# Pacchetto TCP ricevuto dal nodo, Pod => Nodo
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Il nodo registrerà le seguenti informazioni:

1# NAT table interna del nodo (esempio)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Successivamente, il nodo eseguirà SNAT come segue e invierà il pacchetto all'esterno.

1# Pacchetto TCP inviato dal nodo, Nodo => Server API
2# Esecuzione di SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Il processo successivo è lo stesso del caso dello smartphone con il router.

Quando si comunica con un Pod dall'esterno del cluster tramite NodePort

Uno dei modi per esporre un servizio all'esterno in Kubernetes è utilizzare i servizi NodePort. Un servizio NodePort apre una porta specifica (NodePort) su tutti i nodi all'interno del cluster e inoltra il traffico in entrata su questa porta ai Pod che appartengono al servizio. Gli utenti esterni possono accedere al servizio tramite l'indirizzo IP del nodo del cluster e la NodePort.

In questo caso, il NAT svolge un ruolo cruciale e, in particolare, si verificano DNAT (Destination NAT) e SNAT (Source NAT) contemporaneamente. Quando il traffico arriva a una NodePort di un nodo specifico dall'esterno, la rete Kubernetes deve infine inoltrare questo traffico al Pod che fornisce il servizio. In questo processo, si verifica prima il DNAT, che modifica l'indirizzo IP di destinazione e il numero di porta del pacchetto con l'indirizzo IP e il numero di porta del Pod.

Ad esempio, supponiamo che un utente esterno al cluster (203.0.113.10, porta: 30000) acceda a un servizio tramite la NodePort (30001) di un nodo del cluster Kubernetes (192.168.1.5). Si assume che questo servizio punti internamente a un Pod con indirizzo IP 10.0.2.15 e porta 8080.

1Utente esterno (203.0.113.10:30000) ==> Nodo Kubernetes (esterno:192.168.1.5:30001 / interno: 10.0.1.1:42132) ==> Pod Kubernetes (10.0.2.15:8080)

In questo caso, il nodo Kubernetes possiede sia l'indirizzo IP 192.168.1.5, accessibile dall'esterno, sia l'indirizzo IP 10.0.1.1, valido all'interno della rete Kubernetes. (Le politiche relative a ciò variano a seconda del tipo di CNI utilizzato, ma in questo articolo la spiegazione si basa su Cilium.)

Quando la richiesta dell'utente esterno arriva al nodo, il nodo deve inoltrarla al Pod che elaborerà la richiesta. A questo punto, il nodo applica la seguente regola DNAT per modificare l'indirizzo IP di destinazione e il numero di porta del pacchetto.

1# Pacchetto TCP che il nodo sta preparando per inviare al Pod
2# Dopo l'applicazione del DNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |

Un aspetto cruciale è che quando il Pod invia una risposta a questa richiesta, l'indirizzo IP di origine è il suo IP (10.0.2.15) e l'indirizzo IP di destinazione è l'IP dell'utente esterno che ha inviato la richiesta (203.0.113.10). In tal caso, l'utente esterno riceverebbe una risposta da un indirizzo IP inesistente che non ha mai richiesto e semplicemente DROPperebbe il pacchetto. Pertanto, il nodo Kubernetes esegue un SNAT aggiuntivo quando il Pod invia il pacchetto di risposta all'esterno, modificando l'indirizzo IP di origine del pacchetto con l'indirizzo IP del nodo (192.168.1.5 o l'IP di rete interno 10.0.1.1, in questo caso 10.0.1.1).

1# Pacchetto TCP che il nodo sta preparando per inviare al Pod
2# Dopo l'applicazione di DNAT e SNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

Ora, il Pod che ha ricevuto il pacchetto risponde al nodo che ha inizialmente ricevuto la richiesta tramite NodePort, e il nodo applica inversamente lo stesso processo DNAT e SNAT per restituire le informazioni all'utente esterno. Durante questo processo, ogni nodo memorizzerà le seguenti informazioni.

1# NAT table interna del nodo
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# SNAT table interna del nodo
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Corpo principale

Generalmente, in Linux, questi processi NAT sono gestiti e operano tramite iptables, attraverso il sottosistema conntrack. Infatti, altri progetti CNI come flannel o calico utilizzano questo meccanismo per gestire i problemi sopra menzionati. Tuttavia, il problema è che Cilium, utilizzando la tecnologia eBPF, ignora completamente questo stack di rete tradizionale di Linux. 🤣

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

Di conseguenza, Cilium ha scelto di implementare direttamente solo le funzionalità necessarie nel contesto di Kubernetes tra i compiti svolti dal tradizionale stack di rete Linux, come mostrato nella figura precedente. Pertanto, per il processo SNAT menzionato in precedenza, Cilium gestisce direttamente la SNAT table sotto forma di LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Cilium SNAT table
2# !Esempio per una spiegazione semplificata. La definizione effettiva è: 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 altri metadati
4----------------------------------------------
5|            |          |         |          |

Essendo una Hash Table, per una ricerca rapida, esiste un valore chiave, e si utilizza una combinazione di src ip, src port, dst ip, dst port come chiave.

Identificazione del problema

Fenomeno - 1: Ricerca

Di conseguenza, sorge un problema: un pacchetto che attraversa eBPF deve interrogare la Hash Table sopra menzionata per verificare se è necessario eseguire un processo SNAT o DNAT. Come abbiamo visto, esistono due tipi di pacchetti nel processo SNAT: 1. pacchetti in uscita dall'interno verso l'esterno, e 2. pacchetti in entrata dall'esterno verso l'interno come risposta. Entrambi questi pacchetti richiedono una traduzione NAT e sono caratterizzati dall'inversione dei valori di src ip, port e dst ip, port.

Pertanto, per una ricerca rapida, è necessario aggiungere un'altra voce alla Hash Table con i valori di src e dst invertiti come chiave, oppure, anche per pacchetti non correlati a SNAT, è necessario eseguire due ricerche sulla stessa Hash Table. Naturalmente, Cilium, per prestazioni migliori, ha adottato un approccio che inserisce gli stessi dati due volte, sotto il nome di RevSNAT.

Fenomeno - 2: LRU

Indipendentemente dal problema precedente, non possono esistere risorse illimitate in alcun hardware, e in particolare, in una logica a livello hardware che richiede prestazioni elevate, non è possibile utilizzare strutture dati dinamiche. In tali situazioni, è necessario eliminare i dati esistenti quando le risorse sono insufficienti. Cilium ha risolto questo problema utilizzando la LRU Hash Map, una struttura dati di base fornita di default in Linux.

Fenomeno 1 + Fenomeno 2 = Perdita di connessione

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

In altre parole, per una singola connessione TCP (o UDP) con SNAT:

  1. Gli stessi dati vengono registrati due volte in un'unica Hash Table per i pacchetti in uscita e in entrata.
  2. In base alla logica LRU, uno dei due dati può essere perso in qualsiasi momento.

Se una delle voci NAT (ovvero entry) per un pacchetto in uscita o in entrata viene eliminata dall'LRU, la NAT non può essere eseguita correttamente, il che può portare alla perdita dell'intera connessione.

Soluzione

Qui entrano in gioco le PR menzionate in precedenza:

bpf:nat: restore a NAT entry if its REV NAT is not found

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

In precedenza, quando un pacchetto attraversava eBPF, tentava di eseguire una ricerca nella SNAT table utilizzando una combinazione di src ip, src port, dst ip, dst port come chiave. Se la chiave non esisteva, veniva creata una nuova informazione NAT secondo le regole SNAT e registrata nella tabella. Nel caso di una nuova connessione, ciò porterebbe a una comunicazione normale. Tuttavia, se la chiave fosse stata inavvertitamente rimossa dall'LRU, verrebbe eseguito un nuovo NAT con una porta diversa da quella utilizzata per la comunicazione esistente, il che porterebbe al rifiuto della ricezione da parte del destinatario del pacchetto e alla terminazione della connessione con un pacchetto RST.

L'approccio adottato dalla PR menzionata è semplice:

Ogni volta che un pacchetto viene osservato in una direzione, aggiornare anche l'entry per la direzione inversa.

Quando la comunicazione viene osservata in una delle due direzioni, entrambe le entry vengono aggiornate, allontanandosi dalla priorità di eviction della logica LRU. Questo riduce la probabilità di uno scenario in cui solo una delle entry viene eliminata, portando al collasso dell'intera comunicazione.

Questo può sembrare un approccio molto semplice e una semplice idea, ma attraverso questo approccio è stato possibile risolvere efficacemente il problema della disconnessione dovuta alla scadenza prematura delle informazioni NAT per i pacchetti di risposta e migliorare significativamente la stabilità del sistema. Si tratta inoltre di un miglioramento importante che ha portato ai seguenti risultati in termini di stabilità della rete.

benchmark

Conclusione

Ritengo che questa PR sia un ottimo esempio che mostra come una semplice idea possa apportare un cambiamento significativo all'interno di un sistema complesso, partendo da una conoscenza CS di base su come funziona il NAT.

Ah, naturalmente, non ho mostrato direttamente esempi di sistemi complessi in questo articolo. Tuttavia, per comprendere appieno questa PR, ho supplicato DeepSeek V3 0324 per quasi 3 ore, persino aggiungendo la parola "Please", e di conseguenza ho acquisito nuove conoscenze su Cilium e ho ottenuto il seguente diagramma. 😇

diagram

E leggendo gli issues e le PR, ho scritto questo articolo come una sorta di compensazione per le sinistre premonizioni che i problemi potessero essere stati causati da qualcosa che avevo creato in passato.

Postfazione - 1

A proposito, esiste un modo molto efficace per evitare questo problema. Poiché la causa principale del problema è la mancanza di spazio nella NAT table, è sufficiente aumentare le dimensioni della NAT table. :-D

Mentre qualcuno, di fronte allo stesso problema, avrebbe potuto semplicemente aumentare le dimensioni della NAT table e scomparire senza lasciare un issue, sono rimasto ammirato e rispetto la passione di gyutaeb, che ha analizzato e compreso a fondo il problema, pur non essendo direttamente correlato, e ha contribuito all'ecosistema Cilium con dati oggettivi.

Questa è stata la ragione per cui ho deciso di scrivere questo articolo.

Postfazione - 2

Questa storia, in realtà, non è direttamente attinente a Gosuda, che si occupa professionalmente del linguaggio Go. Tuttavia, poiché il linguaggio Go e l'ecosistema cloud sono strettamente correlati e i contributori di Cilium hanno una certa competenza nel linguaggio Go, ho voluto portare qui un contenuto che avrei potuto pubblicare sul mio blog personale.

Dato che ho avuto il permesso di uno degli amministratori (me stesso), credo che vada bene.

Se non pensate che sia il caso, salvatelo subito in PDF, perché non si sa mai quando potrebbe essere eliminato. ;)

Postfazione - 3

Nella stesura di questo articolo, ho ricevuto un grande aiuto da Cline e Llama 4 Maveric. Sebbene abbia iniziato l'analisi con Gemini e supplicato DeepSeek, l'aiuto effettivo mi è arrivato da Llama 4. Llama 4 è ottimo. Provatelo assolutamente.