GoSuda

Cilium-historien: Den bemærkelsesværdige forbedring af netværksstabilitet, som en lille kodeændring frembragte

By iwanhae views ...

Introduktion

For nylig kiggede jeg på en PR fra en tidligere kollega til Cilium-projektet.

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

Selve ændringen (uden testkoden) er lille og består af at tilføje et enkelt if-statement-blok. Men den indflydelse, denne ændring har haft, er enorm, og det forekommer mig personligt interessant, at en enkel idé kan bidrage så kraftigt til systemstabilitet. Derfor vil jeg forsøge at fortælle historien, så den også kan forstås af personer uden specialiseret viden om netværk.

Baggrundsviden

Hvis der er en nutidig nødvendighed, der er lige så vigtig som smartphonen, er det nok en Wi-Fi-router. En Wi-Fi-router kommunikerer med enheder via Wi-Fi-standarden og har til opgave at dele sin offentlige IP-adresse, så den kan bruges af flere enheder. Det tekniske spørgsmål, der opstår her, er: Hvordan "deles" den?

Teknologien, der bruges her, er Network Address Translation (NAT). NAT er en teknologi, der udnytter, at TCP- og UDP-kommunikation består af kombinationen af en IP-adresse og en port. Den mapper intern kommunikation i formen privat IP:Port til en offentlig IP:Port, som ikke er i brug, så der kan kommunikeres med omverdenen.

NAT

Når en intern enhed forsøger at oprette forbindelse til internettet, omsætter en NAT-enhed kombinationen af enhedens private IP-adresse og portnummer til sin egen offentlige IP-adresse og et ubenyttet, tilfældigt portnummer. Denne omsætningsinformation registreres i en tabel inde i NAT-enheden, kaldet NAT-tabellen.

Lad os for eksempel antage, at en smartphone i hjemmet (privat IP: a.a.a.a, port: 50000) forsøger at oprette forbindelse til en webserver (offentlig IP: c.c.c.c, port: 80).

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

Når routeren modtager anmodningen fra smartphonen, vil den se følgende TCP-pakke.

1# 공유기가 받은 TCP 패킷, 스마트폰 => 공유기
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |
1# TCP-pakke modtaget af routeren, smartphone => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Hvis denne pakke blev sendt videre til webserveren (c.c.c.c) uændret, ville svaret aldrig nå tilbage til smartphonen med den private IP-adresse (a.a.a.a). Derfor finder routeren først en tilfældig port, der ikke er involveret i den aktuelle kommunikation (fx 60000), og registrerer den i sin interne NAT-tabel.

1# 공유기 내부 NAT 테이블
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |
1# Routerens interne NAT-tabel
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Efter at have registreret den nye post i NAT-tabellen, ændrer routeren kildens IP-adresse og portnummer i TCP-pakken fra smartphonen til sin egen offentlige IP-adresse (b.b.b.b) og den nytildelte portnummer (60000), og sender den derefter til webserveren.

1# 공유기가 보낸 TCP 패킷, 공유기 => 웹 서버
2# SNAT 수행
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |
1# TCP-pakke sendt af routeren, router => webserver
2# SNAT udført
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Webserveren (c.c.c.c) genkender nu anmodningen som kommende fra port 60000 på routeren (b.b.b.b), og sender svarpakken tilbage til routeren som følger.

1# 공유기가 받은 TCP 패킷, 웹 서버 => 공유기
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |
1# TCP-pakke modtaget af routeren, webserver => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Når routeren modtager denne svarpakke, slår den op i NAT-tabellen efter den oprindelige private IP-adresse (a.a.a.a) og portnummer (50000), der svarer til destinations-IP-adressen (b.b.b.b) og portnummeret (60000), og ændrer pakkens destination til smartphonen.

1# 공유기가 보낸 TCP 패킷, 공유기 => 스마트폰
2# DNAT 수행
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |
1# TCP-pakke sendt af routeren, router => smartphone
2# DNAT udført
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Gennem denne proces opleves det for smartphonen, som om den selv har en offentlig IP-adresse og kommunikerer direkte med webserveren. Takket være NAT kan flere interne enheder bruge internettet samtidigt med én offentlig IP-adresse.

Kubernetes

