Das Networking von Kubernetes
Dieser Blog-Beitrag dreht sich um die Frage aller Fragen, welche bereits zahlreiche Neugierige verschlungen und nie wieder das Licht der Welt erblicken haben dürfte, und nun auch mich in ihren Bann gezogen hat: Wie um alles in der Welt funktioniert das Networking von Kubernetes? Mit ein paar Netzwerk-Tools, der Blog-Serie Understanding Kubernetes Networking und einem eigens zusammengebauten Kubernetes-Cluster gerüstet mache ich mich auf die Suche nach Antworten.
Aber beginnen wir am Anfang: Was ist Kubernetes? Kubernetes stellt sich selbst als «Open-Source-System zur Automatisierung der Bereitstellung, Skalierung und Verwaltung von containerisierten Anwendungen» vor. Das kling komplex, die Idee ist aber im Grunde einfach: Anstatt eine Applikation in ihrer Gesamtheit auf einer Maschine laufen zu lassen, schneidet man sie in einzelne Module (Container), die man an einen Kubernetes-Cluster übergibt. Einmal richtig konfiguriert, kümmert sich Kubernetes darum, dass alle Container laufen und miteinander kommunizieren können. Aus einer grossen Einheit werden so viele kleine Teile, die zusammenarbeiten, um gemeinsam die Applikation bereitzustellen.
Das macht es leicht, die Applikation zu skalieren. Wenn ein gewisser Container mit der Arbeit nicht mehr nachkommt, fügt man eine weitere Einheit – in Kubernetes Pod genannt – zum Cluster hinzu. Und Kubernetes macht die Pods unabhängig von der unterliegenden Maschine – in Kubernetes Nodes genannt. Wenn ein Kubernetes-Cluster insgesamt an seine Grenzen stösst, fügt man eine weitere Node hinzu, auf der Pods laufen können. Diese horizontale Skalierung ist ein fundamentales Feature von Kubernetes.
So viel Flexibilität auf der Anwendungsebene bedingt aber eine gewisse Komplexität unter der Haube. Wie erreicht es Kubernetes, dass Pods zuverlässig miteinander kommunizieren können, ohne fix an eine Node gebunden zu sein? Und wie kann diese Kommunikation bestehen, wenn Pods neu gestartet, gelöscht oder hinzugefügt werden?
Das Pod-Netzwerk
Um das Networking untersuchen zu können, brauche ich zunächst einen Kubernetes-Cluster. Ich habe mir ein Terraform-Modul gebaut, mit dem ich auf AWS VMs provisionieren und sie mithilfe von kubeadm zu einem Kubernetes-Cluster zusammenbauen kann. Der ist natürlich nicht produktionsreif, aber super, um Shell-Commands auf einen wehrlosen Cluster abfeuern zu können. Die Konfiguration des Sandbox-Clusters sieht so aus:
module "cluster" {
source = "git::https://github.com/BytesAndBosons/KubernetesSandbox"
cluster_config = {
name = "schnell-test-cluster"
version = "1.34.0"
}
networking = {
node_cidr_range = "10.0.0.0/24"
pod_cidr_range = "10.10.0.0/24"
service_cidr_range = "10.20.0.0/24"
}
ssh = {
key_pair_name = "schnell-test-cluster"
private_key_path = "/Users/schnell/.ssh/test-cluster/key.pem"
whitelist_cidr_ranges = ["X.X.X.X/32"]
}
control_plane_node = {
name = "cp"
instance_type = "t3.medium"
}
worker_nodes = {
"worker-1" = {
name = "w1"
instance_type = "t3.small"
}
"worker-2" = {
name = "w2"
instance_type = "t3.small"
}
}
}Der Cluster besteht aus einer Control Plane Node (cp) und zwei Worker Nodes (w1 und w2), auf die ich via SSH zugreifen kann. Der Key dafür ist in der Konfiguration angegeben und die Nodes erlauben nur meiner Source-IP den Zugriff (in der Konfiguration oben durch X.X.X.X ersetzt). Auf diesem Cluster lasse ich zwei Pods laufen: test-pod-1 auf der Node w1 und test-pod-2 auf der Node w2:
apiVersion: v1
kind: Pod
metadata:
name: test-pod-1
labels:
app: test
annotations:
cni.projectcalico.org/ipAddrs: '["10.10.0.193"]'
spec:
nodeName: w1
containers:
- name: test-container
image: raesene/alpine-nettools
command:
- /bin/sh
- -c
- |
while true; do
sleep 10
curl --silent --show-error --connect-timeout 5 \
--max-time 10 http://10.10.0.1:8080
echo "[$(date)] Request completed"
done
restartPolicy: Always
---
apiVersion: v1
kind: Pod
metadata:
name: test-pod-2
labels:
app: test
annotations:
cni.projectcalico.org/ipAddrs: '["10.10.0.1"]'
spec:
nodeName: w2
containers:
- name: test-container
image: hashicorp/http-echo
args:
- -listen=:8080
- -text="Oh, hi there!"
ports:
- containerPort: 8080
restartPolicy: AlwaysDer Pod test-pod-1 sendet alle 10 Sekunden einen Request an die IP von test-pod-2. Dieser lauscht auf Port 8080 und antwortet auf HTTP-Requests mit einem freundlichen «Oh, hi there!». Ich gebe explizit an, auf welchen Nodes (w1 und w2) die Pods laufen und welche IPs (10.10.0.193 und 10.10.0.1) ihnen zugewiesen werden sollen. Das ist im Allgemeinen nicht nötig, Kubernetes teilt die Pods automatisch auf die Nodes auf und weist ihnen IPs zu. Fürs Erkunden des Netzwerk-Setups ist es aber einfacher, wenn es stets gleich bleibt, daher habe ich es hier in der Konfiguration fixiert.
Via kubectl get pods -o wide kann ich überprüfen, dass die Pods tatsächlich angelegt wurden:
NAME READY STATUS RESTARTS AGE IP NODE
test-pod-1 1/1 Running 0 34s 10.10.0.193 w1
test-pod-2 1/1 Running 0 34s 10.10.0.1 w2Auch die Kommunikation zwischen den Pods funktioniert, wie man den Logs von test-pod-1
"Oh, hi there!"
[Sun Dec 7 12:40:06 UTC 2025] Request completed
"Oh, hi there!"
[Sun Dec 7 12:40:16 UTC 2025] Request completed
"Oh, hi there!"
[Sun Dec 7 12:40:26 UTC 2025] Request completedund test-pod-2
2025/12/07 12:40:06 10.10.0.1:8080 10.10.0.193:43294 "GET / HTTP/1.1" 200 16 "curl/7.79.1" 8.274µs
2025/12/07 12:40:16 10.10.0.1:8080 10.10.0.193:43486 "GET / HTTP/1.1" 200 16 "curl/7.79.1" 8.537µs
2025/12/07 12:40:26 10.10.0.1:8080 10.10.0.193:52184 "GET / HTTP/1.1" 200 16 "curl/7.79.1" 6.396µsentnehmen kann. Mich interessiert nun, wie genau diese Kommunikation unter der Haube aussieht. Dazu zeichne ich erstmal die CIDR-Ranges und IPs auf, die in meinem Sandbox-Setup vorkommen:

