GoSuda

Cilium : une amélioration remarquable de la stabilité réseau obtenue grâce à une petite modification du code

By iwanhae views ...

Introduction

Il y a peu de temps, j'ai jeté un œil à la PR d'un ancien collègue sur le projet Cilium.

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

(À l'exclusion du code de test, la modification elle-même est modeste, se limitant à l'ajout d'un bloc if. Toutefois, l'impact de cette modification est considérable, et le fait qu'une idée simple puisse contribuer de manière très importante à la stabilité d'un système m'a personnellement semblé intéressant. C'est pourquoi je tente de raconter cette histoire de manière à ce que même les personnes sans expertise particulière en réseaux puissent facilement comprendre ce cas.)

Connaissances de base

Si l'on devait citer un objet aussi indispensable aujourd'hui que le smartphone, il s'agirait sans doute du routeur Wi-Fi. Le routeur Wi-Fi communique avec les appareils grâce à la norme de communication Wi-Fi et assure le rôle de partager l'adresse IP publique qu'il possède afin qu'elle puisse être utilisée par plusieurs appareils. La question technique singulière qui se pose ici est : comment ce « partage » est-il effectué ?

La technologie utilisée à cet effet est la Network Address Translation (NAT). Le NAT est une technique qui, sachant que les communications TCP ou UDP reposent sur une combinaison d'adresse IP et de numéro de port, permet de mettre en correspondance les communications internes, réalisées sous la forme IP privée:Port, avec un IP publique:Port qui n'est pas actuellement utilisé, afin de pouvoir communiquer avec l'extérieur.

NAT

Lorsqu'un appareil interne tente d'accéder à Internet, l'équipement NAT convertit la combinaison de l'adresse IP privée et du numéro de port de cet appareil en son adresse IP publique et en un numéro de port arbitraire non utilisé. Cette information de conversion est enregistrée dans une table appelée table NAT, à l'intérieur de l'équipement NAT.

Par exemple, supposons qu'un smartphone à domicile (IP privée : a.a.a.a, port : 50000) tente d'accéder à un serveur Web (IP publique : c.c.c.c, port : 80).

1Smartphone (a.a.a.a:50000) ==> Routeur (b.b.b.b) ==> Serveur Web (c.c.c.c:80)

Lorsqu'il reçoit la requête du smartphone, le routeur verra un paquet TCP tel que celui-ci.

1# Paquet TCP reçu par le routeur, smartphone => routeur
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Si ce paquet était envoyé tel quel au serveur Web (c.c.c.c), la réponse ne reviendrait pas vers le smartphone possédant l'adresse IP privée (a.a.a.a). Le routeur identifie donc d'abord un port arbitraire qui n'est pas impliqué dans la communication actuelle (par exemple 60000) et l'enregistre dans sa table NAT interne.

1# Table NAT interne du routeur
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Après avoir enregistré la nouvelle entrée dans la table NAT, le routeur modifie l'adresse IP source et le numéro de port source du paquet TCP reçu du smartphone en son adresse IP publique (b.b.b.b) et le port nouvellement attribué (60000), puis le transmet au serveur Web.

1# Paquet TCP envoyé par le routeur, routeur => serveur Web
2# Exécution du SNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Le serveur Web (c.c.c.c) reconnaît désormais la requête comme provenant du port 60000 du routeur (b.b.b.b) et renvoie le paquet de réponse au routeur comme suit.

1# Paquet TCP reçu par le routeur, serveur Web => routeur
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Lorsqu'il reçoit ce paquet de réponse, le routeur recherche dans la table NAT, à partir de l'adresse IP de destination (b.b.b.b) et du numéro de port (60000), l'adresse IP privée d'origine (a.a.a.a) et le port d'origine (50000), puis modifie la destination du paquet pour le smartphone.

1# Paquet TCP envoyé par le routeur, routeur => smartphone
2# Exécution du DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Grâce à ce processus, le smartphone a l'impression de communiquer directement avec le serveur Web en possédant lui-même une adresse IP publique. Grâce au NAT, plusieurs appareils internes peuvent utiliser Internet simultanément avec une seule adresse IP publique.

Kubernetes

