Kubernetes NetworkPolicy, explained for CKA, CKAD and CKS
Out of the box, every Pod in a Kubernetes cluster can open a connection to every other Pod, in every namespace. That is convenient until a compromised web Pod can talk straight to the payroll database. NetworkPolicy is the built-in way to say which Pods may talk to which, on which ports, and it is a small API with a surprising number of ways to get it wrong.
All three Kubernetes exams use it. CKAD asks you to let one app reach another and nothing else. CKA adds default-deny policies and the debugging that follows them, usually a DNS failure. CKS treats it as a security control and asks what it can and can't protect. The YAML is short; the marks are in the semantics.
The model: default allow, then isolation
A Pod that no policy selects is non-isolated: all traffic in and out is allowed. The moment any NetworkPolicy selects a Pod and lists Ingress in its policyTypes, that Pod becomes isolated for ingress, and only connections some policy explicitly allows can reach it. Egress works the same way, independently. A Pod can be isolated for ingress and wide open for egress.
Three rules follow from this, and most exam questions hang on one of them:
- Policies only add allows. There is no deny rule and no priority. The traffic a Pod may receive is the union of every policy that selects it, so the order in which they were created makes no difference. A "default deny" policy is just a policy that selects Pods and allows nothing.
- Both ends must agree. A connection from Pod A to Pod B needs A's egress (if A is isolated for egress) and B's ingress (if B is isolated for ingress) to allow it. Fixing one side is not enough when both are locked down.
- Replies are automatic. If a connection is allowed, its return traffic is allowed too. You never write a rule for the response.
A few things are always allowed and can't be blocked by NetworkPolicy: traffic between a Pod and the node it runs on, and a Pod talking to itself.
Anatomy of a policy
A NetworkPolicy is namespaced and has four parts that matter:
podSelectorchooses which Pods in the policy's own namespace the policy applies to. An empty selector,{}, means every Pod in the namespace.policyTypeslistsIngress,Egressor both. If you leave it out,Ingressis always assumed andEgressis added only when the policy hasegressrules. Write it out explicitly; a policy meant to deny all egress with noegresssection and nopolicyTypeswill only touch ingress.ingressis a list of rules, each withfrompeers andports. A rule allows traffic that matches its peers and its ports.egressis the same shape withtoinstead offrom.
Peers come in three forms. podSelector picks Pods in the policy's own namespace. namespaceSelector picks whole namespaces by label. ipBlock picks a CIDR range, optionally with except ranges, and is meant for addresses outside the cluster, since Pod IPs change. Ports can be numbers or named ports, and endPort turns a port into an inclusive range.
Namespaces can't be named directly. Instead, every namespace carries an immutable label, kubernetes.io/metadata.name, set to its own name, so namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: monitoring}} selects exactly one namespace.
The AND/OR gotcha
This is the most tested detail in the whole API, and it comes down to one YAML dash.
When namespaceSelector and podSelector sit in the same list element (one dash, two keys), they are combined with AND: Pods with the given label, inside namespaces with the given label.
When they are separate list elements (two dashes), they are combined with OR: any Pod in a matching namespace, or any Pod with the label in the policy's own namespace. The second form is far more permissive, and it looks almost identical in an editor.
So a rule meant to allow only Prometheus Pods from the monitoring namespace must have one - before namespaceSelector and podSelector indented underneath it with no dash. If you add a dash before podSelector, every Pod in monitoring gets in, plus any Pod in your own namespace that happens to carry the Prometheus label. When unsure, kubectl describe networkpolicy prints how Kubernetes read it.
Default deny and the DNS trap
The usual starting point is a namespace-wide default deny: podSelector: {} with policyTypes: [Ingress, Egress] and no rules. Every Pod in the namespace becomes isolated in both directions, and you then add narrow allow policies on top.
The trap is DNS. Once Pods are isolated for egress, their DNS queries to CoreDNS in kube-system are blocked like everything else. The application never gets as far as connecting to the database; it fails resolving the database's name, and the logs say "lookup failed" or "no such host" rather than "connection refused". The fix is an egress rule allowing UDP and TCP port 53 to the DNS Pods, selected with a namespaceSelector for kube-system and a podSelector for the DNS Pods' label in the same element. TCP matters too, because large DNS responses fall back to it.
Your CNI has to enforce it
The API server stores NetworkPolicy objects whether or not anything enforces them. Enforcement is the network plugin's job. With a plugin that doesn't support NetworkPolicy, kubectl apply succeeds, kubectl get networkpolicy lists your policy, and traffic flows exactly as before. Calico and Cilium enforce it; some simple plugins do not. If a policy "has no effect", check the CNI before rewriting YAML.
Other limits worth knowing for CKS:
- It works at layers 3 and 4 only, and is guaranteed only for TCP, UDP and SCTP. Behaviour for ICMP and other protocols depends on the plugin.
- There is no TLS, no logging of dropped connections, no rule by Service name, and no cluster-wide default that applies to new namespaces. Those need a service mesh, admission control or a plugin's own policy resources.
- Pods on the host network are usually treated as node traffic, and processes on the node such as the kubelet are outside its reach.
- New policies take effect asynchronously, and whether an existing connection is cut when a policy changes depends on the implementation.
How to choose
- Lock down a namespace: one default-deny policy with
podSelector: {}and bothpolicyTypes. - Let one app reach another: an ingress policy on the destination selecting the source by label, plus an egress policy on the source if it is isolated for egress.
- Allow traffic from a specific set of Pods in another namespace:
namespaceSelectorandpodSelectorin the same peer element. - Allow a whole namespace, such as an ingress controller's:
namespaceSelectoralone, usingkubernetes.io/metadata.name. - Allow an external API or a managed database:
ipBlockwith the provider's CIDR. - Any egress lockdown: add DNS on port 53, UDP and TCP, first.
Common exam traps
- Believing a deny policy overrides an allow policy. Nothing overrides anything; policies are unioned.
- Forgetting DNS after default-deny egress, then chasing the database port.
- Splitting
namespaceSelectorandpodSelectorinto two elements and opening access far wider than intended. - Omitting
policyTypeson an egress-only deny and getting an ingress deny instead. - Applying a policy on a cluster whose CNI doesn't enforce NetworkPolicy and assuming the YAML is wrong.
- Using a NetworkPolicy to protect a node-level service such as the kubelet API.
A worked example
In namespace shop, the api Pods must accept traffic only from the web Pods in the same namespace on TCP 8080, and must only be able to reach the postgres Pods on 5432. A default-deny policy for both directions already exists, and so does an egress policy that lets web reach api and the cluster DNS. After adding an ingress policy on api that allows web on 8080, the web pages load. After adding an egress policy on api that allows postgres on 5432, the API logs show database connection errors that mention a failed name lookup.
The web-to-api path works because both ends now allow it: web's egress and api's ingress. The api-to-database path fails because api is isolated for egress and the only destination it may reach is postgres, so its query to CoreDNS for postgres.shop.svc.cluster.local is dropped. The fix is a further egress rule from api (or from every Pod in shop) to the DNS Pods in kube-system on port 53 over UDP and TCP. One more check: postgres is also under the default deny, so it needs its own ingress allow for api on 5432, or the connection is refused at the other end.
Practice questions
- Default-deny applied, but the app can't reach its database
- Locking down a kubelet flagged by a benchmark
- A short name that resolves in one namespace but not another
- Pods running but the Service gets no traffic