Wie alle VMs haben die Nodes IP-Adressen in der Range des Subnets, hier 10.0.0.0/24. Die Kommunikation zwischen den Nodes verläuft über den VPC-Router, welcher die IP 10.0.0.1 hat und von AWS intern verwaltet wird. Wie im Code-Snippet oben ersichtlich, kriegen auch die Pods IPs zugewiesen. Diese liegen aber in 10.10.0.0/24, sind also nicht Teil des AWS-Networkings. Wie geht das?
Kubernetes verwendet ein Overlay-Network (in meinem Fall vom CNI-Plugin Calico umgesetzt), das basierend auf dem Netzwerk der Nodes ein zusätzliches Netzwerk für die Pods aufbaut. Damit können Pods bestimmte IPs in diesem Overlay-Network zugewiesen werden und man kann sie darüber erreichen, ohne als Nutzer wissen zu müssen, auf welcher Node die Pods laufen. Um mehr Informationen darüber zu kriegen, wie Calico das macht, habe ich mir mit ip addr show die Netzwerk-Interfaces auf der Node w1 ausgeben lassen:
...
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
link/ether 02:fa:a5:50:8a:99 brd ff:ff:ff:ff:ff:ff
altname enp0s5
inet 10.0.0.35/24 metric 100 brd 10.0.0.255 scope global dynamic ens5
valid_lft 2376sec preferred_lft 2376sec
inet6 fe80::fa:a5ff:fe50:8a99/64 scope link
valid_lft forever preferred_lft forever
3: tunl0@NONE: <NOARP,UP,LOWER_UP> mtu 8981 qdisc noqueue state UNKNOWN group default qlen 1000
link/ipip 0.0.0.0 brd 0.0.0.0
inet 10.10.0.192/32 scope global tunl0
valid_lft forever preferred_lft forever
...Das erste Network-Interface, ens5, scheint das normale Ethernet-Interface von AWS zu sein. Die VM ist über die IP 10.0.0.35 im VPC-Subnet verfügbar.
Interessanter ist das Tunnel-Interface tunl0. Das scheint das Tor nach Narnia zu sein, wo die mystischen Kreaturen der 10.10.x.x IPs leben. Das Interface trägt selbst eine 10.10.0.192 und liegt damit in der gleichen Range wie test-pod-1 mit der 10.10.0.193. Weitere Informationen, welche Rolle tunl0 genau spielt, finde ich via ip route show:
default via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.35 metric 100
10.0.0.0/24 dev ens5 proto kernel scope link src 10.0.0.35 metric 100
10.0.0.1 dev ens5 proto dhcp scope link src 10.0.0.35 metric 100
10.10.0.0/26 via 10.0.0.142 dev tunl0 proto bird onlink
10.10.0.64/26 via 10.0.0.103 dev tunl0 proto bird onlink
...Die ersten drei Zeilen betreffen wieder das Networking von AWS zwischen den Nodes. Es sind die Zeilen vier und fünf, welche die Chroniken von Narnia erzählen: wenn test-pod-1 ein Paket an die IP 10.10.0.1 von test-pod-2 sendet, muss das Paket erst durch tunl0. Dieses ist ein IP-in-IP Interface, das Paket wird beim Passieren in ein äusseres Paket gepackt, welches Source- und Target-IPs der jeweiligen Nodes trägt. Das Paket wird dann via Interface ens5 und dem Networking von AWS an die IP 10.0.0.142 der Node w2 gesendet, dort ausgepackt, und landet schliesslich an der richtigen Adresse 10.10.0.1 von test-pod-2. Das habe ich mir via tcpdump -i ens5 -nn 'ip proto 4' genauer angeschaut:
...
13:37:39.449715 IP 10.0.0.35 > 10.0.0.142: IP 10.10.0.193.55184 > 10.10.0.1.8080: Flags [P.], seq 1:79, ack 1, win 489, options [nop,nop,TS val 302525847 ecr 1850271268], length 78: HTTP: GET / HTTP/1.1 (ipip-proto-4)
13:37:39.450212 IP 10.0.0.142 > 10.0.0.35: IP 10.10.0.1.8080 > 10.10.0.193.55184: Flags [.], ack 79, win 488, options [nop,nop,TS val 1850271269 ecr 302525847], length 0 (ipip-proto-4)
13:37:39.450715 IP 10.0.0.142 > 10.0.0.35: IP 10.10.0.1.8080 > 10.10.0.193.55184: Flags [P.], seq 1:179, ack 79, win 488, options [nop,nop,TS val 1850271269 ecr 302525847], length 178: HTTP: HTTP/1.1 200 OK (ipip-proto-4)
13:37:39.450779 IP 10.0.0.35 > 10.0.0.142: IP 10.10.0.193.55184 > 10.10.0.1.8080: Flags [.], ack 179, win 488, options [nop,nop,TS val 302525848 ecr 1850271269], length 0 (ipip-proto-4)
...Alle 10 Sekunden sendet test-pod-1 nach dem initialen TCP-Handshake einen HTTP-Request an 10.10.0.1:8080. Dieser wird in ein äusseres Paket mit Source 10.0.0.35 und Target 10.0.0.142 gepackt und mittels AWS-Networking zugestellt. Von test-pod-2 kommt ein ACK zurück – erneut in ein äusseres Packet gepackt –, welches den Erhalt des Requests bestätigt. Anschliessend sendet test-pod-2 die eigentliche Response («Oh, hi there!») mit dem Header HTTP/1.1 200 OK. Der Erhalt der Response wird von test-pod-1 wiederum mit ACK bestätigt, die TCP-Verbindung abgebrochen und nach 10 Sekunden beginnt das Ganze von vorn. Schematisch sieht die IP-in-IP Kommunikation zwischen den Pods also so aus (Request-Richtung):

