Amazon EKS
Your compute scaled out — now DNS is timing out: taming the CoreDNS bottleneck under load
A traffic surge scales your pods flawlessly, then the logs fill with i/o timeout and CoreDNS pins at 100% CPU. The culprit is rarely CoreDNS itself — it's the default ndots:5 quietly turning one lookup into eight, and UDP resolution racing the kernel's conntrack table.
Scope & versions. Amazon EKS with the default CoreDNS add-on and kube-proxy in iptables mode, as documented mid-2026. The Kubernetes DNS spec (ndots:5, three search domains, Pod dnsConfig/dnsPolicy) is stable across recent versions. NodeLocal DNSCache uses the community manifest with the link-local IP 169.254.20.10. The cluster-proportional-autoscaler is v1.x. Re-verify your cluster's kube-dns Service IP (10.100.0.10 or 172.20.0.10) and CoreDNS Corefile before you apply anything below.
Your load test ramps, Karpenter and the HPA do their job, and within ninety seconds you have three times the pods you started with. Compute looks great. Then the application logs start bleeding:
dial tcp: lookup payments.prod.svc.cluster.local on 10.100.0.10:53:
read udp 10.0.14.2:41500->10.100.0.10:53: i/o timeoutYour microservices can't find each other. Requests that normally take single-digit milliseconds hang for a flat five seconds and then succeed. The two CoreDNS pods are pegged at 100% CPU. Nothing is wrong with your code, your Services, or your network policy — you've just discovered that standard Linux DNS resolution, at concurrency, will happily DDoS your own cluster's service registry. Here's exactly how the storm forms, and the three defenses that stop it.
TL;DR
- The default
ndots:5is the amplifier. A pod's/etc/resolv.confships with three search domains andoptions ndots:5. Any name with fewer than five dots — which is almost every name — gets each search domain appended and tried first, in order. One lookup forapi.stripe.combecomes four name attempts, doubled to eight queries by the parallel A + AAAA lookups. Six of them are wastedNXDOMAINround-trips. - UDP resolution races the kernel. glibc fires the A and AAAA queries in parallel on one socket;
kube-proxy's conntrack has a well-known insertion race that drops one packet, and the client waits out its 5-second timeout before retrying. That's the flat five-second hang. - CoreDNS pins because it's doing 8× the work, and the VPC resolver has a hard cap. Behind CoreDNS, each ENI is limited to 1,024 packets/second to the Amazon-provided VPC resolver. Two default replicas saturate fast.
- Fix the platform first: deploy NodeLocal DNSCache as a DaemonSet listening on
169.254.20.10— it caches on the node, skips conntrack entirely, and upgrades cache-miss traffic to persistent TCP upstream. Then size CoreDNS with the cluster-proportional-autoscaler (a replica per 256 cores or 16 nodes), so capacity is already there before the spike. - Fix the application too: set
ndots:2(or use trailing-dot FQDNs) to stop the fan-out at the source — a trailing dot alone has cut pod DNS time by up to 70% in the field.
The anatomy of a DNS storm: how one lookup becomes eight
Start where the kernel starts. When a pod boots, the kubelet writes it an /etc/resolv.conf that looks like this (namespace prod shown):
nameserver 10.100.0.10
search prod.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Two lines do all the damage. The search list gives three suffixes to try. And ndots:5 tells the stub resolver: if a name has fewer than 5 dots, treat it as a relative name and walk the search list first, before trying the name as given. That threshold of five exists so that in-cluster shorthands like payments or payments.prod resolve without a fully-qualified suffix — a genuine convenience. The trap is that it applies to every name, including the external ones.
Count the dots in api.stripe.com: two. Two is less than five, so the resolver does not try it as-is first. It appends each search domain in order and queries those, and only tries the bare name once the search list is exhausted:
getaddrinfo call
② 3 search suffixes tried first
③ bare name tried last
④ 8 queries, 6 wasted
ndots:5 turns a single external lookup into four name attempts, and the parallel A/AAAA pair doubles that to eight queries against CoreDNS — six of them guaranteed NXDOMAIN.Now multiply. That fan-out happens for every external hostname, from every pod, on every connection where the resolver cache has expired. A service that opens connections to api.stripe.com, sqs.us-east-1.amazonaws.com, and a database endpoint is generating dozens of DNS queries per request cycle, most of them destined to fail with NXDOMAIN. Scale from 40 pods to 400 during a surge and you have raised CoreDNS's query rate by a factor of eight on top of the 10× pod growth. That is why the graph looks like a wall: DNS QPS goes vertical while your actual traffic only tripled.
getaddrinfo() is address-family agnostic by default, so glibc requests both the IPv4 (A) and IPv6 (AAAA) records — and it sends them in parallel, on the same UDP socket. Even in an IPv4-only cluster, the AAAA query still goes out and still costs a full walk of the search list. That is the second factor of two in the eight.Why the timeout is exactly five seconds — and why CoreDNS pins at 100%
The amplification explains the load. It doesn't explain the flat five-second hang, and that detail is the tell that separates a DNS storm from ordinary slowness. The five seconds come from the kernel, not from CoreDNS.
DNS is UDP. UDP is connectionless, but kube-proxy in iptables mode still creates a conntrack entry for every DNS packet so it can reverse the DNAT that rewrites the kube-dns Service ClusterIP (10.100.0.10) to a real CoreDNS pod IP. Here's the race: glibc fires the A and AAAA queries at nearly the same instant, from the same source port. Two packets with an identical 5-tuple arrive at the conntrack layer before either has an entry, both try to insert, and the kernel drops one as a duplicate. The application never gets that answer, so it sits on its default 5-second resolver timeout before retrying. This is a documented kernel-level conntrack race, not a Kubernetes bug — two of its three variants were only fixed in Linux 5.1.
Meanwhile CoreDNS is genuinely overworked, and there's a hard ceiling behind it that many teams never learn about until it bites. CoreDNS forwards anything it can't answer locally to the Amazon-provided VPC resolver (the “.2” address). AWS enforces a limit of 1,024 packets per second per network interface to that resolver. The two CoreDNS replicas EKS ships by default sit on two ENIs, so your entire cluster's external DNS is capped at roughly 2,048 packets/second to the VPC resolver — and the 8× amplification means you hit that cap with surprisingly little real traffic. Past the cap, packets are silently dropped, which feeds back into the 5-second-timeout loop. CoreDNS pins at 100% CPU trying to shovel the amplified load, and the VPC resolver quietly throttles what gets through.
| Symptom you see | Actual cause | Which defense fixes it |
|---|---|---|
| Flat 5s (or 5/10/15s) stalls, intermittent | Conntrack insertion race on parallel A/AAAA UDP packets | NodeLocal DNSCache (skips conntrack; TCP upstream) |
| CoreDNS pods at 100% CPU during surges | 8× query amplification from ndots:5 | ndots:2 / FQDNs + autopath, then autoscale replicas |
i/o timeout bursts that track traffic | 1,024 pps/ENI cap to the VPC resolver, hit by 2 replicas | NodeLocal cache (fewer upstream queries) + more replicas |
Defense 1: put a DNS cache on every node with NodeLocal DNSCache
The single highest-leverage change is to stop sending DNS across the node boundary at all. NodeLocal DNSCache runs CoreDNS in cache mode as a DaemonSet — one pod per node in kube-system — listening on the link-local address 169.254.20.10. Pods on that node resolve against the local cache instead of the cluster-wide kube-dns Service. Two things change, and both matter.
It takes conntrack out of the path. Because the cache lives on the node's own loopback-scoped link-local IP, queries to it never traverse the kube-proxy DNAT rules and are never connection-tracked. The parallel-A/AAAA race that produced the 5-second stalls simply cannot happen for a cache hit. And with the vast majority of queries served from the local cache, the number of packets that ever reach conntrack collapses.
It converts flaky UDP into persistent TCP upstream. On a cache miss, NodeLocal forwards to CoreDNS — but it upgrades that connection from UDP to TCP. Per the Kubernetes docs, TCP conntrack entries are removed on connection close, whereas UDP entries have to time out (the default nf_conntrack_udp_timeout is 30 seconds), so TCP both avoids the race and stops the conntrack table from filling with stale UDP entries. Critically, the cache still listens on UDP toward the pods, so applications need no changes at all.
169.254.20.10
② hits served on-node
③ conntrack skipped
④ misses go TCP to CoreDNS
On EKS the manifest is templated with __PILLAR__ placeholders you fill in with sed before applying: the link-local IP, the cluster domain, and your kube-dns Service ClusterIP. One subtlety worth knowing: in kube-proxy iptables mode the node-local pod listens on both 169.254.20.10 and the kube-dns Service IP, so existing pods that still point at the ClusterIP transparently get the local cache. In IPVS mode it listens only on the link-local address, so pods must be pointed at 169.254.20.10 explicitly.
# Render the upstream manifest for EKS (kube-proxy iptables mode).
# kubedns = your kube-dns Service ClusterIP: 10.100.0.10 OR 172.20.0.10
kubedns=$(kubectl get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}')
domain=cluster.local
localdns=169.254.20.10
curl -sO https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml
# iptables mode: keep listening on BOTH the ClusterIP and the link-local IP
sed -i "s/__PILLAR__LOCAL__DNS__/$localdns/g; \
s/__PILLAR__DNS__DOMAIN__/$domain/g; \
s/__PILLAR__DNS__SERVER__/$kubedns/g" nodelocaldns.yaml
kubectl apply -f nodelocaldns.yaml
kubectl -n kube-system rollout status daemonset/node-local-dns
The rendered Corefile for the DaemonSet is where the UDP→TCP upgrade lives — note force_tcp on the upstream forwarders and the cache blocks per zone:
# node-local-dns Corefile (essentials)
cluster.local:53 {
errors
cache {
success 9984 30
denial 9984 5
}
forward . 10.100.0.10 { # kube-dns ClusterIP
force_tcp # UDP -> persistent TCP upstream
}
prometheus :9253
health 169.254.20.10:8080
}
. :53 { # everything else, incl. external names
errors
cache 30
forward . /etc/resolv.conf {
force_tcp
}
prometheus :9253
}
Defense 2: size CoreDNS to the cluster, not to a CPU graph
NodeLocal DNSCache slashes the query volume that reaches CoreDNS, but the central tier still has to be big enough for cache misses and the cold-start stampede at the beginning of a surge. The instinct is to slap an HPA on CoreDNS and scale on CPU. Don't. CoreDNS CPU is spiky and reactive scaling is always a step behind the spike — by the time the HPA reacts, the timeouts have already fired. You want capacity provisioned ahead of load, proportional to how big the cluster is.
That's exactly what the cluster-proportional-autoscaler (CPA) does. It runs as its own Deployment, watches the number of schedulable nodes and cores, and resizes the CoreDNS Deployment on a simple linear formula. No metrics-server, no CPU thresholds — if the cluster is big, CoreDNS is big.
The CPA is configured with a single ConfigMap. AWS's own guidance is to add a replica for every 256 cores or 16 nodes, whichever triggers first — that's coresPerReplica: 256 and nodesPerReplica: 16 in linear mode:
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-autoscaler
namespace: kube-system
data:
linear: |-
{
"coresPerReplica": 256,
"nodesPerReplica": 16,
"min": 2,
"max": 20,
"preventSinglePointFailure": true,
"includeUnschedulableNodes": true
}
The controller evaluates replicas = max(ceil(cores / 256), ceil(nodes / 16)), then clamps to [min, max]. preventSinglePointFailure: true guarantees at least two replicas whenever the cluster has more than one node, and includeUnschedulableNodes: true counts cordoned/draining nodes so a rollout doesn't briefly under-provision DNS. Deploy the controller itself pointed at the CoreDNS Deployment:
# cluster-proportional-autoscaler controller args (excerpt)
- --namespace=kube-system
- --target=deployment/coredns # scale the CoreDNS Deployment
- --configmap=coredns-autoscaler # the ConfigMap above
- --logtostderr=true
- --v=2
lameduck to 30 seconds so a terminating pod keeps answering while iptables rules propagate, and use the /ready endpoint (port 8181) for the readiness probe instead of /health, so a replica only takes traffic once it's actually ready to serve.Defense 3: stop the amplification at the source
The platform defenses make CoreDNS survivable. The application-level fixes make the storm smaller in the first place — and they're free. Start with the one that pays back the most.
Set ndots:2 for external-heavy workloads
If a workload mostly talks to external endpoints, override ndots down from 5. AWS recommends 2: high enough that in-cluster short names like svc.namespace still resolve, low enough that api.stripe.com (two dots) is tried as an absolute name first, skipping the three wasted search-domain queries. Set it per-pod with dnsConfig:
apiVersion: v1
kind: Pod
metadata:
name: checkout
spec:
dnsPolicy: "ClusterFirst" # keep cluster DNS for in-cluster names
dnsConfig:
options:
- name: ndots
value: "2" # api.stripe.com is tried as-is first
- name: single-request-reopen # A and AAAA on separate sockets:
- name: attempts # sidesteps the conntrack race
value: "2"
single-request-reopen is a quiet gem: it forces glibc to use a fresh socket for the AAAA query, so the two lookups no longer share a 5-tuple and the conntrack insertion race can't drop a packet. It's a per-pod band-aid for the 5-second stalls when you can't roll out NodeLocal DNSCache everywhere yet.
Use fully-qualified names with a trailing dot
The most surgical fix costs one character. A trailing dot marks a name as absolute, so the resolver skips the search list entirely — api.stripe.com. is one query pair, not four. Teams have measured pod DNS time dropping by up to 70% from this change alone, and at 10,000 pods it's the difference between millisecond lookups and multi-second stalls. Where you control the hostnames (config, connection strings, service meshes), prefer the trailing dot.
Complete the search path server-side with autopath
If you can't touch every workload's ndots, move the intelligence into CoreDNS. The autopath plugin makes CoreDNS walk the client's search path on the server: on the first query it follows the search chain, and when it finds a real answer it returns a CNAME pointing there — so the client stops after one query instead of walking all four. It trades CPU and memory on CoreDNS (it needs the Kubernetes pod cache to map client IP to namespace) for far fewer round-trips:
.:53 {
errors
health
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods verified # required for autopath's IP -> namespace map
fallthrough in-addr.arpa ip6.arpa
}
autopath @kubernetes # complete the search path server-side
cache 30
forward . /etc/resolv.conf {
force_tcp
prefer_udp
}
prometheus :9153
loop
reload
loadbalance
}
ndots:2/FQDNs where you control the workload, and reach for autopath only when you can't.Disable AAAA where you'll never use IPv6
In an IPv4-only cluster, every AAAA query is pure waste — it's the second factor of two in the eight. You can't easily stop glibc from asking, but you can stop CoreDNS from forwarding pointless AAAA lookups upstream by returning an immediate empty answer for them, which keeps those queries off the 1,024-pps VPC-resolver budget. Combined with ndots:2, this can halve the residual external query load.
Pitfalls that bite in production
Don't
- Leave CoreDNS at the default 2 replicas on a cluster that autoscales — you'll hit the 2×1,024-pps VPC-resolver wall under load.
- Scale CoreDNS with an HPA on CPU. Reactive scaling always lags the spike; the timeouts fire first.
- Assume a 5-second stall means CoreDNS is slow. A flat 5s step is the conntrack A/AAAA race, not saturation.
- Roll NodeLocal DNSCache fleet-wide without checking Security-Groups-per-Pod and your kube-proxy mode (iptables vs IPVS listen behavior differ).
- Set
ndots:1blindly — you'll break in-cluster short names likesvc.namespacethat legitimately need one suffix.
Do
- Deploy NodeLocal DNSCache first — it removes conntrack from the path and cuts upstream queries the most.
- Drive CoreDNS replicas with the cluster-proportional-autoscaler (a replica per 256 cores / 16 nodes) so capacity leads load.
- Set
ndots:2(or trailing-dot FQDNs) on external-heavy workloads to kill the fan-out at the source. - Set
lameduck 30sand use/readyreadiness so scale-down doesn't drop queries. - Alert on CoreDNS
SERVFAILrate and the 5s-latency bucket, not just CPU — they catch the storm earlier.
single-request-reopen. If CoreDNS CPU climbs smoothly to 100%, you have amplification and under-provisioning — cut ndots and let the CPA size the tier.References
- DNS for Services and Pods (search domains,
ndots:5,dnsConfig,dnsPolicy, FQDN) — Kubernetes docs. kubernetes.io/docs/concepts/services-networking/dns-pod-service - Using NodeLocal DNSCache in Kubernetes Clusters (link-local IP, UDP→TCP upgrade, conntrack) — Kubernetes docs. kubernetes.io/docs/tasks/administer-cluster/nodelocaldns
- Cluster Services: scaling CoreDNS,
ndots:2, cluster-proportional-autoscaler, lameduck,/ready— EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/scalability/docs/cluster-services - Install NodeLocalDNS in EKS and troubleshoot (
__PILLAR__variables, 10.100.0.10 / 172.20.0.10, iptables vs IPVS) — AWS re:Post. repost.aws/knowledge-center/eks-install-nodelocaldns-troubleshoot - cluster-proportional-autoscaler (linear mode parameters and formula) — kubernetes-sigs. github.com/kubernetes-sigs/cluster-proportional-autoscaler
- CoreDNS
autopathplugin (server-side search-path completion). coredns.io/plugins/autopath - CoreDNS
cacheplugin (TTL and capacity defaults, prefetch). coredns.io/plugins/cache - Racy conntrack and DNS lookup timeouts (the 5-second A/AAAA race) — Kubernetes issue #56903. github.com/kubernetes/kubernetes/issues/56903
- Kernel 5.1+ in EKS AMI to resolve conntrack race conditions — amazon-eks-ami issue #357. github.com/awslabs/amazon-eks-ami/issues/357
- Fixing EKS DNS (1,024 pps/ENI to the VPC resolver, CoreDNS saturation) — Vlad Ionescu. vladionescu.me/posts/eks-dns
- Reduce DNS Resolution Time for 10,000 Pods on EKS (trailing-dot /
ndotsmeasurements) — Quan Huynh. medium.com/@hmquan08011996/reduce-dns-resolution-time-for-10-000-pods-on-eks - Kubernetes DNS: The ndots:5 Latency Tax — Michal Drozd. michal-drozd.com/en/blog/kubernetes-dns-caching-ndots
- DNS Lookup timeouts in Kubernetes (A/AAAA parallel query race, 5s retry) — Mason. getmason.io/blog/post/dns-lookup-timeouts-in-kubernetes
- Troubleshoot DNS failures with Amazon EKS — AWS re:Post. repost.aws/knowledge-center/eks-dns-failure