Alpine Containers Can’t Resolve External DNS in Kubernetes? It’s ndots, Not Your Network

A pod resolves service names inside the cluster without a hiccup, pings 8.8.8.8 fine, and then fails on ping google.com with three words: bad address. Your first instinct is CNI, NetworkPolicy, kube-dns health. The actual culprit in the classic case is smaller and stranger: musl libc, and one line in /etc/resolv.conf — options ndots:5.

I first hit this running Alpine 3.5/3.6/3.7 images, and it still shows up in 2026 whenever a musl-based image meets Kubernetes’ default DNS configuration. Here’s the full trail, the fix that works today, and why the popular 2018 workaround is the wrong tool.


The symptom

The failing pod could reach the API server, resolve any internal name, and ping raw IPs. Only external hostnames failed:

# kubectl run alpine-test -i -t --image=alpine:3.6 -- sh
/ # ping sudops.com
ping: bad address 'sudops.com'
/ # ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: seq=0 ttl=55 time=8.2 ms

The diagnostic grid ruled out everything above the pod in minutes:

Test Result Rules out
Other images in same namespace resolve external names OK CNI, NetworkPolicy, node network
Same Alpine image in a different namespace Fails Namespace-specific config
ping by IP from the failing pod OK L3 connectivity, NAT
Cluster-internal names (kubernetes.default) OK CoreDNS reachability
External names on Alpine 3.5 / 3.6 / 3.7 All fail Alpine-version-specific behavior

Same cluster, same node, same DNS server — Debian images curl fine, Alpine images don’t. That pattern points at one place: the resolver inside the container.

What kubelet actually hands the pod

Every pod gets a /etc/resolv.conf written by the kubelet at start:

nameserver 10.254.0.2
search devns.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

Read the ndots:5 line carefully, because it changes every lookup the container makes. The rule: a name containing fewer than 5 dots is first tried against each search domain, then as an absolute name. sudops.com has one dot. So the resolver’s first attempts are:

sudops.com.devns.svc.cluster.local    → NXDOMAIN
sudops.com.svc.cluster.local          → NXDOMAIN
sudops.com.cluster.local              → NXDOMAIN
sudops.com.                           → the one that actually works

This looks wasteful on glibc (four queries for one name), but it works there: glibc walks the search list, collects the NXDOMAINs, and falls through to the absolute query. Alpine doesn’t use glibc. It uses musl, and the musl versions shipped in Alpine 3.5–3.7 mishandled exactly this scenario. Between the search-path expansion and the eventual absolute query, the lookup stalled or failed outright.

Two aggravating factors worth knowing, both still real today:

  • Query name length. Appending search domains makes names longer. The DNS limit is 253 characters; long namespace names and deep search lists can blow past it on the intermediate queries even when the real name is short.
  • Query volume. ndots:5 multiplies CoreDNS traffic. In clusters with thousands of pods, search-domain churn is a classic source of CoreDNS load that looks like “DNS is slow” but is really “DNS is asked four times too often.”

The container never sees any of this. From inside, it’s indistinguishable from the network being broken — hence the hours lost to tcpdump on the wrong problem.

The 2018 fix, and its sharp edges

The workaround that circulated back then was to comment the line out before the app starts:

sed -i 's/options ndots:5/#options ndots:5/g' /etc/resolv.conf

Which fails immediately, and instructively:

sed: can't move '/etc/resolv.confMdACPl' to '/etc/resolv.conf': Resource busy

/etc/resolv.conf in a container is a bind-mounted file, not a regular file. sed -i works by writing a temp file and renaming it over the target — and you cannot rename over a mount point. The rewrite-in-place variant gets around that:

echo "$(sed 's/options ndots:5/#options ndots:5/g' /etc/resolv.conf)" > /etc/resolv.conf

With the option gone, musl falls back to the default (ndots:1), tries sudops.com as an absolute name first, and suddenly everything works. The approach loosens up enough to confirm the diagnosis. Then throw it away, for three reasons:

  1. It’s per-container bootstrapping. Every image needs the hack in its entrypoint, and it only runs when the container starts, which makes failures order-dependent.
  2. It’s fragile. Anything that overwrites resolv.conf (a container restart recreates it from the pod spec) silently undoes it.
  3. It was already obsolete when it was written. Kubernetes shipped the proper fix, dnsConfig, in 1.9, back in December 2017.

The fix for 2026: put it in the pod spec

