Headless Services + StatefulSets
Summary
- A headless Service is a Service with
clusterIP: Nonethat returns pod DNS records directly instead of load balancing through a single virtual IP. - Insight: The headless Service is what turns a StatefulSet's stable pod names into resolvable, per-member DNS addresses.
- Key Concepts: clusterIP: None, per-pod A records, SRV records, and peer discovery for clustered software.
- When to Use: Any StatefulSet whose pods must find and address each other, such as databases, message brokers, and consensus systems.
- Limitations: A headless Service does no load balancing and does not provide a stable single entry point for clients.
- Related Topics: StatefulSets, per-pod PVCs, DNS in Kubernetes, quorum and leader election.
Recipe
Create a headless Service, then reference it from the StatefulSet's serviceName.
The Service selector must match the pod labels, and its name must equal serviceName.
Once both exist, each pod resolves as <pod>.<service>.<namespace>.svc.cluster.local.
kubectl apply -f headless-service.yaml
kubectl apply -f statefulset.yaml
kubectl get svc web -o wideWorking Example
Here is a complete, minimal pairing for a three-node cluster application.
apiVersion: v1
kind: Service
metadata:
name: cassandra
labels:
app: cassandra
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: cassandra
ports:
- name: cql
port: 9042
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cassandra
spec:
serviceName: cassandra
replicas: 3
selector:
matchLabels:
app: cassandra
template:
metadata:
labels:
app: cassandra
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
fsGroup: 999
containers:
- name: cassandra
image: cassandra:5.0
ports:
- name: cql
containerPort: 9042
env:
# seed the cluster with the first pod's stable DNS name
- name: CASSANDRA_SEEDS
value: "cassandra-0.cassandra.default.svc.cluster.local"
readinessProbe:
tcpSocket:
port: 9042
initialDelaySeconds: 30
resources:
requests:
cpu: "500m"
memory: 1Gi
limits:
memory: 2Gi
volumes: []
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10GiApply it, then confirm each member resolves by name.
kubectl exec cassandra-0 -- nodetool status
kubectl run dns --rm -it --image=busybox:1.36 -- \
nslookup cassandra.default.svc.cluster.localThe last query returns all ready pod IPs, which is how peers discover the full membership.
Deep Dive
What clusterIP: None actually changes
A normal Service allocates a virtual IP and kube-proxy load balances connections across endpoints.
A headless Service allocates no virtual IP, so DNS returns the backing pod IPs directly.
Querying the Service name returns one A record per ready pod, and querying a pod's own name returns just that pod.
Per-pod records and SRV
Each StatefulSet pod gets an A or AAAA record at <pod>.<service>.<namespace>.svc.cluster.local.
Named ports also produce SRV records, which some clients use to learn both the host and port of each member.
These records exist only because the pod belongs to a StatefulSet backed by this headless Service.
publishNotReadyAddresses
By default DNS only lists ready pods, which is safe for clients but awkward during bootstrap.
Setting publishNotReadyAddresses: true publishes pods before they pass readiness, so seed nodes can resolve peers while the cluster is still forming.
Turn it on for clustered systems that must discover peers during initial formation, and understand it exposes not-ready addresses.
Two-Service pattern
A common production shape uses two Services: one headless for peer discovery and one normal ClusterIP for client traffic.
The headless Service handles member-to-member addressing, while the ClusterIP Service load balances application clients across pods.
This separates the internal topology from the external contract.
Gotchas
The Service name must exactly match the StatefulSet serviceName, or per-pod DNS never appears.
A mismatched selector is silent; pods run, but the Service has no endpoints and lookups fail.
Do not expect load balancing from a headless Service - it returns raw pod IPs, so clients that need balancing should use a separate ClusterIP Service.
Forgetting publishNotReadyAddresses can deadlock a bootstrap where every pod waits for a seed that is not yet Ready.
DNS caching in the application or its libraries can hide membership changes, so tune client-side TTLs for elastic clusters.
Cross-namespace peers need the fully qualified name; the short pod.service form only resolves within the same namespace.
Alternatives
A plain ClusterIP Service is the right choice when clients just need a load-balanced endpoint and do not care which pod answers.
The Kubernetes EndpointSlice API or a discovery library can drive membership when your app already integrates with the API server, though that adds coupling to the cluster.
An external service registry such as Consul is an option for multi-cluster or hybrid deployments, at the cost of running and securing that registry.
For simple leader-only access, a labeled ClusterIP Service whose selector targets the current primary pod can route writes without per-pod DNS.
FAQs
What makes a Service headless?
Setting spec.clusterIP: None. That single field stops virtual-IP allocation and makes DNS return pod records directly.
Why does my StatefulSet have no per-pod DNS?
Almost always the Service metadata.name does not match the StatefulSet spec.serviceName, or the selector does not match pod labels.
Does a headless Service load balance? No. It returns all backing pod IPs and lets the client decide. Use a separate ClusterIP Service for balancing.
When do I need publishNotReadyAddresses? During cluster bootstrap, when seed pods must resolve peers before those peers pass readiness checks.
Can clients still get a single stable endpoint? Yes, by adding a second, normal ClusterIP Service alongside the headless one for application traffic.
Are SRV records automatic? Named ports on the headless Service produce SRV records automatically for the backing pods.
Related
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).