Services and ingress
Pods are ephemeral with changing IPs, so you reach them through a Service: a stable virtual IP that load-balances over pods matching a label selector, with Ingress as the shared HTTP router in front. Selector mismatch means zero endpoints.
A rollout goes green. Pods are Running, the Deployment reports all replicas ready, and yet every request to the app hangs and then times out. kubectl get endpoints api prints a single chilling line: api <none>. The Service is pointing at nothing. Someone renamed the pod label from app: api to app: api-svc and never touched the Service’s selector — so the Service matches zero pods, builds an empty endpoint list, and quietly black-holes traffic. The pods are fine. The wiring between them and the Service is broken.
Why you can’t address a pod directly
When you deploy to Kubernetes, the first instinct is to grab the pod’s IP and call it directly — and that instinct is exactly what gets you into the failure in the hook above. Here is why it can never work reliably.
A pod is the cheapest, most disposable thing in Kubernetes. The scheduler kills it, reschedules it, scales it up and down, and moves it to another node on a whim — and every time it comes back it gets a new IP from the pod network. There is no stable address you can hardcode, no DNS name that survives a rollout, no guarantee the IP you resolved a second ago still belongs to a live pod. Directly dialing a pod IP is a bug waiting for the next reschedule.
The Service is the fix. It is an API object that gives a set of pods one stable identity: a virtual IP (the ClusterIP) and a DNS name (<service>.<namespace>.svc.cluster.local) that live for the lifetime of the Service, independent of which pods exist behind it right now. Clients talk to the Service; the Service load-balances each connection across the healthy pods. Pods churn underneath; the address in front never moves.
The selector-to-endpoints mechanism
The link between a Service and its pods is labels, not IPs. A Service declares a selector — a set of label key/values — and Kubernetes continuously watches for pods that (a) match every label in the selector and (b) are Ready (passing their readiness probe). For each such pod it records the pod IP and port into an EndpointSlice (the modern, sharded replacement for the older single Endpoints object). That EndpointSlice is the load-balancing target list; kube-proxy on every node programs the data plane (iptables or IPVS rules) from it.
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api # must match the pods' labels exactly
ports:
- name: http
port: 80 # the Service's virtual port
targetPort: 8080 # the container port on the podTwo things follow from this. First, the matching is dynamic: scale the Deployment up and new Ready pods are added to the slice within moments; a pod that fails its readiness probe is removed, so traffic stops going to it without you deleting anything. Second — and this is the failure that bites everyone — if the selector matches no Ready pods, the EndpointSlice is empty, and the Service routes to nowhere. It still has a ClusterIP, still resolves in DNS, still accepts connections — and then refuses or times out, because there is no backend to forward to.
▸Why this works
“Ready” is doing real work here. A pod can be Running but not Ready — it is in the EndpointSlice only once its readiness probe passes. This is deliberate: during a rolling update, new pods are kept out of the Service until they can actually serve, and old pods are pulled from the slice before termination. Get the readiness probe wrong and you can ship a Service that has running pods but zero endpoints — same black hole, subtler cause.
Service types: how far the address reaches
Every Service has a type that decides who can reach it. They stack: each builds on the one before.
| Type | Scope | External? | Cost | Use case |
|---|---|---|---|---|
ClusterIP (default) | Internal virtual IP only | No | Free | Service-to-service inside the cluster |
NodePort | A port (30000–32767) on every node | Yes, crude | Free | Basic external access, dev, behind your own LB |
LoadBalancer | Provisions a cloud load balancer | Yes | One paid LB per Service | A single L4 entry point for one Service |
Headless (clusterIP: None) | DNS returns pod IPs, no virtual IP | No | Free | StatefulSets, per-pod addressing, client-side LB |
| Ingress (not a Service type) | L7 HTTP(S) router over many Services | Yes | One LB shared by all routes | Host/path routing + TLS for the whole cluster |
Together these types form a single decision: how far outside the cluster do you need the traffic to reach? Start with ClusterIP for everything internal; reach for Ingress (not LoadBalancer) the moment you need HTTP routing to the public internet.
ClusterIP is the default and the workhorse — it is how your API talks to your database talks to your cache, all by DNS name, all internal. NodePort opens the same port on every node and forwards it to the Service; it is the crude knob for getting traffic in, useful in dev or when you front it with your own load balancer, but you would not hand a raw node-ip:31000 URL to users. LoadBalancer asks the cloud provider to provision a real external load balancer pointing at the Service — clean, but you pay for and manage one LB per Service, which gets expensive and unwieldy fast. Headless opts out of the virtual IP entirely: DNS returns the individual pod IPs, which is exactly what a StatefulSet (database, broker) needs so clients can address a specific replica.
Ingress: one front door for many Services
If LoadBalancer gives you one cloud LB per Service, ten public services cost ten load balancers — and you still have no HTTP-aware routing. Ingress solves both. It is an L7 (HTTP/HTTPS) router that sits in front of your ClusterIP Services and dispatches requests by host and path to the right Service, terminates TLS centrally, and does it all behind a single shared load balancer. One entry point, many backends, far cheaper than an LB per service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: site
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api # routes /api -> the api Service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web # routes everything else -> the web Service
port:
number: 80The crucial catch: the Ingress object is just rules. It does nothing on its own. You must run an ingress controller — nginx, Traefik, HAProxy, a cloud provider’s — which actually watches Ingress objects and turns them into a running reverse proxy. No controller, no routing; the YAML applies cleanly and traffic still goes nowhere. The ingressClassName tells Kubernetes which controller owns this Ingress.
You have eight public HTTP services that need host/path routing and TLS. How do you expose them?
The classic failure: zero endpoints
Return to the hook. The number-one Kubernetes networking bug is a Service whose selector does not match its pods’ labels. Nothing errors at apply time — the Service is valid YAML, gets a ClusterIP, resolves in DNS. But its selector matches no Ready pods, so the EndpointSlice is empty, and every connection is refused or times out. The same empty-endpoints symptom also comes from pods that match the labels but are not Ready (a failing or too-strict readiness probe), or a targetPort that doesn’t match the container’s actual port.
The diagnosis is always the same one command: kubectl get endpoints <svc> (or kubectl get endpointslices). If it shows <none> or no addresses, the Service is talking to no one — stop debugging the app and go check that the Service’s selector exactly equals the pods’ labels and that the pods are Ready.
A Service has a ClusterIP and resolves in DNS, but every request times out. kubectl get endpoints shows <none>. The pods are Running. What is the most likely cause?
- 01Walk through the selector-to-endpoints mechanism: how does a Service decide which pods to load-balance over, and what makes the endpoint list empty?
- 02Why use an Ingress instead of giving each service its own LoadBalancer, and what must be running for an Ingress to actually work?
Pods are ephemeral and get a new IP on every reschedule, so you never address them directly — you put a Service in front. Now when you see kubectl get endpoints <svc> print <none>, you know exactly where to look: the selector, the labels, and the readiness probe — in that order. A Service is an API object with a stable ClusterIP and DNS name that load-balances over a set of pods chosen by a label selector: Kubernetes records the IPs of pods that match the selector AND are Ready into an EndpointSlice, and kube-proxy routes Service traffic across that live list. The type controls reach: ClusterIP is internal-only service-to-service, NodePort opens a port on every node for crude external access, LoadBalancer provisions one paid cloud LB per Service, and a headless Service drops the virtual IP so DNS returns pod IPs directly for StatefulSets. Because an LB-per-service gets expensive and offers no HTTP routing, an Ingress sits in front as a single L7 front door: it routes by host and path to many ClusterIP Services and terminates TLS centrally behind one shared load balancer — but it is only rules, and needs an ingress controller (nginx, Traefik) running to enforce them. The failure that bites hardest is the simplest: if the Service’s selector doesn’t match the pods’ labels (or the pods aren’t Ready), the EndpointSlice is empty, the Service still resolves but routes to nothing, and requests silently time out — so the first thing to check is always kubectl get endpoints.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.