CoreDNS

CoreDNS is the cluster DNS server in every Kubernetes cluster since 1.13. It replaced kube-dns (dnsmasq + kubedns + sidecar). It runs as a Deployment in kube-system, fronted by a Service with a stable ClusterIP that all pods use for DNS.

Most sections below end with a quick knowledge check — track how many you've cleared as you go:

0/0 checks

How Pod DNS Resolution Works

Every pod gets /etc/resolv.conf auto-injected by kubelet:

nameserver 10.96.0.10        ← CoreDNS ClusterIP
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

When a pod does curl postgres:

sequenceDiagram
    participant App as App (pod)
    participant R as libc resolver
    participant CD as CoreDNS

    App->>R: resolve "postgres"
    Note over R: ndots:5 — "postgres" has 0 dots < 5<br/>try search domains first
    R->>CD: postgres.default.svc.cluster.local?
    CD-->>R: 10.96.12.34 ✓ (if service exists)
    R-->>App: 10.96.12.34

    Note over R: If service didn't exist, it would continue:
    R->>CD: postgres.svc.cluster.local?
    CD-->>R: NXDOMAIN
    R->>CD: postgres.cluster.local?
    CD-->>R: NXDOMAIN
    R->>CD: postgres. (absolute)
    CD-->>R: NXDOMAIN

Best case: 1 DNS query (service found on first search domain). Worst case: 4–5 queries for a name that doesn't exist as a K8s service.

Step through the same flow one hop at a time:

1. App calls resolve("postgres"). Zero dots in the name. ndots:5 means "fewer than 5 dots → try every search domain before the name as-is."
2. Try postgres.default.svc.cluster.local. This is the first, most specific entry in /etc/resolv.conf's search line.
3. Found? If the Service exists in this namespace, CoreDNS answers right here — 1 query total, the best case.
4. Not found — keep walking the search list. Next try postgres.svc.cluster.local, then postgres.cluster.local. Each miss is its own round trip to CoreDNS.
5. Last resort — the absolute name. postgres. (trailing dot, no search) is tried last. If that also fails, the app gets NXDOMAIN — 4–5 queries spent to find out the name doesn't exist anywhere.

A pod runs curl postgres and no postgres Service exists anywhere the search domains cover. How many DNS queries does CoreDNS answer before the app finally gets NXDOMAIN?


The ndots:5 Problem

ndots:5 means: if the hostname has fewer than 5 dots, the resolver tries all search domains before attempting the name as-is.

Concrete example — curl https://api.external.com/v1/data:

  • api.external.com has 2 dots < 5
  • Resolver tries: api.external.com.default.svc.cluster.local → NXDOMAIN
  • Then: api.external.com.svc.cluster.local → NXDOMAIN
  • Then: api.external.com.cluster.local → NXDOMAIN
  • Finally: api.external.com. → answer

That's 4 DNS queries for every outbound HTTPS call. At 1000 RPS this is 4000 DNS queries/sec extra load on CoreDNS.

1. api.external.com — 2 dots. That's fewer than ndots:5, so the resolver assumes it could be a short cluster-local name and searches first, instead of just asking upstream directly.
2. Try api.external.com.default.svc.cluster.local. Not a real name → NXDOMAIN.
3. Try api.external.com.svc.cluster.local. Also NXDOMAIN.
4. Try api.external.com.cluster.local. Also NXDOMAIN. Three wasted queries so far.
5. Finally try api.external.com. (absolute). This one actually gets answered. 4 queries spent to resolve a name that was never going to be a cluster Service in the first place.

Fixes

Fix 1 — Use FQDN (trailing dot):

# In app config: always use FQDN for K8s services
postgres.default.svc.cluster.local.   # trailing dot = absolute, no search

Fix 2 — Reduce ndots in pod spec:

spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"   # only search if fewer than 2 dots
  # Now "postgres" still searches (0 dots < 2)
  # But "api.external.com" (2 dots) goes direct — no search domain iteration

Fix 3 — Use fully qualified service names in app config:

DB_HOST=postgres.default.svc.cluster.local

You apply Fix 2 and set ndots: 2 in a pod's dnsConfig. Does curl postgres (a same-namespace Service name) still resolve correctly?


