Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

intermediatekubernetes-service~25 min

Service wrong targetPort

Reported symptoms

  • The ingress controller began returning 502 for `/pay` at 11:04
  • In-cluster callers of `payments.prod.svc.cluster.local` are refused immediately rather than timing out
  • `kubectl describe service payments -n prod` lists three endpoint addresses, so the Service was ruled out in the first two minutes
  • The payments Pods are `1/1` Ready and have been continuously; every readiness probe has passed
  • Port-forwarding to a payments Pod and calling the application directly returns 200 for both `/healthz` and `/pay`
  • One caller is completely unaffected - the reconciliation job, which reaches the same Pods through a second Service called `payments-internal`
  • The change that shipped at 11:03 removed a sidecar proxy from the Deployment and was reviewed as "no functional change"

Evidence

  • · `kubectl describe service payments -n prod` shows `Port 80/TCP`, `TargetPort 8080/TCP`, and three ready endpoint addresses
  • · `kubectl describe service payments-internal -n prod` shows `TargetPort 9090/TCP` and the same three addresses
  • · `kubectl get endpointslices -n prod -l kubernetes.io/service-name=payments -o yaml` shows `ports: [{port: 8080}]`
  • · `kubectl get pod payments-6c5f8b9d47-2vhwq -n prod -o jsonpath='{.spec.containers[*].name}'` returns one container where it used to return two
  • · The remaining container declares `containerPort: 9090` and its readiness probe targets port 9090
  • · From a debug Pod: a request to the Pod IP on 9090 returns 200; the same request on 8080 is refused
  • · From a debug Pod: a request to the `payments` ClusterIP on port 80 is refused; the same request to the `payments-internal` ClusterIP on port 80 returns 200
Diagnosis and resolutionclick to reveal

Root cause

The `payments` Service sends traffic to port 8080 on each Pod, and no Pod is listening on 8080 any more. Until 11:03 the Pods ran two containers: the application on 9090, and a sidecar proxy on 8080 that forwarded to it. The Service was written against the sidecar, which is why `targetPort: 8080` was correct for as long as it existed. The change at 11:03 removed the sidecar. It removed the container, it removed the only listener on 8080, and it left the Service pointing at a port that no longer exists. Nothing objected, because nothing checks. `targetPort` is not validated against the container's `containerPort` by the API server; the EndpointSlice controller copies the resolved number into the slice without examining it; kube-proxy programs whatever the slice says; and readiness has no relationship to it at all. That last point is the one that made the incident hard to read. The readiness probe targets 9090 and passes, because the application is genuinely healthy on 9090 - so the Pods are Ready, the endpoints are populated, and every surface an operator checks in the first five minutes is green. A Pod is in the EndpointSlice because it is ready, not because it is reachable on the port the Service will send traffic to. Those are two different claims, and Kubernetes only ever verifies the first one. The second Service is the proof: `payments-internal` selects the same three Pods and works perfectly, because it was written later against the application rather than against the proxy in front of it.

Remediation

Set `targetPort: 9090` on the `payments` Service and land it in the repository that owns the object. The repair takes effect as soon as the EndpointSlice is rewritten and kube-proxy reprograms - seconds, with no Pod restarted and no capacity moved - and it is a one-line change to one object. The instinct on an incident call will instead be to roll back the sidecar removal, and that is the more expensive answer: it costs a full rollout, it restores a component another team deliberately removed, it re-breaks their migration, and it fixes the outage by putting the wrong thing back rather than by correcting the thing that is wrong. Roll back only if the Service change cannot be landed quickly, and say explicitly that you are trading a correct fix for a fast one. Do not repair this by making the application also listen on 8080, which treats a stale manifest as immovable and leaves the next person two listeners to explain. Once traffic is restored, consider converting the Service to a named target port - `targetPort: http` against a container port declared as `name: http, containerPort: 9090` - so the number lives in exactly one place and moves with the container. That is the durable form, but it is not free: a named target port fails when the name is not declared, so it trades a wrong number for a missing name, and it is only an improvement if the container's port declaration is reviewed as carefully as the Service's.