dnsConfig lets the workload itself control resolv.conf options. No image rebuild, no entrypoint scripts, survives restarts by definition:

apiVersion: v1
kind: Pod
metadata:
  name: alpine-app
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "1"
  containers:
    - name: app
      image: alpine:3.20
      command: ["sh", "-c", "apk add --no-cache bind-tools && sleep 3600"]

With ndots:1, any name containing a dot is tried absolute-first. External lookups become single queries, internal names still resolve through the search list, and CoreDNS load drops proportionally. If you’re worried about short in-cluster names competing with dotted external names, ndots:2 is a reasonable middle ground; most clusters do fine with 1.

Fine-tuning searches is optional — but if your workloads only ever talk to their own namespace and absolute names, narrowing the list shrinks the blast radius of a broken search entry:

  dnsConfig:
    searches:
      - my-app.svc.cluster.local
      - svc.cluster.local
    options:
      - name: ndots
        value: "1"

The alternatives, and when each one is right:

Approach When it fits Cost
dnsConfig with ndots:1 Default choice — any pod spec you control None; one YAML block
FQDN with trailing dot (api.example.com.) App config, client URLs Escaping behavior varies by app/runtime
Upgrade Alpine (3.13+, musl 1.2.x) You control the Dockerfile musl fixed many resolver edge cases; ndots tuning still worthwhile
Switch to a glibc base (debian-slim, ubuntu) Image size is not a hard constraint Bigger image, revisited CVE surface
Edit resolv.conf at entrypoint (2018 style) Never, on anything you can fix above Fragile, per-image, reinvents dnsConfig

If nobody on the team can edit the manifest (a vendor chart, a legacy Deployment under another team), a MutatingAdmissionPolicy (1.32+, replacing the mutation half of admission webhooks) can inject dnsConfig cluster-wide. Heavier, but it centralizes the fix instead of scattering entrypoint hacks.

The debugging toolkit that took me straight to the answer

# What does the resolver see?
kubectl exec -it alpine-app -- cat /etc/resolv.conf

# Query the internal DNS directly (FQDN so musl skips the search list)
kubectl exec -it alpine-app -- nslookup kubernetes.default.svc.cluster.local 10.96.0.10

# Watch actual queries when the pod does a lookup (run in a second terminal)
kubectl exec -it alpine-app -- sh -c 'apk add --no-cache tcpdump; tcpdump -n -i any port 53'

# CoreDNS is answering — what is it asked, and replying?
kubectl -n kube-system logs -l k8s-app=kube-dns --tail=50

Better: kubectl debug -it alpine-app --image=nicolaka/netshoot gives a troubleshooting container with dig, drill, tcpdump and friends already installed — no apk add round trip, and no assumptions about what the app image contains.

Tip: When debugging any DNS complaint inside a pod, reproduce with nslookup and check a lookup that works (an internal name) in the same shell. The contrast — “internal resolves, external doesn’t” — is what turns a vague “DNS is broken” report into a searchable problem in about 60 seconds.

Checklist

  • Internal names resolve, external names fail → check /etc/resolv.conf and the libc, in that order
  • options ndots:5 present → set dnsConfig.ndots:1 on the pod spec
  • Never sed -i a bind-mounted resolv.conf; don’t ship entrypoint hacks when dnsConfig exists
  • Old Alpine (the 3.5–3.7 era) images → upgrade when possible; tuning ndots is still the first move
  • Suspect a deep search list → count the queries with tcpdump before and after the fix
  • Cluster-wide fix needed → MutatingAdmissionPolicy, not per-image scripts

If you want to watch this failure and its fix side by side, a single-node test cluster is enough. The smallest VPS on any mainstream provider ($5–6/month territory: DigitalOcean, Vultr, Hetzner) runs k3s in ten minutes, and reproducing a broken resolv.conf takes thirty seconds.

The takeaway

Kubernetes gives every pod an /etc/resolv.conf tuned for cluster-internal discovery. glibc absorbs the resulting extra queries; a decade-old musl did not, and that gap produced one of the most persistently misdiagnosed failures in Kubernetes troubleshooting. The lesson generalizes past DNS: when a container misbehaves and everything outside it looks healthy, the difference is usually inside the image — and the right fix lives in the pod spec, not in the app’s startup script.

Related reading on this site: Kubernetes Pods That Won’t Delete — same principle, different layer: the symptom misleads, the ownership chain explains. 中文原文:kubernetes 使用基于 alpine 镜像无法正常解析外网DNS。

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply