GoSuda

Cilium: il miglioramento sorprendente della stabilità di rete ottenuto con una piccola modifica al codice

By iwanhae views ...

Introduzione

Qualche tempo fa ho dato un'occhiata alla PR sul progetto Cilium di un ex collega di lavoro.

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

La modifica in sé (esclusi i test) consiste in poco più dell'aggiunta di un blocco if. Tuttavia, l'impatto prodotto da questa modifica è enorme, e personalmente trovo interessante il fatto che un'idea semplice possa offrire un contributo enorme alla stabilità del sistema. Per questo motivo vorrei raccontare questo caso in modo che anche chi non ha competenze specifiche nel campo delle reti possa comprenderlo con facilità.

Nozioni di base

Se c'è un oggetto che, almeno quanto lo smartphone, è indispensabile per le persone di oggi, è probabilmente il router Wi-Fi. Il router Wi-Fi comunica con i dispositivi tramite lo standard di comunicazione Wi-Fi e svolge il ruolo di condividere l'indirizzo IP pubblico in suo possesso affinché possa essere utilizzato da più dispositivi. Il punto tecnicamente peculiare che ne deriva è: in che modo avviene questa "condivisione"?

La tecnologia utilizzata a tal fine è la Network Address Translation (NAT). Il NAT è una tecnologia che, poiché la comunicazione TCP o UDP si basa sulla combinazione di indirizzo IP e informazioni sulla porta, mappa la comunicazione interna nella forma IP privato:Port su una IP pubblico:Port attualmente non utilizzata, consentendo così di comunicare con l'esterno.

NAT

Quando un dispositivo interno tenta di accedere a Internet esterna, il dispositivo NAT converte la combinazione di indirizzo IP privato e numero di porta di quel dispositivo in una combinazione del proprio indirizzo IP pubblico e di un numero di porta arbitrario non utilizzato. Queste informazioni di conversione vengono registrate in un luogo chiamato tabella NAT all'interno del dispositivo NAT.

Ad esempio, supponiamo che uno smartphone in casa (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)

Quando riceve la richiesta dallo smartphone, il router vedrà un pacchetto TCP come il seguente.

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 così com'è al server web (c.c.c.c), la risposta non tornerebbe allo smartphone con indirizzo IP privato (a.a.a.a); pertanto il router individua innanzitutto una porta arbitraria non coinvolta nella comunicazione corrente (ad esempio 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 e il numero di porta di origine del pacchetto TCP ricevuto dallo smartphone, sostituendoli con il proprio indirizzo IP pubblico (b.b.b.b) e con la porta appena assegnata (60000), e lo inoltra al server web.

1# Pacchetto TCP inviato dal router, router => server web
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 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 nel modo seguente.

1# Pacchetto TCP 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    |

Quando riceve questo pacchetto di risposta, il router cerca 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), e modifica la destinazione del pacchetto verso lo 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 la sensazione di comunicare direttamente con il server web utilizzando un proprio indirizzo IP pubblico. Grazie al NAT, più dispositivi interni possono utilizzare Internet contemporaneamente con un unico indirizzo IP pubblico.

Kubernetes

Kubernetes possiede una struttura di rete tra le più sofisticate e complesse emerse di recente. Naturalmente, anche il NAT di cui abbiamo parlato trova largo impiego in vari contesti. I casi rappresentativi sono i due seguenti.

Quando un Pod comunica con l'esterno del cluster

