Kubernetes Service Discovery: How It Works and How to Implement It
Kubernetes service discovery explained across Services, EndpointSlices, kube-proxy, and CoreDNS, with YAML and a debug checklist.
GrowthX8 min read

Kubernetes service discovery maps a stable Service name to the current Internet Protocol (IP) addresses of the Pods behind it. Your workloads keep calling the same name as Kubernetes replaces Pods, and your application code never has to track where each Pod lives. Labels, Services, EndpointSlices, the node dataplane, and CoreDNS each handle part of that mechanism, and knowing their roles gives you a short list of places to inspect when a service is unreachable.
This guide covers the request path from Pod label to Domain Name System (DNS) answer, how native discovery compares with registries such as Consul and Eureka and with service meshes, and a troubleshooting checklist with the exact kubectl commands to run.
Why Do Pods Need Service Discovery?
Pods need service discovery because their IP addresses change. Deployments replace Pods during rolling updates, node drains, and scaling, and each replacement usually gets a new IP, which breaks any hardcoded address. Service discovery is how a client finds a healthy endpoint for a named service at the moment it connects, and Kubernetes builds it in through Services, EndpointSlices, and cluster DNS.
When a Pod dies, it is removed from its EndpointSlices, and new Pods matching the Service selector are added automatically. In an online store, inventory might scale from three to 12 replicas during a sale, and checkout keeps calling inventory without a config change.
How Kubernetes Service Discovery Works
A request between two services passes through five layers in order. Labels mark Pods, a Service selects them, an EndpointSlice records which are ready, kube-proxy or an extended Berkeley Packet Filter (eBPF) dataplane routes the Service's virtual IP, and CoreDNS resolves the name to that IP.
Labels, Selectors, and Services
A Kubernetes Service defines membership with a label selector, and its controller continuously scans for matching Pods and updates the Service's EndpointSlices. Each Service also gets a cluster IP tied to its lifespan, and it won't change while the Service exists. A mismatched selector leaves the Service without matching endpoints. DNS still resolves the name, but no backend ever receives traffic.
EndpointSlices
EndpointSlices hold the backend addresses for a Service. The control plane creates them automatically for every Service with a selector, links them with the kubernetes.io/service-name label, and caps each slice at 100 endpoints by default, configurable up to 1000 with --max-endpoints-per-slice. Endpoints carry ready, serving, and terminating conditions, and the legacy Endpoints API has been deprecated since v1.33.
Readiness probes decide which Pods count as ready. When a probe fails, the EndpointSlice controller removes the Pod's IP from the slices of every matching Service. A running but unready Pod receives no Service traffic as a result.
kube-proxy and the ClusterIP
Each node's dataplane handles Service traffic sent to virtual IPs. kube-proxy watches the control plane for Service and EndpointSlice changes and programs that node to capture traffic sent to a clusterIP and port, then redirect it to one endpoint. In the default iptables mode, per-endpoint rules rewrite the destination to a backend picked at random unless session affinity is set.
Linux nodes offer three kube-proxy modes, and their status differs by release:
- iptables: The default mode.
- nftables: Stable since v1.33.
- IP Virtual Server (IPVS): Deprecated as of the Kubernetes v1.37 release, with plans to disable it by default by v1.40 and remove it by v1.43.
A cluster still on IPVS has a migration ahead, and the docs name nftables as its replacement, provided the nodes run kernel 5.13 or later and the network plugin supports it.
Calico's eBPF mode replaces kube-proxy and points each socket straight at a backend Pod IP. Cilium's kube-proxy replacement rewrites Service connections at the socket level on connect().
DNS with CoreDNS
CoreDNS gives every normal Service an A or AAAA record at my-svc.my-namespace.svc.cluster.local that resolves to the ClusterIP. Named ports also get service (SRV) records at _port-name._port-protocol.my-svc.my-namespace.svc.cluster.local, returning the port number and the Service's domain name. CoreDNS has been the default cluster DNS add-on since v1.13, and the kube-dns subproject is retired, so clusters still on kube-dns should migrate to CoreDNS.
Short names work because the kubelet writes a search list of <namespace>.svc.cluster.local, svc.cluster.local, and cluster.local with ndots:5 into each Pod. A query for data from the test namespace expands to data.test.svc.cluster.local. Names with fewer dots than the resolv.conf ndots option try the search list before the absolute query, so an external hostname can trigger several failed lookups first.
Environment Variables
Environment variables are the older discovery path, and they work only for Services that exist before the client Pod starts. The kubelet injects {SVCNAME}_SERVICE_HOST and {SVCNAME}_SERVICE_PORT, so redis-primary at 10.0.0.11 becomes REDIS_PRIMARY_SERVICE_HOST=10.0.0.11. DNS has no such ordering rule. Setting enableServiceLinks: false turns injection off when the variables clash with your own or you use only DNS.
Kubernetes Service Types Compared
Kubernetes has four Service types plus headless Services, and each one changes where a name is reachable and what DNS returns:
| Type | Reachable from | What DNS returns | Typical use |
|---|---|---|---|
| ClusterIP | Inside the cluster only | A/AAAA record with the ClusterIP | Internal APIs between services |
| NodePort | Each node's IP at a static port, plus the ClusterIP | The ClusterIP | Access through a node IP and static port |
| LoadBalancer | An external load balancer you provide or a cloud integrates, plus NodePort and ClusterIP | The ClusterIP for in-cluster names | Public-facing services |
| ExternalName | Wherever the external host is reachable, with no proxying | A canonical name (CNAME) record | Aliasing an outside database |
| Headless (clusterIP: None) | Pod IPs directly | A/AAAA records for each ready Pod | StatefulSets and peer discovery |
ExternalName needs care with Hypertext Transfer Protocol (HTTP) and Transport Layer Security (TLS) traffic. Clients use the cluster hostname, so HTTP requests carry a Host: header the origin may not recognize, and TLS servers can't present a matching certificate.
How to Implement Service Discovery in Kubernetes
Four short manifests cover the ClusterIP, headless, ExternalName, and selectorless Service patterns, and the last subsection shows how your code calls them.
Define a ClusterIP Service
A ClusterIP Service needs a selector and a port, and a named targetPort points at the container port by name:
apiVersion: v1
kind: Service
metadata: {name: orders}
spec:
selector: {app.kubernetes.io/name: orders}
ports:
- {name: http, port: 80, targetPort: http}
Pods in the namespace can now reach orders, and kube-proxy forwards port 80 to whichever container port each matching Pod names http.
Use a Headless Service for StatefulSets
Setting clusterIP: None removes the virtual IP, so DNS returns the IPs of every selected Pod:
apiVersion: v1
kind: Service
metadata: {name: nginx}
spec:
clusterIP: None
selector: {app: nginx}
ports: [{name: web, port: 80}]
A StatefulSet named web with serviceName: nginx then gets stable per-Pod names such as web-0.nginx.default.svc.cluster.local, which databases and peer-aware systems use to address specific replicas.
Reach External Services
An ExternalName Service gives an outside hostname a cluster-local alias:
apiVersion: v1
kind: Service
metadata: {name: my-service, namespace: prod}
spec:
type: ExternalName
externalName: my.database.example.com
A lookup for my-service.prod.svc.cluster.local returns a CNAME to my.database.example.com, with no proxying at any layer.
For backends with fixed IPs, a Service without a selector needs a manual EndpointSlice, here paired with a Service exposing port http on 80 with targetPort: 9376:
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: my-service-1
labels: {kubernetes.io/service-name: my-service, endpointslice.kubernetes.io/managed-by: staff}
addressType: IPv4
ports: [{name: http, protocol: TCP, port: 9376}]
endpoints: [{addresses: ["10.1.2.3"], conditions: {ready: true}}]
The kubernetes.io/service-name label links the slice to its Service, and the managed-by label identifies its manager.
Call Services by DNS Name from Your Code
Your code needs no discovery library, only the Service's DNS name, because the cluster resolves and routes it. In Java, for example, a Spring RestTemplate can call a Service FQDN (fully qualified domain name) in the form {service-name}.{namespace}.svc.{cluster}.local:{service-port}:
RestTemplate rest = new RestTemplate();
String url = "http://orders.shop.svc.cluster.local:80/api/orders";
String body = rest.getForObject(url, String.class);
Native Kubernetes discovery like this keeps code compatible with Istio, a service mesh that can balance load, trip circuit breakers, and handle failover.
Native Discovery vs. Service Registries and Service Meshes
Native discovery covers traffic inside a single cluster, a registry fits multi-cluster estates or ones that mix virtual machines (VMs) with Kubernetes, and a mesh fits when you need mutual TLS (mTLS), retries, or traffic shaping between services. Kubernetes discovery is server-side by design. In the standard pattern definitions, client-side discovery has the client query a registry and pick an instance, and server-side discovery has a router query the registry and forward the request. A Kubernetes client resolves one name and the node dataplane picks the backend, though headless Services hand that choice back to the client.
Eureka and Consul each keep their own registry. A Spring application with the Eureka client starter registers with Eureka and queries the registry to locate other services, then chooses its own instance. Consul catalog sync registers Kubernetes Services in Consul's catalog so non-Kubernetes nodes can resolve them through Consul DNS, and it can sync Consul services into Kubernetes.
A service mesh keeps Kubernetes discovery and adds traffic policy. Linkerd's automatic mTLS is on by default between meshed Pods. Istio's ambient mode runs mTLS in a per-node ztunnel and adds a waypoint proxy for per-request retries and timeouts. Meshed calls still need distributed tracing to show where latency builds, and Coralogix, a cross-stack observability platform, draws those service interactions automatically in its application performance monitoring (APM) service map.
How to Troubleshoot Service Discovery
Troubleshoot service discovery by checking each layer in request-path order, from EndpointSlices to the Service, DNS, CoreDNS, and network policy, which rules out one layer at a time. The official DNS debugging steps start from a dnsutils Pod, and this checklist adds the Service and network checks around them:
- Confirm the slice has addresses: An empty endpoint list from kubectl get endpointslices can indicate a selector mismatch or Pods with failing readiness probes.
- Compare the Service with the Pods: kubectl describe service shows the selector, ports, and endpoints, which you can compare with the Pods to find a targetPort pointing at the wrong container port.
- Test names from a debug Pod: A lookup that succeeds for the FQDN but fails for the short name points at the Pod's search domains, its ndots setting, or a namespace mismatch.
- Inspect CoreDNS: CoreDNS Pods carry the k8s-app=kube-dns label in kube-system, and adding the log plugin to the coredns ConfigMap records arriving queries.
- Look for NetworkPolicy blocks: A default deny-all egress network policy also blocks DNS traffic, so workloads need a separate policy allowing egress to the cluster DNS Service.
For a Service named orders in the shop namespace, the same checks take six commands:
kubectl get endpointslices -n shop -l kubernetes.io/service-name=orders
kubectl describe service orders -n shop
kubectl apply -f https://k8s.io/examples/admin/dns/dnsutils.yaml
kubectl exec -i -t dnsutils -- nslookup orders.shop
kubectl get pods -n kube-system -l k8s-app=kube-dns
kubectl logs -n kube-system -l k8s-app=kube-dns
If slices list addresses and DNS resolves but connections still fail, kube-proxy is the next component to inspect. On the node, iptables-save shows whether rules exist for the Service in iptables mode, nft list ruleset does the same in nftables mode, and ipvsadm -ln does the same in the deprecated IPVS mode. eBPF dataplanes replace those node rules and need their own tooling. On Cilium, cilium-dbg service list should show a ClusterIP entry for the Service, and Calico's eBPF mode likewise needs its own checks rather than iptables or nftables rules.
Discovery faults can surface as timeouts, connection errors, and 5xx responses between services. Coralogix's Infrastructure Explorer maps the Kubernetes relationships between Pods, nodes, and Services, so you can follow an erroring call back to the Service and Pods behind it. For the request-level view, the distributed tracing guide shows how to follow one call across the services it touches.
Native DNS Simplicity vs. Mesh-Level Control
Native discovery handles a typical service-to-service call with a Service, its EndpointSlices, and a DNS name, and your code needs nothing beyond an HTTP client. A mesh or registry adds encryption, per-request retries, weighted rollouts, or cross-cluster reach, in exchange for another system to run. With either approach, you have to quickly find a mismatched selector or a blocked DNS port once calls start failing.
Coralogix ties those failing calls back to the Pods, nodes, and Services behind them, keeping discovery faults from hiding behind a generic timeout. To trace failing calls across your services alongside CoreDNS logs and Kubernetes events, explore the Coralogix platform. You get call-level telemetry, DNS logs, and cluster events in one place.
Frequently Asked Questions About Kubernetes Service Discovery
Do I need Consul or Eureka with Kubernetes?
No, not for traffic inside one cluster. Services, EndpointSlices, and CoreDNS already resolve and route names there. Consul helps when services span datacenters, because cluster peering connects independent Consul clusters so their services can communicate. Eureka keeps a separate client-side registry that duplicates what the cluster already provides for Pods inside it.
What is the difference between a Service and an Ingress?
A Service gives a set of Pods a stable in-cluster name and a load-balanced virtual IP, and an Ingress routes external HTTP traffic to Services. Ingress handles north-south entry, and Services handle discovery inside the cluster. The Ingress API is frozen, so new external routing work targets the Gateway API.
What is a headless Service used for?
A headless Service returns ready Pod IPs directly instead of a virtual IP, and kube-proxy doesn't load-balance to it. Clients receive the whole set of addresses and are expected to use all of them or pick among them round-robin. The client owns both selection and how long it caches the answer, since a stale list can keep pointing at Pods that have already gone.
How does a Pod reach a Service in another namespace?
A Pod reaches it with service.namespace, such as data.prod, or the full name data.prod.svc.cluster.local. Bare names such as data resolve to the caller's namespace, so they won't find a Service elsewhere.