GoSuda

L'histoire de Cilium : Des améliorations remarquables de la stabilité du réseau grâce à une modification de code minime

By iwanhae
views ...

Introduction

Récemment, j'ai eu l'occasion d'observer une PR sur le projet Cilium d'un ancien collègue.

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

La quantité de modifications (à l'exception du code de test) est minime, se limitant à l'ajout d'un seul bloc d'instruction if. Cependant, l'impact de cette modification est considérable, et j'ai personnellement trouvé fascinant qu'une idée simple puisse contribuer de manière significative à la stabilité d'un système. J'aimerais donc raconter cette histoire de manière à ce qu'elle soit facilement compréhensible même pour les personnes n'ayant pas de connaissances spécialisées dans le domaine des réseaux.

Connaissances de base

Si les smartphones sont des objets essentiels pour les citadins modernes, le routeur Wi-Fi l'est tout autant. Le routeur Wi-Fi communique avec les appareils via la norme de communication Wi-Fi et partage son adresse IP publique avec plusieurs appareils. La particularité technique qui se pose ici est de savoir comment ce "partage" est réalisé.

La technologie utilisée ici est la Network Address Translation (NAT). La NAT est une technologie qui permet la communication avec l'extérieur en mappant une communication interne composée d'une IP privée:Port à une IP publique:Port actuellement non utilisée, étant donné que la communication TCP ou UDP est constituée d'une combinaison d'adresses IP et d'informations de port.

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 sa propre adresse IP publique et un numéro de port aléatoire non utilisé. Ces informations de conversion sont enregistrées dans une table NAT au sein de l'équipement NAT.

Par exemple, imaginons qu'un smartphone (IP privée : a.a.a.a, port : 50000) au sein d'une maison tente de se connecter à 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)

Lorsque le routeur reçoit la requête du smartphone, il verra le paquet TCP suivant :

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 au smartphone (a.a.a.a) qui possède une adresse IP privée. Par conséquent, le routeur recherche d'abord un port aléatoire qui n'est pas impliqué dans la communication actuelle (par exemple : 60000) et l'enregistre dans la 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é une nouvelle entrée dans la table NAT, le routeur modifie l'adresse IP source et le numéro de port du paquet TCP reçu du smartphone en son adresse IP publique (b.b.b.b) et le numéro de port nouvellement attribué (60000), puis l'envoie au serveur web.

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

Désormais, le serveur web (c.c.c.c) reconnaît la requête comme provenant du port 60000 du routeur (b.b.b.b) et envoie 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    |

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

1# Paquet TCP envoyé par le routeur, routeur => smartphone
2# DNAT effectué
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 utilisant sa propre adresse IP publique. Grâce à la NAT, plusieurs appareils internes peuvent utiliser Internet simultanément avec une seule adresse IP publique.

Kubernetes

Kubernetes possède l'une des structures de réseau les plus sophistiquées et complexes parmi les technologies récentes. Et bien sûr, la NAT, mentionnée précédemment, est utilisée dans divers contextes. Voici deux exemples typiques :

Lorsque le Pod communique avec l'extérieur du cluster

Les Pods au sein d'un cluster Kubernetes se voient généralement attribuer des adresses IP privées qui ne leur permettent de communiquer qu'au sein du réseau du cluster. Par conséquent, si un Pod doit communiquer avec l'Internet externe, une NAT est nécessaire pour le trafic sortant du cluster. Dans ce cas, la NAT est principalement effectuée sur le nœud Kubernetes (chaque serveur du cluster) où le Pod est exécuté. Lorsqu'un Pod envoie un paquet vers l'extérieur, ce paquet est d'abord transmis au nœud auquel le Pod appartient. Le nœud modifie l'adresse IP source de ce paquet (l'IP privée du Pod) en sa propre adresse IP publique, et modifie également le port source de manière appropriée avant de le transmettre à l'extérieur. Ce processus est similaire au processus de NAT décrit précédemment pour le routeur Wi-Fi.

Par exemple, si un Pod (10.0.1.10, port : 40000) au sein d'un cluster Kubernetes tente de se connecter à un serveur API externe (203.0.113.45, port : 443), le nœud Kubernetes recevra le paquet suivant du Pod :

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

Puis, il effectue une SNAT comme suit et envoie le paquet à l'extérieur.

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

Ensuite, le processus est le même que dans le cas du routeur de smartphone.

Lorsque la communication avec un Pod s'effectue via NodePort depuis l'extérieur du cluster

L'une des méthodes pour exposer un service à l'extérieur dans Kubernetes est d'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 sur ce port aux Pods appartenant au service. Les utilisateurs externes peuvent accéder au service via l'adresse IP du nœud du cluster et le NodePort.