Corefile — CoreDNS Configuration

CoreDNS config lives in a ConfigMap:

kubectl get configmap coredns -n kube-system -o yaml

Default Corefile:

.:53 {
    errors                        # log errors to stdout
    health {                      # /health endpoint on :8080
       lameduck 5s                # wait 5s after marking unhealthy before shutting down
    }
    ready                         # /ready endpoint on :8181 — returns 200 when all plugins ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {  # handle cluster.local DNS
       pods insecure              # enable pod DNS records (insecure = no verification)
       fallthrough in-addr.arpa ip6.arpa  # pass reverse lookups to next plugin
       ttl 30                     # TTL for cluster.local records
    }
    prometheus :9153              # expose metrics at /metrics
    forward . /etc/resolv.conf {  # forward non-cluster queries to node's resolver
       max_concurrent 1000
    }
    cache 30                      # cache all responses for 30s
    loop                          # detect forwarding loops, panic if found
    reload                        # hot-reload Corefile on ConfigMap change
    loadbalance                   # round-robin DNS (randomize A record order)
}

Plugin reference

Plugin Purpose
errors Log error responses
health Liveness endpoint /health on :8080
ready Readiness endpoint /ready on :8181
kubernetes Serve DNS for cluster services/pods
prometheus Metrics endpoint :9153
forward Forward upstream queries
cache Response caching with TTL
loop Detect + break forwarding loops
reload Watch ConfigMap, hot-reload without restart
loadbalance Randomize record order for basic LB

What's the actual difference between the health and ready plugins on a CoreDNS pod?


Custom Forwarding

Forward a specific domain to an internal DNS server:

.:53 {
    errors
    kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
    }
    # Forward corp.example.com to internal DNS
    forward corp.example.com 10.0.1.53 {
        prefer_udp
    }
    # Forward everything else to public resolvers
    forward . 8.8.8.8 8.8.4.4 {
        max_concurrent 1000
        health_check 5s
    }
    cache 30
    loop
    reload
    loadbalance
}
# Apply the new ConfigMap — reload plugin picks it up automatically
kubectl apply -f coredns-configmap.yaml
# Verify reload happened:
kubectl logs -n kube-system -l k8s-app=kube-dns | grep "Reloading"

Caching

The cache plugin caches all DNS responses in memory.

cache 30          # 30s TTL for positive responses, 15s for NXDOMAIN (half of positive)
# or
cache {
    success 9984 300 60    # max entries, max TTL, min TTL for positive
    denial 9984 300 5      # max entries, max TTL, min TTL for NXDOMAIN
    prefetch 10 1m 10%     # prefetch entries accessed >10 times before 10% of TTL remains
}

Effect: service.namespace.svc.cluster.local first lookup hits K8s API → cached for 30s → subsequent lookups served from memory without hitting API server.

With the simple form cache 30, are NXDOMAIN (negative) responses cached for the same 30s as successful ones?


dnsPolicy Options

Policy Behaviour
ClusterFirst (default) Use CoreDNS. Non-cluster names forwarded upstream.
ClusterFirstWithHostNet Same as ClusterFirst but for pods using hostNetwork: true
Default Use the node's /etc/resolv.conf — no cluster DNS
None No DNS config injected — you must set dnsConfig manually

Flip through what each one actually means for the pod:

The normal case. The pod uses CoreDNS for resolution. Anything that isn't a cluster-local Service or Pod name gets forwarded upstream by CoreDNS's own forward plugin. This is what almost every pod runs, whether it's set explicitly or just inherited as the default.
Same as ClusterFirst — just the variant for pods running with hostNetwork: true. It exists so a host-networked pod still gets cluster DNS instead of falling back to the node's own resolver.
Despite the name, not the cluster default. This tells the pod to inherit the node's /etc/resolv.conf directly — CoreDNS is bypassed entirely. Service names like postgres.default.svc.cluster.local will not resolve under this policy.
Nothing injected at all. kubelet writes no /etc/resolv.conf for you. Unless the pod spec also supplies dnsConfig with its own nameservers/searches/options, the pod has no working DNS whatsoever.