Kubernetes har en af de mest sofistikerede og komplekse netværksarkitekturer blandt de nyere teknologier. Og selvfølgelig bruges den tidligere omtalte NAT også mange steder. De mest typiske eksempler er følgende to.

Når en Pod kommunikerer med det eksterne miljø uden for klyngen

Pods i en Kubernetes-klynge tildeles generelt private IP-adresser, som kun kan kommunikere inden for klyngens netværk. Derfor kræver det NAT for trafik, der går ud af klyngen, hvis en Pod skal kommunikere med internettet. NAT udføres typisk på den Kubernetes-node (hver server i klyngen), hvor Pod'en kører. Når en Pod sender en pakke udad, leveres pakken først til den node, Pod'en tilhører. Noden ændrer pakkens kilde-IP-adresse (Pod'ens private IP) til sin egen offentlige IP-adresse, ændrer også kildeportnummeret passende, og videresender pakken udad. Denne proces ligner den NAT-proces, der tidligere blev beskrevet for Wi-Fi-routeren.

Lad os for eksempel antage, at en Pod i Kubernetes-klyngen (10.0.1.10, port: 40000) forbinder til en ekstern API-server (203.0.113.45, port: 443). Så modtager Kubernetes-noden følgende pakke fra Pod'en.

1# 노드가 받은 TCP 패킷, 파드 => 노드
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |
1# TCP-pakke modtaget af noden, pod => node
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Noden registrerer følgende information

1# 노드 내부 NAT 테이블 (예시)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |
1# Nodens interne NAT-tabel (eksempel)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

og udfører derefter SNAT, før den sender pakken udad.

1# 노드가 보낸 TCP 패킷, 노드 => API 서버
2# SNAT 수행
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |
1# TCP-pakke sendt af noden, node => API-server
2# SNAT udført
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Herefter forløber processen på samme måde som i eksemplet med smartphonen og Wi-Fi-routeren.

Når en ekstern part kommunikerer med en Pod via NodePort

En måde at eksponere en tjeneste udadtil i Kubernetes på er at bruge en NodePort-tjeneste. En NodePort-tjeneste åbner en bestemt port (NodePort) på alle noder i klyngen og videresender trafik, der kommer ind på denne port, til de Pods, der tilhører tjenesten. Eksterne brugere kan få adgang til tjenesten via klyngens nodes IP-adresse og NodePort.

NAT spiller en vigtig rolle her, og især sker DNAT (Destination NAT) og SNAT (Source NAT) samtidigt. Når trafik udefra kommer ind på en bestemt nodes NodePort, skal Kubernetes-netværket til sidst videresende trafikken til den Pod, der leverer tjenesten. I denne proces sker DNAT først, så pakkens destinations-IP-adresse og portnummer ændres til Pod'ens IP-adresse og portnummer.

Lad os for eksempel antage, at en ekstern bruger (203.0.113.10, port: 30000) får adgang til tjenesten via NodePort (30001) på en node i Kubernetes-klyngen (192.168.1.5). Tjenesten peger internt på en Pod med IP-adressen 10.0.2.15 og porten 8080.

1외부 사용자 (203.0.113.10:30000) ==> 쿠버네티스 노드 (외부:192.168.1.5:30001 / 내부: 10.0.1.1:42132) ==> 쿠버네티스 파드 (10.0.2.15:8080)
1Ekstern bruger (203.0.113.10:30000) ==> Kubernetes-node (ekstern: 192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes-pod (10.0.2.15:8080)

I dette tilfælde har Kubernetes-noden både en IP-adresse, der kan tilgås udefra (192.168.1.5), og en IP-adresse, der er gyldig i det interne Kubernetes-netværk (10.0.1.1). (Afhængigt af hvilken CNI der bruges, varierer de relevante politikker, men i denne artikel forklares det med udgangspunkt i Cilium.)

Når den eksterne brugers anmodning ankommer til noden, skal noden videresende den til den Pod, der skal behandle den. Noden anvender da følgende DNAT-regel til at ændre pakkens destinations-IP-adresse og portnummer.

1# 노드가 파드로 보낼려고 준비중인 TCP 패킷
2# DNAT 적용 후
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |
1# TCP-pakke som noden er ved at sende til pod'en
2# Efter DNAT er anvendt
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |

Det vigtige her er, at når Pod'en sender svar på denne anmodning, bliver kilde-IP-adressen dens egen IP-adresse (10.0.2.15), og destinations-IP-adressen bliver den eksterne brugers IP-adresse (203.0.113.10). I så fald modtager den eksterne bruger et svar fra en ukendt IP-adresse, som den aldrig har anmodet om, og dropper simpelthen pakken. Derfor udfører Kubernetes-noden yderligere SNAT, når Pod'en sender svarpakken udad, og ændrer pakkens kilde-IP-adresse til nodens IP-adresse (enten 192.168.1.5 eller det interne netværks IP 10.0.1.1; i dette tilfælde bruges 10.0.1.1).

1# 노드가 파드로 보낼려고 준비중인 TCP 패킷
2# DNAT, SNAT 적용 후
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |
1# TCP-pakke som noden er ved at sende til pod'en
2# Efter DNAT og SNAT er anvendt
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

Pod'en, der modtager denne pakke, svarer nu til den node, der oprindeligt modtog anmodningen via NodePort. Noden anvender derefter præcis de samme DNAT- og SNAT-processer i omvendt rækkefølge og sender informationen tilbage til den eksterne bruger. I denne proces gemmer hver node følgende information.

1# 노드 내부 DNAT 테이블
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# 노드 내부 SNAT 테이블
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |
1# Nodens interne DNAT-tabel
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Nodens interne SNAT-tabel
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Hovedafsnit

Generelt administreres og håndteres disse NAT-processer i Linux af et undersystem kaldet conntrack via iptables. Faktisk bruger andre CNI-projekter såsom flannel og calico denne mekanisme til at håndtere ovenstående problem. Problemet er dog, at Cilium, ved at anvende en teknologi kaldet eBPF, helt ignorerer den traditionelle Linux-netværksstack. 🤣

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

Som følge heraf har Cilium valgt at implementere direkte kun de funktioner, der er nødvendige i en Kubernetes-kontekst, blandt de opgaver som den traditionelle Linux-netværksstack udfører (se figuren ovenfor). Derfor administrerer Cilium selv SNAT-tabellen, som det omtalte SNAT-trin kræver, i form af en LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Cilium SNAT 테이블
2# !쉬운 설명을 위한 예시. 실제 정의는: 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 등 기타등등 메타데이터
4----------------------------------------------
5|            |          |         |          |
1# Cilium SNAT-tabel
2# !Eksempel for en nem forklaring. Den faktiske definition findes her: 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 osv. metadata
4----------------------------------------------
5|            |          |         |          |

Da det er en hash-tabel, bruges en nøgle til hurtig opslag, og nøglen består af kombinationen src ip, src port, dst ip og dst port som nøgle.

Problemidentifikation

Symptom 1: Opslag

Dette fører til ét problem. For at afgøre, om en pakke, der passerer gennem eBPF, skal igennem SNAT- eller DNAT-processen, skal den hash-tabel slås op. Som vi har set, findes der i SNAT-processen to typer pakker: 1. pakker, der går fra det interne til det eksterne net, og 2. pakker, der kommer tilbage fra det eksterne net som svar. Begge disse pakker kræver NAT-transformation, men deres værdier for src ip, src port, dst ip og dst port er byttet om.

Derfor er man for at opnå hurtigt opslag nødt til enten at tilføje endnu en post i hash-tabellen med de ombyttede src- og dst-værdier som nøgle, eller også at slå den samme hash-tabel op to gange for alle pakker, også dem der ikke har noget med SNAT at gøre. Cilium har naturligvis valgt den første metode, hvor de samme data indsættes to gange, under navnet RevSNAT, for at opnå bedre ydeevne.

Symptom 2: LRU

Uafhængigt af ovenstående problem findes der ingen uendelige ressourcer i nogen hardware. Især i hardwarenær logik, hvor høj ydeevne er påkrævet, kan dynamiske datastrukturer heller ikke anvendes. Når ressourcerne er knappe, er det nødvendigt at fjerne (evict) eksisterende data. Cilium har løst dette ved at bruge LRU Hash Map, en grundlæggende datastruktur, som Linux stiller til rådighed.

Symptom 1 + Symptom 2 = Forbindelsestab

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

Det betyder, at for en enkelt SNAT-behandlet TCP- (eller UDP-) forbindelse:

  1. er de samme data registreret to gange i én hash-tabel, både for udgående og indgående pakker, og
  2. afhængigt af LRU-logikken kan en af de to dataposter gå tabt når som helst

I en situation, hvor en af NAT-posterne (herefter entry) for udgående eller indgående pakker fjernes af LRU, kan NAT ikke udføres korrekt, hvilket kan føre til tab af hele forbindelsen.

Løsning

Her kommer de tidligere nævnte PR'er ind i billedet:

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

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

Tidligere, når en pakke passerede eBPF, forsøgte systemet at oprette en nøgle af kombinationen src ip, src port, dst ip og dst port og slå den op i SNAT-tabellen. Hvis nøglen ikke fandtes, oprettede det nye NAT-information i henhold til SNAT-reglerne og registrerede den i tabellen. For en ny forbindelse vil dette føre til normal kommunikation. Men hvis nøglen var fjernet utilsigtet af LRU, ville NAT blive udført med en anden port end den, der blev brugt til den eksisterende kommunikation. Modtageren ville så afvise pakken, og forbindelsen ville blive afsluttet med et RST-pakke.

Den fremgangsmåde, som ovenstående PR anvender, er enkel:

Når en pakke i en retning er observeret én gang, skal entry for den modsatte retning også opdateres på ny.

Når kommunikation observeres i en hvilken som helst retning, opdateres begge entries på ny. De kommer dermed længere fra LRU-logikkens prioritet for eviction, og det reducerer risikoen for, at kun den ene entry slettes, så hele kommunikationen bryder sammen.

Selvom dette er en meget enkel tilgang, og ideen virker simpel, har den effektivt løst problemet, hvor NAT-information for svarpakker udløber først, og forbindelsen derfor afbrydes, og den har betydeligt forbedret systemets stabilitet. Det er desuden en vigtig forbedring, der har opnået følgende resultater med hensyn til netværksstabilitet.

benchmark

Konklusion

Jeg mener, at denne PR er et meget godt eksempel på, hvordan en simpel idé kan skabe stor forandring, selv inde i komplekse systemer, startende fra det grundlæggende CS-kendskab til, hvordan NAT fungerer.

Ja, jeg har selvfølgelig ikke direkte vist eksempler fra komplekse systemer i denne artikel. Men for at forstå denne PR ordentligt tiggede jeg i næsten tre timer hos DeepSeek V3 0324, endda med ordet Please, og resultatet var en smule mere viden om Cilium og billedet nedenfor. 😇

diagram

Og efter at have læst issues og PR'er har jeg skrevet denne artikel som en slags kompensation for de uhyggelige fornemmelser, jeg havde om, at noget, jeg tidligere havde lavet, sandsynligvis forårsagede problemet.

Efterskrift - 1

Forresten har dette issue en meget effektiv omgåelsesmetode. Den grundlæggende årsag til problemet er manglende plads i NAT-tabellen, så man kan blot øge størrelsen af NAT-tabellen. :-D

Jeg beundrer og respekterer den entusiasme hos gyutaeb, der, selvom det ikke var et problem direkte relateret til vedkommende, grundigt analyserede og forstod det, og bidrog til Cilium-økosystemet med objektive dataunderbygninger, i stedet for at ende med at øge NAT-tabellens størrelse uden at oprette et issue og forlade stedet.

Dette var det, der fik mig til at beslutte mig for at skrive denne artikel.

Efterskrift - 2

Denne historie hører faktisk ikke helt naturligt hjemme hos Gosuda, som specialiserer sig i Go-sproget. Men Go-sproget og cloud-økosystemet er tæt forbundet, og bidragyderne til Cilium har et vist kendskab til Go, så jeg har flyttet indholdet, der kunne være publiceret på min personlige blog, til Gosuda.

Jeg tror, at det nok er i orden, da en af administratorerne (mig selv) har givet tilladelse.

Hvis du mener, at det ikke er i orden, kan det blive slettet når som helst, så gem det gerne som PDF med det samme. ;)

Efterskrift - 3

Ved udarbejdelsen af denne artikel fik jeg stor hjælp fra Cline og Llama 4 Maverick. Selvom jeg startede analysen med Gemini og tiggede hos DeepSeek, var den reelle hjælp faktisk fra Llama 4. Llama 4 er god. Prøv det endelig.