Kubernetes possède une structure réseau parmi les plus sophistiquées et complexes des technologies récentes. Bien entendu, le NAT évoqué précédemment est également utilisé dans de nombreux domaines. Les deux cas représentatifs sont les suivants.

Lorsqu'un Pod communique avec l'extérieur du cluster

Les pods à l'intérieur d'un cluster Kubernetes se voient généralement attribuer des adresses IP privées, utilisables uniquement au sein du réseau du cluster. Par conséquent, pour qu'un pod communique avec Internet, un NAT est nécessaire pour le trafic sortant du cluster. Dans ce cas, le NAT est principalement effectué au niveau du nœud Kubernetes (chacun des serveurs du cluster) sur lequel le pod s'exécute. Lorsqu'un pod envoie un paquet à destination de l'extérieur, ce paquet est d'abord transmis au nœud auquel appartient le pod. Le nœud remplace l'adresse IP source du paquet (l'IP privée du pod) par sa propre adresse IP publique, modifie également le port source de manière appropriée, puis transmet le paquet à l'extérieur. Ce processus est similaire à celui du NAT décrit précédemment pour le routeur Wi-Fi.

Par exemple, supposons qu'un pod (10.0.1.10, port : 40000) à l'intérieur d'un cluster Kubernetes se connecte à un serveur API externe (203.0.113.45, port : 443). Le nœud Kubernetes reçoit alors du pod le paquet suivant.

1# Paquet TCP reçu par le nœud, pod => nœud
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Le nœud enregistre les informations suivantes.

1# Table NAT interne du nœud (exemple)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Il effectue ensuite un SNAT puis envoie le paquet à l'extérieur.

1# Paquet TCP envoyé par le nœud, nœud => serveur API
2# Exécution du SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Ensuite, le processus suit les mêmes étapes que dans le cas du routeur Wi-Fi décrit précédemment.

Lorsqu'un Pod communique via NodePort depuis l'extérieur du cluster

Une des méthodes permettant d'exposer un service vers l'extérieur dans Kubernetes consiste à utiliser un service NodePort. Un service NodePort ouvre un port spécifique (NodePort) sur tous les nœuds du cluster et transmet le trafic entrant par ce port aux pods appartenant au service. Les utilisateurs externes peuvent accéder au service via l'adresse IP d'un nœud du cluster et le NodePort.

À ce stade, le NAT joue un rôle important, et en particulier, DNAT (Destination NAT) et SNAT (Source NAT) se produisent simultanément. Lorsque du trafic extérieur arrive au NodePort d'un nœud particulier, le réseau Kubernetes doit finalement transmettre ce trafic au pod qui fournit le service. Au cours de ce processus, un DNAT se produit en premier, modifiant l'adresse IP et le numéro de port de destination du paquet en ceux du pod.

Par exemple, supposons qu'un utilisateur externe au cluster (203.0.113.10, port : 30000) accède au service via le NodePort (30001) d'un nœud du cluster Kubernetes (192.168.1.5). Supposons que ce service pointe, en interne, vers un pod dont l'adresse IP est 10.0.2.15 et le port 8080.

1Utilisateur externe (203.0.113.10:30000) ==> Nœud Kubernetes (externe:192.168.1.5:30001 / interne: 10.0.1.1:42132) ==> Pod Kubernetes (10.0.2.15:8080)

Ici, le nœud Kubernetes possède à la fois l'adresse IP 192.168.1.5, accessible depuis l'extérieur, et l'adresse IP 10.0.1.1, valide au sein du réseau interne de Kubernetes. (Selon le type de CNI utilisé, les politiques relatives à ceci peuvent varier, mais dans cet article, les explications sont fondées sur Cilium.)

Lorsque la requête de l'utilisateur externe arrive au nœud, celui-ci doit la transmettre au pod chargé de la traiter. Le nœud applique alors la règle DNAT suivante pour modifier l'adresse IP et le numéro de port de destination du paquet.

1# Paquet TCP que le nœud s'apprête à envoyer au pod
2# Après application du DNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |

Il est important de noter que, lorsque le pod envoie la réponse à cette requête, son adresse IP source est sa propre adresse (10.0.2.15) et son adresse IP de destination est celle de l'utilisateur externe ayant envoyé la requête (203.0.113.10). Dans ce cas, l'utilisateur externe reçoit une réponse provenant d'une adresse IP qu'il n'a jamais sollicitée et qui n'existe pas à ses yeux, et il rejette simplement ce paquet. C'est pourquoi, lorsque le pod envoie son paquet de réponse vers l'extérieur, le nœud Kubernetes effectue en plus un SNAT pour remplacer l'adresse IP source du paquet par l'adresse IP du nœud (192.168.1.5 ou l'adresse IP du réseau interne 10.0.1.1 ; dans ce cas, on utilise 10.0.1.1).

1# Paquet TCP que le nœud s'apprête à envoyer au pod
2# Après application du DNAT et du SNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

Le pod ayant reçu ce paquet répond alors au nœud qui a initialement reçu la requête NodePort, et le nœud applique à l'inverse les mêmes processus de DNAT et SNAT pour renvoyer les informations à l'utilisateur externe. Au cours de ce processus, chaque nœud enregistre les informations suivantes.

1# Table DNAT interne du nœud
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Table SNAT interne du nœud
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Développement

Dans Linux, ces processus NAT sont généralement gérés et exécutés par le sous-système conntrack, via iptables. En réalité, des projets CNI tels que flannel ou calico exploitent ce mécanisme pour résoudre des problèmes similaires à ceux décrits ci-dessus. Le problème est toutefois que Cilium, en utilisant la technologie eBPF, ignore entièrement cette pile réseau Linux traditionnelle. 🤣

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

En conséquence, parmi les fonctions que la pile réseau Linux existante assurait, Cilium a choisi de n'implémenter directement que celles nécessaires dans le contexte de Kubernetes. Ainsi, pour le SNAT évoqué précédemment, Cilium gère directement sa table SNAT sous la forme d'une LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Table SNAT de Cilium
2# !Exemple pour faciliter la compréhension. La définition réelle se trouve ici : 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 et autres métadonnées
4----------------------------------------------
5|            |          |         |          |

Comme il s'agit d'une table de hachage, une clé est utilisée pour permettre des recherches rapides : la combinaison de src ip, src port, dst ip et dst port est utilisée comme clé.

Identification du problème

Phénomène 1 : recherche

Un problème survient alors : pour déterminer si un paquet traité par eBPF doit subir un SNAT ou un DNAT, il faut interroger cette table de hachage. Or, comme nous l'avons vu, il existe deux types de paquets concernés par le SNAT : 1. les paquets sortant de l'intérieur vers l'extérieur, et 2. les paquets de réponse qui entrent de l'extérieur vers l'intérieur. Ces deux types de paquets nécessitent une conversion NAT, mais leurs valeurs src ip, port et dst ip, port sont inversées l'une par rapport à l'autre.

Ainsi, pour une recherche rapide, il faudrait soit ajouter une entrée supplémentaire dans la table de hachage avec la clé obtenue en inversant src et dst, soit interroger deux fois la même table pour chaque paquet, y compris ceux qui ne sont pas concernés par le SNAT. Naturellement, afin d'obtenir de meilleures performances, Cilium a adopté une approche consistant à insérer deux fois les mêmes données, sous le nom RevSNAT.

Phénomène 2 : LRU

Par ailleurs, indépendamment du problème précédent, aucun matériel ne dispose de ressources infinies. En particulier, pour une logique au niveau matériel où la rapidité est essentielle, il n'est pas possible d'utiliser des structures de données dynamiques ; lorsque les ressources viennent à manquer, il est donc nécessaire d'évincer des données existantes. Cilium résout ce problème en utilisant une LRU Hash Map, structure de données de base fournie nativement par Linux.

Phénomène 1 + Phénomène 2 = perte de connexion

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

Ainsi, pour une connexion TCP (ou UDP) soumise à un SNAT :

  1. les données d'un même enregistrement sont inscrites deux fois dans une même table de hachage, pour les paquets sortants comme pour les paquets entrants, et
  2. selon la logique LRU, l'une de ces deux données peut être perdue à tout moment,

si l'une des entrées de NAT (ci-après « entry ») concernant les paquets sortants ou entrants est supprimée par le LRU, la conversion NAT ne peut plus être effectuée correctement, ce qui peut entraîner la perte complète de la connexion.

Solution

C'est ici qu'interviennent les PR mentionnées précédemment :

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

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

Auparavant, lorsqu'un paquet traversait eBPF, une clé était construite à partir de la combinaison src ip, src port, dst ip et dst port, puis la table SNAT était interrogée. Si la clé n'existait pas, une nouvelle information NAT était créée selon les règles SNAT et enregistrée dans la table. Pour une nouvelle connexion, il s'agit d'une communication normale. En revanche, si la clé avait été supprimée involontairement par le LRU, la conversion s'effectuait avec un port différent de celui utilisé précédemment dans la communication en cours ; le récepteur rejetait alors le paquet, et la connexion se terminait avec un paquet RST.

Ici, l'approche adoptée par cette PR est simple :

Dès qu'un paquet est observé dans une direction, mettons également à jour l'entry de la direction inverse.

Dès qu'une communication est observée dans une direction, les deux entries sont mises à jour, ce qui les éloigne de la priorité d'éviction du LRU. Cela réduit la probabilité qu'une seule entry soit supprimée et que l'ensemble de la communication soit compromis.

Bien que cette approche soit très simple et puisse sembler n'être qu'une idée anodine, elle permet de résoudre efficacement le problème où l'entry de NAT des paquets de réponse expire en premier, provoquant la rupture de la connexion, et d'améliorer considérablement la stabilité du système. Il s'agit en outre d'une amélioration importante qui a produit les résultats suivants en matière de stabilité réseau.

benchmark

Conclusion

Je considère que cette PR constitue un très bon exemple montrant, à partir de connaissances informatiques fondamentales sur le fonctionnement du NAT, combien une idée simple peut entraîner un changement important, même au sein d'un système complexe.

Certes, je n'ai pas présenté directement dans cet article d'exemple de système complexe. Toutefois, pour bien comprendre cette PR, j'ai dû supplier DeepSeek V3 0324 pendant près de trois heures, en ajoutant même le mot « Please », ce qui m'a permis d'acquérir une connaissance de Cilium (+1) ainsi que le schéma suivant. 😇

diagram

Et en lisant les issues et les PR, j'ai ressenti un mauvais pressentiment selon lequel quelque chose que j'avais créé auparavant aurait pu être à l'origine de ces problèmes ; c'est en quelque sorte une compensation psychologique qui m'a poussé à rédiger cet article.

Post-scriptum 1

À titre indicatif, ce problème dispose d'un contournement très efficace : la cause fondamentale du problème étant le manque d'espace dans la table NAT, il suffit d'augmenter sa taille. :-D

Quelqu'un qui rencontrerait le même problème aurait pu se contenter d'augmenter la taille de la table NAT sans même signaler l'issue. Pourtant, gyutaeb, bien que ce problème ne le concerne pas directement, l'a analysé et compris minutieusement, puis a contribué à l'écosystème Cilium en fournissant des données factuelles objectives. J'admire et respecte son engagement.

C'est ce qui m'a décidé à rédiger cet article.

Post-scriptum 2

Ce récit ne correspond pas réellement à un sujet directement lié à Gosuda, qui se consacre spécifiquement au langage Go. Cependant, le langage Go et l'écosystème cloud sont étroitement liés, et les contributeurs de Cilium maîtrisent dans une certaine mesure le langage Go ; j'ai donc décidé d'apporter à Gosuda un contenu qui pourrait figurer sur mon blog personnel.

Un des administrateurs (moi-même) a donné son accord, je pense donc que cela devrait être acceptable.

Si vous estimez que ce n'est pas acceptable, sachez que cet article pourrait être supprimé à tout moment ; enregistrez-le vite au format PDF. ;)

Post-scriptum 3

Pour la rédaction de cet article, j'ai grandement bénéficié de l'aide de Cline et de Llama 4 Maverick. Bien que j'aie commencé l'analyse avec Gemini et supplié DeepSeek, c'est en réalité Llama 4 qui m'a véritablement aidé. Llama 4 est excellent, essayez-le absolument.