Reported symptoms
Three tickets, opened within twenty minutes of each other, by three teams who have not spoken to one another.
- Storefront. Checkout returns 502 for one product area. The platform team confirms the ingress controller is healthy, its Pods have not restarted, and every other backend behind the same controller is serving normally. They hand the ticket to the billing team.
- Observability. Every Grafana panel for billing went flat at once. The metrics team demonstrates that Prometheus is up, that its other targets are being scraped, and that nothing in their configuration changed. They hand the ticket to the billing team.
- Quality. The nightly canary Job that calls the billing Service from inside the same namespace timed out. It has been closed as flaky twice before, so it is closed as flaky again.
The billing team looks at their service and finds nothing wrong.
Every Pod is Running and Ready. Nothing has restarted. The
containers log normal startup and then nothing at all, which is
what a healthy idle service looks like. kubectl exec into a Pod
and curl localhost:8080/healthz returns 200 instantly. The
Service has endpoints - all six Pod addresses, all marked ready -
so the usual empty-endpoints fault is ruled out early and
confidently.
The only change anywhere near this is a NetworkPolicy merged the previous afternoon, reviewed and approved with the note “tighten billing ingress, no functional change”. Two reviewers looked at it. It adds an allow rule. Nobody believes an added allow rule can take a service off the network, which is why the change is not suspected for six hours.
Evidence provided
$ kubectl get networkpolicy -n billingNAME POD-SELECTOR AGE
billing-allow-dns <none> 96d
billing-allow-ingress app=billing 19h
billing-default-deny <none> 96dIllustrative output
$ kubectl get networkpolicy billing-allow-ingress -n billing -o yamlspec:
podSelector:
matchLabels:
app: billing
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: api-gateway
ports:
- protocol: TCP
port: 8080Illustrative output
$ kubectl get pods -n monitoring -l app=api-gatewayNo resources found in monitoring namespace.Illustrative output
$ kubectl exec -n billing deploy/canary -- curl -sS --max-time 5 -o /dev/null -w '%{http_code}' http://billing:8080/curl: (28) Operation timed out after 5001 milliseconds with 0 bytes received
command terminated with exit code 28Illustrative output
$ kubectl exec -n billing deploy/billing -- curl -sS -o /dev/null -w '%{http_code}' localhost:8080/healthz200Illustrative output
$ kubectl get endpointslice -n billing -l kubernetes.io/service-name=billing -o jsonpath='{.items[*].endpoints[*].conditions.ready}'true true true true true trueIllustrative output
Work the evidence before reading on
Four facts are on the table. Three of them are consistent with a healthy service and one is not.
- The application answers on loopback. So the process is fine and the port is open.
- The Service has six ready endpoints. So Pod readiness, label selectors on the Service, and the endpoint controller are all fine.
- A caller inside the same namespace times out rather than being refused. A closed port refuses immediately; a dropped packet produces exactly this - silence until the client gives up.
- The only recent change adds an allow rule to a namespace that already has a default-deny.
Before reading on, look again at the YAML above and answer one question: the rule names two selectors. Are they two permitted sources, or one - and which character in that file decides?
Then ask what set of Pods actually satisfies the reading you arrived at, and whether any such Pod exists.
Root cause
1. Selectors inside one peer are AND-combined
A NetworkPolicy from block is a list of peers. Each list entry
is one permitted source. Within a single entry, the selectors are
combined with AND: a source must satisfy all of them. Across
entries, the combination is OR: a source may satisfy any one of
them.
The rule as merged has one entry:
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: api-gateway
There is one dash under from, so there is one peer, so this
reads as: a Pod labelled app: api-gateway, in a namespace
labelled kubernetes.io/metadata.name: monitoring. The
api-gateway runs in the billing namespace and Prometheus runs in
monitoring, so the intersection is empty. The rule permits
nothing.
What was intended is two peers:
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
- podSelector:
matchLabels:
app: api-gateway
One extra dash and two fewer spaces. That is the entire defect, and it changes the meaning of the file from “nothing” to “two things”.
2. A rule that matches nothing does not fail; it just does not help
NetworkPolicy has no deny. Every rule is additive: the set of permitted traffic is the union of everything every policy allows, and nothing subtracts from it. That design has a consequence worth stating plainly - a broken allow rule is indistinguishable from an absent one. There is no validation failure, because the YAML is valid. There is no admission error, because the selectors are well-formed. There is no event, no log line and no metric, because from the cluster’s point of view nothing went wrong: a rule was created, and it permits an empty set.
The namespace still has billing-default-deny, which selects
every Pod and declares Ingress. With no rule permitting anything,
that deny is the whole policy for billing, and it silently was
from the moment the change merged.
3. Every health check the team ran was blind to this by construction
This is why six hours passed before the policy was suspected.
The readiness probe is an exec probe. It runs a command inside
the container, so no packet crosses any interface and no policy
is consulted. It reports the process is alive, which is true, and
it reports it whether or not anything in the cluster can reach
the Pod.
Because readiness passes, the endpoint controller lists the Pod
addresses as ready in the EndpointSlice. So kubectl get endpointslice shows six healthy endpoints. Readiness is a
statement the Pod makes about itself; it is not a measurement of
reachability, and the endpoint list inherits that limitation.
The loopback curl has the same blind spot for the same reason.
Traffic to localhost never leaves the Pod network namespace.
Three independent checks, all green, none of which sends a packet along the path that is broken.
Resolution
- Decide first whether you are restoring service or reverting a commit, because with a default-deny in place they are different actions. Deleting
billing-allow-ingressdoes not restore anything: it leaves the namespace with a deny and no allow, which is the current outage with fewer objects in it. - Restore service by reapplying the previous allow policy from version control, verbatim. If that policy cannot be recovered in the time available, apply a deliberately broad temporary rule permitting TCP 8080 to
app=billing, and record it in the incident as a security exception with a named owner and an expiry, not as the fix. - Confirm the restore with a connection test rather than with a green dashboard. The canary Job, or a one-off Pod in the billing namespace, must reach
billing:8080and get a 200. - Now write the intended rule properly. Give each permitted source its own policy object - one named for the gateway, one named for the monitoring scrape - so that the AND-versus-OR distinction is carried by object boundaries rather than by a list marker that a reviewer has to notice.
- Before applying, prove every selector matches something real.
kubectl get pods -n billing -l app=api-gatewayandkubectl get pods -n monitoring -l app.kubernetes.io/name=prometheusmust both return Pods. A selector that matches zero objects is a rule that does nothing, and it is the same defect in a different shape. - Apply the two policies, then remove the temporary broad rule in the same change window. A temporary exception that outlives the incident is how a default-deny quietly becomes decorative.
- Write down in the incident record which reading of the rule was intended and which was applied, with both YAML fragments. The next person to write a two-source policy will find it, and the failure is not memorable enough to survive as folklore.
Verification
- The positive test: a Pod running as the api-gateway in the billing namespace reaches
billing:8080and receives a 200. Run it from a Pod carrying the real labels, not from a debug Pod with no labels, because the labels are what the policy matches on. - The second positive test: the Prometheus targets for the billing namespace return to UP and stay UP across at least three scrape intervals. One successful scrape can be a retry landing in a gap.
- The negative test, which is the one that can fail and the one most often skipped: a Pod in an unrelated namespace attempts
billing:8080and must time out. If it connects, the default-deny is not in force any more and the incident has been closed by removing the control rather than by fixing the rule. - The ingress path end to end: the checkout page returns 200 from outside the cluster, not merely from inside it. The 502s were the reported symptom and they are what the storefront team will check.
- Every selector in the new policies resolves to at least one live object, checked with
kubectl get pods -lfor each. Re-run this after any namespace relabelling, becausekubernetes.io/metadata.nameis set by the control plane but other namespace labels are not. - The temporary broad allow rule no longer exists.
kubectl get networkpolicy -n billinglists only the policies you intended to keep. - The canary Job that was closed twice as flaky is re-enabled and passes on its next scheduled run. It was the only automated test in the estate that exercised the broken path, and it was right both times.
Prevention
- Require two test results on every NetworkPolicy change: one connection that must succeed and one that must still fail. Attach both to the review. A YAML diff is not evidence about a dataplane.
- Give every intent its own policy object with a name that states the intent. Two sources means two objects. The AND-versus-OR distinction then cannot be expressed by indentation, which is the only form in which it is invisible.
- Lint selectors against the live cluster before merge. A
podSelector or namespaceSelector matching zero objects should
block the change; it takes one
kubectl getper selector and it catches the whole family of unsatisfiable rules. - Know which namespaces carry a default-deny, and treat every policy change in those namespaces as service-affecting by default. The blast radius of a policy edit depends entirely on what is already denying.
- Do not read Ready as reachable. An exec probe, and to a lesser degree a probe that only touches loopback, tells you a process is alive. If your readiness signal never crosses the interface, it cannot detect anything that blocks the interface.
- Verify once, at cluster level, that the CNI enforces NetworkPolicy at all. Flannel on its own does not, and on such a cluster every negative test above passes for the wrong reason - which is a worse outcome than this incident, because it is permanent and silent.