DNS and Service Discovery
The idea in one sentence
Section titled “The idea in one sentence”CoreDNS gives every Service a predictable DNS name so Pods can find each other by name instead of by IP, and a headless Service flips that from one load-balanced IP to the individual IPs of each backing Pod.
CoreDNS and the Service DNS name
Section titled “CoreDNS and the Service DNS name”CoreDNS runs as a Deployment inside the cluster (typically in kube-system) and serves as the cluster’s internal DNS server. Every Pod is configured, via its /etc/resolv.conf, to send DNS queries to CoreDNS. The moment a Service is created, CoreDNS starts answering for it with a fully qualified name of the form:
<service-name>.<namespace>.svc.cluster.local# From inside any Pod in the clusternslookup payments.default.svc.cluster.localWithin the same namespace, the short form also works, because a Pod’s DNS search path automatically includes its own namespace’s svc.cluster.local suffix:
# From a Pod that lives in the same namespace as the "payments" Servicenslookup paymentscurl http://payments/healthFor a normal (non-headless) Service, that name resolves to a single A/AAAA record: the Service’s ClusterIP. Every request to that name still gets load-balanced across the backing Pods by kube-proxy — DNS just gets a client to the virtual IP.
Headless Services: skipping the load balancer
Section titled “Headless Services: skipping the load balancer”Sometimes a client needs to reach a specific Pod, not “any healthy Pod behind the Service.” Setting clusterIP: None creates a headless Service:
apiVersion: v1kind: Servicemetadata: name: cachespec: clusterIP: None selector: app: cache ports: - port: 6379A headless Service gets no virtual IP at all. Instead, CoreDNS returns the individual IPs of every matching, ready Pod directly. A query still resolves the same name, cache.default.svc.cluster.local, but the answer is now a list of Pod IPs instead of one ClusterIP.
Why StatefulSets rely on this
Section titled “Why StatefulSets rely on this”A StatefulSet gives each of its Pods a stable identity — web-0, web-1, web-2 — and pairs that with a headless Service (via spec.serviceName) so each Pod also gets its own individual, stable DNS record:
<pod-name>.<service-name>.<namespace>.svc.cluster.local# Reach one specific ordinal Pod directly, not a load-balanced peernslookup web-0.cache.default.svc.cluster.localThis is exactly what distributed, stateful systems need: a database replica has to reconnect to the same peer it had before, not a random one a load balancer happens to pick. Without a headless Service, that per-Pod DNS record would not exist at all.
Real proof: svc, svc.ns, fqdn, and headless per-Pod records
Section titled “Real proof: svc, svc.ns, fqdn, and headless per-Pod records”From a real dnsutils Pod (registry.k8s.io/e2e-test-images/jessie-dnsutils:1.7) against a real web-clusterip Service:
$ nslookup web-clusterip.default.svc.cluster.localServer: 10.96.0.10Address: 10.96.0.10#53
Name: web-clusterip.default.svc.cluster.localAddress: 10.96.196.12Headless per-Pod A records, real, from web-headless (clusterIP: None):
$ nslookup web-headless.default.svc.cluster.localServer: 10.96.0.10Address: 10.96.0.10#53
Name: web-headless.default.svc.cluster.localAddress: 10.244.147.20Name: web-headless.default.svc.cluster.localAddress: 10.244.138.135Same query shape, completely different answer shape: the normal Service returns one ClusterIP; the headless Service returns every backing Pod’s real IP directly, exactly as this lesson’s mechanism describes.
ndots:5 and search: the real /etc/resolv.conf, and the extra lookups it causes
Section titled “ndots:5 and search: the real /etc/resolv.conf, and the extra lookups it causes”$ cat /etc/resolv.confsearch default.svc.cluster.local svc.cluster.local cluster.localnameserver 10.96.0.10options ndots:5ndots:5 means: any name with fewer than 5 dots gets every entry in search tried, in order, before the resolver ever tries the name as given. web-clusterip (0 dots) triggers up to three extra lookup attempts (web-clusterip.default.svc.cluster.local, .svc.cluster.local, .cluster.local) before landing on the right one — invisible in a single successful nslookup, but real extra DNS round-trips on every short-name lookup in the cluster, which is exactly why this default is a frequent target for tuning on high-QPS workloads.
Custom dnsPolicy/dnsConfig: real, different resolv.conf
Section titled “Custom dnsPolicy/dnsConfig: real, different resolv.conf”apiVersion: v1kind: Podmetadata: name: custom-dns-demospec: dnsPolicy: None dnsConfig: nameservers: - 10.96.0.10 searches: - default.svc.cluster.local - svc.cluster.local - cluster.local options: - name: ndots value: '2' containers: - name: app image: registry.k8s.io/e2e-test-images/jessie-dnsutils:1.7 command: ['sleep', '3600']$ kubectl exec custom-dns-demo -- cat /etc/resolv.confsearch default.svc.cluster.local svc.cluster.local cluster.localnameserver 10.96.0.10options ndots:2dnsPolicy: None hands full control to dnsConfig — this Pod’s real resolv.conf shows ndots:2 instead of the cluster default 5, proving the override took effect exactly as configured. Lowering ndots is a real, common tuning move for a workload that mostly talks to fully-qualified external names, to cut the wasted in-cluster search attempts described above.
CoreDNS Corefile: the real ConfigMap
Section titled “CoreDNS Corefile: the real ConfigMap”$ kubectl get configmap coredns -n kube-system -o jsonpath='{.data.Corefile}'.:53 { errors health { lameduck 5s } ready kubernetes cluster.local in-addr.arpa ip6.arpa { pods insecure fallthrough in-addr.arpa ip6.arpa ttl 30 } prometheus :9153 forward . /etc/resolv.conf { max_concurrent 1000 } cache 30 { disable success cluster.local disable denial cluster.local } loop reload loadbalance}This is the real plugin chain answering every query in this lesson: the kubernetes plugin serves cluster.local (with pods insecure enabling per-Pod IP-based DNS names, and a 30s TTL), forward sends anything outside cluster.local upstream to whatever /etc/resolv.conf the CoreDNS Pod itself has, and cache 30 bounds how long answers are cached.
ExternalName: a real CNAME, proven
Section titled “ExternalName: a real CNAME, proven”apiVersion: v1kind: Servicemetadata: name: external-apispec: type: ExternalName externalName: kubernetes.io$ nslookup external-api.default.svc.cluster.localServer: 10.96.0.10Address: 10.96.0.10#53
external-api.default.svc.cluster.local canonical name = kubernetes.io.Name: kubernetes.ioAddress: 15.197.167.90Name: kubernetes.ioAddress: 3.33.186.135CoreDNS answers with a real CNAME to kubernetes.io, which the resolver then follows to kubernetes.io’s own real, current A records — exactly what ExternalName is: a DNS-level alias, nothing more. No Service selector, no Endpoints/EndpointSlice, no load balancing — just a name pointing at another name.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated DNS-related manifests and code, check for:
ExternalNamepointing at a raw IP address instead of a hostname — proven above,ExternalNameonly produces aCNAME, and aCNAMEto an IP address is not valid DNS; a fixed IP needs a different mechanism (a headless Service with manually managedEndpoints/EndpointSlice, notExternalName).- Code that assumes every Pod’s
ndots/searchmatches the cluster default without checking for adnsPolicy: None/dnsConfigoverride — proven above, a Pod’s realresolv.confcan differ completely from the cluster norm. - A retry loop for “DNS propagation delay” copied from public-internet DNS habits — CoreDNS answers are generated live from EndpointSlice state on every query; there is no TTL-based propagation delay to wait out the way there is for public DNS.
- Hardcoding
10.96.0.10as “the” cluster DNS server IP in application code instead of reading it from the Pod’s own/etc/resolv.conf— it’s real (proven above) but cluster-specific (kube-dnsService ClusterIP), not a universal constant.
🐛 Spot the bug
apiVersion: v1kind: Servicemetadata:name: legacy-dbspec:type: ExternalNameexternalName: "10.20.30.40"Show the answer
externalName is set to a raw IP address string, "10.20.30.40". Proven above, ExternalName works by having CoreDNS answer with a CNAME record pointing at externalName’s value — but a CNAME target must be a hostname, never an IP address; DNS has no concept of “alias this name to that IP” via CNAME. Depending on the CoreDNS/upstream resolver version this either fails validation, resolves to nothing, or behaves unpredictably — none of which is “pin this Service to a fixed IP,” which is almost certainly what the author wanted. The fix: for a fixed-IP external dependency, create a headless Service with no selector and a manually-managed Endpoints/EndpointSlice object pointing at the real IP — that is the actual supported mechanism for “give this IP a stable in-cluster DNS name.”
Mental Model
Section titled “Mental Model”Every DNS answer in the cluster is really “ask CoreDNS, which is itself just a Deployment watching Services/EndpointSlices” (proven above via its real Corefile) — there’s no separate DNS control plane. ndots/search shape which name a client asks about (proven above with the real resolv.conf and the extra lookups a low dot-count name causes); dnsPolicy/dnsConfig shape which server and search list a Pod uses at all (proven above by overriding both); and headless vs normal Service shape what CoreDNS answers with (individual Pod IPs vs one ClusterIP, both proven above with real nslookup output).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Default ndots:5 + cluster search list | Short in-namespace names (web-clusterip) just work | Every external FQDN with fewer than 5 dots costs extra failed lookups against the cluster search suffixes first, proven above |
Custom dnsConfig with lower ndots | Cuts wasted in-cluster search attempts for external-heavy workloads, proven above | Short in-cluster names now need to be fully qualified, or they may not resolve as expected |
| Headless Service | Per-Pod stable DNS, required for StatefulSet peer discovery, proven above | No load balancing at the DNS layer at all — client must handle multiple A records |
ExternalName | Clean in-cluster name for an external dependency, proven above with a real CNAME | Just a CNAME — no health checking, no load balancing, must be a real resolvable hostname, never an IP (proven above) |
Common Misconceptions
Section titled “Common Misconceptions”- “DNS changes need time to propagate inside the cluster, like public DNS.” CoreDNS answers are generated live from EndpointSlice state (proven above via the real
Corefile’skubernetesplugin), not cached the way public DNS/TTL propagation works. - “
ndotsonly matters for external names.” Proven above — it changes lookup behavior (and cost) for every name, in-cluster or not, that has fewer dots than thendotssetting. - “
ExternalNamecan point at an IP address to pin a Service to a fixed backend.” Proven above — it cannot;ExternalNameonly produces a CNAME to a hostname. Pinning a fixed IP needs a headless Service with manually-managedEndpoints/EndpointSliceinstead.
flowchart TB pod["Client Pod"] -->|"DNS query"| coredns["CoreDNS"] coredns -->|"normal Service"| vip["Service ClusterIP\n(load-balanced)"] coredns -->|"headless Service"| p0["web-0 IP"] coredns -->|"headless Service"| p1["web-1 IP"] coredns -->|"headless Service"| p2["web-2 IP"]