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.

Deep dive·platform & cloud engineers·

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 timeout

Your 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:5 is the amplifier. A pod's /etc/resolv.conf ships with three search domains and options 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 for api.stripe.com becomes four name attempts, doubled to eight queries by the parallel A + AAAA lookups. Six of them are wasted NXDOMAIN round-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.
One idea: CoreDNS is almost never the thing that's broken. The default resolver behavior multiplies your query volume, and UDP + conntrack turns dropped packets into 5-second stalls. Cut the multiplication and take conntrack out of the path, and the “CoreDNS problem” disappears.

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:

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.

Why A + AAAA both fire. 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.

The signature. If your latency histogram has a spike at exactly 5s (or 5s, 10s, 15s — the retries), you are almost certainly looking at the conntrack race, not CPU saturation. CPU saturation gives you smoothly rising latency; the race gives you a hard 5-second step.

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 seeActual causeWhich defense fixes it
Flat 5s (or 5/10/15s) stalls, intermittentConntrack insertion race on parallel A/AAAA UDP packetsNodeLocal DNSCache (skips conntrack; TCP upstream)
CoreDNS pods at 100% CPU during surges8× query amplification from ndots:5ndots:2 / FQDNs + autopath, then autoscale replicas
i/o timeout bursts that track traffic1,024 pps/ENI cap to the VPC resolver, hit by 2 replicasNodeLocal 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.

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
}
Watch the failure mode. A node-local cache is a per-node single point of failure: if the DaemonSet pod restarts, that node briefly loses DNS. Run it with a tight readiness probe and be aware that Security-Groups-per-Pod has historically not composed with NodeLocal DNSCache — confirm compatibility for your setup before rolling it fleet-wide.

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
Scale-down is where DNS breaks. When the CPA removes a replica, in-flight queries can hit a pod whose endpoint is being torn down. Set CoreDNS's 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
}
Autopath has sharp edges. It's known to misbehave when pod IPs are reassigned rapidly (a namespace mismatch), and it doesn't work on Windows nodes. It also raises CoreDNS's memory footprint because of the pod cache. Prefer 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:1 blindly — you'll break in-cluster short names like svc.namespace that 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 30s and use /ready readiness so scale-down doesn't drop queries.
  • Alert on CoreDNS SERVFAIL rate and the 5s-latency bucket, not just CPU — they catch the storm earlier.
Checkpoint: if a load test shows a flat 5-second DNS step, you have a conntrack race — ship NodeLocal DNSCache or 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

  1. DNS for Services and Pods (search domains, ndots:5, dnsConfig, dnsPolicy, FQDN) — Kubernetes docs. kubernetes.io/docs/concepts/services-networking/dns-pod-service
  2. Using NodeLocal DNSCache in Kubernetes Clusters (link-local IP, UDP→TCP upgrade, conntrack) — Kubernetes docs. kubernetes.io/docs/tasks/administer-cluster/nodelocaldns
  3. 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
  4. 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
  5. cluster-proportional-autoscaler (linear mode parameters and formula) — kubernetes-sigs. github.com/kubernetes-sigs/cluster-proportional-autoscaler
  6. CoreDNS autopath plugin (server-side search-path completion). coredns.io/plugins/autopath
  7. CoreDNS cache plugin (TTL and capacity defaults, prefetch). coredns.io/plugins/cache
  8. Racy conntrack and DNS lookup timeouts (the 5-second A/AAAA race) — Kubernetes issue #56903. github.com/kubernetes/kubernetes/issues/56903
  9. Kernel 5.1+ in EKS AMI to resolve conntrack race conditions — amazon-eks-ami issue #357. github.com/awslabs/amazon-eks-ami/issues/357
  10. Fixing EKS DNS (1,024 pps/ENI to the VPC resolver, CoreDNS saturation) — Vlad Ionescu. vladionescu.me/posts/eks-dns
  11. Reduce DNS Resolution Time for 10,000 Pods on EKS (trailing-dot / ndots measurements) — Quan Huynh. medium.com/@hmquan08011996/reduce-dns-resolution-time-for-10-000-pods-on-eks
  12. Kubernetes DNS: The ndots:5 Latency Tax — Michal Drozd. michal-drozd.com/en/blog/kubernetes-dns-caching-ndots
  13. DNS Lookup timeouts in Kubernetes (A/AAAA parallel query race, 5s retry) — Mason. getmason.io/blog/post/dns-lookup-timeouts-in-kubernetes
  14. Troubleshoot DNS failures with Amazon EKS — AWS re:Post. repost.aws/knowledge-center/eks-dns-failure