Troubleshooting NetworkPolicy-Blocked Traffic
A NetworkPolicy denial times out rather than refusing, which makes it look like a dead backend. An egress policy that blocks DNS fails differently again. Those two signatures, plus knowing policies only ever add permission, cover most of the diagnosis.
Troubleshooting Guide 99 of 103 Intermediate
- Kubernetes1.36.4
- CNICalico v3.32.1
- Cluster4 nodes
- Runtimecontainerd 2.2.6
- TimeAbout 35 min
- Reviewed21 August 2026
Written against the versions above. Enforcement is the CNI's job, so the API is portable and the exact failure timing is not. Calico is the enforcer here.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| CKA1001 | 192.168.0.175 | Ubuntu 26.04 LTS | Control Plane Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE01 | 192.168.0.176 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE02 | 192.168.0.177 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA1001-NODE03 | 192.168.0.178 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
Before you start
- The NetworkPolicies guide, which covers authoring them. This one is about diagnosing them.
- The DNS in Kubernetes guide, because the second failure mode is a DNS failure.
- A CNI that enforces NetworkPolicy. Calico here; Flannel on its own does not.
-
The baseline, and the first command to run
With no policy selecting them, Pods can reach anything:
server reachedAnd the count of policies in the whole cluster:
1That one policy is in the
defaultnamespace, left over from a different exercise, and it is worth pausing on because it demonstrates the property that catches people out. NetworkPolicy is namespaced. A policy indefaulthas no effect on Pods innpt, so the count being non-zero says nothing about whethernptis affected.That makes
kubectl get netpol -Athe first command when connectivity is unexplained. It takes a second and it answers a question the rest of the debugging depends on:- No policies anywhere and the problem is not NetworkPolicy. Go to Services, DNS or the application.
- Policies exist, and one selects the Pod in question, and you have a candidate.
The
-Amatters twice over: for the traffic you care about, both the source namespace and the destination namespace can hold a policy that blocks it, and the two are enforced independently. A policy on the client's egress and a policy on the server's ingress are separate decisions, and both must permit the connection.One prerequisite that is easy to forget. The NetworkPolicy API accepts objects whether or not anything enforces them. On a cluster with a CNI that does not implement policy, every policy you write applies cleanly and nothing changes. If policies exist and appear to do nothing at all, check the CNI before checking the policies.
bash Example session kubectl get netpol -A --no-headers 2>/dev/null | wc -l1kubectl get netpol -n default --no-headers 2>&1default-deny <none> 6h31mkubectl logs base -n nptserver reachedExpected resultOne policy in the cluster, in a namespace unrelated to the test, and an unrestricted Pod reaching the server. The probe Pod ran
wget -qO- --timeout=5 http://server/.Success conditionYou can count the policies in the cluster and confirm connectivity works before any are added.
-
The signature: it times out, it does not refuse
Apply a default-deny for ingress and repeat the same request:
wget: download timed out Command exited with non-zero status 1 real 0m 5.00sTimed out, and the
real 0m 5.00sis exactly the--timeout=5that was asked for, meaning the client waited the full budget and gave up.This is the single most useful thing to know about NetworkPolicy failures, because the alternative is very different. A refused connection means something answered: a TCP RST came back, so the packet reached a host that had no listener. A timeout means nothing answered at all: packets went out and were dropped in silence, and the client waited.
So the two failures point in opposite directions:
- Connection refused - routing works, nothing is listening on that port. Wrong port, application not started, wrong
targetPort. - Timeout - packets are being dropped. A NetworkPolicy, a firewall, a broken route, or a Service with no endpoints backing an address that is nonetheless routable.
That is why NetworkPolicy denials get misdiagnosed as dead backends. The symptom is identical to a hung application, and people go to read application logs, where there is nothing at all, because the request never arrived.
One detail to be careful with. A policy drops packets, it does not close connections. Established connections often continue working after a policy is applied, because conntrack already has an entry for them. So a policy can break every new connection while long-lived ones look healthy, which makes the change look harmless right after it is applied and broken later when something reconnects. When testing a policy, always test with a fresh connection.
And the deny itself is four lines with nothing in it:
spec: podSelector: {} policyTypes: [Ingress]podSelector: {}is an empty selector, which matches every Pod in the namespace, not none.policyTypes: [Ingress]with noingressrules is the deny: it isolates the selected Pods for ingress and then permits nothing. Written by hand this is a two-character difference from something that has no effect, and it is a common accident.bash Example session kubectl apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-ingress namespace: nptspec: podSelector: {} policyTypes: [Ingress]EOFnetworkpolicy.networking.k8s.io/default-deny-ingress createdkubectl logs denied -n npt 2>&1 | head -4wget: download timed outCommand exited with non-zero status 1real 0m 5.00suser 0m 0.00sExpected resultA timeout after exactly the client's timeout budget, not a refusal. The probe ran
time wget -qO- --timeout=5 http://server/, so therealline is the request's own duration.Success conditionYou can produce a policy denial and recognise it as a timeout rather than a refusal.
- Connection refused - routing works, nothing is listening on that port. Wrong port, application not started, wrong
-
describe explains the policy in words
Two commands identify the culprit. The listing, showing selector and types:
default default-deny map[] [Ingress] npt default-deny-ingress map[] [Ingress]map[]is how an emptypodSelectorprints, and it means all Pods in that namespace. Both policies here are namespace-wide denies, and reading this table is usually enough to know whether a policy could be responsible.Then
describe, which is unusually good for this resource because it translates the semantics into sentences:Spec: PodSelector: <none> (Allowing the specific traffic to all pods in this namespace) Allowing ingress traffic: <none> (Selected pods are isolated for ingress connectivity) Not affecting egress traffic Policy Types: IngressThree lines worth reading closely.
PodSelector:confirms the empty selector means all, which is the opposite of what "none" suggests on its own.(... to all pods in this namespace) Allowing ingress traffic:is the deny stated plainly. "Isolated" is the term the API uses: once any policy selects a Pod for a direction, that Pod is isolated in that direction, and only explicit rules let traffic back in.(Selected pods are isolated for ingress connectivity) Not affecting egress trafficis the reassurance that this policy does not touch outbound. Reading this on every candidate policy is how you build the picture of which directions are actually restricted, and the phrasing makes it hard to misread.On a cluster with many policies,
kubectl describe netpol -ndescribes all of them at once, which is faster than reading YAML and much harder to misinterpret.bash Example session kubectl get netpol -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,POD-SELECTOR:.spec.podSelector,TYPES:.spec.policyTypes --no-headersdefault default-deny map[] [Ingress]npt default-deny-ingress map[] [Ingress]kubectl describe netpol default-deny-ingress -n npt | head -14Name: default-deny-ingressNamespace: nptCreated on: 2026-08-21 12:07:08 +0000 UTCLabels: <none>Annotations: <none>Spec: PodSelector: <none> (Allowing the specific traffic to all pods in this namespace) Allowing ingress traffic: <none> (Selected pods are isolated for ingress connectivity) Not affecting egress traffic Policy Types: IngressExpected resultThe selector printed as
map[]in the listing and explained as "all pods in this namespace" by describe, with the deny stated as isolation.Success conditionYou can identify which policies select a Pod and read what each one permits.
-
Policies only ever add permission
The instinct on finding a deny policy is to edit it. That is the wrong move, and understanding why prevents a class of mistakes.
There is no such thing as a deny rule in NetworkPolicy. Every rule grants. The effective permission for a Pod is the union of every rule in every policy that selects it, and a Pod becomes isolated the moment any policy selects it for a direction. So a deny-all is simply a policy that selects Pods and grants nothing, and the fix is to add another policy that grants what is needed:
spec: podSelector: {matchLabels: {app: server}} policyTypes: [Ingress] ingress: - from: - podSelector: {matchLabels: {role: client}} ports: - {protocol: TCP, port: 80}Both policies now select the server Pod, and the result is the union:
with role=client: server reached without the label: FAILEDTwo policies in the namespace, neither modified:
allow-from-client default-deny-ingressThis has three consequences worth internalising.
You cannot make an exception. "Allow everything except this one Pod" is not expressible in a NetworkPolicy. Deny-by-default plus explicit allows is the only shape. Calico's own CRDs add ordered policies with deny actions, and so do some other CNIs, but that is beyond the portable API.
Adding a policy can only widen access, never narrow it. So a new policy cannot be the cause of something that stopped working, unless it is the *first* policy to select those Pods, which flips them from unisolated to isolated. That single exception is where nearly all NetworkPolicy incidents come from.
Removing an allow policy narrows access, which is the reverse of the intuition people carry from firewalls. Deleting what looks like an unused policy is how working traffic gets broken.
The practical debugging move follows from the union rule: to find out why a Pod is unreachable, list every policy selecting it and check whether any of them permits the traffic. One permitting rule is enough; the others are irrelevant.
And note what the probes prove about matching. Both probe Pods were identical busybox Pods in the same namespace, and the only difference was the
role=clientlabel. ThepodSelectorin afromblock matches on labels and nothing else: not names, not IPs, not ServiceAccounts. A Pod that should have access and does not, when the policy looks right, is nearly always a label that does not match, andkubectl get pod --show-labelssettles it in one command.bash Example session kubectl apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-from-client namespace: nptspec: podSelector: {matchLabels: {app: server}} policyTypes: [Ingress] ingress: - from: - podSelector: {matchLabels: {role: client}} ports: - {protocol: TCP, port: 80}EOFnetworkpolicy.networking.k8s.io/allow-from-client createdecho "with role=client: $(kubectl logs allowed -n npt 2>&1 | tail -1)"; echo "without the label: $(kubectl logs unlabelled -n npt 2>&1 | tail -1)"with role=client: server reachedwithout the label: FAILEDkubectl get netpol -n npt --no-headers | awk '{print $1}'allow-from-clientdefault-deny-ingressExpected resultThe labelled Pod through, the unlabelled Pod still blocked, and the deny policy untouched. Both Pods were the same image in the same namespace.
Success conditionAdding an allow policy restores access for matching Pods without editing the deny.
-
The egress trap: DNS fails, and it fails differently
Now the most common way a NetworkPolicy rollout breaks things, and the reason to read the failure text rather than just noting that it failed.
Apply a default-deny for egress on the client and try again:
wget: bad address 'server'Not a timeout.
bad addressis a name resolution failure, and it happened before any connection to the server was attempted.The reason is that DNS is egress. A Pod resolving
serversends a UDP packet to the CoreDNS Service on port 53, and an egress policy that grants nothing blocks that too. So a policy meant to restrict where an application can connect breaks its ability to look up anything at all, including the destinations you intended to allow.The two signatures are worth committing to memory, because they split the problem in half instantly:
bad address,Name or service not known,Temporary failure in name resolution- DNS. The egress policy is missing a rule for port 53 to kube-dns.- Timed out on a connection to an address that resolved - the policy is blocking the application traffic itself, or the destination's ingress policy is.
The fix needs both rules, and note the
toselector for DNS:egress: - to: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: kube-system} ports: - {protocol: UDP, port: 53} - {protocol: TCP, port: 53} - to: - podSelector: {matchLabels: {app: server}} ports: - {protocol: TCP, port: 80}With both, resolution and the request work:
Server: 10.96.0.10 Address: 10.96.0.10:53 Name: server.npt.svc.cluster.local Address: 10.104.91.161 server reachedFour details in that DNS rule that each cause their own failure.
kubernetes.io/metadata.nameis a label Kubernetes sets automatically on every namespace, holding the namespace's own name. It is the reliable way to select a namespace, because it is always there. Selecting on a hand-applied label likename=kube-systemworks only if somebody applied it, and a namespace with no labels cannot be selected at all.Both UDP and TCP on 53. DNS uses UDP for ordinary queries and falls back to TCP for large responses. Allowing only UDP works until a response exceeds the UDP limit, and then fails intermittently in a way that is very hard to attribute.
A
namespaceSelector, not apodSelector. CoreDNS runs inkube-system, and a barepodSelectorin an egress rule only ever matches Pods in the policy's own namespace. This is the same limitation step 6 covers, and getting it wrong here is why so many DNS allow-rules silently do nothing.The Service ClusterIP is not what the policy matches. Pods talk to
10.96.0.10, the kube-dns Service, but policy is enforced on the Pod IPs behind it, which is why selecting the CoreDNS Pods by namespace is the right approach and anipBlockfor the Service CIDR generally is not.Almost every cluster running policies has a namespace-wide allow-DNS-egress policy for exactly this reason. It is close to boilerplate, and writing it once per namespace is cheaper than diagnosing it once.
bash Example session kubectl apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: deny-egress-from-client namespace: nptspec: podSelector: {matchLabels: {role: client}} policyTypes: [Egress]EOFnetworkpolicy.networking.k8s.io/deny-egress-from-client createdkubectl logs noegress -n npt 2>&1 | head -5wget: bad address 'server'kubectl apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-dns-and-server namespace: nptspec: podSelector: {matchLabels: {role: client}} policyTypes: [Egress] egress: - to: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: kube-system} ports: - {protocol: UDP, port: 53} - {protocol: TCP, port: 53} - to: - podSelector: {matchLabels: {app: server}} ports: - {protocol: TCP, port: 80}EOFnetworkpolicy.networking.k8s.io/allow-dns-and-server createdkubectl logs fixed2 -n npt 2>&1 | grep -vE '^$' | tail -6Server: 10.96.0.10Address: 10.96.0.10:53Name: server.npt.svc.cluster.localAddress: 10.104.91.161server reachedExpected result
bad addressunder the egress deny, then a successful lookup and request once DNS and the application port are both allowed. The probe rannslookup server.npt.svc.cluster.localfollowed by the samewget.Success conditionYou can tell a DNS-blocked egress failure from a connection-blocked one by the error text, and fix it with a DNS egress rule.
-
Cross-namespace: a podSelector cannot see another namespace
The last trap, and it produces a confident wrong conclusion.
The
allow-from-clientpolicy permitspodSelector: {matchLabels: {role: client}}. A Pod in a different namespace, carrying exactly that label, tries the server:wget: download timed out FAILEDBlocked. The label matches, and it does not matter.
A bare
podSelectorin afromortoblock is scoped to the policy's own namespace. It is not a cluster-wide label search. So a Pod in another namespace can carry every label the rule names and still be denied, which is a genuinely surprising result when you are reading the policy and the Pod side by side.Selecting across namespaces needs a
namespaceSelector, and the combination has two forms that mean different things:# Pods labelled role=client, in ANY namespace labelled team=web: - namespaceSelector: {matchLabels: {team: web}} podSelector: {matchLabels: {role: client}} # EITHER any Pod in a namespace labelled team=web, # OR a Pod labelled role=client in this namespace: - namespaceSelector: {matchLabels: {team: web}} - podSelector: {matchLabels: {role: client}}The difference is one hyphen. Two selectors in one list element are ANDed; two separate list elements are ORed. That is the most consequential piece of YAML punctuation in the NetworkPolicy API, and the second form is much more permissive than people intend.
Selecting a namespace also needs the namespace to be selectable, and by default the only label it has is the automatic one:
{"kubernetes.io/metadata.name":"npt-other"}So
namespaceSelector: {matchLabels: {team: web}}matches nothing until somebody labels the namespace, and the policy that references it is valid, silent and useless.kubernetes.io/metadata.nameis the label to reach for when you want one specific namespace, because it always exists.Worth being precise about what "selectable" means: an empty
namespaceSelector: {}matches all namespaces, the same inversion as an emptypodSelector.- namespaceSelector: {}in an ingress rule is "from anywhere in the cluster", which is occasionally what you want and frequently a mistake.bash Example session kubectl logs outsider -n npt-other 2>&1 | tail -2wget: download timed outFAILEDkubectl get ns npt-other -o jsonpath='{.metadata.labels}{"\n"}'{"kubernetes.io/metadata.name":"npt-other"}Expected resultA Pod with the right label in the wrong namespace, blocked. It requested the fully qualified
http://server.npt.svc.cluster.local/, so this is not a DNS problem.Success conditionYou know a bare podSelector does not cross namespaces, and which label to use to select one.
-
Where the CNI's own view of policy lives
One more place to look when the Kubernetes objects seem fine and traffic is still wrong: the CNI's own policy layer.
Calico has native policy CRDs alongside the Kubernetes API, and this cluster has some already:
calico-system calico-system.apiserver-access 7h17m calico-system calico-system.default-deny 7h17m calico-system calico-system.kube-controller-access 7h17mNobody wrote those. The Tigera operator created them to protect its own namespace, and they are invisible to
kubectl get netpol, which only showsnetworking.k8s.ioobjects. On any cluster where the CNI or a service mesh installs its own policies,kubectl get netpol -Ais an incomplete picture, and this is worth knowing before concluding that no policy is involved.Calico's native policies also support things the portable API does not: an explicit
Denyaction, ordering, and rules that apply cluster-wide viaGlobalNetworkPolicy. AGlobalNetworkPolicydenying something is not namespaced and will not appear in any per-namespace listing.The reverse question, whether Calico's API shows the Kubernetes policies you wrote, has a clear answer on this install:
No resources found in npt namespace.It does not. The Kubernetes NetworkPolicy objects in
nptare enforced by Calico but not surfaced through its aggregated API here, so do not read an empty result as "no policy applies". Usekubectl get netpolfor the Kubernetes objects and the CNI's own resources for the CNI's, and check both.For a specific connection, the enforcement layer itself is the last resort: Calico's per-node Felix agent renders policy into the node's packet filter, and
calicoctlcan evaluate which policies apply to a given endpoint. That is deeper than the CKA needs, and the Calico internals guide covers where those pieces live.bash Example session kubectl get networkpolicies.crd.projectcalico.org -A --no-headers 2>&1 | head -3calico-system calico-system.apiserver-access 7h17mcalico-system calico-system.default-deny 7h17mcalico-system calico-system.kube-controller-access 7h17mkubectl get networkpolicies.projectcalico.org -n npt 2>&1 | head -4No resources found in npt namespace.Expected resultCalico's own policies, which
kubectl get netpolnever shows, and an empty result for the Kubernetes policies through Calico's API. Both halves are the point.Success conditionYou know to check the CNI's own policy resources as well as
networking.k8s.ioones.
Troubleshooting
A connection times out and the backend's logs show nothing.
Why: Packets are being dropped before arrival, and a NetworkPolicy is the usual reason.
Fix:The absence of anything in the backend's logs is the clue: the request never arrived.
kubectl get netpol -Aand check both the client's namespace and the server's. Compare against a refusal, which means routing worked and nothing was listening, and is a different problem entirely.wget: bad addressorTemporary failure in name resolutionfrom a Pod.Why: An egress policy selects the Pod and does not allow DNS.
Fix:Add an egress rule to
kube-systemon both UDP and TCP port 53, selected withnamespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}. A barepodSelectordoes not reachkube-systemand is the most common reason an allow-DNS rule does nothing.A Pod in another namespace has the right label and is still blocked.
Why: A bare
podSelectorin afromortoblock only matches Pods in the policy's own namespace.Fix:Add a
namespaceSelector. Mind the punctuation: two selectors in one list element are ANDed (that namespace and that label), while separate list elements are ORed (that whole namespace or that label locally). The second is far more permissive.A
namespaceSelectormatches nothing even though the namespace exists.Why: The namespace does not carry the label the selector names. Namespaces get only
kubernetes.io/metadata.nameautomatically.Fix:
kubectl get ns <name> --show-labels. Either label the namespace, or select onkubernetes.io/metadata.name, which is always present. NotenamespaceSelector: {}matches every namespace, which is rarely what is intended.You edited the deny policy to allow traffic and it made things worse.
Why: Policies are additive and there is no deny rule. Editing a deny is not how exceptions are made.
Fix:Leave the deny alone and add a policy granting exactly what is needed. Effective access is the union of all policies selecting the Pod, so one permitting rule is enough. "Allow all except X" is not expressible; deny-by-default plus explicit allows is the only shape.
A new policy appeared to work and traffic broke hours later.
Why: Policies drop packets rather than closing connections, so established connections survive via conntrack.
Fix:Always test with a fresh connection from a new Pod. A long-lived connection pool can keep working across a policy change and fail on the next reconnect, which puts the breakage a long way from the change that caused it.
Policies exist and have no effect at all.
Why: The CNI does not enforce NetworkPolicy. The API accepts the objects regardless.
Fix:Check which CNI is installed. Calico, Cilium and several others enforce policy; Flannel on its own does not. A cluster that accepts every policy and enforces none is a security problem that looks like a working configuration.
kubectl get netpol -Ashows nothing relevant but traffic is still blocked.Why: The CNI or a service mesh has its own policy resources, which that command does not show.
Fix:On Calico, check
kubectl get networkpolicies.crd.projectcalico.org -Aandglobalnetworkpolicies.crd.projectcalico.org, the latter being cluster-wide and absent from any namespaced listing. Native CNI policies can also carry explicit deny actions, which the portable API cannot express.