GoSuda

Cilium-historien: En liten kodeendring som førte til bemerkelsesverdig nettverksstabilitetsforbedring

By iwanhae
views ...

Innledning

For en tid tilbake observerte jeg en Pull Request (PR) fra en tidligere kollega vedrørende Cilium-prosjektet.

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

Mengden endringer (bortsett fra testkoden) var liten, kun en tilleggelse av en if-setningsblokk. Imidlertid var virkningen av denne endringen betydelig, og det faktum at en enkel idé kan bidra enormt til systemstabilitet, fant jeg personlig interessant. Derfor ønsker jeg å presentere denne historien på en måte som er lett forståelig, selv for personer uten spesialisert kunnskap innen nettverksområdet.

Bakgrunnskunnskap

Dersom det finnes en essensiell moderne gjenstand som er like viktig som smarttelefonen, er det sannsynligvis Wi-Fi-ruteren. En Wi-Fi-ruter kommuniserer med enheter via Wi-Fi-standarden og deler sin offentlige IP-adresse slik at flere enheter kan benytte den. Det tekniske særtrekket som oppstår her, er spørsmålet om hvordan denne "delingen" utføres.

Teknologien som benyttes her, er Network Address Translation (NAT). NAT er en teknologi som muliggjør kommunikasjon med eksterne nettverk ved å mappe intern kommunikasjon, bestående av privat IP:Port, til en ledig offentlig IP:Port, gitt at TCP- eller UDP-kommunikasjon er bygget opp av en kombinasjon av IP-adresse og portinformasjon.

NAT

Når en intern enhet forsøker å koble seg til det eksterne internettet, transformerer NAT-enheten enhetens private IP-adresse og portnummerkombinasjon til sin egen offentlige IP-adresse og et tilfeldig ubrukt portnummer. Denne transformasjonsinformasjonen lagres i en NAT-tabell inne i NAT-enheten.

Anta for eksempel at en smarttelefon hjemme (privat IP: a.a.a.a, port: 50000) forsøker å koble seg til en webserver (offentlig IP: c.c.c.c, port: 80).

1Smarttelefon (a.a.a.a:50000) ==> Ruter (b.b.b.b) ==> Webserver (c.c.c.c:80)

Når ruteren mottar en forespørsel fra smarttelefonen, vil den se følgende TCP Packet:

1# TCP-pakke mottatt av ruter, smarttelefon => ruter
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Dersom denne pakken ble sendt direkte til webserveren (c.c.c.c), ville ikke smarttelefonen (a.a.a.a) med sin private IP-adresse motta noe svar. Derfor finner ruteren først en ledig port (f.eks. 60000) som ikke er involvert i gjeldende kommunikasjon, og registrerer den i den interne NAT-tabellen.

1# Ruterens interne NAT-tabell
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Etter å ha registrert en ny oppføring i NAT-tabellen, endrer ruteren kilde-IP-adressen og portnummeret i TCP-pakken mottatt fra smarttelefonen til sin egen offentlige IP-adresse (b.b.b.b) og det nylig tildelte portnummeret (60000), og sender den deretter til webserveren.

1# TCP-pakke sendt av ruter, ruter => webserver
2# SNAT utført
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Nå vil webserveren (c.c.c.c) oppfatte dette som en forespørsel fra port 60000 på ruteren (b.b.b.b), og sender en svarpakke til ruteren som følger:

1# TCP-pakke mottatt av ruter, webserver => ruter
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Når ruteren mottar denne svarpakken, søker den i NAT-tabellen etter den opprinnelige private IP-adressen (a.a.a.a) og portnummeret (50000) som tilsvarer destinasjons-IP-adressen (b.b.b.b) og portnummeret (60000), og endrer deretter pakkens destinasjon til smarttelefonen.

1# TCP-pakke sendt av ruter, ruter => smarttelefon
2# DNAT utført
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Gjennom denne prosessen vil smarttelefonen oppleve det som om den direkte kommuniserer med webserveren via sin egen offentlige IP-adresse. Takket være NAT kan flere interne enheter samtidig benytte internett med én enkelt offentlig IP-adresse.

