Scenario
For the next round of hands-on Kubernetes practice, I asked my local LLM for a harder live scenario.
This one looked almost too easy. Two green catalog pods, a Service with populated endpoints, a client that simply could not connect. I had already fixed stranger things this month - a targetPort pointing at a port nobody was listening on in Payments API Down While Pods Are Ready, and a readiness probe aimed at a /healthz path that never existed in Pod Is Running but Not Ready.
I was expecting a port number. Instead, I ran into a misconfigured NetworkPolicy (if you need a primer on how ingress and egress isolation works by default, check out What is Network Policy in Kubernetes).
The Incident
The catalog API was unreachable from storefront in the shop namespace:
deploy/catalog— 2 replicas ofnginx, serving HTTP on port 80svc/catalog—ClusterIPon port 80deploy/storefront— a client looping onhttp://catalog/pod/tester— a generic debug pod
Requests to http://catalog/ were refused, even though the catalog pods were 1/1 Running and the Service had both pod IPs in its EndpointSlice.
Troubleshooting
Everything Green, Still Refused
Logging into the Lima VM, the first thing I did was the usual sweep.
ashutosh@lima-k8s-lab:~$ k get nsNAME STATUS AGEdefault Active 17dkube-node-lease Active 17dkube-public Active 17dkube-system Active 17dlogistics Active 10dpayments Active 17dprod Active 17dshipping Active 17dshop Active 2d2hashutosh@lima-k8s-lab:~$ alias k="kubectl -n shop"ashutosh@lima-k8s-lab:~$ k get podsNAME READY STATUS RESTARTS AGEcatalog-754cffb478-4664l 1/1 Running 0 92scatalog-754cffb478-s4nc8 1/1 Running 0 95sstorefront-66bdcb66bf-jkbmw 1/1 Running 0 87stester 1/1 Running 1 (2m42s ago) 2d2hEvery pod green. No restarts, no crashes, no Pending, nothing to read in the events.
ashutosh@lima-k8s-lab:~$ k get pods -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATEScatalog-754cffb478-4664l 1/1 Running 0 10m 10.42.0.136 lima-k8s-lab <none> <none>catalog-754cffb478-s4nc8 1/1 Running 0 10m 10.42.0.135 lima-k8s-lab <none> <none>storefront-66bdcb66bf-jkbmw 1/1 Running 0 10m 10.42.0.137 lima-k8s-lab <none> <none>tester 1/1 Running 1 (11m ago) 2d2h 10.42.0.116 lima-k8s-lab <none> <none>ashutosh@lima-k8s-lab:~$ k get svcNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEcatalog ClusterIP 10.43.203.211 <none> 80/TCP 2d2hashutosh@lima-k8s-lab:~$ k get endpointslicesNAME ADDRESSTYPE PORTS ENDPOINTS AGEcatalog-mtwrs IPv4 80 10.42.0.135,10.42.0.136 2d2hThe EndpointSlice had both pod IPs on port 80, which is exactly where nginx listens. The Service was pointing at real, live backends. If this were a targetPort bug like the last one, the slice would have shown a different number.
So the routing table was right and the destination was right. Something was throwing traffic away before it arrived.
ashutosh@lima-k8s-lab:~$ k logs catalog-754cffb478-4664l --tail=1010.42.0.1 - - [05/Oct/2024:12:39:31 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:39:36 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:39:41 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:39:46 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:39:51 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:39:56 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:40:01 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:40:06 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:40:11 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-"10.42.0.1 - - [05/Oct/2024:12:40:16 +0000] "GET / HTTP/1.1" 200 615 "-" "kube-probe/1.36" "-" ashutosh@lima-k8s-lab:~$ k logs storefront-66bdcb66bf-jkbmw --tail=1012:38:30 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:38:42 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:38:53 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:04 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:15 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:26 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:37 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:48 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:39:59 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refused12:40:10 storefront->catalog: wget: can't connect to remote host (10.43.203.211): Connection refusedHold onto those two log blocks. I did not appreciate them at the time, and they turned out to be the most important evidence in the entire investigation.
The Detour: Chasing Labels
Having exhausted the obvious suspects, I started poking at labels. It did not make sense even as I typed it, but I had run out of other ideas.
ashutosh@lima-k8s-lab:~$ k get pods --show-labelsNAME READY STATUS RESTARTS AGE LABELScatalog-754cffb478-4664l 1/1 Running 0 29m app=catalog,pod-template-hash=754cffb478,tier=backendcatalog-754cffb478-s4nc8 1/1 Running 0 29m app=catalog,pod-template-hash=754cffb478,tier=backendstorefront-66bdcb66bf-jkbmw 1/1 Running 0 29m app=storefront,pod-template-hash=66bdcb66bftester 1/1 Running 1 (30m ago) 2d2h app=testerashutosh@lima-k8s-lab:~$ k get svc -o wide --show-labelsNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR LABELScatalog ClusterIP 10.43.203.211 <none> 80/TCP 2d2h app=catalog <none>Service selector app=catalog, catalog pods labeled app=catalog. That is a clean match, and it was never the problem.
Then I made it worse. I tried to relabel storefront as app=catalog, on the half-formed theory that maybe the two were supposed to share a label:
ashutosh@lima-k8s-lab:~$ k edit deploy storefronterror: deployments.apps "storefront" is invalidA copy of your changes has been stored to "/tmp/kubectl-edit-2035367222.yaml"error: Edit cancelled, no valid changes were saved.The Deployment's spec.selector is immutable, the same wall I hit in The Pod That Refused to Be Edited, just one layer up. The controller uses that selector to identify which pods it owns, so letting it drift would orphan live pods.
ashutosh@lima-k8s-lab:~$ k get allNAME READY STATUS RESTARTS AGEpod/catalog-754cffb478-4664l 1/1 Running 0 32mpod/catalog-754cffb478-s4nc8 1/1 Running 0 32mpod/storefront-66bdcb66bf-jkbmw 1/1 Running 0 32mpod/tester 1/1 Running 1 (33m ago) 2d3h NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEservice/catalog ClusterIP 10.43.203.211 <none> 80/TCP 2d3h NAME READY UP-TO-DATE AVAILABLE AGEdeployment.apps/catalog 2/2 2 2 2d3hdeployment.apps/storefront 1/1 1 1 2d3h NAME DESIRED CURRENT READY AGEreplicaset.apps/catalog-754cffb478 2 2 2 2d3hreplicaset.apps/storefront-66bdcb66bf 1 1 1 2d3hreplicaset.apps/storefront-75c7846698 0 0 0 2d3hThat second storefront ReplicaSet, storefront-75c7846698 sitting at zero, is a fossil. It records a template edit that I made and then reverted, the rollout happened, and then it was rolled back. Nothing was broken by it, but it is the kind of detail that tells you a manifest has been through a few hands.
The Hint: NetworkPolicy
When the Service, the EndpointSlice, the labels, and the ports all line up and traffic still dies, the remaining explanation is that something is choosing to drop it. I asked a local LLM for a nudge and it pointed me at the one object I had not yet listed:
ashutosh@lima-k8s-lab:~$ k get networkpolicy --show-labelsNAME POD-SELECTOR AGE LABELScatalog-ingress app=catalog 2d2h <none>There it is.
What the Policy Was Actually Doing
Before I edited anything, I want to be honest about how NetworkPolicy works, because the object is far more dangerous than it looks.
A NetworkPolicy is not a rule that adds a permission. It is a rule that subtracts from unlimited access. The moment any policy selects a pod for ingress, that pod becomes isolated for ingress and every single connection to it is denied unless some policy explicitly allows it. There is no implicit allow. A policy that permits nothing is not a weak policy; it is a total blackout.
Two details in this policy were enough to guarantee that blackout.
First, the peer selector matched nobody. The from block asked for pods carrying access: catalog-client:
spec: ingress: - from: - podSelector: matchLabels: access: catalog-client ports: - port: 8080 protocol: TCP podSelector: matchLabels: app: catalog policyTypes: - IngressNo pod in the shop namespace had ever carried access: catalog-client. Not one. And a selector that matches zero pods is not an error k8s accepts it, stores it, and reports no warning anywhere. It simply produces an empty allowlist. The policy as written said: nobody, on any port, may reach `catalog`.
Second, the port was wrong, and wrong in a way that surprises everyone. The policy allowed 8080. The Service exposed 80. My first instinct was "those are different numbers, that's the bug," and it was but the reason is the part I had to think about properly.
NetworkPolicy ports are evaluated after kube-proxy has rewritten the packet. By the time a policy is consulted, the ClusterIP destination has already been DNAT'd to the pod's own address and port. So a policy's port is matched against the container's port on the pod, not the Service's front-door port.
When the Service port and targetPort differ, you must write the policy against targetPort. Here both happened to be 80, so the policy needed 80. It said 8080, which was a port nothing in this namespace had ever used.
So even after fixing the label, traffic would still have been dropped. Two independent faults, stacked which is exactly the shape of these scenarios, and the reason fixing one thing so rarely finishes the job.
Layer 1: The Wrong Label
I fixed the peer selector first.
ashutosh@lima-k8s-lab:~$ k edit networkpolicy catalog-ingressnetworkpolicy.networking.k8s.io/catalog-ingress editedspec: ingress: - from: - podSelector: matchLabels: app: storefront ports: - port: 8080 protocol: TCP podSelector: matchLabels: app: catalog policyTypes: - IngressAnd it still did not work:
ashutosh@lima-k8s-lab:~$ kubectl -n shop exec -it tester -- sh -c 'wget -T3 -qO- http://catalog/; echo exit=$?'wget: can't connect to remote host (10.43.203.211): Connection refusedexit=1ashutosh@lima-k8s-lab:~$ kubectl -n shop exec -it deploy/storefront -- sh -c 'wget -T3 -qO- http://catalog/; echo exit=$?'wget: can't connect to remote host (10.43.203.211): Connection refusedexit=1Both conditions had to be satisfied, right peer and right port. The peer was now correct, and the port was not.
Layer 2: The Wrong Port
Once the peer selector was matching, the port was the only thing left standing between storefront and a working request.
ashutosh@lima-k8s-lab:~$ k edit networkpolicy catalog-ingressnetworkpolicy.networking.k8s.io/catalog-ingress editedashutosh@lima-k8s-lab:~$ k get networkpolicy catalog-ingress -o yamlapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: annotations: kubectl.kubernetes.io/last-applied-configuration: | {"apiVersion":"networking.k8s.io/v1","kind":"NetworkPolicy","metadata":{"annotations":{},"name":"catalog-ingress","namespace":"shop"},"spec":{"ingress":[{"from":[{"podSelector":{"matchLabels":{"access":"catalog-client"}}}],"ports":[{"port":8080,"protocol":"TCP"}]}],"podSelector":{"matchLabels":{"app":"catalog"}},"policyTypes":["Ingress"]}} creationTimestamp: "2024-10-03T09:59:59Z" generation: 6 name: catalog-ingress namespace: shop resourceVersion: "20915" uid: a24b3f84-d32c-4895-8d20-83363d101b36spec: ingress: - from: - podSelector: matchLabels: app: storefront ports: - port: 80 protocol: TCP podSelector: matchLabels: app: catalog policyTypes: - IngressNote the stale last-applied-configuration annotation still showing the original broken policy. That annotation is what kubectl apply uses for its three-way merge, which is a trap of its own and one I have been burned by elsewhere.
The Proof
Now the asymmetry showed up exactly as it should. storefront had the label, so it got through. tester did not, so it stayed locked out — which is the policy working correctly, and a far more convincing confirmation than a single successful request.
ashutosh@lima-k8s-lab:~$ k exec -it deploy/storefront -- sh -c 'wget -T3 -qO- http://catalog/; echo exit=$?'<!DOCTYPE html><html><head><title>Welcome to nginx!</title><style>html { color-scheme: light dark; }body { width: 35em; margin: 0 auto;font-family: Tahoma, Verdana, Arial, sans-serif; }</style></head><body><h1>Welcome to nginx!</h1><p>If you see this page, the nginx web server is successfully installed andworking. Further configuration is required.</p> <p>For online documentation and support please refer to<a href="http://nginx.org/">nginx.org</a>.<br/>Commercial support is available at<a href="http://nginx.com/">nginx.com</a>.</p> <p><em>Thank you for using nginx.</em></p></body></html>exit=0ashutosh@lima-k8s-lab:~$ k exec -it tester -- sh -c 'wget -T3 -qO- http://catalog/; echo exit=$?'wget: can't connect to remote host (10.43.203.211): Connection refusedexit=1The Learnings
I need to learn NetworkPolicy in depth this was the first time I had to reason about one, and I only found it because I ran out of more obvious things to blame. That is not a great discovery strategy, so here is what I am taking away. UNDERSTAND networkpolicy in depth.
For the next layer of this series, where a single broken selector strands an entire Service, see Waybill API Down: Four Broken Layers.