Does dnsPolicy: Default give a pod the cluster's normal DNS behavior?

spec:
  dnsPolicy: "None"
  dnsConfig:
    nameservers:
      - 10.96.0.10
    searches:
      - svc.cluster.local
      - cluster.local
    options:
      - name: ndots
        value: "2"
      - name: timeout
        value: "2"
      - name: attempts
        value: "3"

Scaling CoreDNS

Default: 2 replicas. Under heavy load you'll see CoreDNS CPU spike and DNS latency rise.

# Scale manually
kubectl scale deployment coredns -n kube-system --replicas=4

# Or use CoreDNS Autoscaler (proportional to nodes/cores)
kubectl get configmap coredns-autoscaler -n kube-system -o yaml
# Data: {"linear":{"coresPerReplica":256,"nodesPerReplica":16,"min":2}}

Anti-affinity (spread replicas across nodes — do not let both CoreDNS pods land on same node):

# Already set by default in most clusters — verify:
kubectl get deployment coredns -n kube-system -o jsonpath='{.spec.template.spec.affinity}'

Why does it matter whether CoreDNS's 2 replicas land on the same node or on different nodes?


Debugging DNS

# Test from inside a pod
kubectl run dnstest --rm -it --image=busybox --restart=Never -- sh
nslookup kubernetes                           # should resolve
nslookup my-svc.my-namespace.svc.cluster.local
nslookup google.com                           # external resolution

# Check resolv.conf
kubectl exec <pod> -- cat /etc/resolv.conf

# CoreDNS logs (set log plugin in Corefile to enable query logging)
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=100

# CoreDNS metrics (if Prometheus is scraping)
# coredns_dns_requests_total
# coredns_dns_responses_total{rcode="NXDOMAIN"}
# coredns_forward_requests_duration_seconds_bucket

# Check CoreDNS pod health
kubectl get pods -n kube-system -l k8s-app=kube-dns
kubectl describe pod -n kube-system -l k8s-app=kube-dns

# Validate ConfigMap syntax before applying
docker run --rm -v $(pwd)/Corefile:/Corefile coredns/coredns:1.11.1 -conf /Corefile -dryrun

Common Failure Modes

Symptom Cause Fix
5s timeout on DNS then success UDP packet dropped, resolver retries after 5s timeout Check conntrack table full: sysctl net.netfilter.nf_conntrack_max; use TCP for DNS
NXDOMAIN storm App retrying DNS in tight loop on failure Fix app to cache NXDOMAIN, use exponential backoff
CoreDNS OOMKilled Too many cached entries or high QPS Increase memory limit; reduce cache size; scale replicas
dial tcp: lookup X: no such host Service doesn't exist, or wrong namespace in FQDN `kubectl get svc -A
DNS works locally, fails in pod NetworkPolicy blocks UDP/TCP 53 to kube-dns Add egress rule: allow UDP 53 to kube-system namespace

conntrack full → 5s DNS timeout — this is the most insidious:

# On the node:
sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max
# If count ≈ max: table full, new UDP flows dropped → 5s timeout

# Fix: increase nf_conntrack_max
sysctl -w net.netfilter.nf_conntrack_max=1048576
# Or: use nodeLocalDNS cache (runs on every node, uses link-local IP, bypasses conntrack)

NodeLocal DNSCache — the best fix for DNS at scale. Each node runs a DNS cache DaemonSet on a link-local IP (169.254.20.10); pods resolve to this local cache first, with no conntrack entry and no network hop involved. Only a cache miss actually reaches CoreDNS:

graph LR
    Pod["Pod"] -->|DNS query, local IP<br/>no conntrack, no network hop| Local["NodeLocal DNSCache<br/>DaemonSet on 169.254.20.10"]
    Local -->|cache hit| Pod
    Local -->|cache miss| CD["CoreDNS"]
    CD -->|answer, cached locally| Local

    style Local fill:#4f8fcf,stroke:#274b6e,color:#fff

Reduces CoreDNS load by ~60–80%.

A pod's DNS lookups intermittently take ~5 seconds before eventually succeeding. What's the most likely root cause, and why does it look like a timeout instead of an instant failure?