Verification

Verify through the Service, never through the Pod, because reaching the application directly is exactly what worked throughout the outage. `kubectl get endpointslices -n prod -l kubernetes.io/service-name=payments -o yaml` must show `port: 9090`, and `kubectl describe service payments -n prod` must show `TargetPort 9090/TCP`. Then send a real request: from a debug Pod, call the `payments` ClusterIP on port 80 and require a 200. Confirm the ingress returns 200 for `/pay`, since that caller reached the Service by a different route and raised the incident. Confirm `payments-internal` still works, so the repair did not regress the one path that was never broken. Then close the loop on the blind spot that allowed this: demonstrate on a staging Service that a wrong `targetPort` leaves the Pods Ready and the endpoints populated, and confirm that the post-deploy check you have just added fails against it. A validation step that passes on a broken cluster is not a validation step, and this failure mode defeats every probe-based and rollout-based check in the pipeline by construction.

Prevention

Assert through the Service in post-deploy validation. A readiness probe answers a question about the application, a port-forward answers a question about the Pod, and neither has ever been asked whether a caller can reach the workload the way callers actually reach it; one request to the ClusterIP after every rollout closes the gap that all three of this cluster's existing checks left open. Second, name the ports. A container port declared with a name and a Service that targets that name keeps the number in one place, so it cannot be changed on one side only. Third, treat a sidecar's port as a published interface. It looks like an implementation detail and it is referenced by Services, network policies and probes that live in other files owned by other people, so removing a sidecar requires enumerating everything that pointed at it rather than reviewing the Deployment in isolation. Fourth, add the comparison the platform does not make: `containerPort` is documentation, but it is checkable documentation, and a rule that flags a Service whose numeric `targetPort` is declared by none of the containers it selects would have caught this in review. Finally, learn the signature. Ready endpoints plus connections refused is a port mismatch almost every time; empty endpoints is a selector or readiness problem; a timeout rather than a refusal is a network path problem. Those three shapes separate cleanly, and knowing which one you have is worth more than any single command.

Reported symptoms

At 11:04 the ingress controller started returning 502 for /pay. Within a minute, five services calling payments.prod.svc.cluster.local were failing too. The callers are not timing out; they are being refused, which is fast enough that the retry storm arrived before the page did.

The on-call ran the standard first check and cleared the Service in two minutes:

$ kubectl describe service payments -n prod | grep Endpoints
Endpoints:  10.244.3.11:8080,10.244.5.22:8080,10.244.7.14:8080

Three addresses. Not an empty EndpointSlice, not a selector problem. The Pods are 1/1 Ready and have been continuously through the incident, and every readiness probe has passed on every interval.

The application is also fine. Port-forwarding to a payments Pod and calling it directly returns 200 for /healthz and, more convincingly, 200 for /pay with a real request body.

So: DNS resolves, the Service has endpoints, the Pods are ready, the application works. That combination is what sent the first thirty minutes into the CNI, the NetworkPolicy that a different team had applied that morning, and the ingress controller’s upstream configuration.

One fact did not fit, and it is the one that solves the incident. The nightly reconciliation job is completely unaffected. It calls the same three Pods, from the same cluster, over the same network - through a second Service named payments-internal.

The change log for the day has one entry, at 11:03. It removed a sidecar proxy from the payments Deployment. It was reviewed as “no functional change”, and the mesh team confirms the removal was intentional and that the application never depended on the proxy for anything.

Evidence provided

Read-only / Safepopulated endpoints - and read the port they carry
$ kubectl describe service payments -n prod | grep -E 'Port:|TargetPort|Endpoints'
Port:              http  80/TCP
TargetPort:        8080/TCP
Endpoints:         10.244.3.11:8080,10.244.5.22:8080,10.244.7.14:8080

Illustrative output

Read-only / Safesame three Pods, different port, and this one works
$ kubectl describe service payments-internal -n prod | grep -E 'Port:|TargetPort|Endpoints'
Port:              http  80/TCP
TargetPort:        9090/TCP
Endpoints:         10.244.3.11:9090,10.244.5.22:9090,10.244.7.14:9090

