Azure Troubleshooting

Troubleshooting DNS on AKS: CoreDNS, Custom Forwarders, Private DNS Zones, and NXDOMAIN Storms

A pod logs dial tcp: lookup api.internal.contoso.com on 10.0.0.10:53: no such host and your service mesh falls apart. Another resolves kubernetes.default fine but times out reaching your Azure SQL private endpoint. A third was healthy yesterday and today every external call is slow by exactly five seconds. These are three faces of one subsystem: DNS resolution on Azure Kubernetes Service, the layer every pod depends on and almost nobody instruments until it breaks. In-cluster name resolution is handled by CoreDNS — a pluggable DNS server running as a Deployment in kube-system, fronted by a stable ClusterIP Service (typically 10.0.0.10). Every pod’s /etc/resolv.conf points at that ClusterIP; CoreDNS answers cluster-internal names and forwards the rest to Azure’s platform DNS at 168.63.129.16. When any link in that chain breaks, the symptom is rarely “DNS is down” — it’s a 5-second hang, an intermittent NXDOMAIN, or a private FQDN that resolves to a public IP.

This is the diagnostic playbook for that chain. We treat resolution failures as a small set of symptom classesno such host/NXDOMAIN, slow lookups, private-endpoint FQDNs resolving wrong, and CoreDNS crash-looping or throttled — each with a fan-out of root causes you confirm with specific commands. You will learn to read the path — pod → /etc/resolv.conf → CoreDNS ClusterIP → Corefile → upstream Azure DNS (168.63.129.16) → Private DNS Zone — and localise a failure to one hop with the tools that tell the truth: kubectl exec ... nslookup, dig +search, the CoreDNS logs and Corefile, the kube-dns endpoints, and az network private-dns. Every diagnosis comes with the exact confirm command and the precise fix.

By the end you will stop guessing. When a pod can’t resolve a name you will know within ninety seconds whether you face a CoreDNS pod that’s down or throttled, an ndots:5 storm, a custom forwarder pointing at a dead server, a Private DNS Zone not linked to the cluster’s VNet (so private endpoints resolve to public IPs), or a NetworkPolicy quietly blocking UDP 53. Knowing which fast is what separates a five-minute incident from a two-hour war room.

What problem this solves

Kubernetes makes service discovery feel free: you call http://orders and it works. That magic is DNS, and when it misbehaves the abstraction becomes an opaque wall. A failing lookup never announces itself as a DNS fault — it surfaces as your application’s error (a timeout, host not found, a gRPC UNAVAILABLE, a driver that can’t reach its server), so the on-call engineer stares at application logs for an hour. The reflexes that follow — restart the app (does nothing), restart CoreDNS (sometimes clears a bad cache, the wrong lesson), scale the cluster (masks throttling for a day) — all leave the real cause untouched.

Who hits this: nearly every team running AKS. It bites hardest on private-endpoint clusters (the Private DNS Zone link is the single most common production DNS failure on AKS), custom upstream DNS (hybrid/corporate forwarders), external-API-heavy services (a CPU-throttled CoreDNS), and anyone who copied a NetworkPolicy without allowing egress to kube-dns. The fix is almost never “restart something” — it’s “find the hop in the resolution path that’s lying and make it tell the truth.”

To frame the whole field before the deep dive, here is every symptom class this article covers, the question it forces, and the one place to look first:

Symptom class What the pod is really seeing First question to ask First place to look Most common single cause
NXDOMAIN / no such host The name does not resolve to any record Internal name or external/private FQDN? kubectl exec ... nslookup <name> Wrong FQDN, missing record, or unlinked Private DNS Zone
Slow lookups (≈5 s hangs) Resolution eventually works but stalls Does it stall then succeed, or fail outright? dig +search round-trip count ndots:5 search-list fan-out / UDP timeout retry
Private FQDN resolves to public IP Name resolves, but to the wrong address Is the answer a public or private IP? nslookup <fqdn>; az network private-dns record-set Private DNS Zone not linked to the cluster VNet
Intermittent failures under load Some lookups fail, mostly under traffic Does it fail only under load? CoreDNS CPU / throttling; error log lines CoreDNS under-provisioned / CPU-throttled
All resolution dead in a pod/namespace Every lookup times out One pod, one namespace, or the cluster? kube-dns Service endpoints; NetworkPolicy CoreDNS pods down, or egress to UDP 53 blocked

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand AKS basics: a cluster has a managed control plane and a data plane of node pools, reached with kubectl via az aks get-credentials. You should know what a Service, a ClusterIP, and a namespace are, and that pods get a /etc/resolv.conf injected by the kubelet. Familiarity with kubenet vs Azure CNI helps. You should be able to run az in Cloud Shell, read YAML/JSON, and read a dig answer section.

This sits in the Networking & Troubleshooting track. It assumes the fundamentals from AKS Cluster Architecture: Control Plane vs Data Plane and the routing model from AKS Networking: kubenet vs Azure CNI. It is the name-resolution sibling of Troubleshooting AKS Ingress: 502/503, TLS & Application Routing — many “ingress can’t reach the backend” incidents are really DNS. The private-endpoint half builds on Azure Private Link & Private DNS for PaaS; when the failure is below DNS (a route or NSG dropping UDP 53), cross into Troubleshooting VNet Connectivity: NSGs, UDRs & Network Watcher.

Ownership is split: the pod resolv.conf and CoreDNS Corefile are platform/app concerns, the upstream resolver and Private DNS Zone links belong to the network team, and NSG/UDR/NetworkPolicy rules that can silently drop UDP 53 sit with network/security — so an incident often needs two teams on the bridge. The next section maps these layers to a hop-by-hop diagnostic path.

Core concepts

Six mental models make every later diagnosis obvious.

DNS in AKS is a Service like any other, and it can have zero endpoints. Cluster DNS is CoreDNS (a Deployment in kube-system) behind a Service named kube-dns with a fixed ClusterIP (commonly 10.0.0.10) that every pod’s resolv.conf lists as its nameserver. If the CoreDNS pods are unschedulable, crash-looping, or the Service’s EndpointSlices are empty, the ClusterIP answers nothing and every lookup times out. “Is kube-dns backed by ready pods?” is the first question in any total-resolution outage.

The pod’s resolv.conf is generated, and ndots:5 is the load-bearing line. The kubelet injects /etc/resolv.conf based on the pod’s dnsPolicy (default ClusterFirst): a search list (prod.svc.cluster.local svc.cluster.local cluster.local), the kube-dns nameserver, and crucially options ndots:5if a name has fewer than 5 dots, try each search suffix first before the bare name. That makes http://orders resolve to orders.prod.svc.cluster.local (a feature) but taxes every short external name with wasted suffix attempts (detailed in the ndots section).

CoreDNS answers cluster names and forwards the rest. Its Corefile (in the coredns ConfigMap) chains plugins: kubernetes (answers *.cluster.local), cache (~30 s), and forward sending the rest to the node’s upstream — on AKS, Azure DNS at 168.63.129.16. You do not edit coredns directly (the add-on overwrites it); overrides go in a separate coredns-custom ConfigMap.

168.63.129.16 is the door to all Azure name resolution. That magic IP is Azure’s platform resolver, honouring the VNet’s DNS settings. With Azure-provided DNS (default) it resolves public names and any Private DNS Zones linked to that VNet. With custom DNS servers on the VNet, CoreDNS’s upstream is those servers instead — and they must know how to resolve Azure private zones. Where the VNet points DNS is the hinge for every private-endpoint and hybrid scenario.

A Private DNS Zone only works if it’s linked to the right VNet. A private-endpoint FQDN’s A record lives in an Azure Private DNS Zone like privatelink.database.windows.net. For a pod to get the private IP, that zone needs a virtual network link to the cluster’s VNet; without it, 168.63.129.16 returns the public IP and the pod connects wrong. This missing link is the most common AKS-to-PaaS DNS failure in production (full mechanism below).

Resolution latency is not an error, until it trips a timeout. A lookup that pays the ndots tax, retries after a dropped UDP packet, or waits on a slow upstream can take seconds; a ~5-second hang is the fingerprint of one UDP DNS timeout. It is “DNS is slow on one path,” fixed by removing round-trips or the bottleneck — not by restarting the app.

Every term above (and dnsPolicy, conntrack, the forward plugin) is defined for lookup in the Glossary at the end.

The resolution path, hop by hop

Every diagnosis is “which hop failed,” so pin the six hops down — from the pod’s getaddrinfo through resolv.conf, the kube-dns ClusterIP, the CoreDNS Corefile, and the upstream forward to 168.63.129.16, to the Private DNS Zone:

Hop What happens here What can break Fastest confirm
1. App / libc getaddrinfo, reads resolv.conf App passes a wrong/short name kubectl exec ... cat /etc/resolv.conf
2. search + ndots Suffixes appended, query order set ndots:5 fan-out; wrong search domain dig +search <name> vs dig <name>.
3. ClusterIP (kube-dns) UDP 53 to the DNS VIP, DNAT to a pod Service has no ready endpoints kubectl -n kube-system get endpointslices -l k8s-app=kube-dns
4. CoreDNS plugins kubernetescacheforward Crash-loop, throttle, bad Corefile kubectl -n kube-system logs -l k8s-app=kube-dns
5. Upstream (168.63.129.16) Forward to Azure / custom DNS Custom DNS unreachable/wrong kubectl exec ... dig @168.63.129.16 <name>
6. Private DNS Zone Private A record resolved Zone not linked; record missing az network private-dns record-set a list

Three distinctions that save time:

Distinction The trap How to tell them apart
Resolution failure vs connectivity failure “Can’t reach the DB” might resolve fine but be blocked by NSG If nslookup returns an IP, DNS works — the problem is below it (route/NSG/firewall)
Internal vs external name Same NXDOMAIN, totally different cause *.cluster.local failing → CoreDNS/kubernetes plugin; public/private FQDN failing → forwarder/zone
Public IP vs private IP in the answer Looks “resolved” but connects to the wrong endpoint Check the address in the answer: a public IP for a privatelink name = unlinked/missing zone

Anatomy of NXDOMAIN and “no such host”

An NXDOMAIN (libc form: no such host) means the name resolved to no record. Five distinct causes on AKS — scan the matrix, then read the matching detail:

# NXDOMAIN cause Tell-tale signal Confirm with Real fix
1 Wrong or short name (search-list miss) Works with FQDN, fails bare dig +search vs dig <fqdn>. Use the right FQDN or dnsConfig
2 Service/name doesn’t exist or wrong namespace <svc>.<ns>.svc.cluster.local empty kubectl get svc -n <ns> Create/fix the Service; correct namespace
3 Private endpoint FQDN, zone not linked Public name returns public IP or NXDOMAIN az network private-dns link vnet list Link the privatelink.* zone to the VNet
4 Custom forwarder points at dead DNS Only a specific domain fails CoreDNS log forward errors Fix the forward target in coredns-custom
5 CoreDNS down / kube-dns no endpoints Everything fails cluster-wide kubectl -n kube-system get pods -l k8s-app=kube-dns Restore CoreDNS scheduling/health

Cause 1 — A short name and the search-list miss

An unqualified name like payments resolves via the search list to payments.<ns>.svc.cluster.local — but only if a Service by that name is on the search path. A genuinely external short name produces a burst of NXDOMAINs from the suffix attempts but the real query still succeeds, so it manifests as slow, not failed. The pure-failure case matches no Service and no external record.

Confirm. Compare a search-list query against a fully-qualified one:

# From inside a pod — applies the search list:
kubectl exec -it deploy/myapp -- nslookup payments
# Fully qualified — bypasses the search list:
kubectl exec -it deploy/myapp -- nslookup payments.prod.svc.cluster.local

If the FQDN resolves but the bare name doesn’t, it’s a search-path/namespace issue. Fix: call the Service by its correct in-namespace name, its FQDN for cross-namespace, or qualify an external short name (trailing dot / dnsConfig).

Cause 2 — The Service or name genuinely doesn’t exist (or wrong namespace)

orders.prod.svc.cluster.local returns NXDOMAIN because there’s no Service orders in prod — it’s in staging, named order-svc, or never created. The kubernetes plugin only mints records for objects that exist.