Kubernetes

Kubernetes besitter en av de mest sofistikerte og komplekse nettverksstrukturene blant nylige teknologier. Og selvfølgelig benyttes den tidligere nevnte NAT også på ulike steder. De to mest representative eksemplene er som følger.

Når en Pod internt kommuniserer med eksterne deler av klyngen

Pods i en Kubernetes-klynge tildeles vanligvis private IP-adresser som kun kan kommunisere innenfor klyngenettverket. Derfor, for at en Pod skal kunne kommunisere med det eksterne internettet, kreves NAT for utgående trafikk fra klyngen. I slike tilfeller utføres NAT primært på Kubernetes-noden (hver server i klyngen) der Poden kjører. Når en Pod sender en pakke utover, videresendes denne pakken først til noden Poden tilhører. Noden endrer pakkens kilde-IP-adresse (Podens private IP) til sin egen offentlige IP-adresse, justerer kildeporten deretter, og sender den videre utover. Denne prosessen ligner NAT-prosessen beskrevet tidligere for Wi-Fi-rutere.

Anta for eksempel at en Pod i en Kubernetes-klynge (10.0.1.10, port: 40000) kobler seg til en ekstern API-server (203.0.113.45, port: 443). Da vil Kubernetes-noden motta følgende pakke fra Poden:

1# TCP-pakke mottatt av node, Pod => node
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Noden vil deretter registrere følgende informasjon:

1# Nodens interne NAT-tabell (eksempel)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Og deretter utføre SNAT som følger, før den sender pakken ut:

1# TCP-pakke sendt av node, node => API-server
2# SNAT utført
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Den påfølgende prosessen er den samme som i eksempelet med smarttelefonruteren.

Når kommunikasjon skjer med en Pod via NodePort fra utsiden av klyngen

En metode for å eksponere tjenester eksternt i Kubernetes er å benytte NodePort-tjenester. En NodePort-tjeneste åpner en spesifikk port (NodePort) på alle noder i klyngen og videresender trafikk som ankommer denne porten til Pods som tilhører tjenesten. Eksterne brukere kan aksessere tjenesten via nodens IP-adresse og NodePort.

I denne konteksten spiller NAT en avgjørende rolle, og spesielt oppstår DNAT (Destination NAT) og SNAT (Source NAT) samtidig. Når trafikk ankommer en NodePort på en spesifikk node fra utsiden, må Kubernetes-nettverket til slutt videresende denne trafikken til Poden som leverer den aktuelle tjenesten. I denne prosessen utføres først DNAT for å endre pakkens destinasjons-IP-adresse og portnummer til Podens IP-adresse og portnummer.

Anta for eksempel at en ekstern bruker (203.0.113.10, port: 30000) aksesserer en tjeneste via NodePort (30001) på en Kubernetes-node (192.168.1.5) i klyngen. Anta at denne tjenesten internt peker på en Pod med IP-adresse 10.0.2.15 og port 8080.