Dans ce cas, la NAT joue un rôle important, et plus particulièrement, le DNAT (Destination NAT) et le SNAT (Source NAT) se produisent simultanément. Lorsqu'un trafic arrive sur le NodePort d'un nœud spécifique depuis l'extérieur, le réseau Kubernetes doit finalement transmettre ce trafic au Pod qui fournit le service. Au cours de ce processus, un DNAT se produit d'abord, modifiant l'adresse IP de destination et le numéro de port du paquet en l'adresse IP et le numéro de port du Pod.

Par exemple, supposons qu'un utilisateur externe (203.0.113.10, port : 30000) accède à un service via le NodePort (30001) d'un nœud (192.168.1.5) du cluster Kubernetes. Ce service pointe en interne vers un Pod avec l'adresse IP 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 dans le réseau Kubernetes interne. (Les politiques associées varient selon le type de CNI utilisé, mais cet article se base sur Cilium.)

Lorsque la requête de l'utilisateur externe arrive au nœud, le nœud doit la transmettre au Pod qui la traitera. À ce stade, le nœud applique la règle DNAT suivante pour modifier l'adresse IP de destination et le numéro de port du paquet.

1# Paquet TCP que le nœud est sur le point d'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     |

Le point crucial ici est que lorsque le Pod envoie une réponse à cette requête, l'adresse IP source est sa propre adresse IP (10.0.2.15) et l'adresse IP de destination est l'adresse IP de l'utilisateur externe (203.0.113.10) qui a envoyé la requête. Dans ce cas, l'utilisateur externe recevrait une réponse d'une adresse IP inexistante qu'il n'a jamais sollicitée et laisserait simplement tomber le paquet. C'est pourquoi le nœud Kubernetes effectue une SNAT supplémentaire lorsque le Pod envoie un paquet de réponse à l'extérieur, modifiant l'adresse IP source du paquet en l'adresse IP du nœud (192.168.1.5 ou l'IP du réseau interne 10.0.1.1, dans ce cas 10.0.1.1).

1# Paquet TCP que le nœud est sur le point d'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     |

Maintenant, le paquet reçu par le Pod est renvoyé au nœud qui a initialement reçu la requête via NodePort, et le nœud applique les mêmes processus DNAT et SNAT en sens inverse pour renvoyer les informations à l'utilisateur externe. Au cours de ce processus, chaque nœud stockera 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            |

Corps du texte

Généralement, sous Linux, ces processus NAT sont gérés et fonctionnent par le sous-système conntrack via iptables. En fait, d'autres projets CNI comme flannel ou calico l'utilisent pour gérer les problèmes mentionnés ci-dessus. Cependant, le problème est que Cilium utilise la technologie eBPF et ignore complètement cette pile réseau Linux traditionnelle. 🤣

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

En conséquence, Cilium a choisi d'implémenter directement les fonctionnalités nécessaires dans un environnement Kubernetes parmi les tâches que la pile réseau Linux existante effectuait, comme le montre le diagramme ci-dessus. Ainsi, pour le processus SNAT mentionné précédemment, Cilium gère directement la table SNAT sous forme de LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Table SNAT de Cilium
2# !Exemple pour une explication simple. La définition réelle est : 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|            |          |         |          |

Et comme il s'agit d'une table de hachage, une clé existe pour une recherche rapide, et la combinaison de src ip, src port, dst ip, dst port est utilisée comme clé.

Identification du problème

Phénomène - 1 : Recherche

Un problème se pose : pour vérifier si un paquet passant par eBPF doit effectuer une opération SNAT ou DNAT dans le processus NAT, il est nécessaire de consulter la table de hachage ci-dessus. Or, comme nous l'avons vu, il existe deux types de paquets dans le processus SNAT : 1. les paquets sortant de l'intérieur vers l'extérieur, et 2. les paquets entrants de l'extérieur vers l'intérieur en réponse. Ces deux types de paquets nécessitent une transformation liée au processus NAT, et se caractérisent par l'inversion des valeurs src ip, port et dst ip, port.

Par conséquent, pour une recherche rapide, il est nécessaire d'ajouter une autre valeur à la table de hachage avec les valeurs src et dst inversées, ou d'effectuer deux recherches dans la même table de hachage pour tous les paquets, même ceux qui ne sont pas liés à SNAT. Naturellement, Cilium a adopté la méthode consistant à insérer les mêmes données deux fois sous le nom de RevSNAT pour de meilleures performances.

Phénomène - 2 : LRU

En plus du problème précédent, il n'existe pas de ressources infinies sur tous les matériels. En particulier, dans le cas d'une logique matérielle nécessitant des performances rapides, où les structures de données dynamiques ne peuvent pas être utilisées, il est nécessaire d'évincer les données existantes lorsque les ressources sont insuffisantes. Cilium a résolu ce problème en utilisant la LRU Hash Map, une structure de données de base fournie par défaut sous Linux.

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

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

C'est-à-dire, pour une connexion TCP (ou UDP) SNATée :

  1. Les mêmes données sont enregistrées deux fois dans une table de hachage pour les paquets sortants et entrants.
  2. Selon la logique LRU, l'une des deux données peut être perdue à tout moment.

Si l'une des informations NAT (ci-après dénommée entrée) pour les paquets sortants ou entrants est supprimée par le LRU, la NAT ne peut pas être effectuée correctement, ce qui peut entraîner une perte de connexion complète.

Résolution

C'est là 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 passait par eBPF, il tentait une recherche dans la table SNAT en créant une clé à partir de la combinaison src ip, src port, dst ip, dst port. Si la clé n'existait pas, une nouvelle information NAT était générée et enregistrée dans la table selon les règles SNAT. Si c'était une nouvelle connexion, cela entraînerait une communication normale. Cependant, si la clé était involontairement supprimée par le LRU, une nouvelle NAT serait effectuée avec un port différent de celui utilisé pour la communication existante, le récepteur refuserait de recevoir le paquet et la connexion serait terminée avec un paquet RST.

L'approche adoptée par cette PR est simple :

Si un paquet est observé dans une direction quelconque, mettons à jour l'entrée correspondante dans la direction inverse.

Lorsqu'une communication est observée dans une direction quelconque, les deux entrées sont mises à jour, ce qui les éloigne de la priorité d'éviction de la logique LRU. Cela réduit la probabilité qu'une seule entrée soit supprimée, entraînant un effondrement de la communication globale.

Il s'agit d'une approche très simple et cela peut sembler une idée banale, mais cette approche a permis de résoudre efficacement le problème de la rupture de connexion due à l'expiration prématurée des informations NAT pour les paquets de réponse, améliorant considérablement la stabilité du système. On peut également dire qu'il s'agit d'une amélioration importante qui a permis d'atteindre les résultats suivants en termes de stabilité du réseau.

benchmark

Conclusion

Je pense que cette PR est un excellent exemple qui, partant des connaissances fondamentales en informatique sur le fonctionnement de la NAT, montre à quel point une idée simple peut apporter un grand changement même au sein de systèmes complexes.

Ah, bien sûr, je n'ai pas directement présenté d'exemples de systèmes complexes dans cet article. Cependant, pour bien comprendre cette PR, j'ai supplié DeepSeek V3 0324 pendant près de 3 heures, en ajoutant même le mot Please, et en conséquence, j'ai acquis des connaissances supplémentaires sur Cilium et j'ai pu obtenir le diagramme suivant. 😇

diagram

Et en lisant les problèmes et les PR, j'écris cet article comme une forme de compensation pour les sombres pressentiments que j'ai eus concernant des problèmes qui auraient pu être causés par quelque chose que j'avais créé auparavant.

Postface - 1

À titre informatif, il existe une méthode très efficace pour éviter ce problème. La cause fondamentale du problème étant le manque d'espace dans la table NAT, il suffit d'augmenter la taille de la table NAT. :-D

Alors que certains auraient simplement augmenté la taille de la table NAT et se seraient enfuis sans laisser de problème, je suis impressionné par la passion et le respect que je porte à gyutaeb qui, bien que n'étant pas directement impliqué dans le problème, l'a analysé et compris en profondeur, et a contribué à l'écosystème Cilium avec des données objectives.

C'est la raison pour laquelle j'ai décidé d'écrire cet article.

Postface - 2

Cette histoire n'est en fait pas directement liée à Gosuda, qui se spécialise dans le langage Go. Cependant, le langage Go et l'écosystème cloud sont étroitement liés, et les contributeurs de Cilium ont une certaine connaissance du langage Go, j'ai donc décidé d'apporter un contenu qui pourrait être publié sur un blog personnel à Gosuda.

Je pense que c'est probablement acceptable puisque j'ai eu la permission de l'un des administrateurs (moi-même).

Si vous pensez que ce n'est pas acceptable, je vous conseille de le sauvegarder rapidement au format PDF, car il pourrait être supprimé à tout moment. ;)

Postface - 3

Pour la rédaction de cet article, j'ai grandement bénéficié de l'aide de Cline et Llama 4 Maveric. Bien que j'aie commencé l'analyse avec Gemini et supplié DeepSeek, c'est en fait Llama 4 qui m'a le plus aidé. Llama 4 est excellent. N'hésitez pas à l'essayer.