Confirm:

kubectl get svc -n prod orders
kubectl get svc --all-namespaces | grep orders   # find where it actually lives

Fix. Create or rename the Service, or use the correct namespace — the bare name only resolves within the caller’s namespace; cross-namespace calls need <svc>.<ns>.svc.cluster.local. One variant: a headless Service (clusterIP: None) with no ready pods returns NXDOMAIN for the name even though the Service exists — check kubectl get endpoints.

Causes 3, 4, 5 — covered in depth below

The remaining three each have a dedicated section, so the short version here:

Scope is itself diagnostic: one name failing points at causes 1–4; everything failing points at cause 5.

ndots, the search list, and NXDOMAIN storms

The most misunderstood DNS behaviour on Kubernetes is ndots:5 — not a bug (it makes short Service names work) but a tax on every external lookup that, at scale, becomes an “NXDOMAIN storm” pressuring CoreDNS.

What ndots:5 actually does

A pod’s /etc/resolv.conf (namespace prod):

nameserver 10.0.0.10
search prod.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

The resolver counts dots: with fewer than 5, it tries each search suffix first, then the bare name. So orders (0 dots) hits orders.prod.svc.cluster.local on the first try — the intended in-cluster path. But api.github.com (3 dots, < 5) burns three guaranteed NXDOMAINs on the cluster suffixes before querying api.github.com. for real: four lookups where one would do, three wasted on CoreDNS. How many round-trips a name costs, by dot count:

Name Dots Search attempts before the real query Net DNS queries
orders 0 hits on first suffix 1 (best case)
cache.redis 1 up to 3 suffixes then bare up to 4
api.github.com 3 3 suffix misses, then real 4
api.github.com. (trailing dot) absolute none — queried as-is 1
r.region.svc.cluster.local 4 < 5, still suffixed first multiple
a.b.c.d.e.example.com 6 ≥ 5, queried as-is first 1

Why it surfaces as a 5-second hang, not an error

Each round-trip is fast alone; the pain appears when one UDP query is dropped (a conntrack race on parallel A/AAAA lookups, or a busy CoreDNS) and the resolver waits its full UDP timeout (5 s) before retrying. More wasted queries means more chances to drop one, so external-heavy workloads hit periodic ~5-second stalls. The fix is to reduce the queries, not chase the app.

The fixes, in order of preference

The cheapest fix is a trailing-dot FQDN (api.github.com.) for known external endpoints — the resolver skips the search list entirely. For pods that are overwhelmingly external, lower ndots via a pod-level dnsConfig (which overrides the injected value):

apiVersion: v1
kind: Pod
metadata:
  name: external-heavy
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"   # external names with >=2 dots skip the cluster search suffixes
  containers:
    - name: app
      image: myrepo/app:1.0

Trade-off: lowering ndots can break single-label in-cluster lookups (orders) — only do it for pods that talk mostly outward, and qualify internal calls. Beyond that, CoreDNS negative caching serves repeated storms from cache, not the upstream. The mitigation menu, ranked:

Technique What it does Effort Risk / trade-off Best for
Trailing-dot FQDN Skips search list for that name Trivial (config) Must know the name is external Known external endpoints
Pod dnsConfig ndots:2 Fewer suffix attempts for the pod Low Can break single-label internal names External-heavy pods
Negative caching (CoreDNS) Wasted NXDOMAINs served from cache Low Slightly stale negatives High-volume repeated lookups
dnsPolicy: Default Use the node’s resolver, no cluster search Low Loses in-cluster Service discovery Pods that never call Services
Connection reuse (app) Fewer name lookups overall Medium Code change Chatty external clients

Custom forwarders: sending domains where they belong

By default CoreDNS forwards non-cluster names to 168.63.129.16. You need a custom forwarder when a private domain is owned by a specific DNS server (corporate corp.contoso.com on your DCs) or you want explicit control over a domain’s queries. Since you must not edit the coredns ConfigMap (the add-on reverts it), the supported mechanism is the coredns-custom ConfigMap in kube-system, whose named blocks CoreDNS imports into the effective Corefile.

The coredns-custom ConfigMap

To forward a corporate domain to two domain controllers:

apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
  corp.server: |
    corp.contoso.com:53 {
      errors
      cache 30
      forward . 10.50.0.4 10.50.0.5 {
        policy sequential
      }
    }

Apply it, then reload CoreDNS (a rollout forces the import immediately):

kubectl apply -f coredns-custom.yaml
kubectl -n kube-system rollout restart deployment coredns
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=50   # verify the import + health

The block’s directives are the ones you’ll reuse: <domain>:53 { } scopes a server block to a zone; forward . <ip...> sends matched names to those resolvers (with policy sequential/random controlling failover order); cache <ttl> sets positive/negative caching; and errors logs failures (add it or you fly blind). You can also override the default upstream for everything via a global forward block — but be deliberate, since getting it wrong breaks all external resolution at once.

Forwarding to an Azure Private DNS Zone — the common mistake

Engineers often try to “forward” privatelink.database.windows.net to some server — the wrong instinct. Azure Private DNS Zones are not a DNS server you forward to; they’re data attached to a VNet. Link the zone to the cluster VNet and CoreDNS’s default forward to 168.63.129.16 resolves the private record with no custom block. Reach for a custom forward only for resolvers you actually run (corporate DCs, a DNS firewall):

You need to resolve… Right mechanism Custom forwarder needed?
*.cluster.local (Services/Pods) CoreDNS kubernetes plugin (built-in) No
Public internet names Default forward . 168.63.129.16 No
Azure private endpoint FQDNs Private DNS Zone linked to the VNet No (just the link)
Corporate corp.contoso.com forward to your domain controllers Yes
Split-horizon / DNS firewall forward to the firewall’s DNS Yes

Private DNS Zones: why private endpoints resolve to the wrong IP

This ruins more AKS-to-PaaS integrations than any other failure. The mechanism is precise, and once you see it the fix is mechanical.

How private-endpoint name resolution is supposed to work