1Ekstern bruker (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 tilfellet har Kubernetes-noden både den eksternt tilgjengelige IP-adressen 192.168.1.5 og den internt gyldige Kubernetes-nettverks-IP-adressen 10.0.1.1. (Policyene knyttet til dette varierer avhengig av CNI-typen som brukes, men i denne artikkelen forklares det basert på Cilium.)

Når den eksterne brukerens forespørsel ankommer noden, må noden videresende denne forespørselen til Poden som skal behandle den. I denne prosessen anvender noden følgende DNAT-regel for å endre pakkens destinasjons-IP-adresse og portnummer:

1# TCP-pakke noden forbereder å sende til Poden
2# Etter DNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10  | 30000    | 10.0.2.15 | 8080     |

Det sentrale her er at når Poden sender et svar på denne forespørselen, vil kilde-IP-adressen være dens egen IP-adresse (10.0.2.15), og destinasjons-IP-adressen vil være IP-adressen til den eksterne brukeren som sendte forespørselen (203.0.113.10). I et slikt tilfelle vil den eksterne brukeren motta et svar fra en ikke-eksisterende IP-adresse som den aldri har sendt en forespørsel til, og vil ganske enkelt DROPpe pakken. Derfor utfører Kubernetes-noden en tilleggs-SNAT når Poden sender svarpakker utover, for å endre pakkens kilde-IP-adresse til nodens IP-adresse (192.168.1.5 eller den interne nettverks-IP-en 10.0.1.1, i dette tilfellet 10.0.1.1).

1# TCP-pakke noden forbereder å sende til Poden
2# Etter DNAT, SNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1      | 40021    | 10.0.2.15 | 8080     |

Når Poden nå mottar denne pakken, vil den svare til noden som opprinnelig mottok NodePort-forespørselen, og noden vil reversere de samme DNAT- og SNAT-prosessene for å returnere informasjonen til den eksterne brukeren. I denne prosessen vil hver node lagre følgende informasjon:

1# Nodens interne DNAT-tabell
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-tabell
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Hoveddel

Normalt sett administreres og utføres slike NAT-prosesser i Linux via iptables av et subsystem kalt conntrack. Faktisk benytter andre CNI-prosjekter som flannel og calico dette for å håndtere de ovennevnte problemene. Problemet er imidlertid at Cilium, ved å benytte teknologien eBPF, ignorerer hele denne tradisjonelle Linux-nettverksstakken. 🤣

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

Som et resultat har Cilium valgt å implementere de funksjonene som er nødvendige i et Kubernetes-scenario, direkte fra de oppgavene den eksisterende Linux-nettverksstakken utførte, som vist i figuren ovenfor. Derfor administrerer Cilium SNAT-tabellen direkte i form av en LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) for den tidligere nevnte SNAT-prosessen.

1# Cilium SNAT-tabell
2# !Eksempel for enklere forklaring. Faktisk definisjon: 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 og annen metadata
4----------------------------------------------
5|            |          |         |          |

Og som en Hash Table eksisterer det nøkkelverdier for rask oppslag, og kombinasjonen av src ip, src port, dst ip, dst port benyttes som nøkkelverdi.

Problemanalyse

Fenomen - 1: Oppslag

Dette fører til et problem: Når en pakke passerer gjennom eBPF, må den sjekke Hash Table for å verifisere om den er en pakke som krever SNAT- eller DNAT-prosessen i NAT-prosessen. Som vi har sett tidligere, finnes det to typer pakker i SNAT-prosessen: 1. Pakker som går fra innsiden til utsiden, og 2. Pakker som kommer fra utsiden til innsiden som svar på disse. Disse to pakkene krever transformasjon for NAT-prosessen og kjennetegnes ved at src ip, port og dst ip, port verdiene byttes om.

For rask oppslag er det derfor nødvendig å enten legge til en ekstra verdi i Hash Table med omvendte src- og dst-verdier som nøkkel, eller å utføre to oppslag i den samme Hash Table for alle pakker, selv de som ikke er relatert til SNAT. Cilium har naturligvis valgt metoden med å legge inn de samme dataene to ganger, under navnet RevSNAT, for bedre ytelse.

Fenomen - 2: LRU

Uavhengig av det ovennevnte problemet kan det ikke eksistere uendelige ressurser i all maskinvare, og spesielt i maskinvarebasert logikk som krever høy ytelse, der dynamiske datastrukturer ikke kan brukes, er det nødvendig å evict eksisterende data når ressurser er knappe. Cilium har løst dette ved å benytte LRU Hash Map, en grunnleggende datastruktur som er tilgjengelig i Linux.

Fenomen 1 + Fenomen 2 = Tap av tilkobling

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

Det vil si, for en SNAT-tilkoblet TCP (eller UDP) forbindelse:

  1. I én Hash Table er de samme dataene registrert to ganger for utgående og innkommende pakker.
  2. I en situasjon hvor en av de to dataene kan gå tapt når som helst på grunn av LRU-logikken.

Dersom minst én av NAT-informasjonene (heretter entry) for utgående eller innkommende pakker slettes av LRU, kan det føre til at NAT ikke utføres korrekt, og dermed resultere i et fullstendig tap av tilkobling.

