GoSuda

L'histoire de Cilium : des modifications de code mineures ont conduit à des améliorations remarquables de la stabilité du réseau

By iwanhae
views ...

Introduction

J'ai récemment eu l'occasion d'examiner une Pull Request sur le projet Cilium, soumise par un ancien collègue.

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

(Hormis le code de test) la quantité de modifications est minime, se limitant à l'ajout d'un bloc d'instruction if. Cependant, l'impact de cette modification est considérable, et j'ai personnellement trouvé fascinant qu'une idée aussi simple puisse apporter une contribution aussi significative à la stabilité du système. Je souhaite donc partager cette histoire de manière accessible, même pour les personnes n'ayant pas d'expertise en réseau.

Connaissances de base

Si les smartphones sont devenus indispensables à la vie moderne, le routeur Wi-Fi l'est tout autant. Un routeur Wi-Fi communique avec les appareils via le protocole Wi-Fi et partage son adresse IP publique avec plusieurs appareils. La particularité technique qui en découle est la manière dont ce "partage" est effectué.

La technologie utilisée à cette fin est le Network Address Translation (NAT). Le NAT permet de réaliser des communications externes en mappant les communications internes, composées d'une IP privée:Port, à une IP publique:Port actuellement inutilisée, étant donné que les communications TCP ou UDP sont basées sur 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 d'adresse IP privée et de numéro de port de cet appareil en son 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) dans 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 identifie d'abord un port aléatoire (par exemple : 60000) qui n'est pas impliqué dans la communication actuelle 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é 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 par sa propre 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# 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       |

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 d'origine (a.a.a.a) et le numéro de port (50000) correspondant à l'adresse IP de destination (b.b.b.b) et au numéro de port (60000), 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, comme s'il possédait sa propre adresse IP publique. Le NAT permet ainsi à plusieurs appareils internes d'utiliser Internet simultanément avec une seule adresse IP publique.

Kubernetes

Kubernetes possède l'une des architectures réseau les plus sophistiquées et complexes parmi les technologies récentes. Et bien sûr, le NAT, mentionné précédemment, est utilisé 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 peuvent communiquer qu'au sein du réseau du cluster. Par conséquent, si un Pod doit communiquer avec l'Internet externe, un NAT est nécessaire pour le trafic sortant du cluster. Dans ce cas, le NAT est principalement effectué sur le nœud Kubernetes (chaque serveur du cluster) où le Pod est en cours d'exécution. 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) par sa propre adresse IP publique, adapte également le port source, puis le transmet vers l'extérieur. Ce processus est similaire au processus 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 se connecte à 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 enregistrera 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       |

Il effectuera ensuite le SNAT comme suit et enverra 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      |

Le processus ultérieur sera le même que dans l'exemple du routeur de smartphone.

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

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 achemine le trafic entrant sur ce port vers les Pods associés 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, le NAT joue un rôle crucial, et plus particulièrement, le DNAT (Destination NAT) et le SNAT (Source NAT) se produisent simultanément. Lorsqu'un trafic arrive d'un utilisateur externe sur le NodePort d'un nœud spécifique, le réseau Kubernetes doit finalement acheminer ce trafic vers le Pod qui fournit le service. Au cours de ce processus, un DNAT se produit d'abord pour modifier l'adresse IP de destination et le numéro de port du paquet par l'adresse IP et le numéro de port du Pod.

Par exemple, imaginons 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. (Bien que les politiques associées varient selon le type de CNI utilisé, cet article se base sur Cilium.)

Lorsqu'une requête d'un utilisateur externe arrive au nœud, celui-ci doit la transmettre au Pod qui la traitera. À ce stade, le nœud applique les règles DNAT suivantes pour modifier l'adresse IP de destination et le numéro de port 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     |

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 qui a envoyé la requête (203.0.113.10). Dans ce cas, l'utilisateur externe recevrait une réponse d'une adresse IP inexistante qu'il n'a jamais sollicitée et laisserait tomber ce paquet. C'est pourquoi le nœud Kubernetes effectue un SNAT supplémentaire lorsque le Pod envoie un paquet de réponse vers l'extérieur, modifiant l'adresse IP source du paquet par 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 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 qui reçoit ce paquet répondra alors au nœud qui a initialement reçu la requête via le NodePort, et le nœud inversera les processus DNAT et SNAT identiques 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 exécutés par le sous-système conntrack via iptables. En effet, d'autres projets CNI comme flannel ou calico utilisent cette approche pour résoudre les problèmes susmentionnés. Cependant, le problème est que Cilium, en utilisant la technologie eBPF, 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 uniquement les fonctionnalités nécessaires dans un contexte Kubernetes parmi les tâches que la pile réseau Linux existante effectuait, comme illustré dans le diagramme ci-dessus. C'est pourquoi, 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 simplifiée. 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 en tant que table de hachage, pour une recherche rapide, il existe une clé qui utilise la combinaison de src ip, src port, dst ip, dst port 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 nécessite un processus SNAT ou DNAT pour le NAT, il faut effectuer une recherche dans la table de hachage ci-dessus. Comme nous l'avons vu précédemment, 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 conversion NAT et ont la particularité que les valeurs src ip, port et dst ip, port sont inversées.