Das Overlay-Netzwerk der Pods vereinfacht das Networking zwischen den Pods beachtlich. Es ermöglicht, einen Pod unter seiner Pod-IP anzusprechen, ohne wissen zu müssen, auf welcher Node er läuft. Mittels IP-in-IP und den Netzwerk-Routes erreicht Calico unter der Haube automatisch, dass das Paket am richtigen Ort ankommt. So weit, so gut.
Kubernetes-Services
Aber ein Problem besteht: Pods leben nicht für immer. Wenn zum Beispiel ein Fehler in einem Pod auftritt und er dadurch «unhealthy» wird, wird er gestoppt und auf einer beliebigen Node mit freier Kapazität wieder neu gestartet. Normalerweise pinnt man nämlich die Pods nicht an bestimmte Nodes, wie ich das oben zu Testzwecken gemacht habe. Wird der Pod auf einer neuen Node gestartet, kriegt er eine neue IP – muss er, da die Pod-Ranges (z.B. 10.10.0.192/26) ja an die Nodes (w1 in dem Fall) gebunden sind. In dem Fall würde mein bisheriges Setup nicht mehr funktionieren, da ich die IP von test-pod-2 hardgecoded habe. Damit die Kommunikation zwischen Pods langfristig funktioniert, braucht es ein persistenteres Konzept als Pod-IPs.
In Kubernetes wird das durch die Services umgesetzt. Um herauszufinden, wie sie funktionieren, füge ich zu den zwei Test-Pods einen Service hinzu
apiVersion: v1
kind: Service
metadata:
labels:
app: test
name: test-service
spec:
clusterIP: 10.20.0.2
ports:
- name: http
port: 8080
protocol: TCP
targetPort: 8080
selector:
app: test
type: ClusterIPund passe test-pod-1 an, damit er neu den Service anspricht
apiVersion: v1
kind: Pod
metadata:
name: test-pod-1
...
spec:
containers:
- name: test-container
...
command:
- /bin/sh
- -c
- |
while true; do
sleep 10
curl --silent --show-error --connect-timeout 5 \
--max-time 10 http://10.20.0.2:8080
echo "[$(date)] Request completed"
done
...Der Service ist auf dem TCP-Port 8080 verfügbar und zeigt auf die zwei Pods. Das wird über das Label app: test erreicht, das beide Pods tragen. Ich habe wieder die IP des Services in der Konfiguration fixiert (via clusterIP: 10.20.0.2), auch das ist normalerweise nicht nötig. Mit kubectl describe service test-service überprüfe ich, dass der Service entsprechend angelegt wurde:
Name: test-service
Namespace: default
Labels: app=test
Annotations: <none>
Selector: app=test
Type: ClusterIP
IP Family Policy: SingleStack
IP Families: IPv4
IP: 10.20.0.2
IPs: 10.20.0.2
Port: http 8080/TCP
TargetPort: 8080/TCP
Endpoints: 10.10.0.193:8080,10.10.0.1:8080
Session Affinity: None
Internal Traffic Policy: Cluster
Events: <none>Tatsächlich zeigt der Service wie gewünscht auf die zwei Pods (siehe Endpoints). Auch die IP wurde richtig angelegt: 10.20.0.2. Damit liegt sie aber ausserhalb aller bisher bekannten IP-Ranges. Wie kann das sein? Ich habe mir ja alle Interfaces und Routes angeschaut, da war nirgends was von 10.20.x.x zu sehen?! Es stellt sich heraus, dass die Service-IPs eher folgender Natur sind:

Wenn test-pod-1 ein Paket an 10.20.0.2 sendet, wird es zunächst an die Node weitergeleitet (per Default, da Target-IP unbekannt). Dort kommt «Fairydust» ins Spiel: mitten im Flug werden die Target- und manchmal auch die Source-IP des Pakets via iptables netfilter Regeln umgeschrieben. Die Regeln selbst werden vom sogenannten kube-proxy kontrolliert, welches als Pod auf jeder Node läuft (via DaemonSet). Um zu verstehen, was genau passiert, habe ich mir die iptables Regeln auf w1 via iptables -t nat -L -v -n anzeigen lassen:
...
Chain OUTPUT (policy ACCEPT 1513 packets, 113K bytes)
pkts bytes target prot opt in out source destination
174K 13M KUBE-SERVICES all -- * * 0.0.0.0/0 0.0.0.0/0
Chain KUBE-SERVICES (2 references)
pkts bytes target prot opt in out source destination
4 240 KUBE-SVC-NPX46M4PTMTKRN6Y tcp -- * * 0.0.0.0/0 10.20.0.1 tcp dpt:443
0 0 KUBE-SVC-TCOU7JCQXEZGVUNU udp -- * * 0.0.0.0/0 10.20.0.10 udp dpt:53
0 0 KUBE-SVC-ERIFXISQEP7F7OF4 tcp -- * * 0.0.0.0/0 10.20.0.10 tcp dpt:53
0 0 KUBE-SVC-JD5MR3NA4I4DYORP tcp -- * * 0.0.0.0/0 10.20.0.10 tcp dpt:9153
176 10560 KUBE-SVC-B62C23KNXVA7TMZN tcp -- * * 0.0.0.0/0 10.20.0.2 tcp dpt:8080
...
Chain KUBE-SVC-B62C23KNXVA7TMZN (1 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-MARK-MASQ tcp -- * * !10.10.0.0/24 10.20.0.2 tcp dpt:8080
105 6300 KUBE-SEP-EHEWRATQVM46RUFY all -- * * 0.0.0.0/0 0.0.0.0/0 statistic mode random probability 0.50000000000
71 4260 KUBE-SEP-TNC5BR52KTVSHDTO all -- * * 0.0.0.0/0 0.0.0.0/0
Chain KUBE-SEP-EHEWRATQVM46RUFY (1 references)
pkts bytes target prot opt in out source destination
105 6300 KUBE-MARK-MASQ all -- * * 10.10.0.193 0.0.0.0/0
105 6300 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp to:10.10.0.193:8080
Chain KUBE-SEP-TNC5BR52KTVSHDTO (1 references)
pkts bytes target prot opt in out source destination
0 0 KUBE-MARK-MASQ all -- * * 10.10.0.1 0.0.0.0/0
71 4260 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp to:10.10.0.1:8080
Chain KUBE-MARK-MASQ (14 references)
pkts bytes target prot opt in out source destination
109 6540 MARK all -- * * 0.0.0.0/0 0.0.0.0/0 MARK or 0x4000
...Da die IP 10.20.0.2 auch der Node unbekannt ist, möchte sie das Paket per Default an den AWS Gateway weiterleiten (in den Routes oben stand default via 10.0.0.1 dev ens5). Daher starten die iptables Regeln bei der Chain OUTPUT. Von dort geht es weiter zur Chain KUBE-SERVICES. Beim Erstellen des test-service legte das kube-proxy die Chain KUBE-SVC-B62C23KNXVA7TMZN an, die auf Pakete mit Target-IP 10.20.0.2 angewendet wird. Das trifft für die Pakete von test-pod-1 zu. In der Chain KUBE-SVC-B62C23KNXVA7TMZN werden erst Pakete mit Source-IP ausserhalb der Pod-Range für SNAT markiert. Das ist relevant, wenn ein Paket von ausserhalb des Clusters zur Node gekommen ist (via NodePort), auf diesen Fall möchte ich in einem separaten Blog-Post eingehen. Mit einer 50-50 Wahrscheinlichkeit geht es dann entweder mit der Chain KUBE-SEP-EHEWRATQVM46RUFY oder der Chain KUBE-SEP-TNC5BR52KTVSHDTO weiter. In ersterer wird die Target-IP des Packets auf 10.10.0.193 umgeschrieben, und das Paket damit zurück an test-pod-1 gesendet, in zweiterer auf 10.10.0.1, der IP von test-pod-2. Von jetzt an greift wieder das Networking der Pod-IPs, das Paket wird wie oben beschrieben an den jeweiligen Pod geliefert.
Die IPs der Services sind also gewissermassen «Variablennamen», welche vom kube-proxy via iptables in die richtigen Pod-IPs umgeschrieben werden. Damit ist es möglich sie persistent zu machen, selbst wenn sich die IPs der Pods ändern, in die sie umgewandelt werden.
Insgesamt sieht das Networking zwischen den Pods via Service also so aus (Request-Richtung):

Falls die Chain KUBE-SEP-EHEWRATQVM46RUFY eintritt und das Paket zurück an test-pod-1 gesendet wird, wird es für SNAT markiert (siehe KUBE-MARK-MASQ) – die Source-IP wird auf die IP der Node umgeschrieben. Das stellt sicher, dass die Response von test-pod-1, die auch direkt an sich selbst gehen könnte, wieder über das Node-Networking läuft und sich der Netzwerk-Kreis so schliesst. Daher habe ich in der schematischen Darstellung das entsprechende Paket blau gefärbt, wie die Nodes.
Damit ist mir das Networking von Kubernetes klarer geworden. Ich habe vor mir in einem separaten Blog-Post auch die Funktionsweisen von NodePort Services, Ingresses und der Gateway API genauer anzuschauen. Aber erstmal entbinde ich die Nodes mit einem schwungvollen
terraform destroyvon ihren Aufgaben.
Quellen
Beitragsbild: Von Kinsey Wang auf Unsplash.