Løsning

Her kommer de tidligere nevnte PR-ene inn i bildet.

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 passerte gjennom eBPF, forsøkte den å slå opp i SNAT-tabellen med en nøkkel konstruert av src ip, src port, dst ip og dst port. Dersom nøkkelen ikke eksisterte, ble ny NAT-informasjon generert i henhold til SNAT-reglene og registrert i tabellen. For en ny tilkobling ville dette føre til normal kommunikasjon, men dersom nøkkelen utilsiktet ble fjernet av LRU, ville en ny NAT bli utført med en annen port enn den som ble brukt i den eksisterende kommunikasjonen. Dette ville føre til at mottakeren av pakken nektet å motta den, og tilkoblingen ville bli avsluttet med en RST-pakke.

Metoden som ble brukt i den nevnte PR er enkel.

Når en pakke observeres i én retning, fornyes også oppføringen for den motsatte retningen.

Når kommunikasjon observeres i en hvilken som helst retning, fornyes begge oppføringene, noe som reduserer sjansen for at de blir evictet fra LRU-logikken. Dette minimerer muligheten for et scenario der kun én av oppføringene slettes og hele kommunikasjonen brytes sammen.

Dette kan virke som en svært enkel tilnærming og en simpel idé, men gjennom denne tilnærmingen har man effektivt løst problemet med at NAT-informasjonen for svarpakker utløper for tidlig, noe som fører til brudd i tilkoblingen, og dermed betydelig forbedret systemets stabilitet. Dette kan også betraktes som en viktig forbedring som har oppnådd følgende resultater når det gjelder nettverksstabilitet:

benchmark

Konklusjon

Jeg anser denne PR-en som et utmerket eksempel som illustrerer hvordan selv en enkel idé kan forårsake store endringer i et komplekst system, basert på grunnleggende CS-kunnskap om hvordan NAT fungerer.

Jeg har selvfølgelig ikke vist eksempler på komplekse systemer direkte i denne artikkelen. Men for å forstå denne PR-en ordentlig, måtte jeg tigge DeepSeek V3 0324 i nesten 3 timer, til og med med ordet "Please", og som et resultat fikk jeg kunnskap om Cilium +1 og følgende diagram. 😇

diagram

Og etter å ha lest gjennom problemene og PR-ene, skriver jeg denne artikkelen som en kompensasjon for de illevarslende følelsene av at noe jeg tidligere hadde laget, sannsynligvis forårsaket problemene.

Etterord - 1

Forresten, det finnes en svært effektiv metode for å unngå dette problemet. Den grunnleggende årsaken til problemet er mangel på plass i NAT-tabellen, så det er bare å øke størrelsen på NAT-tabellen. :-D

Noen andre som møtte det samme problemet, økte sannsynligvis NAT-tabellstørrelsen og stakk av uten å rapportere et problem. Jeg beundrer og respekterer lidenskapen til gyutaeb som, til tross for at problemet ikke var direkte relatert til ham, analyserte og forsto det grundig, presenterte objektive data og bidro til Cilium-økosystemet.

Dette var grunnen til at jeg bestemte meg for å skrive denne artikkelen.

Etterord - 2

Denne historien er egentlig ikke direkte relevant for Gosuda, som spesialiserer seg på Go-språket. Imidlertid er Go-språket og sky-økosystemet nært beslektet, og bidragsytere til Cilium har en viss forståelse av Go-språket. Derfor har jeg valgt å publisere innhold som kunne vært på en personlig blogg, her på Gosuda.

Med tillatelse fra en av administratorene (meg selv), antar jeg at dette er akseptabelt.

Hvis du mener det ikke er akseptabelt, bør du lagre det som PDF umiddelbart, da det kan bli slettet når som helst. ;)

Etterord - 3

Jeg mottok betydelig hjelp fra Cline og Llama 4 Maveric under utarbeidelsen av denne artikkelen. Selv om jeg startet analysen med Gemini og tigget DeepSeek, fikk jeg faktisk hjelp fra Llama 4. Llama 4 er utmerket. Du bør absolutt prøve det.