GoSuda

La storia di Cilium: un incredibile miglioramento della stabilità del network realizzato da una piccola modifica al codice

By iwanhae
views ...

Introduzione

Poco tempo fa ho esaminato una PR (Pull Request) relativa al progetto Cilium di un ex collega.

bpf:nat: Ripristina l'entry ORG NAT se non viene trovata

(Escluso il codice di test) la quantità di modifica in sé è esigua, pari all'aggiunta di un singolo blocco di istruzione if. Tuttavia, l'impatto generato da questa modifica è notevole, e il fatto che una singola idea semplice possa apportare un contributo significativamente grande alla stabilità del sistema mi è sembrato personalmente interessante, pertanto intendo esporre la questione in modo che anche coloro privi di conoscenze specialistiche nel campo della rete possano facilmente comprendere questo caso.

Cenni preliminari

Se esiste un elemento essenziale per le persone moderne altrettanto importante quanto lo smartphone, probabilmente si tratta del router WiFi. Il router WiFi esegue la comunicazione con i dispositivi attraverso lo standard di comunicazione chiamato WiFi e svolge il ruolo di condividere l'indirizzo IP pubblico di cui dispone affinché possa essere utilizzato da più dispositivi. La peculiarità tecnica che ne deriva è: come avviene questa "condivisione"?

La tecnologia qui utilizzata è precisamente il Network Address Translation (NAT). NAT è una tecnologia che, basandosi sul fatto che la comunicazione TCP o UDP è composta da una combinazione di indirizzo IP e informazioni sulla porta, consente di eseguire comunicazioni con l'esterno mappando la comunicazione interna (IP privato:Porta) con un IP pubblico:Porta che non è attualmente in uso.

NAT

Quando un dispositivo interno tenta di accedere a Internet esterno, il dispositivo NAT converte la combinazione di indirizzo IP privato e numero di porta di tale dispositivo nel proprio indirizzo IP pubblico e in un numero di porta arbitrario non in uso. Questa informazione di conversione viene registrata in una posizione all'interno del dispositivo NAT chiamata tabella NAT.

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

1스마트폰 (a.a.a.a:50000) ==> 공유기 (b.b.b.b) ==> 웹 서버 (c.c.c.c:80)

Quando il router riceve la richiesta dello smartphone, visualizzerà un TCP Packet 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 web server (c.c.c.c), poiché la risposta non tornerebbe allo smartphone (a.a.a.a) che possiede un indirizzo IP privato, 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 una nuova entry nella tabella NAT, il router modifica l'indirizzo IP di origine e il numero di porta del pacchetto TCP ricevuto dallo smartphone nel proprio indirizzo IP pubblico (b.b.b.b) e nel numero di porta appena allocato (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) lo riconosce come una richiesta proveniente dal router (b.b.b.b) sulla porta 60000 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 tabella NAT 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), e modifica 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 la percezione di comunicare con il web server come se possedesse direttamente un indirizzo IP pubblico. Grazie a NAT, è possibile che più dispositivi interni utilizzino contemporaneamente Internet con un singolo indirizzo IP pubblico.

Kubernetes

Tra le tecnologie emerse di recente, Kubernetes possiede una struttura di rete estremamente sofisticata e complessa. E, naturalmente, anche il NAT menzionato in precedenza viene utilizzato in diverse aree. I casi rappresentativi sono i seguenti due.

Quando si effettua la comunicazione con l'esterno del cluster dall'interno di un Pod

I Pod all'interno di un cluster Kubernetes ricevono generalmente indirizzi IP privati che possono comunicare solo all'interno della rete del cluster. Pertanto, affinché un Pod possa 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 è in esecuzione il Pod in questione. Quando un Pod invia un pacchetto diretto verso l'esterno, tale pacchetto 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) nel proprio indirizzo IP pubblico e modifica opportunamente anche la porta di origine, inoltrandolo verso l'esterno. Questo processo è simile al processo NAT descritto in precedenza con il router WiFi.

Ad esempio, ipotizzando che un Pod all'interno di un cluster Kubernetes (10.0.1.10, Porta: 40000) si connetta a un API server esterno (203.0.113.45, Porta: 443), il nodo Kubernetes riceverà un pacchetto come il seguente dal Pod e...

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à i seguenti contenuti e...

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       |

...eseguirà SNAT come segue e invierà il pacchetto verso l'esterno.

1# Pacchetto TCP inviato dal nodo, nodo => API server
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 seguirà lo stesso iter dell'esempio precedente del router per smartphone.

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

Uno dei metodi per esporre un Service all'esterno in Kubernetes consiste nell'utilizzare un Service di tipo NodePort. Un Service NodePort mantiene aperta una porta specifica (NodePort) su tutti i nodi all'interno del cluster ed è un metodo per inoltrare il traffico in entrata su questa porta ai Pod appartenenti al Service. Gli utenti esterni possono accedere al Service tramite l'indirizzo IP del nodo del cluster e la NodePort.

