Kubernetes Services
How Services give pods stable networking. ClusterIP, NodePort, LoadBalancer and headless, DNS naming, port vs targetPort, and why a Service has no endpoints.
Pods come and go, and their IP addresses go with them. A Service is the stable thing in front of them: one name, one virtual IP, and automatic load balancing across whichever pods currently match its selector.
Without Services, every deploy would break every caller.
How a Service finds its pods
A Service does not reference pods by name. It carries a label selector, and Kubernetes continuously maintains a list of the pod IPs matching it.
apiVersion: v1
kind: Service
metadata:
name: payments-api
spec:
selector:
app: payments-api # matches pod labels, not pod names
ports:
- port: 80 # the port the Service listens on
targetPort: 8080 # the port on the pod
Service → Endpoints → Pods
Client
other pod / user
Service
my-svc:80
stable IP · DNS name
Endpoints
10.0.0.1:8080 · 10.0.0.2:8080
Pod A
Pod B
That matching list is stored in EndpointSlice objects, and it is the first thing to check when a Service does not work:
kubectl get endpointslices -l kubernetes.io/service-name=payments-api
NAME ADDRESSTYPE PORTS ENDPOINTS
payments-api-x7k2q IPv4 8080 10.244.1.17,10.244.2.31,10.244.1.94
If that list is empty, the Service is a dead end: it will accept connections and then have nowhere to send them. Two things cause it, and they account for most Service problems people hit:
- The selector does not match any pod's labels. Compare them directly:
kubectl get pods --show-labels kubectl get service payments-api -o jsonpath='{.spec.selector}' - The pods match but are not Ready. Only ready pods are added to a Service, which is the whole point of a readiness probe, but it means a failing probe silently empties your Service.
port, targetPort, nodePort
These three are the most commonly confused fields in Kubernetes.
| Field | Who uses it | Meaning |
|---|---|---|
port | Clients inside the cluster | The port the Service listens on |
targetPort | The Service | The port on the pod to forward to |
nodePort | External clients | A port opened on every node (NodePort/LoadBalancer only) |
So port: 80, targetPort: 8080 means callers connect to payments-api:80 and traffic arrives at the container on 8080. They are frequently the same number, which hides the distinction until the day they are not.
targetPort can also name a port declared in the pod spec, which is more readable and survives a port change:
# in the pod
ports:
- name: http
containerPort: 8080
# in the Service
targetPort: http
DNS
Every Service gets a DNS record. Inside the same namespace, the short name works:
payments-api
Across namespaces you need the fully qualified form:
payments-api.production.svc.cluster.local
│ │ │
│ │ └── always "svc.cluster.local"
│ └── namespace
└── service name
payments-api.production also resolves. A call that works from one namespace and fails from another is nearly always a missing namespace segment.
To test resolution from inside the cluster:
kubectl run tmp --rm -it --image=nicolaka/netshoot -- \
nslookup payments-api.production.svc.cluster.local
The four types
ClusterIP: the default
Reachable only from inside the cluster. This is what almost every Service should be; internal services have no business being exposed.
spec:
type: ClusterIP
NodePort
Opens the same port on every node, in the range 30000 to 32767, forwarding to the Service.
spec:
type: NodePort
ports:
- port: 80
targetPort: 8080
nodePort: 30080 # omit to get one assigned
Useful for local clusters and quick tests. It is a poor production choice: the port range is unmemorable, you need something in front to spread traffic across nodes anyway, and every node ends up listening on a port for a service it may not run.
LoadBalancer
Asks the cloud provider for a real load balancer pointing at the Service.
kubectl get service payments-api
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
payments-api LoadBalancer 10.96.41.203 a1b2c3.elb.amazonaws.com 80:31234/TCP 2m
The catch is cost and scale: this is one cloud load balancer per Service, each with its own hourly charge and its own IP. Ten public services means ten load balancers. That is the problem Ingress solves. One load balancer routing to many services by host and path.
An EXTERNAL-IP stuck at <pending> means no controller is fulfilling the request. On a managed cloud cluster, check subnet tagging and controller logs; on a bare-metal or local cluster, there is no cloud provider to answer, and you need something like MetalLB.
ExternalName
No proxying at all, just a CNAME to an external hostname. Handy for pointing an in-cluster name at a managed database while you migrate.
spec:
type: ExternalName
externalName: payments-db.abc123.us-east-1.rds.amazonaws.com
Headless Services
Set clusterIP: None and Kubernetes allocates no virtual IP. DNS then returns all pod IPs instead of one Service address.
spec:
clusterIP: None
selector:
app: cassandra
This is what StatefulSets use. Clients that need to address individual replicas. Database drivers doing their own topology discovery, quorum members talking to named peers. Need the real addresses, not a load-balanced abstraction over them.
A Service is not a proxy server
There is no process sitting behind a ClusterIP. kube-proxy programs iptables or IPVS rules on every node, and the "load balancing" is packet rewriting in the kernel. Nothing terminates TLS, nothing inspects HTTP, and there is no retry logic.
Two practical consequences:
- Balancing is per-connection, not per-request. A client holding a keep-alive HTTP/1.1 connection, or any HTTP/2 connection, pins to one pod for the connection's life. gRPC clients in particular will hammer a single replica unless they do client-side balancing or you put a proxy in the path.
- Path and host routing need an Ingress. A Service routes on port only.
For stickiness there is sessionAffinity: ClientIP, but as with load balancer stickiness generally, externalising session state is the better fix.
Explore further in this section
| Sub-topic | What you will learn |
|---|---|
| ClusterIP | Internal Services and DNS naming |
| NodePort | Exposing a Service on a static port per node |
| LoadBalancer | Cloud load balancers for public access |
Quick reference
| Task | Command |
|---|---|
| List Services | kubectl get services |
| Backing pod IPs | kubectl get endpointslices -l kubernetes.io/service-name=<n> |
| Detail and events | kubectl describe service <n> |
| Read the selector | kubectl get svc <n> -o jsonpath='{.spec.selector}' |
| Compare pod labels | kubectl get pods --show-labels |
| Reach it locally | kubectl port-forward service/<n> 8080:80 |
| Expose a Deployment | kubectl expose deployment <n> --port=80 --target-port=8080 |
| Test DNS | kubectl run tmp --rm -it --image=nicolaka/netshoot -- nslookup <svc> |
Key takeaways
- Services select pods by label, never by name, and the result lands in EndpointSlices.
- An empty endpoint list is the diagnosis for most broken Services. Either the selector does not match, or the pods are not Ready.
portis what clients dial,targetPortis where the pod listens. They are not the same field.- Cross-namespace calls need the namespace in the DNS name.
- LoadBalancer is one cloud load balancer per Service. Use Ingress when you have several public services.
- Balancing is per-connection. Long-lived HTTP/2 and gRPC connections pin to one pod.
- Headless Services (
clusterIP: None) return pod IPs, which is what StatefulSet clients need.
Check your understanding
A Service that goes nowhere
Requests to a ClusterIP Service time out. The pods are Running and serving correctly when reached by pod IP. kubectl get endpointslices shows no endpoints. What should you check first?
Uneven load across replicas
A gRPC service has 5 replicas behind a ClusterIP Service, but one pod receives nearly all the traffic. Why?
Frequently Asked Questions
What is the difference between ClusterIP, NodePort and LoadBalancer?
They are layers on top of one another. ClusterIP gives a stable internal-only virtual IP and is the default. NodePort does everything ClusterIP does and additionally opens the same port, between 30000 and 32767, on every node so external traffic can reach it. LoadBalancer does everything NodePort does and additionally asks the cloud provider to create a real load balancer pointing at those node ports. Use ClusterIP for internal services, and prefer an Ingress over many LoadBalancer Services when exposing several applications publicly.
Why does my Service have no endpoints?
Either no pod's labels match the Service selector, or matching pods are not passing their readiness probe. Compare them directly with kubectl get pods --show-labels and kubectl get svc <name> -o jsonpath='{.spec.selector}'. A mismatch as small as app: payments versus app: payments-api is enough. If the labels do match, check pod readiness, because only Ready pods are added to a Service. This is intentional, but it means a misconfigured readiness probe empties the Service without any obvious error.
What is the difference between port and targetPort?
port is the port the Service itself listens on, so it is what clients inside the cluster connect to. targetPort is the port on the pod that traffic is forwarded to. With port: 80, targetPort: 8080, a client connects to my-service:80 and the container receives the request on 8080. They are often the same value, which is why the distinction only becomes visible when they differ. targetPort can also reference a named port from the pod spec, which is more readable and does not break if the number changes.
How do I call a Service in another namespace?
Use the namespace in the DNS name: service-name.namespace or the fully qualified service-name.namespace.svc.cluster.local. The bare short name only resolves within the same namespace, because the cluster's DNS search path appends the caller's own namespace first. A request that works from one namespace and fails from another is almost always this.
Why is my LoadBalancer Service stuck with EXTERNAL-IP pending?
Nothing is fulfilling the request. On a managed cloud cluster the cloud controller creates the load balancer, so check its logs and, on AWS, confirm your subnets carry the required role tags, since the controller discovers placement from them. On a local cluster such as kind or minikube, or on bare metal, there is no cloud provider at all and the field stays pending forever; install something like MetalLB, or use kubectl port-forward for local access.
When should I use a headless Service?
When clients need the individual pod addresses rather than a single balanced endpoint. Setting clusterIP: None makes DNS return every ready pod IP instead of one virtual IP. StatefulSets use this so each replica is individually addressable, which database clients and quorum-based systems require. It is also the foundation for client-side load balancing, which is how you get even distribution for gRPC traffic that would otherwise pin to one pod.