Creating a private endpoint for Azure SQL mydb does three things: a NIC with a private IP; a CNAME from mydb.database.windows.net to mydb.privatelink.database.windows.net; and (if you let Azure do it) an Azure Private DNS Zone privatelink.database.windows.net holding the A record mapping mydb to the private IP. To follow that chain a client needs the zone linked to its VNet — the AKS cluster’s VNet. With the link, 168.63.129.16 answers the privatelink name with the private IP; without it, it falls back to public DNS and returns the public IP, which the private endpoint refuses.

Each Azure service has its own canonical privatelink zone name; the wrong one means the record is never found:

Azure service Public suffix Private DNS Zone name
Azure SQL Database database.windows.net privatelink.database.windows.net
Blob Storage blob.core.windows.net privatelink.blob.core.windows.net
Key Vault vault.azure.net privatelink.vaultcore.azure.net
Cosmos DB (SQL) documents.azure.com privatelink.documents.azure.com
Container Registry azurecr.io privatelink.azurecr.io
Service Bus / Event Hubs servicebus.windows.net privatelink.servicebus.windows.net

Confirming the failure

The fingerprint is “resolves, but to a public IP.” Resolve from a pod, then check on the Azure side that the zone exists, holds the record, and is linked to the cluster VNet:

# From a pod: does the FQDN resolve to a PRIVATE (10.x/172.16-31/192.168) or PUBLIC IP?
kubectl exec -it deploy/myapp -- nslookup mydb.database.windows.net
# 1. Does the privatelink zone have the A record?
az network private-dns record-set a list \
  --resource-group rg-net --zone-name privatelink.database.windows.net -o table

# 2. Is that zone linked to the AKS cluster's VNet?
az network private-dns link vnet list \
  --resource-group rg-net --zone-name privatelink.database.windows.net \
  --query "[].{link:name, vnet:virtualNetwork.id, registration:registrationEnabled}" -o table

If the record-set list is empty, the endpoint never registered (or it’s in a different zone); if the vnet-link list doesn’t include the cluster’s VNet, that’s your bug.

The fix: link the zone to the cluster VNet

Create the virtual network link from the Private DNS Zone to the AKS VNet (registration disabled — pods don’t register, they only resolve):

VNET_ID=$(az network vnet show -g rg-aks -n vnet-aks --query id -o tsv)
az network private-dns link vnet create \
  --resource-group rg-net \
  --zone-name privatelink.database.windows.net \
  --name link-aks-vnet \
  --virtual-network "$VNET_ID" \
  --registration-enabled false
resource zoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
  parent: privateDnsZone            // existing privatelink.database.windows.net zone
  name: 'link-aks-vnet'
  location: 'global'
  properties: {
    registrationEnabled: false
    virtualNetwork: { id: vnetAks.id }
  }
}

After the link, flush CoreDNS’s cache so it stops serving the stale public answer:

kubectl -n kube-system rollout restart deployment coredns

The custom-DNS-server twist

If the AKS VNet has custom DNS servers (your own VMs/firewall, not Azure DNS), linking the zone is not enough — CoreDNS forwards to those servers, which don’t see Azure Private DNS Zones. They must conditionally forward the privatelink.* domains to 168.63.129.16. This is the most common “I linked it and it still resolves public” trap, so check the VNet’s DNS setting first:

az network vnet show -g rg-aks -n vnet-aks \
  --query "dhcpOptions.dnsServers" -o tsv   # empty = Azure DNS; IPs = custom DNS

The decision matrix for private-endpoint resolution on AKS:

VNet DNS setting Is the zone linked? Do custom DNS forward privatelink→168.63.129.16? Result
Azure-provided No n/a Public IP / NXDOMAIN — link the zone
Azure-provided Yes n/a ✅ Private IP resolved
Custom DNS Yes No Public IP — DNS server ignores the zone
Custom DNS Yes Yes (conditional forward) ✅ Private IP resolved
Custom DNS No Yes Depends — server may still miss the record

CoreDNS under load: throttling, replicas, and caching

When DNS fails only under load, CoreDNS is usually the bottleneck. AKS runs 2 CoreDNS replicas by default with modest CPU limits; a burst of queries — amplified by ndots storms — pins CPU against the limit, the kernel CPU-throttles it, and UDP timeouts fire, giving intermittent failures that vanish at rest. The coredns-autoscaler scales replicas with node/core count — but on cluster size, not query rate, so a small cluster with one chatty service can still starve CoreDNS.

Confirming CoreDNS is the bottleneck

Look for CPU throttling and timeouts:

# CoreDNS CPU vs its limit (needs metrics-server)
kubectl -n kube-system top pod -l k8s-app=kube-dns

# Throttling and upstream timeouts in the logs
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=200 | grep -iE "error|timeout|SERVFAIL"

# The autoscaler's current target replica count
kubectl -n kube-system get configmap coredns-autoscaler -o yaml

If CoreDNS CPU sits at its limit during the incident and recovers after, it’s throttled. (Leading signals to alert on are tabulated in Best practices.)

Fixes: scale out, cache more, store less work

Add replicas by tuning the autoscaler’s ConfigMap — it manages the replica count, so don’t set replicas on the Deployment directly (it’s reconciled back):

# coredns-autoscaler ConfigMap: more CoreDNS as the cluster grows (and a higher floor)
data:
  linear: |
    {
      "coresPerReplica": 256,
      "nodesPerReplica": 8,
      "min": 3,
      "max": 10,
      "preventSinglePointFailure": true
    }

Raise the cache TTL in coredns-custom so repeated lookups and storms hit cache, not the upstream:

data:
  cache.override: |
    cache 60 {
      success 9984 60
      denial 9984 30
    }

Cut the query volume at the source — the most effective CoreDNS “scaling” is not resolving the same wasted names ten thousand times a second. Ranked by impact: reduce ndots storms first (fewer queries hit CoreDNS at all), then raise the cache TTL, the autoscaler floor, and the per-replica CPU limit; for very large/chatty clusters, a node-local DNS cache cuts hops and conntrack pressure.

Architecture at a glance

The diagram traces a name lookup left to right and maps each failure class to the hop where it bites. A pod issues getaddrinfo("mydb.database.windows.net"); its resolv.conf applies ndots:5 and fires UDP 53 at the kube-dns ClusterIP, which DNATs to a CoreDNS pod; the Corefile’s kubernetes plugin claims *.cluster.local and the forward block sends the rest to Azure DNS at 168.63.129.16 — which resolves mydb to its private IP only if privatelink.database.windows.net is linked to the cluster’s VNet.