I Pod all'interno di un cluster Kubernetes ricevono generalmente un indirizzo IP privato utilizzabile solo per la comunicazione all'interno della rete del cluster. Pertanto, affinché un Pod comunichi con Internet esterna, è 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 è in esecuzione il Pod. Quando il Pod invia un pacchetto diretto all'esterno, tale pacchetto viene inoltrato innanzitutto al nodo a cui appartiene il Pod. Il nodo sostituisce l'indirizzo IP di origine del pacchetto (l'IP privato del Pod) con il proprio indirizzo IP pubblico, modifica opportunamente anche la porta di origine e inoltra il pacchetto 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) si connetta a un server API esterno (203.0.113.45, porta: 443). In tal caso, il nodo Kubernetes riceve dal Pod il seguente pacchetto

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 registra 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       |

e successivamente esegue SNAT, inviando il pacchetto all'esterno come segue.

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      |

Da questo punto in poi, il processo segue gli stessi passaggi del caso dello smartphone con il router Wi-Fi.

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

Uno dei metodi per esporre un servizio all'esterno in Kubernetes consiste nell'utilizzare un servizio NodePort. Il servizio NodePort apre una specifica porta (NodePort) su tutti i nodi del cluster e inoltra il traffico che arriva a tale porta ai Pod appartenenti al servizio. Gli utenti esterni possono accedere al servizio tramite l'indirizzo IP di un nodo del cluster e il NodePort.

In questo caso il NAT svolge un ruolo importante, e in particolare si verificano contemporaneamente DNAT (Destination NAT) e SNAT (Source NAT). Quando il traffico proveniente dall'esterno arriva al NodePort di un determinato nodo, la rete Kubernetes deve inoltrare tale traffico al Pod che fornisce il servizio. In questo processo, dapprima avviene il DNAT, in cui l'indirizzo IP di destinazione e il numero di porta del pacchetto vengono sostituiti 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 al servizio tramite il NodePort (30001) di un nodo del cluster Kubernetes (192.168.1.5). Si assume inoltre 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 nella rete interna di Kubernetes. (A seconda del tipo di CNI utilizzato, le politiche relative a questo aspetto possono variare; tuttavia, in questo articolo la spiegazione si basa su Cilium.)

Quando la richiesta dell'utente esterno giunge al nodo, quest'ultimo deve inoltrarla al Pod che la gestirà. A tal fine, il nodo applica la seguente regola DNAT, modificando l'indirizzo IP di destinazione e il numero di porta del pacchetto.

1# Pacchetto TCP che il nodo si prepara a inviare al Pod
2# Dopo l'applicazione di DNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |

Qui è importante notare che, quando il Pod invia la risposta a questa richiesta, l'indirizzo IP di origine è il proprio indirizzo IP (10.0.2.15) e l'indirizzo IP di destinazione è quello dell'utente esterno che ha effettuato la richiesta (203.0.113.10). In tal caso, l'utente esterno riceverebbe una risposta da un indirizzo IP che non ha mai contattato e che non esiste per lui, e scarterebbe semplicemente il pacchetto (DROP). Per questo motivo, quando il Pod invia il pacchetto di risposta verso l'esterno, il nodo Kubernetes esegue inoltre SNAT, sostituendo l'indirizzo IP di origine con l'indirizzo IP del nodo (192.168.1.5 oppure l'IP della rete interna 10.0.1.1; in questo caso si procede con 10.0.1.1).

1# Pacchetto TCP che il nodo si prepara 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     |

A questo punto il Pod che ha ricevuto il pacchetto risponde al nodo che aveva inizialmente ricevuto la richiesta NodePort, e il nodo applica in senso inverso le stesse operazioni di DNAT e SNAT, restituendo le informazioni all'utente esterno. Durante questo processo, ciascun nodo memorizza le informazioni seguenti.

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            |

Corpo principale

In generale, in Linux questi processi NAT vengono gestiti e svolti tramite iptables, attraverso un sottosistema chiamato conntrack. In effetti, progetti CNI diversi come flannel o calico utilizzano questo meccanismo per risolvere problemi di questo tipo. Il problema, però, è che Cilium, utilizzando una tecnologia chiamata eBPF, ignora completamente questo stack di rete Linux tradizionale. 🤣

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

Di conseguenza, tra le funzioni svolte dallo stack di rete Linux tradizionale, Cilium ha scelto di implementare direttamente soltanto quelle necessarie nel contesto di Kubernetes, come mostrato nella figura sopra. Pertanto, per quanto riguarda il processo SNAT di cui si è detto, Cilium gestisce direttamente la tabella SNAT sotto forma di LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabella SNAT di Cilium
2# !Esempio per una spiegazione semplice. La definizione effettiva è in: 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|            |          |         |          |

Inoltre, poiché si tratta di una Hash Table, per garantire ricerche rapide viene utilizzata come chiave la combinazione di src ip, src port, dst ip, dst port come chiave.

Riconoscimento del problema

Fenomeno 1: ricerca

Ne deriva un problema: affinché un pacchetto che attraversa eBPF possa verificare se deve essere sottoposto a un processo SNAT o DNAT, è necessario eseguire una ricerca in questa Hash Table. Come abbiamo visto, nel processo SNAT esistono due tipi di pacchetti: 1. quelli che vanno dall'interno verso l'esterno e 2. quelli di risposta che tornano dall'esterno verso l'interno. Entrambi questi pacchetti richiedono una conversione NAT, ma presentano la caratteristica che i valori di src ip e porta e di dst ip e porta risultano invertiti.

Pertanto, per una ricerca rapida, sarebbe necessario aggiungere alla Hash Table un'ulteriore voce con la chiave in cui src e dst sono invertiti, oppure eseguire la ricerca due volte nella stessa Hash Table per ogni pacchetto, anche per quelli non rilevanti per SNAT. Cilium, per garantire prestazioni migliori, ha adottato il metodo di inserire due volte gli stessi dati, con il nome RevSNAT.

Fenomeno 2: LRU