Illustrative output

Read-only / Safeone container, where this Deployment used to have two
$ kubectl get pod payments-6c5f8b9d47-2vhwq -n prod -o jsonpath='{.spec.containers[*].name}'
payments

Illustrative output

Read-only / Safethe container declares 9090 and is health-checked on 9090
$ kubectl get pod payments-6c5f8b9d47-2vhwq -n prod -o jsonpath='{.spec.containers[0].ports}{.spec.containers[0].readinessProbe.httpGet.port}'
[{"containerPort":9090,"name":"http","protocol":"TCP"}]9090

Illustrative output

Read-only / Safethe Pod answers on 9090 and refuses on 8080
$ kubectl run probe --rm -it --image=curlimages/curl --restart=Never -- sh -c 'curl -s -o /dev/null -w %{http_code} http://10.244.3.11:9090/healthz; curl -s -m 3 http://10.244.3.11:8080/healthz'
200
curl: (7) Failed to connect to 10.244.3.11 port 8080: Connection refused

Illustrative output

Work the evidence before reading on

Every layer of the canonical flow answers correctly until one of them does not. Walk it: DNS, Service, EndpointSlice, Pod IP, application port.

  1. Two Services select the same three Pods. One works and one does not. Rule out, one by one, every explanation that lives outside the Service objects themselves - DNS, CNI, NetworkPolicy, the ingress controller, the Pods. What is left?
  2. The endpoints are populated. Read the port on each address rather than the address. What port is the EndpointSlice telling kube-proxy to send traffic to, and where did that number come from?
  3. The readiness probe passes on every interval. Write down, precisely, what a passing readiness probe proves about the Pod - and what it does not.
  4. The change removed a container. What did that container have that the Deployment manifest is no longer the only file to have referenced?

Before continuing, answer this: the Pods are in the EndpointSlice because they are Ready. What would have had to be true for them to be excluded, and would that condition have caught this fault?

Root cause

1. The Service targets a port that nothing listens on

Until 11:03 the payments Pods ran two containers: the application on 9090, and a sidecar proxy on 8080 that forwarded to it. The Service was written against the sidecar. targetPort: 8080 was correct, and had been for as long as the sidecar existed.

The change removed the sidecar. It removed the container, and with it the only process listening on 8080 inside the Pod’s network namespace. The Service was never touched, so it still sends every request to port 8080 on a Pod where nothing is bound to it, and the Pod’s kernel refuses the connection immediately.

That is why callers are refused rather than timed out, and it is a useful distinction to keep: a refusal means the packet arrived somewhere and was rejected by a live host, so the routing worked and the listener did not exist. A timeout would have meant the opposite.

2. Nothing validates a targetPort against anything

There are three independent statements about ports in this workload, and the platform does not compare them:

StatementWhere it livesWhat it actually does
containerPort: 9090Pod specDocumentation. Declaring it does not open a port and omitting it does not close one.
readiness probe port 9090Pod specDecides whether the Pod is Ready, and therefore whether it is in the EndpointSlice.
targetPort: 8080Service specDecides where traffic is sent.

The API server accepts a Service whose targetPort matches no container. The EndpointSlice controller copies the resolved number into the slice without examining it. kube-proxy programs whatever the slice says. At no point does any component ask whether anything is listening there.

3. Readiness answered a different question

This is the part that cost thirty minutes.

A Pod appears in an EndpointSlice because it matched the selector and its readiness probe passed. The probe targets 9090, the application is healthy on 9090, so the probe passes and the Pod is Ready - correctly, and it will stay correct for as long as the outage lasts.

Readiness is a claim about the container. Reachability through the Service is a claim about a port number written in a different object. Kubernetes verifies the first and assumes the second, so on this fault the endpoint list is populated, green, accurate, and completely irrelevant.