The four numbered badges mark where each failure class bites — ndots:5 storms at resolv.conf (1), empty kube-dns endpoints (2), a bad forwarder or CPU throttling at CoreDNS (3), the unlinked Private DNS Zone returning a public IP (4) — and the legend narrates each as symptom · confirm · fix. The whole method on one page: localise the symptom to a hop, run the named check, apply the fix.

AKS DNS resolution path drawn left to right: a pod issues getaddrinfo and reads its injected resolv.conf with ndots:5 and the cluster search list, sends a UDP 53 query to the kube-dns ClusterIP 10.0.0.10 which DNATs to a CoreDNS pod, CoreDNS runs the Corefile where the kubernetes plugin answers cluster.local names and the forward plugin sends everything else upstream to Azure DNS at 168.63.129.16, which resolves public names and resolves private-endpoint FQDNs from an Azure Private DNS Zone only when that zone is linked to the cluster VNet — with four numbered failure points marking ndots search-list storms at resolv.conf, empty kube-dns endpoints when CoreDNS is down, a bad custom forwarder or CPU throttling at CoreDNS, and an unlinked private DNS zone returning a public IP

Real-world scenario

Finvel Payments runs a microservices platform on AKS: an Azure CNI cluster, 18 services, in Central India, spend about ₹2,10,000/month. The payments service talks to Azure SQL and Key Vault, both moved behind private endpoints the week before in a security review. The cluster sits on a hub-and-spoke network with a custom DNS server (a pair of domain controllers) set on every VNet — a standard enterprise pattern.

The incident began the Monday after the migration: the payments service logged dial tcp: lookup mydb.database.windows.net: connection refused — but only sometimes, and a debug-pod nslookup returned a public IP (20.x). Restarting the deployment did nothing. The database team confirmed the endpoint was healthy and az network private-dns record-set a list showed the private IP registered in privatelink.database.windows.net. So the zone had the record, yet pods resolved public — and an hour in, “DNS is fine, it’s the app” was the prevailing (wrong) theory.

The breakthrough came from asking which resolver the pod actually used. az network vnet show --query dhcpOptions.dnsServers returned the two domain-controller IPscustom DNS, not Azure DNS. That was the whole bug: the team had correctly linked the zone, but a link only helps clients that resolve via 168.63.129.16. The pods forwarded to the DCs, which had no idea the zone existed and answered from public DNS. The “sometimes” was stale public answers cached in CoreDNS and on the DCs.

The durable fix was to make the DCs conditionally forward the privatelink suffixes to 168.63.129.16 — but that needed a networking change ticket and a day’s lead. The fast mitigation, applied that night, was a coredns-custom block forwarding exactly those Azure PaaS suffixes to 168.63.129.16 from CoreDNS, bypassing the DCs for just those domains:

data:
  azure-privatelink: |
    database.windows.net:53 {
      forward . 168.63.129.16
    }
    vaultcore.azure.net:53 {
      forward . 168.63.129.16
    }

They applied it, ran rollout restart deployment coredns to flush the stale cache, and within a minute pods resolved to the private IP; connection refused stopped. The following week the DCs got proper conditional forwarders and the override was removed. The lesson on the wall: “Linking a Private DNS Zone only helps whoever resolves through 168.63.129.16 — with custom DNS, the link is necessary but not sufficient.”

The incident as a timeline — the order of moves is the lesson:

Time Symptom Action taken Effect What it should have been
09:10 connection refused to SQL, intermittent (alert fires) Ask: does the name resolve, and to what IP?
09:14 Still failing Restart payments deployment No change Don’t restart the app for a DNS issue
09:25 nslookup returns a public IP Database team checks the zone Zone + record confirmed present Right check, wrong conclusion drawn
09:50 “It must be the app” Stare at app logs Dead end The name resolved wrong — look at DNS
10:05 Breakthrough az network vnet show … dnsServers = custom DCs Root cause: custom DNS bypasses the link This was the key check
10:20 Mitigated coredns-custom forwards PaaS suffixes to 168.63.129.16 + rollout restart Private IP resolved; errors stop Correct night-of fix
+1 week Fixed DCs conditional-forward PaaS suffixes; remove override One source of truth The durable fix is on the resolvers

Advantages and disadvantages

CoreDNS-as-a-Service — DNS that is itself a Kubernetes workload, fronted by a ClusterIP and configured by ConfigMap — both causes this problem class and makes it diagnosable:

