La narrazione di Cilium: Straordinario miglioramento della stabilità della rete conseguito grazie a modeste modifiche al codice
Introduzione
Poco tempo fa, ho esaminato una Pull Request (PR) relativa al progetto Cilium di un mio ex collega di lavoro.
bpf:nat: Restore ORG NAT entry if it's not found
La quantità della modifica (escludendo i test code) è esigua, limitandosi all'aggiunta di un singolo blocco di istruzioni if
. Tuttavia, l'impatto derivante da questa correzione è considerevole, e trovo personalmente affascinante come una singola idea semplice possa contribuire in modo così significativo alla stabilità del sistema; pertanto, intendo esporre questa trattazione in modo che anche coloro privi di una competenza specialistica nel campo delle reti possano comprendere agevolmente questo esempio.
Conoscenze di Base
Se esistesse un bene indispensabile per i moderni paragonabile in importanza agli smartphone, probabilmente sarebbe il router Wi-Fi. Il router Wi-Fi esegue la comunicazione con i dispositivi tramite lo standard di comunicazione denominato Wi-Fi e svolge la funzione di condividere l'indirizzo IP pubblico in suo possesso affinché possa essere utilizzato da molteplici dispositivi. La peculiarità tecnica che emerge in questo contesto riguarda il modo in cui tale "condivisione" viene effettuata.
La tecnologia impiegata in tale circostanza è la Network Address Translation (NAT). La NAT è una tecnologia che, dato che la comunicazione TCP o UDP è costituita dalla combinazione di indirizzo IP e informazione di porta, consente la comunicazione esterna mappando la comunicazione interna, realizzata tramite IP Privato:Porta
, su un IP Pubblico:Porta
attualmente non in uso.
NAT
Un dispositivo NAT, quando un apparato interno tenta di accedere a Internet, trasforma la combinazione dell'indirizzo IP privato e del numero di porta di tale apparato nell'indirizzo IP pubblico del dispositivo stesso e in un numero di porta arbitrario non utilizzato. Questa informazione di trasformazione viene registrata in una struttura interna al dispositivo NAT denominata tabella NAT.
Ad esempio, ipotizziamo che uno smartphone all'interno di una abitazione (IP Privato: a.a.a.a
, Porta: 50000) tenti di connettersi a un server web (IP Pubblico: c.c.c.c
, Porta: 80).
1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Server Web (c.c.c.c:80)
Ricevendo la richiesta dello smartphone, il router visualizzerà il seguente TCP Packet.
1# TCP Packet 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 inoltrato direttamente al server web (c.c.c.c
), la risposta non tornerebbe allo smartphone (a.a.a.a
), che possiede un indirizzo IP privato; pertanto, il router individua preventivamente una porta arbitraria non attualmente impegnata nella comunicazione (es.: 60000) e la registra nella tabella NAT interna.
1# Tabella NAT 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 la nuova voce nella tabella NAT, il router modifica l'indirizzo IP sorgente e il numero di porta del TCP Packet ricevuto dallo smartphone con il proprio indirizzo IP pubblico (b.b.b.b
) e il numero di porta appena assegnato (60000), quindi lo trasmette al server web.
1# TCP Packet inviato dal router, Router => Server Web
2# Esecuzione SNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
A questo punto, il server web (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# TCP Packet ricevuto dal router, Server Web => Router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Ricevendo questo pacchetto di risposta, il router individua nella tabella NAT l'indirizzo IP privato originale (a.a.a.a
) e il numero di porta originale (50000) corrispondenti all'indirizzo IP di destinazione (b.b.b.b
) e al numero di porta (60000) del pacchetto, modificando così la destinazione del pacchetto verso lo smartphone.
1# TCP Packet inviato dal router, Router => Smartphone
2# Esecuzione 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 percepisce la comunicazione con il server web come se stesse avvenendo direttamente mediante il possesso di un indirizzo IP pubblico. Grazie alla NAT, è possibile che molteplici dispositivi interni utilizzino Internet simultaneamente con un unico indirizzo IP pubblico.
Kubernetes
Kubernetes possiede una struttura di rete tra le più sofisticate e complesse tra le tecnologie emergenti di recente. E, naturalmente, la NAT precedentemente menzionata viene utilizzata in diverse aree. Due sono gli esempi rappresentativi.
Quando i Pod interni comunicano con l'esterno del cluster
I Pod all'interno di un cluster Kubernetes ricevono tipicamente indirizzi IP privati che consentono la comunicazione solo all'interno della rete del cluster. Pertanto, affinché un Pod possa comunicare con Internet esterna, è necessaria la NAT per il traffico diretto al di fuori del cluster. In questo frangente, la NAT viene eseguita prevalentemente sul nodo Kubernetes (ciascun server del cluster) su cui il Pod in questione è in esecuzione. Quando un Pod invia un pacchetto destinato all'esterno, tale pacchetto viene prima inoltrato al nodo a cui appartiene il Pod. Il nodo modifica l'indirizzo IP sorgente di questo pacchetto (l'IP privato del Pod) con il proprio indirizzo IP pubblico, e altera in modo appropriato anche la porta sorgente prima di inoltrarlo all'esterno. Questo procedimento è analogo al processo di NAT descritto in precedenza con riferimento al router Wi-Fi.
Ad esempio, ipotizzando che un Pod all'interno del cluster Kubernetes (10.0.1.10
, Porta: 40000) si connetta a un server API esterno (203.0.113.45
, Porta: 443), il nodo Kubernetes riceverà il seguente pacchetto dal Pod:
1# TCP Packet 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, dopo aver registrato le seguenti informazioni,
1# Tabella NAT 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 |
esegue la SNAT come segue prima di inoltrare il pacchetto all'esterno.
1# TCP Packet inviato dal nodo, Nodo => Server API
2# Esecuzione 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 segue le medesime fasi del caso del router per smartphone esposto in precedenza.
Quando si comunica con un Pod tramite NodePort dall'esterno del cluster
Uno dei metodi per esporre un servizio all'esterno in Kubernetes consiste nell'utilizzare un servizio NodePort. Il servizio NodePort prevede di lasciare aperti specifici numeri di porta (NodePort) su tutti i nodi all'interno del cluster e di inoltrare il traffico in ingresso su tali porte ai Pod associati al servizio. L'utente esterno può accedere al servizio tramite l'indirizzo IP del nodo del cluster e il NodePort.
In questa situazione, la NAT svolge un ruolo cruciale, e in particolare si verificano DNAT (Destination NAT) e SNAT (Source NAT) simultaneamente. Quando il traffico in ingresso arriva al NodePort di un nodo specifico dall'esterno, la rete Kubernetes deve inoltrare tale traffico in ultima istanza al Pod che eroga il servizio. Durante questo processo, si verifica inizialmente una DNAT, mediante la quale l'indirizzo IP di destinazione e il numero di porta del pacchetto vengono modificati rispettivamente nell'indirizzo IP e nel numero di porta del Pod.
Ad esempio, ipotizziamo che un utente esterno al cluster (203.0.113.10
, Porta: 30000) acceda al servizio tramite il NodePort (30001
) di un nodo del cluster Kubernetes (192.168.1.5
). Si assume che questo servizio indirizzi internamente un Pod avente 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)
Qui, il nodo Kubernetes possiede sia l'indirizzo IP accessibile dall'esterno del nodo, 192.168.1.5, sia l'indirizzo IP valido nella rete interna di Kubernetes, 10.0.1.1. (Le politiche relative a ciò variano a seconda del tipo di CNI utilizzato, ma in questo articolo la spiegazione procede assumendo Cilium come riferimento.)
Quando la richiesta dell'utente esterno giunge al nodo, il nodo deve inoltrarla al Pod che gestirà tale richiesta. In questo momento, il nodo applica la regola DNAT seguente per modificare l'indirizzo IP di destinazione e il numero di porta del pacchetto.
1# TCP Packet che il nodo si appresta a inviare al Pod
2# Dopo l'applicazione della DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Il punto cruciale qui è che, quando il Pod invia una risposta a questa richiesta, l'indirizzo IP sorgente sarà il suo (10.0.2.15
) e l'indirizzo IP di destinazione sarà l'indirizzo IP dell'utente esterno che ha inviato la richiesta (203.0.113.10
). In questo scenario, l'utente esterno riceverebbe una risposta da un indirizzo IP inesistente che non ha mai richiesto, e scarterebbe semplicemente tale pacchetto (DROP). Perciò, il nodo Kubernetes esegue una SNAT aggiuntiva quando il Pod invia il pacchetto di risposta verso l'esterno, modificando l'indirizzo IP sorgente del pacchetto con l'indirizzo IP del nodo (192.168.1.5 oppure l'IP di rete interno 10.0.1.1; in questo caso, si procede con 10.0.1.1).
1# TCP Packet che il nodo si appresta a 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 riceve tale pacchetto risponde al nodo che inizialmente ha ricevuto la richiesta tramite NodePort, e il nodo restituisce le informazioni all'utente esterno applicando inversamente lo stesso processo DNAT e SNAT. In questo processo, ciascun nodo memorizzerà le seguenti informazioni:
1# Tabella DNAT 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# Tabella SNAT interna del nodo
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Discorso Principale
Generalmente, in Linux, tali processi NAT sono gestiti e operano tramite iptables, ad opera di un sottosistema denominato conntrack. Infatti, altri progetti CNI come flannel o calico utilizzano questa funzionalità per gestire le problematiche sopra descritte. Il problema, tuttavia, è che Cilium, utilizzando la tecnologia eBPF, ignora completamente lo stack di rete tradizionale di Linux. 🤣
Di conseguenza, Cilium ha adottato la strada di implementare direttamente solo le funzionalità necessarie per lo scenario Kubernetes tra quelle svolte dallo stack di rete Linux preesistente, come illustrato nell'immagine sopra. Pertanto, per quanto riguarda la SNAT descritta in precedenza, Cilium gestisce direttamente la tabella SNAT nella forma di una LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Tabella SNAT di Cilium
2# !Esempio per una spiegazione semplificata. La definizione reale è: 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 | protocollo, conntrack e altri metadati
4----------------------------------------------
5| | | | |
E poiché si tratta di una Hash Table, per una ricerca rapida esiste una chiave di valore che utilizza la combinazione di src ip
, src port
, dst ip
, dst port
come chiave.
Riconoscimento del Problema
Fenomeno - 1: Ricerca
Di conseguenza, si verifica un problema: per verificare se un pacchetto che attraversa eBPF necessita di un processo di SNAT o DNAT, è necessario eseguire una ricerca nella Hash Table sopra menzionata. Come abbiamo visto, nella procedura SNAT sono presenti due tipi di pacchetti: 1. pacchetti che escono verso l'esterno, e 2. pacchetti che rientrano dall'esterno in risposta al primo. Entrambi questi pacchetti richiedono una trasformazione NAT e presentano la caratteristica che i valori di src ip, porta e dst ip, porta sono invertiti.
Quindi, per una ricerca rapida, è necessario aggiungere una voce aggiuntiva alla tabella Hash utilizzando i valori scambiati di src e dst come chiave, oppure è necessario eseguire due ricerche sulla stessa Hash Table per tutti i pacchetti, anche quelli non correlati alla SNAT. Naturalmente, Cilium ha adottato il metodo di inserire gli stessi dati due volte, denominato RevSNAT, per ottenere prestazioni migliori.
Fenomeno - 2: LRU
Inoltre, indipendentemente dal problema precedente, non tutte le risorse hardware sono infinite, e poiché si tratta di una logica hardware che richiede prestazioni elevate, non è possibile utilizzare strutture dati dinamiche, rendendo necessaria l'evacuazione dei dati esistenti quando le risorse sono insufficienti. Cilium ha risolto questo problema utilizzando la LRU Hash Map, una struttura dati fornita di base da Linux.
Fenomeno 1 + Fenomeno 2 = Perdita di Connessione
https://github.com/cilium/cilium/issues/31643
In sostanza, per una singola connessione TCP (o UDP) sottoposta a SNAT:
- Gli stessi dati sono registrati due volte nella medesima Hash Table, una volta per il pacchetto in uscita e una per quello in entrata;
- Data la logica LRU, uno dei due dati può essere perso in qualsiasi momento.
Se anche solo una delle informazioni NAT (di seguito, "entry") relativa al pacchetto in uscita o in entrata viene eliminata a causa della LRU, la NAT non può essere eseguita correttamente, il che può portare alla perdita completa della connessione.
Soluzione
È qui che 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 tabella SNAT utilizzando come chiave la combinazione di src ip, src port, dst ip, dst port. Se la chiave non esisteva, veniva generata una nuova informazione NAT in base alla regola SNAT e registrata nella tabella. Se si trattava di una nuova connessione, ciò avrebbe portato a una comunicazione normale; se invece la chiave era stata rimossa involontariamente a causa della LRU, la SNAT sarebbe stata eseguita con una porta diversa da quella utilizzata per la comunicazione esistente, il che avrebbe portato il lato ricevente a rifiutare la ricezione e a terminare la connessione con un pacchetto RST.
L'approccio adottato dalle suddette PR è semplice:
Se un pacchetto viene osservato in una direzione qualsiasi, aggiorniamo anche l'entry corrispondente alla direzione inversa.
Quando la comunicazione viene osservata in qualsiasi direzione, entrambe le entry vengono aggiornate nuovamente, allontanandole dalla priorità di Eviction della logica LRU, riducendo così la possibilità di uno scenario in cui un'unica entry venga eliminata, causando il collasso dell'intera comunicazione.
Questo è un approccio molto semplice e può sembrare un'idea banale, ma attraverso questo approccio è stato possibile risolvere efficacemente il problema della disconnessione dovuto alla scadenza anticipata delle informazioni NAT relative ai pacchetti di risposta, migliorando significativamente la stabilità del sistema. Inoltre, è un miglioramento importante che ha portato ai seguenti risultati in termini di stabilità di rete.
Conclusione
Ritengo che questa PR sia un ottimo esempio che dimostra, partendo dalle nozioni fondamentali di CS su come opera la NAT, quanto una singola idea semplice possa apportare un cambiamento così grande anche all'interno di sistemi complessi.
Ah, ovviamente non ho mostrato direttamente un esempio di sistema complesso in questo articolo. Tuttavia, per comprendere appieno questa PR, ho supplicato per quasi tre ore DeepSeek V3 0324
, includendo persino la parola Please
, e come risultato ho ottenuto una conoscenza aggiuntiva su Cilium +1 e il diagramma seguente. 😇
Inoltre, leggendo le issue e le PR, scrivo questo articolo come forma di compensazione per i presentimenti inquietanti che le issue potessero essere state causate da qualcosa che avevo creato in passato.
Postfazione - 1
A proposito, esiste un metodo molto efficace per evitare questo problema. La causa fondamentale del problema è la mancanza di spazio nella tabella NAT, quindi è sufficiente aumentare la dimensione della tabella NAT. :-D
Mi sono meravigliato e nutro rispetto per la dedizione di gyutaeb, che, pur non essendo direttamente collegato al problema, ha analizzato e compreso a fondo la questione, fornendo dati oggettivi e contribuendo all'ecosistema Cilium, mentre qualcun altro avrebbe potuto semplicemente aumentare la dimensione della tabella NAT senza lasciare alcuna traccia dell'issue.
Questo è stato il motivo che mi ha spinto a scrivere questo articolo.
Postfazione - 2
Questa trattazione non è strettamente attinente al contesto di Gosuda, che si occupa professionalmente del linguaggio Go. Tuttavia, poiché il linguaggio Go e l'ecosistema cloud sono strettamente correlati, e dato che i contributori di Cilium hanno una certa competenza in Go, ho deciso di portare su Gosuda contenuti che potrei pubblicare sul mio blog personale.
Credo che sia accettabile poiché ho ricevuto il permesso da uno degli amministratori (io stesso).
Se pensate che non sia accettabile, vi consiglio di salvarlo subito in PDF, poiché potrebbe essere cancellato in qualsiasi momento. ;)
Postfazione - 3
Nella stesura di questo articolo, ho ricevuto un notevole aiuto da Cline e Llama 4 Maveric. Sebbene l'analisi sia iniziata con Gemini e abbia mendicato da DeepSeek, alla fine l'aiuto maggiore è giunto da Llama 4. Llama 4 è ottimo. Provatelo assolutamente.