Why pods need a Service
Pods move; a Service stays put.
Your app has two halves. There's the frontend, the part users click around in, and the backend, the part doing the real work out of sight. The frontend needs data from the backend. For reliability you've run the backend as three identical copies, so if one falls over the other two keep serving. Fine. Which of the three does the frontend call? Each running copy sits in a Pod, the smallest thing Kubernetes manages: a thin wrapper around one or more containers. A container is your app plus everything it needs to run, packed into a single bundle that starts the same way on any machine. Now the catch. Pods are disposable. Kubernetes deletes one and starts a fresh replacement whenever it needs to, during an update, after a crash, or when you scale down. Every replacement comes up with a new IP address (IP stands for Internet Protocol, the numeric label one computer uses to find another over a network).
A reception desk staffed by temps works the same way. The people change from one shift to the next, but the desk phone number never does. You dial the number and whoever is on duty picks up. Nobody hands out their personal mobile, because next week they're somewhere else. A Pod's IP address is that personal mobile number. It works right up until the Pod gets replaced, and then it rings into empty air. So you never wire one part of your app to another by Pod IP. Hard-code a backend Pod's address into your frontend, let a routine update swap that Pod out, and the number your frontend memorised now dials nobody. It will break. It tends to break at 2 a.m.
You don't have to take any of that on faith. Start the backend, then list the Pods with -o wide, a flag that adds each Pod's IP and the node it landed on to the usual output.
$ kubectl get pods -o wideNAME READY STATUS RESTARTS AGE IP NODEweb-6b8f4c9d7-4xk2p 1/1 Running 0 2m 10.244.1.7 node-aweb-6b8f4c9d7-8lprd 1/1 Running 0 2m 10.244.2.4 node-bweb-6b8f4c9d7-r9m2t 1/1 Running 0 2m 10.244.3.9 node-c
Three copies, three IPs. Say your frontend memorised the first one, 10.244.1.7. Delete that Pod the way a rollout or a crash would, and watch the Deployment quietly stand up a replacement.
$ kubectl delete pod web-6b8f4c9d7-4xk2ppod "web-6b8f4c9d7-4xk2p" deleted$ kubectl get pods -o wideNAME READY STATUS RESTARTS AGE IP NODEweb-6b8f4c9d7-q7ntz 1/1 Running 0 9s 10.244.2.15 node-bweb-6b8f4c9d7-8lprd 1/1 Running 0 4m 10.244.2.4 node-bweb-6b8f4c9d7-r9m2t 1/1 Running 0 4m 10.244.3.9 node-c
Same app, new Pod, new IP: 10.244.2.15. The address your frontend was holding, 10.244.1.7, now belongs to nothing at all, so a call to it hangs until it gives up.
$ curl 10.244.1.7:8080curl: (28) Failed to connect to 10.244.1.7 port 8080 after 133024 ms: Connection timed out
A Service is the desk number that never changes
A Service is a Kubernetes object that sits in front of a group of Pods and hands them one steady name and one steady IP address. Both stay fixed for as long as the Service exists, however much the Pods behind it churn. How does it know which Pods belong to it? By label. A label is a small key-and-value sticker you put on an object, like app: web. Every Pod wearing the matching sticker joins the group. When a request arrives, the Service passes it to one of the healthy Pods and spreads the work around, so no single copy drowns while the others idle. That's the host at a busy restaurant, seating each new party in a different waiter's section instead of piling them all on one. The even spreading has a name: load balancing.
The fastest way to put a Service in front of Pods you already have is a single command run through kubectl, the command-line tool you use to send instructions to a Kubernetes cluster (the group of machines Kubernetes runs your apps on). Those Pods are usually looked after by a Deployment. A Deployment is a controller, meaning a small background program that watches your app and keeps the right number of Pod copies alive. The command below reads that Deployment's labels and builds a matching Service for you.
$ kubectl expose deployment web --port=80 --target-port=8080service/web exposed
Two port numbers turned up there, and they trip almost everybody at first. port is the number the Service answers on, the one other apps dial. targetPort is the port your container actually listens on inside the Pod. The two are allowed to differ, and often should, which is half of why people muddle them. Traffic arrives on port 80 and gets forwarded to 8080. Ask Kubernetes for the Service now, and you'll see the permanent address it handed you.
$ kubectl get service webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb ClusterIP 10.96.211.34 <none> 80/TCP 9s
That CLUSTER-IP value, 10.96.211.34, is the steady address the Service claimed, and it stays put for the whole life of the Service. Notice it sits in a different range from the Pod IPs you watched change. A Service address is handed out once and never recycled while the Service lives. The TYPE column reads ClusterIP, which means the address works from inside the cluster. Running a command is fine for a quick test, but real projects keep their setup in files they can review and re-apply. Here's the same Service written as a YAML file (YAML is a plain-text format for describing configuration, using indentation instead of brackets). This one is complete. Save it and run kubectl apply -f svc.yaml exactly as it stands.
apiVersion: v1kind: Servicemetadata:name: webspec:selector:app: web # send traffic to Pods wearing this labelports:- port: 80 # the Service answers heretargetPort: 8080 # forwarded to this port inside the Pod
Apply the file, then run the one command that tells you whether the Service actually found any Pods. get endpoints lists the live Pod addresses the Service is sending traffic to right now. If the labels line up, you get one address per running Pod, and the replacement Pod from earlier turns up on its own with no editing from you.
$ kubectl apply -f svc.yamlservice/web configured$ kubectl get endpoints webNAME ENDPOINTS AGEweb 10.244.2.15:8080,10.244.2.4:8080,10.244.3.9:8080 1m
What the Service quietly handles
Once that Service exists, three headaches go away. Identity is the first: other apps get a fixed name and IP they can trust for good, even as the Pods behind it are swapped out. Balance is the second, which you already saw, spreading requests so that scaling up adds real capacity instead of idle copies. The third is the one people underrate. The Service keeps its own membership list. When a new Pod passes its health check and goes live, it joins by itself. When a Pod dies or scales away, it drops off. You edit nothing. The list you see in get endpoints reshapes itself as your Pods come and go.
There's a second address the Service gives you, and it's the one that belongs in your config. Alongside the fixed IP, the Service gets an entry in the cluster's own phone book. That phone book is DNS (Domain Name System), the thing that turns names into IP addresses. From inside the cluster you can call web, or spell it out in full as web.default.svc.cluster.local, and stop thinking about numbers entirely. Behind that name, a component called kube-proxy (or whatever data plane your cluster runs in its place) programs every node so traffic aimed at the Service lands on one of the current endpoints. Names in your config, IP addresses nowhere.
An empty endpoint list has two very different causes, and from the caller's seat they look identical. Either the selector matches no Pod at all, or the Pods exist but none of them is Ready. Your client gets the same symptom either way, a request that goes nowhere and eventually times out, so the timeout tells you nothing about which situation you're in. The endpoints list is where the two stories come apart.
This is why the rule exists at all. A frontend with a backend Pod IP baked into its config will sail through every test you throw at it, then die on the first rollout, at whatever hour that rollout happens to run. Point it at the Service instead and the rollout stops being an event.
Try this
Run it yourself. Put a ClusterIP Service in front of a couple of Pods, curl it from a separate Pod using the cluster DNS name, then delete a backend Pod and check that the Service keeps answering through the replacement.
$ kubectl create deployment api --image=nginx:1.27 --replicas=2deployment.apps/api created$ kubectl expose deployment api --port=80 --target-port=80service/api exposed$ kubectl get svc api; kubectl get endpoints apiNAME TYPE CLUSTER-IP PORT(S) AGEapi ClusterIP 10.96.45.12 80/TCP 5sNAME ENDPOINTS AGEapi 10.244.1.40:80,10.244.1.41:80 5s$ kubectl run curl --image=busybox:1.36 --restart=Never --command -- sleep 300pod/curl created$ kubectl exec curl -- wget -qO- http://api.default.svc.cluster.local | Select-String -Pattern 'Welcome|nginx' | Select-Object -First 1Welcome to nginx!$ kubectl delete pod curl; kubectl delete svc api; kubectl delete deployment apipod "curl" deletedservice "api" deleteddeployment.apps "api" deleted
Takeaway
Carry one habit out of this lesson. When something inside the cluster can't reach a Service, run kubectl get endpoints before you blame the network. Addresses on that list mean traffic is being handed somewhere real and the fault lies further along. An empty list means the selector or readiness is at fault, and no amount of staring at packets will ever show you that.