In questo caso, il NAT svolge un ruolo importante, e in particolare DNAT (Destination NAT) e SNAT (Source NAT) avvengono contemporaneamente. Quando il traffico in entrata raggiunge la NodePort di un nodo specifico dall'esterno, la rete Kubernetes deve infine inoltrare questo traffico al Pod che fornisce il Service corrispondente. In questo processo, avviene innanzitutto il DNAT, per cui l'indirizzo IP di destinazione e il numero di porta del pacchetto vengono modificati nell'indirizzo IP e nel numero di porta del Pod.

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

1외부 사용자 (203.0.113.10:30000) ==> 쿠버네티스 노드 (외부:192.168.1.5:30001 / 내부: 10.0.1.1:42132) ==> 쿠버네티스 파드 (10.0.2.15:8080)

Qui, nel caso del nodo Kubernetes, esso possiede sia l'indirizzo IP del nodo accessibile dall'esterno (192.168.1.5) sia l'indirizzo IP valido nella rete Kubernetes interna (10.0.1.1). (Le policy correlate 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 inoltrare questa richiesta al Pod che la elaborerà. In questo caso, il nodo modifica l'indirizzo IP di destinazione e il numero di porta del pacchetto applicando una regola DNAT come segue.

1# Pacchetto TCP che il nodo sta preparando per 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    |

Il punto cruciale qui è che quando il Pod invia una risposta a questa richiesta, l'indirizzo IP di origine sarà il suo indirizzo IP (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 tal caso, l'utente esterno riceverà una risposta da un indirizzo IP inesistente che non aveva richiesto e semplicemente DROPpa tale pacchetto. Pertanto, quando il Pod invia un pacchetto di risposta verso l'esterno, il nodo Kubernetes esegue ulteriormente SNAT per modificare l'indirizzo IP di origine del pacchetto nell'indirizzo IP del nodo (192.168.1.5 o 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 sta preparando per inviare al Pod
2# Dopo l'applicazione di DNAT, 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 tale pacchetto risponderà al nodo che ha ricevuto inizialmente la richiesta tramite NodePort, e il nodo, applicando in senso inverso lo stesso processo di DNAT e SNAT, restituirà le informazioni all'utente esterno. Durante 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            |

Corpo principale

Generalmente, in Linux, questi processi NAT vengono gestiti e operano tramite iptables, mediante un sottosistema chiamato conntrack. Infatti, altri progetti CNI come flannel o calico gestiscono problemi simili a quelli sopra menzionati sfruttando questo meccanismo. Il problema, tuttavia, è che Cilium, utilizzando una tecnologia chiamata eBPF, ignora completamente questo stack di rete tradizionale di Linux. 🤣

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

Di conseguenza, Cilium ha adottato la strada di implementare direttamente solo le funzionalità necessarie nel contesto Kubernetes, tra i compiti svolti dallo stack di rete Linux tradizionale mostrato nella figura sopra. Pertanto, per quanto riguarda il processo SNAT menzionato in precedenza, 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 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 ecc. altri metadati
4----------------------------------------------
5|            |          |         |          |

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

Identificazione del problema

Fenomeno - 1: Lookup

Di conseguenza, sorge un problema: per verificare se un pacchetto che passa attraverso eBPF necessiti di eseguire un processo SNAT o DNAT per il NAT, è necessario eseguire una ricerca nella suddetta Hash Table, ma, come visto in precedenza, nel processo SNAT esistono due tipi di pacchetti. 1. Pacchetti in uscita dall'interno verso l'esterno, e 2. Pacchetti in entrata dall'esterno verso l'interno come risposta a quelli. Questi due pacchetti, pur richiedendo una trasformazione per il processo NAT, presentano la caratteristica che i valori di src ip, port e dst ip, port sono scambiati.

Pertanto, per una ricerca rapida, è necessario o aggiungere un altro valore alla Hash Table utilizzando i valori di src e dst scambiati come chiave, oppure eseguire una ricerca nella stessa Hash Table due volte per tutti i pacchetti, anche quelli non correlati a SNAT. Naturalmente, Cilium ha adottato il metodo di inserire gli stessi dati due volte, con il nome RevSNAT, per ottenere prestazioni migliori.

Fenomeno - 2: LRU

Inoltre, indipendentemente dal problema precedente, poiché su tutto l'hardware non possono esistere risorse infinite, e in particolare trattandosi di logica a livello hardware che richiede prestazioni rapide, in una situazione in cui non è possibile utilizzare nemmeno strutture dati dinamiche, quando le risorse sono insufficienti è necessario evictare i dati esistenti. Cilium ha risolto questo problema utilizzando la struttura dati di base fornita di default in Linux, ovvero LRU Hash Map.