Advantages (why this model helps you) Disadvantages (why it bites)
DNS is a normal workload: kubectl logs, kubectl top, ConfigMap config — everything is inspectable with tools you already know The failure surfaces as the app’s error (timeout, refused), never “DNS broke” — you must know to look
ndots:5 + search list make in-cluster discovery effortless (http://orders just works) The same ndots:5 taxes every external lookup and causes NXDOMAIN storms and 5-second hangs
Pluggable Corefile: forward, cache, rewrite per-domain — precise control via coredns-custom Editing the wrong ConfigMap (coredns) is silently reverted; a syntax error crash-loops all DNS
Azure DNS (168.63.129.16) transparently resolves public and linked private zones — no extra servers If the VNet uses custom DNS, the zone link is necessary-but-not-sufficient — a notorious trap
Private DNS Zones keep PaaS traffic on the backbone and resolve to private IPs cluster-wide A missing VNet link means private FQDNs resolve to public IPs — “resolves fine” but connects wrong
CoreDNS scales with the cluster via the autoscaler; caching absorbs repeat load The autoscaler scales on node count, not query rate — a chatty service can starve a small cluster

The model is right for standard microservice platforms wanting free, centralised, inspectable DNS. It bites hardest on private-endpoint-heavy clusters (the zone-link trap), external-API-heavy services (ndots storms), and hybrid/custom-DNS networks (the conditional-forward requirement). Every disadvantage is manageable — but only if you know it exists, which is the point of this article.

Hands-on lab

Reproduce the signature AKS DNS failures, observe them, and fix them. Use a tiny existing cluster or a 1-node cluster (a few rupees/hour, deleted at the end). Run in Cloud Shell (Bash).

Step 1 — Variables and cluster. Use an existing cluster or create a minimal one:

RG=rg-aksdns-lab
LOC=centralindia
AKS=aks-dns-lab
az group create -n $RG -l $LOC -o table
az aks create -g $RG -n $AKS --node-count 1 --network-plugin azure --generate-ssh-keys -o table
az aks get-credentials -g $RG -n $AKS --overwrite-existing

Step 2 — Inspect the resolver a pod is handed. Launch a debug pod and read its resolv.conf:

kubectl run dnsbox --image=busybox:1.36 --restart=Never -it --rm -- sh
cat /etc/resolv.conf   # expect: nameserver 10.0.0.10, search ...svc.cluster.local, options ndots:5

Step 3 — See the ndots storm. Inside the pod, compare a search-list query with a fully-qualified one:

nslookup api.github.com    # search-list query — tries cluster suffixes first
nslookup api.github.com.   # absolute (trailing dot) — one clean lookup
exit

The first makes CoreDNS field the suffix misses before the real answer; the second skips them — the storm, live.

Step 4 — Confirm the cluster DNS Service is healthy. From your shell (not the pod):

kubectl -n kube-system get pods -l k8s-app=kube-dns -o wide
kubectl -n kube-system get endpointslices -l k8s-app=kube-dns   # empty = "everything down"

Expect 2 Ready CoreDNS pods and endpointslices listing their IPs.

Step 5 — Add a custom forwarder and reload CoreDNS. Prove the coredns-custom mechanism (and the reload):

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
  lab.override: |
    contoso-lab.example:53 {
      errors
      cache 30
      forward . 168.63.129.16
    }
EOF
kubectl -n kube-system rollout restart deployment coredns
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=20   # expect a clean reload, no parse errors

A syntax mistake here crash-loops CoreDNS — that’s the lesson worth feeling once.

Step 6 — Inspect a Private DNS Zone link (read-only). If you have a private endpoint and zone in the subscription, confirm the link logic without changing anything:

az network private-dns link vnet list \
  --resource-group rg-net --zone-name privatelink.database.windows.net -o table
# An empty list (cluster VNet absent) is the unlinked-zone bug from the scenario.

Validation checklist. You saw ndots:5 in a real resolv.conf, killed a search-list storm with a trailing dot, confirmed kube-dns has Ready endpoints (the “everything down” check), applied a coredns-custom forwarder (learning the reload requirement and that a syntax error crash-loops CoreDNS), and ran the az check that exposes an unlinked Private DNS Zone.

Cleanup (avoid lingering node charges).

az group delete -n $RG --yes --no-wait

Cost note. A single-node cluster is a few rupees per hour — under ₹50 for this lab — and deleting the resource group stops everything.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. A scannable table you can read mid-incident, then extra detail on the entries that bite hardest. It spans the everyday (a short name, a missing Service) and the gnarly (custom DNS bypassing a zone link, conntrack races).

# Symptom Root cause Confirm (exact cmd / portal path) Fix
1 Bare name fails, FQDN works Search-list/namespace miss nslookup payments vs nslookup payments.prod.svc.cluster.local Use correct in-ns name / FQDN
2 <svc>.<ns>.svc.cluster.local NXDOMAIN Service absent or wrong namespace kubectl get svc -n <ns> <svc>; kubectl get svc -A | grep <svc> Create/rename Service; right namespace
3 Private PaaS FQDN resolves to a public IP Private DNS Zone not linked to cluster VNet nslookup mydb.database.windows.net (public IP); az network private-dns link vnet list (cluster VNet absent) az network private-dns link vnet create … --registration-enabled false
4 Linked the zone, still resolves public VNet uses custom DNS that ignores the zone az network vnet show --query dhcpOptions.dnsServers (IPs, not empty) Conditional-forward PaaS suffixes to 168.63.129.16 (DCs or coredns-custom)
5 One corporate domain fails, rest fine Custom forward points at a dead/unreachable DNS kubectl -n kube-system logs -l k8s-app=kube-dns (i/o timeout to forwarder IP) Fix the forward target in coredns-custom
6 Every lookup times out cluster-wide CoreDNS down / kube-dns no endpoints kubectl -n kube-system get endpointslices -l k8s-app=kube-dns (empty) Restore CoreDNS scheduling; revert bad custom config
7 DNS broke right after a Corefile edit Edited coredns ConfigMap (reverted/crash) kubectl -n kube-system get cm coredns -o yaml; CoreDNS CrashLoopBackOff Use coredns-custom; fix the syntax; rollout restart
8 Periodic ~5-second hangs on external calls ndots:5 fan-out + dropped UDP retry dig +search api.github.com (multiple NXDOMAIN); compare dig api.github.com. Trailing-dot FQDN; dnsConfig ndots:2; TCP/use-vc
9 Intermittent failures only under load CoreDNS CPU-throttled / too few replicas kubectl -n kube-system top pod -l k8s-app=kube-dns (pinned at limit) Raise autoscaler floor; cache TTL; cut query volume
10 New pods can’t resolve; egress-locked namespace NetworkPolicy blocks UDP/TCP 53 to kube-dns kubectl get networkpolicy -n <ns>; test from a pod in the ns Allow egress to kube-system/kube-dns on 53 UDP+TCP
11 External names fail; cluster names fine Custom upstream DNS unreachable / wrong kubectl exec … dig @168.63.129.16 api.github.com (works) vs default (fails) Fix VNet custom DNS; ensure 168.63.129.16 reachable
12 Headless Service name returns NXDOMAIN No ready pods behind the headless Service kubectl get endpoints <svc> -n <ns> (empty) Fix pod readiness/selector so endpoints populate
13 Resolution slow after scaling nodes Per-node conntrack table full; UDP drops dmesg | grep nf_conntrack (“table full”); node metrics Raise conntrack max; reduce connection churn
14 nslookup works but app says no such host App caches DNS / uses musl (Alpine) quirks Reproduce with the app’s runtime, not busybox Set app DNS TTL; be aware of musl ndots/parallel-query behaviour

The table above carries the confirm-and-fix for every row; here is the extra reasoning for the four that bite hardest, where knowing why matters as much as the command.

Row 3 — a private PaaS FQDN resolves to a public IP. The trap is that DNS appears to work — the unlinked zone means 168.63.129.16 falls back to public DNS and returns an address — so engineers blame the app instead of the missing vnet-link.

Row 4 — you linked the zone and it still resolves public. Custom DNS on the VNet sends CoreDNS to your servers, which don’t see the Azure Private DNS Zone — the link is necessary but not sufficient. They must conditionally forward the PaaS suffixes to 168.63.129.16 (or a coredns-custom block does it as a stopgap).

Row 7 — DNS broke immediately after a Corefile change. Editing the coredns ConfigMap directly either gets silently reverted by the add-on or crash-loops CoreDNS on a syntax error. The lesson is procedural: never touch coredns; overrides go in coredns-custom.

Row 8 — periodic ~5-second hangs on external calls. A dropped UDP packet (conntrack race on parallel A/AAAA lookups) makes the resolver wait its full 5-second timeout — so the hang is almost exactly 5 seconds, the fingerprint. ndots:5 multiplies the queries and thus the chances of a drop; the fix is removing round-trips, not chasing the app.

The error-string reference

The exact strings you’ll see, where they come from, and what they mean on AKS:

Error string (where it appears) Source Means First move
lookup X on 10.0.0.10:53: no such host (app log) glibc via CoreDNS NXDOMAIN — name has no record Is it internal or a PaaS FQDN? Pick the matching playbook row
i/o timeout / server misbehaving (app log) resolver No answer in time — upstream/CoreDNS slow or unreachable Check CoreDNS health + upstream reachability
SERVFAIL (dig) CoreDNS/upstream Upstream failed the query (often a dead forwarder) Check forward target + CoreDNS logs
[ERROR] plugin/errors … HINFO: read udp … i/o timeout (CoreDNS log) CoreDNS forward CoreDNS couldn’t reach its upstream Verify forwarder IP / 168.63.129.16 reachability
Name does not resolve (busybox nslookup) busybox NXDOMAIN from the search attempts Try the FQDN with a trailing dot
Resolves to a 20.x/40.x IP for a *.windows.net name public DNS fallback Private zone not linked / custom DNS ignoring it Link the zone; conditional-forward to 168.63.129.16
CrashLoopBackOff on coredns pods kubelet Bad Corefile (usually a bad coredns-custom) kubectl logs the pod; fix/revert the custom config

Best practices

Signals worth wiring before the next incident — leading, not the lagging “service down”:

Alert on Signal Threshold (starting point) Why it’s leading
CoreDNS CPU top pod vs limit > 70% sustained 5 min Predicts throttling → timeouts
CoreDNS errors coredns_dns_responses_total{rcode="SERVFAIL"} rising trend Upstream/forwarder trouble before users feel it
CoreDNS request latency coredns_dns_request_duration_seconds p99 > your SLO Cold path / overload creeping up
Cache hit ratio coredns_cache_hits/misses dropping Storm or cache-miss surge
CoreDNS pod restarts kube_pod_container_status_restarts ≥ 1 Bad config / OOM crash-loop
kube-dns endpoints ready endpoint count < replicas Heading toward total outage

Security notes

The security controls that also prevent these incidents — secure and resilient pull the same way here:

Control Mechanism Secures against Also prevents
Scoped zone RBAC Private DNS Zone Contributor on the zone Over-broad network rights Accidental zone-wide changes
registration-enabled false On the cluster VNet link Zone pollution / spoofable names Stray pod records confusing resolution
RBAC on kube-system ConfigMaps Kubernetes RBAC Unauthorised Corefile edits A bad coredns-custom crash-looping DNS
Validated conditional forwarders Forward privatelink only to 168.63.129.16 DNS hijack of PaaS names Private FQDNs resolving public (and failing)
Egress allow-list incl. 53 NetworkPolicy / firewall rule Data exfiltration via open egress Silent DNS blackout in locked namespaces

Cost & sizing

DNS is nearly free on AKS — CoreDNS runs on existing nodes — so the “cost” is the supporting Azure resources plus the indirect cost of getting it wrong (outages, retries, wasted egress).

A rough monthly picture: Private DNS Zones for the PaaS dependencies (~₹50–200 across a handful of zones plus queries), zero for VNet links, and a negligible compute delta for a larger CoreDNS. The cost drivers and what each buys you:

Cost driver What you pay for Rough INR / month What it fixes Watch-out
Private DNS Zone Per zone + per-query ~₹15–60 per zone + queries Private-endpoint resolution One zone per service suffix
VNet link (free) ₹0 Linking zones to the cluster VNet Easy to forget — the #1 bug
Extra CoreDNS capacity Node CPU/mem slice negligible Throttling under load Don’t over-allocate tiny clusters
Node-local DNS cache Per-node memory small Conntrack/latency at scale Only when CoreDNS is the bottleneck
Wasted egress (storms) Public DNS query egress small but avoidable (eliminated by fixing ndots) Adds up on very chatty fleets

Interview & exam questions

1. A pod resolves mydb.database.windows.net to a public IP even though the private endpoint and Private DNS Zone exist. What’s wrong and how do you confirm? The Private DNS Zone is not linked to the cluster’s VNet, so 168.63.129.16 has no private record and falls back to public DNS. Confirm with az network private-dns link vnet list --zone-name privatelink.database.windows.net — the cluster’s VNet won’t be in the list. Fix by creating a vnet-link (--registration-enabled false) and flushing CoreDNS.

2. You linked the zone and pods still resolve public. What did you miss? The VNet uses custom DNS servers, so CoreDNS forwards to them, not to 168.63.129.16, and they don’t see Azure Private DNS Zones. Check az network vnet show --query dhcpOptions.dnsServers. Fix by making those servers conditionally forward the privatelink suffixes to 168.63.129.16 (or, as a stopgap, a coredns-custom forward).

3. What does ndots:5 do and why does it slow external lookups? It makes the resolver try the cluster search suffixes for any name with fewer than 5 dots before querying it as-is — great for short Service names, but it turns api.github.com (3 dots) into three NXDOMAIN attempts before the real query, adding latency and, on a dropped UDP packet, a 5-second timeout. Fix with trailing-dot FQDNs or a pod dnsConfig ndots:2.

4. Where do you customise CoreDNS on AKS, and where must you not? Customise via the coredns-custom ConfigMap in kube-system. You must not edit the coredns ConfigMap directly — the managed add-on reconciles it back, and a syntax error crash-loops CoreDNS cluster-wide. After applying coredns-custom, rollout restart deployment coredns to load it.

5. Every pod in the cluster suddenly can’t resolve anything. First check? Whether the kube-dns Service has ready endpoints: kubectl -n kube-system get endpointslices -l k8s-app=kube-dns. Empty endpoints (CoreDNS pods down, unschedulable, or crash-looping on a bad coredns-custom) mean the ClusterIP answers nothing. Restore the pods or revert the bad config.

6. What is 168.63.129.16 and why does it matter for AKS DNS? It’s Azure’s platform DNS resolver, a special non-routable IP reachable from every Azure VM/node. CoreDNS forwards non-cluster names to it (when the VNet uses Azure DNS), and it resolves both public names and any Private DNS Zone linked to the VNet. It’s the hinge for private-endpoint and external resolution.

7. An app reports no such host but your debug pod’s nslookup resolves the name fine. Why? The app likely runs on musl (Alpine), whose resolver handles ndots/parallel queries differently than the glibc busybox pod, or it caches DNS with a long in-process TTL serving a stale negative. Reproduce with the app’s actual runtime.

8. How do you tell a DNS failure from a connectivity failure for “can’t reach the database”? Run nslookup/dig from the pod: if it returns an IP, DNS works and the problem is below it (route, NSG, firewall, or the endpoint’s rules); if it NXDOMAINs or times out, it’s DNS. The address (public vs private) further separates an unlinked zone from a true reachability problem.

9. DNS works at rest but fails intermittently under load. What’s the likely cause and the confirm? CoreDNS is CPU-throttled or under-replicated — query latency climbs until UDP timeouts fire, clearing when load drops. Confirm with kubectl -n kube-system top pod -l k8s-app=kube-dns (CPU pinned at the limit) and error/timeout lines in the logs. Fix by raising the autoscaler floor, caching more, and cutting query volume.

10. A NetworkPolicy was applied to a namespace and now its pods can’t resolve names. Why? A default-deny egress policy that doesn’t allow DNS — pods can’t reach the kube-dns Service on port 53. Confirm with kubectl get networkpolicy -n <ns> and a failing lookup from a pod in that namespace. Fix by adding an egress rule permitting UDP and TCP 53 to kube-system/kube-dns.

11. How do you make pods resolve a corporate domain hosted on your domain controllers, and why is it different from a private endpoint? Add a forward block for that domain to the coredns-custom ConfigMap pointing at the DCs’ IPs, then rollout restart deployment coredns; verify with dig @<dc-ip> from a pod. It’s a real custom-forwarder case — unlike Azure Private DNS Zones, which you link, not forward to.

These map to AZ-104 (Administrator)configure and manage virtual networking, name resolution, and AKS — and AZ-700 (Network Engineer)design and implement private access to Azure services, Private DNS Zones, and hybrid name resolution. The CoreDNS/Kubernetes specifics also appear in the CKA networking domain. A compact cert-mapping for revision:

Question theme Primary cert Exam objective area
Private DNS Zones, vnet-links, private endpoints AZ-700 Design & implement private access to Azure services
168.63.129.16, custom DNS, conditional forwarding AZ-700 / AZ-104 Name resolution for VNets & hybrid
CoreDNS, Corefile, kube-dns Service CKA Cluster networking & services/DNS
ndots, search list, dnsConfig, dnsPolicy CKA Pod DNS configuration
NetworkPolicy egress for DNS CKA / AZ-500 Network security & policies
AKS networking model context AZ-104 Manage Azure Kubernetes Service

Quick check

  1. A pod resolves an Azure SQL private-endpoint FQDN to a public IP, and the Private DNS Zone exists with the right record. What is the single most likely cause, and the one az command to confirm it?
  2. Your VNet uses custom DNS servers and you’ve linked the privatelink zone, yet pods still resolve public. What additional thing must be true?
  3. True or false: editing the coredns ConfigMap directly is the supported way to add a custom forwarder on AKS.
  4. An external API call from a pod periodically hangs for almost exactly 5 seconds, then succeeds. What behaviour is most likely responsible, and one fix?
  5. Every pod in the cluster suddenly fails all lookups. What’s the first thing you check, and the command?

Answers

  1. The Private DNS Zone is not linked to the cluster’s VNet, so 168.63.129.16 has no private record and returns the public IP. Confirm with az network private-dns link vnet list --resource-group <rg> --zone-name privatelink.database.windows.net — the cluster’s VNet will be absent. Fix by creating the vnet-link with --registration-enabled false, then flushing CoreDNS.
  2. The custom DNS servers must conditionally forward the Azure PaaS privatelink suffixes (e.g. database.windows.net) to 168.63.129.16. Linking a zone only helps clients that resolve through 168.63.129.16; custom DNS bypasses it unless it explicitly forwards those domains there.
  3. False. The AKS-managed add-on reconciles coredns back (losing your edit) and a syntax error crash-loops CoreDNS. The supported mechanism is the coredns-custom ConfigMap, followed by a CoreDNS rollout restart.
  4. ndots:5 forcing extra search-suffix queries, one of which has its UDP packet dropped — the resolver waits the full 5-second timeout before retrying. Fix by using a trailing-dot FQDN for the external name (skips the search list) or setting a pod dnsConfig of ndots:2.
  5. Whether the kube-dns Service has ready endpoints: kubectl -n kube-system get endpointslices -l k8s-app=kube-dns. Empty endpoints mean CoreDNS is down (unschedulable, crash-looping on a bad coredns-custom, or evicted) and the ClusterIP has nothing to answer with.

Glossary

Next steps

You can now localise any AKS DNS failure to a hop and fix it. Go deeper:

AzureAKSCoreDNSDNSPrivate DNS ZoneNetworkingKubernetesTroubleshooting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading