Services
Stable identity and load balancing for pods.
Pods don't keep their addresses. A Pod (the smallest thing you can deploy in Kubernetes, one or more containers that share one network identity) gets an IP address when it starts and loses it the moment it dies. An IP address is just a number on the network, the way a house has a street number. Roll out a new version of a Deployment (the object that manages a batch of identical Pods and swaps them out on every update) and every old Pod IP is thrown away and replaced with fresh ones. So if one app hard-codes the IP of another, that link snaps the first time the other side restarts, scales, or gets moved to a different node, meaning a different worker machine. A Service is how Kubernetes makes a moving target hold still.
Think of a big office with a front desk. You call and ask for Sales. You never learn anyone's desk extension, and you don't care who picks up, you just want someone in Sales who's free right now. People in that department move desks, take leave, and get hired, and none of it changes the number you dial. A Service is that front desk. Clients ask for a name, the Service hands them off to whichever healthy Pod is available behind it, and the Pods can churn all day without a single client noticing.
A stable front door for a moving target
A Service is a small object built around a label selector. A label is a key/value tag you stick on Pods, like app: web. A selector is a standing search for that tag. Give the Service the selector app: web and Kubernetes hands you two things that never change: a ClusterIP (a stable virtual IP, handed out once by the API server, the control plane's front door, from the cluster's pool of service addresses) and a name that other Pods can look up. That lookup runs through DNS, the Domain Name System, the internet's phonebook that turns a readable name into an address. Inside the cluster, CoreDNS (the built-in name service) answers web.default.svc.cluster.local, or just web from inside the same namespace, which is Kubernetes' folder-like way of grouping objects. The selector is the whole trick. The Service doesn't name specific Pods, it describes them, and Kubernetes keeps the membership list current for you as Pods come and go.
apiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 3selector:matchLabels: { app: web }template:metadata:labels: { app: web }spec:containers:- name: webimage: hashicorp/http-echo:1.0args: ["-listen=:8080", "-text=hi"]ports:- { name: http, containerPort: 8080 }readinessProbe:httpGet: { path: /, port: http }---apiVersion: v1kind: Servicemetadata:name: webspec:selector: { app: web }ports:- port: 80 # clients hit web:80targetPort: http # forwarded to the container's named port (8080)
kubectl apply -f web.yamlkubectl get svc webkubectl get endpointslices -l kubernetes.io/service-name=web
deployment.apps/web createdservice/web createdNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb ClusterIP 10.96.142.30 <none> 80/TCP 9sNAME ADDRESSTYPE PORTS ENDPOINTS AGEweb-x7k2p IPv4 8080 10.244.1.7,10.244.2.4,10.244.1.9 9s
Three Pods, three IP addresses on the roster, one Service fronting them at 10.96.142.30 and at the name web. Nothing you deploy later has to know those Pod IPs. It talks to web and lets the Service do the routing.
The live roster behind the Service
Here's what's actually happening behind the desk. Picture a roster, the sign-in sheet that says who's on shift and reachable right now. A controller in the control plane, the EndpointSlice controller (part of kube-controller-manager, the process that runs Kubernetes' built-in background controllers), watches Pods and Services. Every time a Pod that matches the selector becomes Ready, the controller writes that Pod's IP into an EndpointSlice object. Delete the Pod and its entry goes away. Let it stay up but fail its readiness probe, and the entry stays too: the controller flips that endpoint's ready condition to false, and kube-proxy is what then refuses to send it traffic. So the roster is a sign-in sheet with a ready flag beside each name, not just a list of names. The Service itself stores almost nothing about who's behind it. The EndpointSlice is what kube-proxy reads to program the actual routing. kube-proxy is the small network agent running on every node. EndpointSlice is the modern form of this roster. The older single Endpoints object still exists for compatibility, but slices scale better, because a busy Service spreads its members across several small objects instead of one giant one.
kubectl describe svc web
Name: webNamespace: defaultSelector: app=webType: ClusterIPIP Family Policy: SingleStackIP Families: IPv4IP: 10.96.142.30Port: <unset> 80/TCPTargetPort: http/TCPEndpoints: 10.244.1.7:8080,10.244.2.4:8080,10.244.1.9:8080Session Affinity: NoneEvents: <none>
Readiness is the gate that keeps this safe. A Pod can be Running and still be kept off the roster, because Running only means the container process started, while Ready means its readiness probe passed. A readiness probe is a health check Kubernetes runs against the Pod, like knocking on the door to see if anyone answers. Only Ready Pods get traffic. That's how a rolling update never sends a request into a Pod that's still warming up, and how a Pod that starts failing its probe gets pulled out of rotation within seconds without anyone deleting it. Pulled out of rotation, though, not struck off the sheet. The Endpoints line above lists ready addresses only, so a failing Pod drops off that line while kubectl get endpointslice web-x7k2p -o yaml still shows its address, marked ready: false.
port, targetPort, and the four ways in
Two port numbers trip people up, because they look interchangeable and aren't. port is the number clients dial on the Service (web:80). targetPort is the port on the container that traffic is forwarded to (8080 here). They're allowed to differ, and usually should, so you can keep a clean public port while the app listens wherever it likes. Point targetPort at a named container port (http above) instead of a raw number and the Service stops keeping its own copy of the number: change containerPort in the Pod spec and the Service follows, with no second file to remember. That closes the gap between the Service and the Pod spec. It does nothing about the gap between the Pod spec and the process actually running, because a container's ports: block is a label, not a promise, and nothing verifies it.
One Service object, four ways to be reached, and they are not four separate boxes you pick one from. ClusterIP is the default and gives an internal-only virtual IP, right for almost all Pod-to-Pod traffic inside the cluster. NodePort opens the same high-numbered port (30000 to 32767 by default) on every node and forwards it inward, a blunt way to reach a Service from outside when you have no cloud, and it keeps a ClusterIP as well. LoadBalancer asks the cloud provider to set up a real external load balancer pointing at the Service, the standard front door on a managed cluster, and underneath it gets both a NodePort and a ClusterIP, which is how the cloud balancer reaches your nodes at all. That is why kubectl get svc still prints a CLUSTER-IP next to a LoadBalancer, and it is not a bug. Headless is the odd one out and is not a fourth type: spec.type stays ClusterIP and you set clusterIP: None, which skips the virtual IP and makes DNS return the individual Pod IPs, which is what StatefulSet members want. A StatefulSet is a workload whose Pods keep stable identities, the way database replicas do. For many HTTP apps you don't hand each one its own load balancer at all. You put an Ingress in front of plain ClusterIP Services. An Ingress is a single HTTP doorway that routes by hostname or URL path, and it's covered later in this section.
When the front desk answers but the line is dead
A Service is reported down. Before you touch anything, split it into two questions asked in order. Is the roster empty? And if it isn't, does the roster point where the app actually listens? Start with the roster, because it's one command and it decides everything. kubectl describe svc (or kubectl get endpointslices) tells you instantly whether any Pod sits behind the Service. Empty endpoints mean the Service is fine and the real problem is upstream: either no Pod carries the label the selector wants, or Pods carry it but are stuck not-Ready. Both show up in seconds.
# clients hitting web get 'connection refused'. who's on the roster?kubectl describe svc web | grep -i endpoints# empty roster: do the Pods actually carry the label the selector wants?kubectl get pods -l app=webkubectl get pods --show-labels | grep web
Endpoints: <none>No resources found in default namespace.web-6d4c8f9b7-abcde 1/1 Running 0 5m app=web-app,pod-template-hash=6d4c8f9b7
There it is. The Pods are healthy (1/1 Running), but they're labeled app=web-app while the Service selects app=web, so the selector matches nothing and the roster stays empty. Fix whichever side is wrong and the endpoints populate within a second. If instead the Pods matched but showed 0/1, you'd go read the readiness probe or the container logs for why they never go Ready. And once the roster fills but traffic still won't flow, you've crossed out of Service territory into how the kernel actually rewrites ClusterIP packets onto a real Pod, which is kube-proxy's job and the next lesson.
ClusterIP, NodePort, and LoadBalancer are exposure modes, not different apps. Start with ClusterIP inside the cluster.
sessionAffinity is sticky and can hide bad replicas. Prefer fixing readiness.
headless Services return pod IPs for StatefulSets and similar. That is DNS design, not a missing ClusterIP bug.
Try this
Apply web.yaml, then curl the Service from a throwaway client Pod, because the http-echo image carries no shell and no curl of its own. Once you get a reply, point the Service's selector at a label no Pod carries. The roster empties, the same curl comes back refused, and putting the selector back fills the roster again within a second.
$ kubectl apply -f web.yaml# http-echo has no shell, so bring your own client Pod$ kubectl run client --rm -it --image=curlimages/curl:8.5.0 --restart=Never -- curl -s http://webhipod "client" deleted# break it on purpose: aim the selector at a label no Pod carries$ kubectl patch svc web -p '{"spec":{"selector":{"app":"web-app"}}}'service/web patched$ kubectl describe svc web | grep -i endpointsEndpoints: <none>$ kubectl run client --rm -it --image=curlimages/curl:8.5.0 --restart=Never -- curl -s http://webcurl: (7) Failed to connect to web port 80 after 1 ms: Connection refusedpod "client" deleted# put the selector back and the roster refills$ kubectl patch svc web -p '{"spec":{"selector":{"app":"web"}}}'service/web patched
Takeaway
Services give stable virtual IPs and DNS in front of changing pods. Endpoints follow ready pods that match the selector.
kubectl describe svc web prints Endpoints: <none> and clients get 'connection refused'. What's the most likely cause, and the fastest confirming check?kubectl describe svc web shows three populated Endpoints, yet clients still get 'connection refused.' Curling a backing Pod's own IP on the Service's targetPort is refused too. What's the fault?