The second Service is the control experiment the incident handed you for free. payments-internal selects the same three Pods over the same network, and it works, because it was written later against the application instead of against the proxy in front of it. When two Services in front of one workload disagree, the disagreement is in the Service objects. Nothing else can produce it.

Resolution

  1. Confirm the port mismatch from the EndpointSlice rather than from the Service. kubectl get endpointslices -n prod -l kubernetes.io/service-name=payments -o yaml shows the port kube-proxy is actually programming, which is the number that matters.
  2. Confirm what the Pod is listening on from outside it. A request from a debug Pod to the Pod IP on each candidate port answers this without needing a shell, a package, or a debug sidecar in the target image.
  3. Set targetPort: 9090 on the payments Service in the repository that owns it, and apply from there. Patching the live object first is defensible while callers are down, but only if the repository change lands in the same incident.
  4. Do not roll back the sidecar removal unless the Service change is genuinely blocked. It is slower, it undoes work another team did on purpose, and it repairs the outage by restoring the thing that was correctly removed.
  5. Do not make the application listen on 8080 as well. That treats a stale manifest as fixed and leaves the next reader two listeners and no explanation for either.
  6. Watch the EndpointSlice rewrite before testing traffic. The controller updates the slice and kube-proxy reprograms from it; if the slice still says 8080, the change has not taken effect and no amount of curling will help.
  7. Check the other Service. payments-internal was working throughout and must still be working afterwards - a repair that regresses the one healthy path is not a repair.
  8. Once traffic is restored, decide whether to move to a named target port. targetPort: http against a container declaring name: http, containerPort: 9090 keeps the number in one place. It is the better shape and it has its own failure mode, so make it a deliberate follow-up rather than part of the incident fix.

Verification

  1. The EndpointSlice carries the new port. kubectl get endpointslices -n prod -l kubernetes.io/service-name=payments -o yaml shows port: 9090 on each endpoint, and kubectl describe service payments -n prod shows TargetPort 9090/TCP.
  2. A request through the ClusterIP succeeds. From a debug Pod, call the payments ClusterIP on port 80 and require a 200. Do not verify with a port-forward and do not verify against a Pod IP - reaching the application directly is precisely what worked throughout the outage.
  3. The ingress path returns 200 for /pay. That caller reached the Service by a different route and raised the incident, so it is the one that proves the recovery is complete.
  4. The callers stopped retrying. Connection-refused counters on the five affected services return to zero rather than merely falling, since a partially reprogrammed dataplane looks like improvement.
  5. payments-internal still returns 200. The healthy path must survive the fix.
  6. The blind spot is demonstrated, not assumed. On a staging Service, set a targetPort that nothing listens on and confirm the Pods stay Ready and the endpoints stay populated. This is what makes the case for the new check to whoever has to approve it.
  7. The new post-deploy check fails against that staging Service. A validation step that passes on a cluster you have deliberately broken is not a validation step.

Prevention

  • Assert through the Service after every rollout. One request to the ClusterIP is the check that none of readiness, rollout status or a port-forward performs. All three were green for the entire outage, and all three were answering questions nobody had asked.
  • Name the ports. A container port declared with a name, and a Service that targets the name, keeps the number in exactly one place. The number then moves with the container instead of being maintained in two files by two teams.
  • Treat a sidecar’s port as a published interface. It looks like an implementation detail and it is referenced by Services, network policies and probes in other files. Removing a sidecar means enumerating what pointed at its port, not reviewing the Deployment on its own.
  • Add the comparison the platform declines to make. containerPort is documentation, but it is checkable documentation: a rule that flags a Service whose numeric targetPort is declared by no container it selects would have failed this change in review, in about a second.
  • Learn the three signatures. Ready endpoints with connections refused is a port mismatch. No endpoints at all is a selector or readiness problem. A timeout rather than a refusal is a network path problem. Those separate cleanly, and picking the right one is worth more than any single command in this file.
  • Keep a second Service in mind as a diagnostic. When two Services front one workload and only one of them fails, everything outside the Service objects is eliminated at once. That is a rare gift and it is worth noticing quickly when the cluster hands it to you.