Inoltre, indipendentemente dal problema precedente, nessun hardware dispone di risorse infinite e, in particolare, trattandosi di una logica a livello hardware che richiede prestazioni elevate, non è possibile utilizzare strutture dati dinamiche; quindi, quando le risorse scarseggiano, è necessario eliminare (evict) dati esistenti. Cilium ha risolto questo problema utilizzando la LRU Hash Map, una struttura dati di base fornita nativamente da Linux.

Fenomeno 1 + Fenomeno 2 = perdita della connessione

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

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

  1. nella stessa Hash Table, i dati dei pacchetti in uscita e di quelli in entrata sono registrati due volte, e
  2. secondo la logica LRU, in qualsiasi momento uno dei due dati può andare perso,

se anche una sola delle informazioni NAT (di seguito "voce") relative ai pacchetti in uscita verso l'esterno o in ingresso dall'esterno viene eliminata dalla LRU, il NAT non può essere eseguito correttamente, con la conseguenza che l'intera connessione può andare perduta.

Soluzione

A questo punto 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, veniva creata una chiave con la combinazione di src ip, src port, dst ip, dst port e si tentava una ricerca nella tabella SNAT. Se la chiave non esisteva, venivano generate nuove informazioni NAT secondo le regole SNAT e registrate nella tabella. Nel caso di una nuova connessione, ciò porta a una comunicazione normale; tuttavia, se la chiave era stata rimossa involontariamente dalla LRU, il NAT veniva eseguito con una porta diversa da quella utilizzata nella comunicazione precedente, e il lato ricevente rifiutava il pacchetto, terminando la connessione con un pacchetto RST.

Il metodo adottato dalla PR in questione è semplice:

Ogni volta che un pacchetto viene osservato in una direzione, aggiorniamo anche la voce relativa alla direzione opposta.

Quando si osserva una comunicazione in una qualsiasi direzione, entrambe le voci vengono aggiornate, allontanandosi così dalla priorità di eviction della LRU; in questo modo si riduce la probabilità che venga eliminata una sola delle due voci e che l'intera comunicazione collassi.

Sebbene si tratti di un approccio molto semplice e possa sembrare una semplice idea, grazie a questo metodo il problema per cui le informazioni NAT della risposta scadono per prime, interrompendo la connessione, è stato risolto in modo efficace, e la stabilità del sistema è stata notevolmente migliorata. Inoltre, si può considerare un miglioramento importante che ha prodotto risultati significativi in termini di stabilità di rete.

benchmark

Conclusione

Ritengo che questa PR sia un ottimo esempio di come, anche all'interno di sistemi complessi, un'idea semplice possa produrre cambiamenti notevoli, partendo dalle basi di informatica su come funziona il NAT.

Certo, in questo articolo non ho mostrato direttamente un sistema complesso. Tuttavia, per comprendere a fondo questa PR, ho chiesto a DeepSeek V3 0324 per quasi tre ore, aggiungendo perfino la parola Please, e il risultato è stato un punto in più di conoscenza su Cilium e il diagramma sottostante. 😇

diagram

Inoltre, leggendo le issue e le PR, ho avuto diversi brutti presentimenti sul fatto che qualcosa che avevo creato in passato potesse aver causato quei problemi; come compenso per questa sensazione, ho deciso di scrivere questo articolo.

Nota 1

A proposito, per questo problema esiste un metodo molto efficace per evitarlo. Poiché la causa principale è la mancanza di spazio nella tabella NAT, basta aumentarne le dimensioni. :-D

Chi, incontrando lo stesso problema, avrebbe potuto aumentare la dimensione della tabella NAT e andarsene senza nemmeno aprire una issue, ha invece analizzato a fondo il problema, lo ha compreso e ha contribuito all'ecosistema Cilium con dati oggettivi, pur non essendo direttamente coinvolto nella questione. Ammiro e rispetto profondamente l'entusiasmo di gyutaeb.

Questo è stato il motivo che mi ha spinto a decidere di scrivere questo articolo.

Nota 2

In realtà, questa storia non è un argomento particolarmente attinente a Gosuda, che si occupa specificamente del linguaggio Go. Tuttavia, Go e l'ecosistema cloud sono strettamente collegati, e i contributori di Cilium possiedono una certa familiarità con Go; per questo motivo ho portato su Gosuda un contenuto che avrei potuto pubblicare sul mio blog personale.

Poiché ho ottenuto il consenso di uno degli amministratori (io stesso), penso che dovrebbe andare bene.

Se ritenete che non vada bene, questo articolo potrebbe essere eliminato in qualsiasi momento, quindi vi consiglio di salvarlo subito in formato PDF. ;)

Nota 3

Per la stesura di questo articolo ho ricevuto un grande aiuto da Cline e Llama 4 Maverick. Pur avendo iniziato l'analisi con Gemini e avendo chiesto aiuto a DeepSeek, l'aiuto concreto è arrivato da Llama 4. Llama 4 è davvero valido. Vi consiglio di provarlo.