Par conséquent, pour une recherche rapide, il faudrait soit ajouter une entrée supplémentaire à la table de hachage avec les valeurs src et dst inversées comme clé, soit effectuer deux recherches dans la même table de hachage pour tous les paquets, même ceux non liés au SNAT. Naturellement, Cilium a adopté la méthode d'insertion des mêmes données deux fois sous le nom de RevSNAT pour de meilleures performances.

Phénomène - 2 : LRU

En dehors du problème précédent, aucune ressource matérielle n'est infinie, et d'autant plus dans une logique matérielle qui exige des performances rapides, il est impossible d'utiliser des structures de données dynamiques. Il est donc nécessaire d'évincer les données existantes lorsque les ressources sont insuffisantes. Cilium a résolu ce problème en utilisant une LRU Hash Map, une structure de données de base fournie par Linux.

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

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

En d'autres termes, pour une connexion TCP (ou UDP) SNATée :

  1. Deux entrées identiques sont enregistrées dans une seule table de hachage pour les paquets sortants et entrants.
  2. En raison de la logique LRU, l'une des deux entrées peut être perdue à tout moment.

Si l'une des entrées NAT (ci-après "entry") pour les paquets sortants ou entrants est supprimée par LRU, la traduction d'adresses réseau ne peut pas être effectuée correctement, ce qui peut entraîner la perte de l'ensemble de la connexion.

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, de nouvelles informations NAT étaient créées et enregistrées dans la table selon les règles SNAT. Si c'était une nouvelle connexion, cela aboutirait à une communication normale. Cependant, si la clé avait été supprimée involontairement par LRU, un nouveau NAT serait effectué avec un port différent de celui utilisé pour la communication existante, ce qui entraînerait le rejet de la réception par le destinataire du paquet et la fin de la connexion avec un paquet RST.

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

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

Lorsque la 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 l'effondrement de l'ensemble de la communication.

Bien que cette approche puisse sembler simple et être perçue comme une idée élémentaire, elle a permis de résoudre efficacement le problème de la perte de connexion due à l'expiration précoce des informations NAT pour les paquets de réponse, améliorant ainsi considérablement la stabilité du système. Il s'agit également d'une amélioration significative qui a permis d'obtenir les résultats suivants en termes de stabilité du réseau.

benchmark

Conclusion

Je considère cette PR comme un excellent exemple qui, partant de connaissances fondamentales en informatique sur le fonctionnement du NAT, démontre comment une idée simple peut apporter un changement considérable, même au sein de systèmes complexes.

Bien sûr, je n'ai pas montré directement 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 obtenu le diagramme suivant. 😇

diagram

Et en lisant les problèmes et les PR, j'ai ressenti un pressentiment inquiétant que des problèmes auraient pu survenir à cause de quelque chose que j'avais créé auparavant, alors j'écris cet article par compensation.

Postface - 1

À titre informatif, il existe une méthode très efficace pour éviter ce problème. La cause profonde 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 quelqu'un d'autre, confronté au même problème, aurait pu augmenter la taille de la table NAT et s'enfuir sans laisser de trace, j'ai été impressionné et j'admire la passion de gyutaeb qui, bien que n'étant pas directement lié au problème, l'a analysé et compris en profondeur, et a contribué à l'écosystème Cilium avec des données objectives.

C'est ce qui m'a motivé à écrire cet article.

Postface - 2

Cette histoire, bien que n'étant pas directement liée à Gosuda, qui se spécialise dans le langage Go, a été publiée sur Gosuda car Go et l'écosystème cloud sont étroitement liés, et les contributeurs de Cilium ont une certaine maîtrise de Go.

Étant donné que l'un des administrateurs (moi-même) a donné son accord, je suppose que tout ira bien.

Si vous pensez que ce n'est pas le cas, vous devriez rapidement l'enregistrer au format PDF, car on ne sait jamais quand il pourrait être supprimé. ;)

Postface - 3

Cet article a largement 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 Llama 4 qui m'a finalement aidé. Llama 4 est excellent. Je vous le recommande vivement.