Fenomeno 1 + Fenomeno 2 = Perdita di connessione

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

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

  1. In una singola Hash Table, gli stessi dati sono registrati due volte per i pacchetti in uscita e in entrata,
  2. e in una situazione in cui, secondo la logica LRU, uno dei due dati può essere perso in qualsiasi momento,

...se anche una sola delle informazioni NAT (di seguito denominata entry) relative ai pacchetti in uscita o in entrata viene persa a causa dell'LRU, il NAT non potrà essere eseguito correttamente, il che può portare alla perdita totale della connessione.

Soluzione

Qui appaiono le seguenti PR menzionate in precedenza.

bpf:nat: ripristina una NAT entry se la sua REV NAT non viene trovata

bpf:nat: Ripristina l'entry ORG NAT se non viene trovata

In precedenza, quando un pacchetto passava attraverso eBPF, si tentava una ricerca nella tabella SNAT creando una chiave dalla combinazione di src ip, src port, dst ip, dst port. Se la chiave non esiste, viene creata una nuova informazione NAT secondo le regole SNAT e registrata nella tabella. Nel caso di una nuova connessione, ciò porterà a una comunicazione normale; se invece la chiave è stata rimossa involontariamente dall'LRU, il NAT verrà eseguito nuovamente con una porta diversa da quella utilizzata nella comunicazione esistente, la parte ricevente rifiuterà la ricezione di tale pacchetto e la connessione verrà terminata con un pacchetto RST.

Il metodo adottato dalle suddette PR è semplice.

Indipendentemente dalla direzione, se un pacchetto viene osservato una volta, aggiorniamo anche l'entry corrispondente alla direzione inversa.

Se la comunicazione viene osservata in una qualsiasi direzione, entrambe le entry vengono aggiornate, allontanandosi così dall'essere candidate per la priorità di Eviction della logica LRU, e attraverso ciò si riduce la possibilità di uno scenario in cui solo una delle entry viene eliminata, portando al collasso dell'intera comunicazione.

Sebbene questo sia un approccio molto semplice e possa sembrare una semplice idea, attraverso tale approccio è stato possibile risolvere efficacemente il problema della disconnessione dovuta al fatto che le informazioni NAT per i pacchetti di risposta scadevano prima, e migliorare significativamente la stabilità del sistema. Inoltre, può essere considerata un miglioramento importante che ha conseguito risultati come i seguenti in termini di stabilità di rete.

benchmark

Conclusione

Ritengo che questa PR sia un ottimo esempio che, partendo dalla conoscenza CS di base su come funziona il NAT, dimostra quanto una singola idea semplice possa apportare un grande cambiamento anche all'interno di sistemi complessi.

Ah, naturalmente, in questo articolo non ho mostrato direttamente un esempio di sistema complesso. Tuttavia, per comprendere appieno questa PR, ho 'implorato' DeepSeek V3 0324 per quasi 3 ore, aggiungendo persino la parola Please, e come risultato sono riuscito a ottenere +1 conoscenza su Cilium e una figura come quella seguente. 😇

diagram

E leggendo gli issue e le PR, scrivo questo articolo come compensazione psicologica per le inquietanti premonizioni che un issue possa essersi verificato a causa di qualcosa che avevo creato in passato.

Post Scriptum - 1

Per riferimento, per questo issue esiste un metodo di evasione molto efficace. Poiché la causa fondamentale della comparsa dell'issue è la mancanza di spazio nella tabella NAT, è sufficiente aumentare la dimensione della tabella NAT. :-D

Mentre qualcuno, incontrando lo stesso issue, avrebbe potuto aumentare la dimensione della tabella NAT e 'fuggire' senza nemmeno lasciare un issue, ammiro e rispetto la passione di gyutaeb che, nonostante non fosse un issue direttamente correlato a lui/lei, ha analizzato e compreso a fondo, contribuendo persino all'ecosistema Cilium con dati oggettivi a supporto.

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

Post Scriptum - 2

Questa storia, in realtà, non è un argomento direttamente pertinente a Gosuda, che si occupa professionalmente del linguaggio Go. Tuttavia, il linguaggio Go e l'ecosistema cloud hanno una stretta relazione e poiché i contributori di Cilium possiedono una certa competenza nel linguaggio Go, ho provato a portare su Gosuda un contenuto che avrei potuto pubblicare sul mio blog personale.

Poiché ho avuto il permesso da uno degli amministratori (me stesso), penso che probabilmente andrà bene.

Se ritenete che non vada bene, dato che non si sa quando potrebbe essere eliminato, vi prego di salvarlo rapidamente come PDF. ;)

Post Scriptum - 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 'implorato' DeepSeek, l'aiuto effettivo l'ho ricevuto da Llama 4. Llama 4 è ottimo. Provatelo assolutamente.