Kubernetes network policies: default-deny, keep DNS up
Lock down pod-to-pod traffic with a default-deny NetworkPolicy, then re-allow DNS and the flows your apps actually need.
In a cluster without NetworkPolicy every pod can open a connection to every other pod, in every namespace, on every port. A compromised front-end can therefore reach the billing database directly, and the only thing stopping it is that nobody told it the address. NetworkPolicy objects are the fix, with two properties that shape the rollout: they are enforced by the CNI plugin, not by Kubernetes itself, and they are purely additive, so the first policy that selects a pod flips it from allow-all to deny-all for the direction the policy names, and every later policy only adds allowances.
Once a pod is selected by any policy for a direction, that direction is closed except for what the policies open. DNS egress is the allowance everyone forgets and everyone needs.
Step 0: confirm something enforces it
A NetworkPolicy object is accepted by the API server whether or not the network plugin implements it. Calico, Cilium and the managed CNIs on the major clouds do; plain Flannel does not, and a cluster on Flannel will store your policies and enforce nothing. The test is empirical: apply a deny-all to a scratch namespace and check that a connection that worked before now times out. If it still connects, no amount of additional YAML will change that. One detail the run made obvious: test by ClusterIP, not by name. A deny of both directions also cuts the pod off from CoreDNS, so the first thing a probe by name reports is a failed lookup, which says nothing about enforcement; the timeout against the IP is the evidence.
kubectl exec -n shop web -- nc -zv -w 3 db 5432db (10.96.249.231:5432) openkubectl apply -f default-deny.yaml && sleep 4kubectl exec -n shop web -- nc -zv -w 3 db 5432nc: bad address 'db'the name no longer resolves: egress to CoreDNS is denied too, so this line proves nothing about the connection yetkubectl exec -n shop web -- nc -zv -w 3 10.96.249.231 5432; echo exit=$?nc: 10.96.249.231 (10.96.249.231:5432): Connection timed outexit=1timed out by IP after the deny: this CNI enforces policy. Still open: it does not, stop hereStep 1: deny both directions
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: default-denynamespace: shopspec:podSelector: {} # every pod in the namespacepolicyTypes: [Ingress, Egress]# no ingress: or egress: rules, so nothing is allowed in either direction
Listing Egress in policyTypes is what most examples leave out and what makes this a real boundary: without it, a compromised pod can still reach every other namespace and the internet. With it, the namespace also loses DNS, because CoreDNS lives in kube-system and name resolution is an egress connection; that is the bad address above. Every application in the namespace now fails on the first lookup, so the next policy goes in the same change, not the next morning.
Step 2: give DNS back
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: allow-dnsnamespace: shopspec:podSelector: {}policyTypes: [Egress]egress:- to:- namespaceSelector:matchLabels: { kubernetes.io/metadata.name: kube-system }podSelector:matchLabels: { k8s-app: kube-dns }ports:- { protocol: UDP, port: 53 }- { protocol: TCP, port: 53 } # large responses and DNS-over-TCP fall back to TCP
The to entry combines a namespaceSelector and a podSelector in one list item, which means both must match: CoreDNS pods, in kube-system. Written as two separate list items the same selectors would mean either, which allows every pod in kube-system plus every pod labelled k8s-app: kube-dns anywhere. The kubernetes.io/metadata.name label is set on every namespace by the API server, so it is the reliable way to select a namespace by name.
Step 3: allow the flows the architecture needs, on both ends
Policies match pod labels, not Service names, so the selector must match the labels on the Pod template of the Deployment, and a flow has two ends: the destination's ingress policy must admit the source, and, once egress is denied, the source's egress policy must admit the destination. Forgetting the second half produces the classic symptom of web timing out on db:5432 although db clearly allows it; the run reproduced it exactly, with db-accepts-web applied alone the probe still timed out, and it opened only when web-reaches-db followed.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: db-accepts-webnamespace: shopspec:podSelector:matchLabels: { app: db }policyTypes: [Ingress]ingress:- from:- podSelector:matchLabels: { app: web }ports:- { protocol: TCP, port: 5432 }---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: web-reaches-dbnamespace: shopspec:podSelector:matchLabels: { app: web }policyTypes: [Egress]egress:- to:- podSelector:matchLabels: { app: db }ports:- { protocol: TCP, port: 5432 }
Step 4: prove the denied path
A policy that has only been tested from the pod it allows has not been tested. Run the same probe from a pod without the app: web label and expect a timeout; run it from another namespace and expect the same. The two results together are what the policy claims, and they are what to re-run after every CNI upgrade.
kubectl exec -n shop web -- nc -zv -w 3 db 5432 # allow-dns, db-accepts-web and web-reaches-db applieddb (10.96.249.231:5432) openkubectl run probe -n shop --rm -i --image=busybox:1.37 --labels=app=probe -- nc -zv -w 3 db.shop.svc 5432nc: db.shop.svc (10.96.249.231:5432): Connection timed outkubectl run probe -n default --rm -i --image=busybox:1.37 -- nc -zv -w 3 db.shop.svc 5432nc: db.shop.svc (10.96.249.231:5432): Connection timed outkubectl exec -n shop web -- nc -zv -w 3 10.96.249.231 5433 # a port the policy does not listnc: 10.96.249.231 (10.96.249.231:5433): Connection timed outunlabelled pod in the namespace: denied. Pod from another namespace: denied. Allowed pod, wrong port: denied. That is the policykubectl delete networkpolicy --all -n shop && sleep 4networkpolicy.networking.k8s.io "allow-dns" deleted from shop namespacenetworkpolicy.networking.k8s.io "db-accepts-web" deleted from shop namespacenetworkpolicy.networking.k8s.io "default-deny" deleted from shop namespacenetworkpolicy.networking.k8s.io "web-reaches-db" deleted from shop namespacekubectl run probe -n default --rm -i --image=busybox:1.37 -- nc -zv -w 3 db.shop.svc 5432db.shop.svc (10.96.249.231:5432) openthe reversal of a deny that broke something is the deletion of the policy objects: on the fixture cluster every path was open again by the time the fixture re-probed, four seconds later, including the ones you did not mean to reopen. Restore the intended set in one applyMistakes that look like a broken cluster
| Symptom | Cause | Fix |
|---|---|---|
| Everything hangs after the deny | egress denied, DNS included | apply allow-dns in the same change |
| Policy applied, traffic still flows | CNI does not enforce NetworkPolicy | a CNI that does; the YAML is inert until then |
web times out although db allows it | only the ingress half exists | an egress policy on web to db |
| A rule allows far more than intended | namespaceSelector and podSelector as separate list items (OR) | one list item with both selectors (AND) |
| Selector matches nothing | selector uses Service name or Deployment labels | labels from the Pod template |
| 502s from the Ingress after the deny | the ingress controller’s pods are no longer allowed in (kubelet probes come from the node and are usually unaffected) | allow ingress from the controller’s namespace and pods on the app port |
When a policy change breaks the namespace: recovery in order of preference
| Symptom | Diagnosis | Reversal and verification |
|---|---|---|
every lookup fails right after a deny (bad address) | egress is denied and allow-dns is missing; kubectl get networkpolicy -n <ns> shows only the deny | apply allow-dns; verify with a probe by name from an affected pod (executed: the name resolved again, the connection stayed denied until its own policy came) |
| a flow that should be allowed times out although the destination policy admits it | only the ingress half exists; kubectl describe networkpolicy on the source shows no egress rule to the destination | apply the source egress policy; verify with the probe from the allowed pod (executed) |
| the namespace is down and the right set of policies is unclear | more than one policy was changed at once | kubectl delete networkpolicy --all -n <ns> restores the flat network (executed on kindnet: open again at the four-second re-probe; how fast another CNI converges is its own property), then re-apply the reviewed set in one commit; verify the allowed and the denied probes both |
| policies applied, nothing changes | the CNI does not enforce NetworkPolicy | nothing to revert; the probe by ClusterIP after a deny is the test, and until a CNI that enforces is installed the objects are inert (documentation-backed for CNIs other than kindnet) |
NetworkPolicy limits where a compromised pod can connect. It says nothing about what that pod can ask the API server for (RBAC) or whether it runs as root with host mounts (Pod Security Admission); the three together are the boundary around one bad workload.