Stel je dit voor: een aanvaller kraakt één pod in je cluster, via een kwetsbare image of een gelekte token. Vanuit dat ene bruggenhoofd kan deze elke database, elke interne API en elke secret-ophalende sidecar bereiken die je draait. Niets houdt hem tegen, want standaard probeert niets dat.
Network Policies zijn precies het ding dat hem tegenhoudt. Ze maken van “één gecompromitteerde pod” gewoon “één gecompromitteerde pod, en daar blijft het bij.” Iedereen weet dat ze ze zouden moeten gebruiken. Bijna niemand doet het echt, want de YAML ziet er eng uit en het gedrag is raar tot het mentale model klikt.
Ik heb uren verbrand aan het debuggen van policies die “zouden moeten werken” en het niet deden. Daarom bouwt deze post het model laag voor laag op, met een diagram bij elke stap. Begin bovenaan, stop zodra je genoeg hebt.
De default: alles praat met alles
Dit is het deel dat mensen verrast. Out of the box laat Kubernetes elke pod met elke andere pod praten, over elke namespace. Helemaal geen restricties.
flowchart TD
subgraph cluster["Cluster"]
Pod1["Pod"] <--> Pod2["Pod"]
Pod2 <--> Pod3["Pod"]
Pod3 <--> Pod4["Pod"]
Pod1 <--> Pod3
Pod1 <--> Pod4
Pod2 <--> Pod4
end
note["Iedereen kan praten"]
Prima om op dag één iets draaiend te krijgen. Vreselijk zodra je om blast radius geeft. Compromitteer één pod en de aanvaller bereikt het hele cluster. Dat is precies het platte trust-model dat zero trust ons afraadt: ga uit van een breach en zorg dat die zich niet kan verspreiden.
Network Policies: de firewall voor pods
Een NetworkPolicy beschrijft hoe pods mogen communiceren. Het is een firewall-regel die groepen pods selecteert op hun labels in plaats van op IP, wat belangrijk is omdat pod-IP’s vluchtig zijn en je gek zou worden als je ze met de hand bijhield.
Vier concepten dragen de hele feature:
- Ingress: verkeer dat binnenkomt bij een pod
- Egress: verkeer dat uitgaat vanaf een pod
- Selectors: hoe je kiest op welke pods een regel van toepassing is (via labels)
- Policy Types: of je ingress, egress, of beide controleert
Dat is de woordenschat. Alles hieronder is gewoon een combinatie van deze vier.
De belangrijkste regel: default deny
De eerste policy die je op elke namespace toepast is default deny. Die blokkeert al het verkeer, en daarna open je precies wat je nodig hebt. Dit is de simpelst mogelijke policy en tegelijk de belangrijkste.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Leeg = alle pods
policyTypes:
- Ingress
- Egress
flowchart TD
subgraph production["production namespace"]
Pod1["Pod"] x--x Pod2["Pod"]
Pod2 x--x Pod3["Pod"]
Pod3 x--x Pod4["Pod"]
end
note["Al het verkeer standaard geblokkeerd"]
Nu communiceert er niets. Dat voelt kapot, en dat is de bedoeling. Je begint vanaf nul en bouwt op, wat de enige manier is om te weten wat je apps echt nodig hebben. Alles wat je vergeet toe te staan faalt luid tijdens het testen in plaats van stilletjes een gat open te laten in productie.
Ingress toestaan: wie mag NAAR deze pod praten?
Stel dat je een frontend hebt die verkeer moet ontvangen. Je selecteert die pod op zijn label en spelt uit wie hem mag bereiken.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-ingress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
- podSelector:
matchLabels:
app: ingress-controller
ports:
- protocol: TCP
port: 8080
flowchart LR
subgraph ingress-nginx["ingress-nginx ns"]
IC["ingress controller"]
end
subgraph production["production ns"]
FE["frontend<br/>app:frontend"]
Other["andere pods"]
end
IC -->|"✓"| FE
Other x--x|"✗"| FE
De frontend accepteert nu verkeer van de ingress controller op poort 8080, en verder niets. Andere pods in de namespace mogen kloppen wat ze willen; de deur blijft dicht.
Egress toestaan: waar mag deze pod NAAR praten?
Ingress regelt wie een pod bereikt. Egress regelt waar een pod naartoe mag. De frontend moet de backend API aanroepen, dus we openen dat pad en niets meer.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-egress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 3000
- to: # DNS toestaan
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
flowchart TD
subgraph production["production namespace"]
FE["frontend"] -->|"✓"| BE["backend"]
BE x--x|"✗"| DB["database"]
FE -->|"✓ alleen DNS"| DNS["kube-dns"]
end
De frontend kan de backend bereiken op poort 3000 en DNS resolven. Hij kan de database niet direct aanraken, en dat is het hele idee. Als iemand de frontend compromitteert, zit die vast aan de backend en kan niet rechtstreeks naar je data springen.
Het complete three-tier voorbeeld
Nu komen de lagen samen. Een realistische webapp: ingress naar frontend, frontend naar backend, backend naar database. Elke tier krijgt een policy, elke policy staat alleen zijn directe buur toe.
# Frontend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-policy
spec:
podSelector:
matchLabels:
tier: frontend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress
ports:
- port: 80
egress:
- to:
- podSelector:
matchLabels:
tier: backend
ports:
- port: 8080
- to: # DNS
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
---
# Backend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
spec:
podSelector:
matchLabels:
tier: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
tier: frontend
ports:
- port: 8080
egress:
- to:
- podSelector:
matchLabels:
tier: database
ports:
- port: 5432
- to: # DNS
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
---
# Database policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
spec:
podSelector:
matchLabels:
tier: database
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
tier: backend
ports:
- port: 5432
egress: [] # Database hoeft geen verbindingen te initiëren
flowchart LR
Internet --> Ingress
Ingress --> Frontend
Frontend --> Backend
Backend --> Database
Frontend -.->|"Alleen DNS"| DNS["kube-dns"]
Backend -.->|"Alleen DNS"| DNS
Elke tier praat alleen met de tier ernaast. De frontend kan de database niet bereiken. De database kan het internet helemaal niet bereiken, wat betekent dat zelfs een volledige database-compromittering geen data kan exfiltreren via een uitgaande verbinding. Dat is least privilege uitgedrukt als een graaf, en het is hetzelfde enkele-stap-model van eerder, gewoon drie keer herhaald.
Veelvoorkomende valkuilen
Hier gingen mijn debug-uren naartoe. Geen van deze is voor de hand liggend tot ze je bijten.
Valkuil 1: vergeet DNS niet
Je pods resolven service-namen via kube-dns. Pas een default-deny egress policy toe zonder DNS toe te staan en ineens resolvet niets, wat eruitziet als honderd verschillende bugs voor je doorhebt dat poort 53 gewoon geblokkeerd is.
egress:
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
Zonder dit resolvet backend.production.svc.cluster.local nooit en zit je app daar maar te timen-outen.
Valkuil 2: policies zijn additief
Meerdere policies op dezelfde pod worden gecombineerd met OR-logica. Policy A staat verkeer van X toe, policy B staat verkeer van Y toe, dus de pod accepteert zowel X als Y.
Er is geen “deny”-regel die een “allow” overschrijft. De enige manier om iets te denyen is het nooit toe te staan. Als je gewend bent aan firewall-systemen met expliciete deny-regels en volgorde: verleer dat hier.
Valkuil 3: lege selector betekent “alles”
podSelector: {} # Alle pods in deze namespace
namespaceSelector: {} # Alle namespaces
Deze pakt bijna iedereen. Een lege selector matcht alles, niet niets. Het leest als “geen pods” en gedraagt zich als “elke pod,” en precies daarom gebruikt default-deny podSelector: {} om de hele namespace te grijpen.
Valkuil 4: policies beïnvloeden alleen geselecteerde pods
Een NetworkPolicy raakt alleen pods die matchen met zijn podSelector. Elke pod die geen enkele policy selecteert heeft nul restricties en praat vrij.
Dus je kunt prachtige policies schrijven voor de helft van je namespace en de andere helft wijd open laten zonder het te merken. Default-deny lost dit op door te garanderen dat elke pod door minstens één policy wordt geselecteerd.
Network Policies testen
Vertrouw geen policy die je niet getest hebt. Start een wegwerp-pod en probeer dingen te bereiken die wel en niet zouden moeten werken.
# Maak een test pod
kubectl run test-pod --image=busybox --rm -it -- sh
# Probeer een andere service te bereiken
wget -qO- --timeout=2 http://backend.production:8080/health
# Probeer de database direct te bereiken (zou moeten falen)
nc -zv database.production 5432
Als je meer nodig hebt dan busybox: netshoot komt met elke netwerktool die je maar wilt:
kubectl run netshoot --image=nicolaka/netshoot -it --rm -- bash
De “zou moeten falen”-tests zijn belangrijker dan de “zou moeten werken”-tests. Bevestigen dat toegestaan verkeer stroomt is makkelijk. Het hele punt is bevestigen dat de geblokkeerde paden ook echt geblokkeerd zijn.
CNI-vereisten
Eén addertje voor je hierop vertrouwt: Network Policies worden afgedwongen door je CNI, niet door Kubernetes zelf. Als je CNI de NetworkPolicy API niet implementeert, accepteert het cluster je policies vrolijk en negeert ze volledig, wat een vervelende manier is om te ontdekken dat je geen isolatie hebt.
- Calico: volledige ondersteuning, plus eigen uitbreidingen
- Cilium: volledige ondersteuning, plus L7 policies
- Weave: volledige ondersteuning
- Flannel: geen ondersteuning (draai Calico erbovenop)
Check wat je draait voor je aanneemt dat een policy iets doet. Op mijn eigen clusters draai ik Cilium, deels voor de L7-features hieronder.
Voorbij basis policies
Zodra de basis klikt, gaan de CNI-specifieke uitbreidingen open:
- Cilium Network Policies: L7 (HTTP) filtering, DNS-based policies
- Calico Network Policies: host-bescherming, global policies
- Service Mesh: Istio of Linkerd voor mutual TLS en L7 policies
De kale Kubernetes NetworkPolicy API blijft bewust simpel, en dat is een feature. Hij dekt L3/L4, en wanneer je echt L7 nodig hebt grijp je naar een CNI die het levert. Grijp er niet naar voor je het nodig hebt.
Mijn aanbeveling
Begin klein en bouw op, net zoals deze post deed:
- Pas default-deny toe op elke namespace
- Voeg policies toe die alleen de communicatie toestaan die je echt nodig hebt
- Test grondig, vooral de paden die zouden moeten falen, voor productie
- Gebruik Cilium’s policy editor om te visualiseren wat je schrijft
Network Policies zijn een van de meest waardevolle security-verbeteringen in Kubernetes. Geen extra infrastructuur om te draaien, declaratief dus ze leven in Git zoals al het andere, en ze snijden je blast radius terug naar bijna niets.
Het mentale model kost een paar sessies van vallen en opstaan. Zodra het landt, voel je je een beetje ongemakkelijk bij elk cluster dat je zonder draait.
Network Policies zetten least privilege op het netwerk. Elke connectie die niet hoeft te bestaan zou niet moeten bestaan.
