DNS Resolution & /etc/resolv.conf
Mental Model
A phone's contact list with autocomplete. Type "Mom" and the phone tries Mom-work, Mom-cell, Mom-home, Mom-landline, Mom-office before just dialing "Mom" as a raw number. Each failed prefix wastes a ring cycle. The search domains in resolv.conf are those prefixes. ndots controls how many characters the name needs before the phone treats it as a full number and dials it directly, skipping the prefix guessing. The contact app itself (getaddrinfo) is not built into the phone hardware -- it is an app that first checks a local favorites list (/etc/hosts), then asks directory assistance (DNS), depending on a settings file (nsswitch.conf) that says which order to try.
The Problem
A Kubernetes pod resolves "api-server" and waits 5 seconds for the answer. The name has zero dots, ndots is 5, and the search list has 5 entries -- so glibc dutifully tries api-server.default.svc.cluster.local, api-server.svc.cluster.local, api-server.cluster.local, api-server.us-east-1.compute.internal, api-server.compute.internal, and finally api-server itself. Six suffixes times two record types (A + AAAA) equals 12 DNS queries, 10 of which return NXDOMAIN with a 1-second timeout each. Meanwhile, a Node.js service blocks its 4-thread libuv pool on dns.lookup(), turning DNS into a global bottleneck that stalls the entire event loop. In a 500-pod cluster, the CoreDNS deployment handles 50,000+ DNS queries per second, and one misconfigured ndots value multiplies that by 6x.
Architecture
What happens when a program calls getaddrinfo("redis", "6379", ...)?
Most engineers treat DNS resolution as a black box: put a hostname in, get an IP out. That works until it does not -- and then it fails in ways that look like network problems, timeout bugs, or application crashes, because almost nobody understands the actual resolution path.
getaddrinfo() Is Not a Syscall
This is the most important thing to understand about DNS resolution on Linux. getaddrinfo() is a C library function (glibc, musl), not a kernel syscall. The kernel has no idea what DNS is. When a program calls getaddrinfo(), glibc does the following:
- Reads
/etc/nsswitch.confto determine resolution order - For each configured source, attempts resolution in order
- If DNS is a source, reads
/etc/resolv.conffor nameserver addresses and options - Constructs UDP packets and sends them to the configured nameserver
- Parses the DNS wire-format response
- Returns a linked list of
addrinfostructs
The actual kernel involvement is just the socket(), connect(), sendto(), and recvfrom() syscalls to send and receive UDP packets. The kernel moves bytes on the network; glibc handles all DNS protocol logic.
/etc/nsswitch.conf: The Resolution Order
hosts: files dns myhostname
This single line controls everything. It means:
- files -- Check
/etc/hostsfirst. If the name is found there, return immediately. No network query, no DNS, instant result. - dns -- If not found in files, query DNS using the configuration in
/etc/resolv.conf. - myhostname -- If DNS also fails, check if the name matches the local hostname. This is a systemd-provided module that ensures a machine can always resolve its own name.
Other possible sources include mdns4_minimal (Avahi/Bonjour for .local names), resolve (systemd-resolved), and nis / ldap (legacy enterprise). The brackets matter: [NOTFOUND=return] means "if this source handled the domain but found nothing, stop searching." Without action items, glibc continues to the next source, which may produce different results.
/etc/resolv.conf: The DNS Configuration
This file tells the stub resolver where to send DNS queries and how to construct them.
nameserver
nameserver 10.96.0.10
nameserver 169.254.169.253
nameserver 8.8.8.8
Up to 3 nameserver directives are respected. Glibc ignores any beyond the third. Queries go to the first nameserver; if it times out, the second is tried, then the third. With the default timeout of 5 seconds and 2 attempts per nameserver, worst-case resolution time before failure is: 3 nameservers * 5 seconds * 2 attempts = 30 seconds.
search
search default.svc.cluster.local svc.cluster.local cluster.local us-east-1.compute.internal compute.internal
Up to 6 search domains (8 on some implementations). When resolving a name that glibc considers "not fully qualified," each search domain is appended in order. "redis" becomes redis.default.svc.cluster.local, then redis.svc.cluster.local, and so on. The domain directive is an older syntax that sets a single search domain.
options ndots:N
This is the single most impactful DNS setting in container environments. The default is ndots:1, meaning any name with at least 1 dot is tried as-is first before appending search domains.
Kubernetes sets ndots:5. This means a name must have 5 or more dots to be treated as absolute. Since almost no hostname has 5 dots, virtually every lookup goes through the full search list first.
options timeout:N and attempts:N
- timeout -- Seconds to wait for a response from one nameserver. Default: 5. Range: 1-30.
- attempts -- Number of times to try each nameserver. Default: 2.
Total worst-case wait = nameservers * timeout * attempts. With defaults: 3 * 5 * 2 = 30 seconds.
Other options
- rotate -- Round-robin across nameservers instead of always hitting the first. Helps load-balance but makes debugging harder.
- single-request-reopen -- Some buggy NAT devices merge A and AAAA UDP queries (same socket) and return only one response. This forces a new socket for the AAAA query. Common workaround in older AWS VPC environments.
The Kubernetes ndots:5 Problem
This deserves its own section because it causes more DNS-related performance issues than anything else in container environments.
A Kubernetes pod has this /etc/resolv.conf:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local us-east-1.compute.internal compute.internal
options ndots:5
When the pod resolves "api-server" (an external hostname with 0 dots):
- 0 < 5, so search domains are tried first
- Query: api-server.default.svc.cluster.local -- NXDOMAIN
- Query: api-server.svc.cluster.local -- NXDOMAIN
- Query: api-server.cluster.local -- NXDOMAIN
- Query: api-server.us-east-1.compute.internal -- NXDOMAIN
- Query: api-server.compute.internal -- NXDOMAIN
- Query: api-server (as-is) -- SUCCESS
That is 6 queries just for the A record. But glibc also sends AAAA queries for IPv6. So the real number is 12 UDP packets for a single hostname resolution, 10 of which return NXDOMAIN.
In a 500-pod cluster where each pod makes 100 DNS lookups per second, this means 500 * 100 * 12 = 600,000 DNS queries per second hitting CoreDNS, when only 500 * 100 * 2 = 100,000 are actually needed (one A, one AAAA per lookup).
Fixes
- Trailing dot: Resolve "api-server." (with dot). The trailing dot marks the name as fully qualified, bypassing the search list entirely.
- ndots:1: Set
dnsConfig.options: [{name: ndots, value: "1"}]in the pod spec. Names with 1+ dots are tried as-is first. This works well for pods that mainly resolve external names but breaks short in-cluster names like "redis" (still needs search domains for redis.default.svc.cluster.local). - FQDN everywhere: Use the full "redis.default.svc.cluster.local." for in-cluster services. Verbose but eliminates search domain queries.
CoreDNS in Kubernetes
CoreDNS is the cluster DNS server. Every pod's /etc/resolv.conf points to its ClusterIP (typically 10.96.0.10, configured via kubelet's --cluster-dns flag).
Resolution path: query arrives at CoreDNS, names ending in cluster.local get resolved from the Kubernetes API, everything else gets forwarded to upstream DNS (the node's /etc/resolv.conf). Responses are cached for 30 seconds by default (the cache 30 directive in the Corefile).
CoreDNS runs as a Deployment (typically 2 replicas) in kube-system. Under high load, it becomes a bottleneck. The autopath plugin helps by short-circuiting the search domain walk inside CoreDNS itself, returning the answer on the first matching query instead of sending NXDOMAIN for each suffix.
Docker DNS
Docker provides DNS differently depending on the network type.
Default bridge network: No embedded DNS. Containers get the host's DNS servers directly in /etc/resolv.conf. Container name resolution does not work. --link provides /etc/hosts entries as a legacy workaround.
User-defined bridge networks: Docker runs an embedded DNS server at 127.0.0.11. Container names are registered automatically. When container A looks up "backend", the embedded DNS checks its registry of running containers on the same network. If found, it returns the container's IP. If not found, it forwards to the host's configured DNS servers.
Configuration flags:
--dns 8.8.8.8-- Override upstream DNS servers--dns-search example.com-- Add search domains--dns-opt ndots:1-- Set resolver options
In Docker Compose, all services defined in the same docker-compose.yml share a network by default and get automatic name resolution.
Go: Two Resolvers
Go is unique among major languages in shipping its own DNS resolver that does not use getaddrinfo().
Pure-Go resolver (GODEBUG=netdns=go): Reads /etc/resolv.conf directly, constructs DNS packets, sends them over UDP/TCP, and parses responses. Runs entirely in Go. No cgo required. Fully async -- thousands of concurrent lookups use goroutines, not OS threads. Does not support mDNS, LDAP, NIS, or anything beyond standard DNS.
cgo resolver (GODEBUG=netdns=cgo): Calls getaddrinfo() from the C library. Respects nsswitch.conf fully. Each call occupies a real OS thread for the duration of the lookup. Under high concurrency, this can exhaust the GOMAXPROCS thread limit.
Selection logic: If GODEBUG=netdns=go or cgo is set, that wins. Otherwise: CGO_ENABLED=0 forces pure-Go; simple nsswitch.conf ("files dns") prefers pure-Go; custom modules (mdns, resolve, nis) or missing nsswitch.conf triggers cgo. Set GODEBUG=netdns=2 to log the choice.
Node.js: Two APIs, One Trap
Node.js provides two DNS interfaces that behave completely differently.
dns.lookup(): Calls getaddrinfo() via libuv's thread pool. Respects /etc/hosts and nsswitch.conf. The default libuv thread pool has 4 threads. Each dns.lookup() call blocks one thread until the OS returns a result. Under load, DNS lookups queue behind each other and the event loop stalls waiting for available threads.
dns.resolve() / dns.resolve4() / dns.resolve6(): Uses the c-ares library for async DNS. Sends DNS queries directly without blocking any thread. Does NOT read /etc/hosts -- it only queries real DNS servers. Fast under high concurrency.
The http and https modules use dns.lookup() by default. Every http.get() or https.request() triggers a blocking getaddrinfo() call. For high-throughput services:
- Set
UV_THREADPOOL_SIZE=64(environment variable, must be set before Node starts) to allow more concurrent lookups - Pass a custom
lookupfunction in the HTTP agent options that usesdns.resolve4()instead - Use the
cacheable-lookupnpm package to add TTL-aware caching on top of either resolver
DNS Caching (Or Lack Thereof)
glibc: Zero caching. Every getaddrinfo() call sends fresh DNS queries. A web server hitting the same API endpoint 1000 times per second generates 1000 DNS queries per second for that hostname. This is by design -- glibc avoids the complexity of tracking TTLs and cache invalidation.
musl (Alpine Linux): Also zero caching.
nscd: Traditional glibc caching daemon. Caches DNS, passwd, group lookups with configurable TTLs. Has a reputation for bugs and staleness. Many distributions disable it.
systemd-resolved: Modern caching stub resolver at 127.0.0.53. Supports DNS-over-TLS, DNSSEC, per-link DNS config. The recommended solution on systemd-based distributions.
dnsmasq: Lightweight DNS forwarder and cache. Common on desktop Linux and as a container sidecar.
/etc/hosts for Local Overrides
/etc/hosts provides static name-to-IP mappings that bypass DNS entirely -- no network query, no timeout, instant result. Because nsswitch.conf lists files before dns, entries here always win. Docker adds the container's hostname and IP, Kubernetes adds the pod's IP and any hostAliases, and --add-host injects custom entries. Useful for development overrides, but causes confusion when DNS records change and stale IPs remain hardcoded.
IPv4, IPv6, and Happy Eyeballs
Calling getaddrinfo() with AF_UNSPEC (the default) queries both A (IPv4) and AAAA (IPv6) records, doubling DNS traffic. The AI_ADDRCONFIG flag (default in most applications) skips AAAA queries when the system has no IPv6 address, halving queries on IPv4-only hosts.
Happy Eyeballs (RFC 8305) races both protocols: send A and AAAA in parallel, start connecting to the first AAAA result, wait 250ms, then race the first A result. Whichever connection completes first wins. This avoids 30+ second timeouts when IPv6 is configured but broken. Go implements it in net.Dialer by default; Node.js added it in v20 with autoSelectFamily.
Common Questions
Why is DNS slow in Kubernetes? ndots:5 + 5 search domains = up to 12 DNS queries per lookup. Fix: trailing dots on external names, ndots:1 for external-heavy pods, or the CoreDNS autopath plugin.
Why does dns.lookup() block in Node.js? It calls getaddrinfo(), a synchronous C library function. libuv wraps it in a 4-thread pool. When all 4 threads are blocked on DNS, new lookups queue and event loop latency spikes. Not a Node.js bug -- a POSIX getaddrinfo() limitation.
How to debug DNS in a container? Start with cat /etc/resolv.conf to see the container's DNS config. Then dig or nslookup to test directly. If unavailable, getent hosts <name> uses the same getaddrinfo() path. For deeper analysis, strace -e trace=network on the process shows actual UDP packets.
Why does a name resolve on the host but fail in the container? The container has its own /etc/resolv.conf pointing to a different nameserver (Docker's 127.0.0.11, K8s CoreDNS) with different search domains. Compare both files.
Why do DNS lookups in Go sometimes use cgo? If /etc/nsswitch.conf is missing (common in Alpine) or contains unknown modules, Go falls back to the cgo resolver. Force pure-Go with GODEBUG=netdns=go.
How Technologies Use This
A container calls fetch("http://backend/api") and gets NXDOMAIN -- the service name resolves nowhere. The same name works from the host. The container's /etc/resolv.conf points to 127.0.0.11, Docker's embedded DNS server, but the container was started with --network=host, bypassing Docker's internal DNS entirely.
Docker runs an embedded DNS server at 127.0.0.11 inside every user-defined bridge network. When container A looks up container B by name, the query hits 127.0.0.11, which checks its internal registry of container names and IPs. If the name does not match a running container, it forwards the query to the DNS servers configured on the host (or whatever --dns flags specified at container start). On the default bridge network, Docker does NOT provide embedded DNS -- containers only get host DNS servers in /etc/resolv.conf, and container name resolution fails silently.
The fix is straightforward: use a user-defined bridge network (docker network create mynet) and attach containers to it. Docker then injects 127.0.0.11 as the nameserver and registers container names automatically. For external DNS customization, --dns 8.8.8.8 overrides upstream servers, --dns-search example.com adds search domains, and --dns-opt ndots:1 controls resolution behavior. In Docker Compose, all services on the same network get automatic name resolution without any DNS configuration.
A pod resolves "api-server" expecting to reach an external service, but the response takes 5 seconds instead of 50ms. Packet captures show 10 DNS queries for a single lookup -- five AAAA queries and five A queries, each hitting a different search domain before the final unqualified name resolves.
Kubernetes injects a resolv.conf with ndots:5 and a long search list: default.svc.cluster.local svc.cluster.local cluster.local us-east-1.compute.internal compute.internal. When a pod resolves a name without at least 5 dots, glibc appends each search domain in order, generating api-server.default.svc.cluster.local, api-server.svc.cluster.local, api-server.cluster.local, api-server.us-east-1.compute.internal, api-server.compute.internal, and finally api-server itself. Each suffix generates an A and AAAA query. That is 12 DNS queries for one hostname, 10 of which return NXDOMAIN.
CoreDNS runs as the cluster DNS service. Every pod's resolv.conf points to CoreDNS's ClusterIP (typically 10.96.0.10). CoreDNS checks its cluster zone first -- any name ending in cluster.local gets resolved from the Kubernetes API. Names that do not match get forwarded to upstream DNS (usually the node's /etc/resolv.conf). The fix for the ndots problem: append a trailing dot to external names (api-server.) to skip the search list entirely, or set dnsConfig.options ndots:1 in the pod spec for workloads that primarily resolve external names.
A Go HTTP server making outbound API calls shows intermittent 5-second delays. Profiling reveals goroutines blocked in net.LookupHost. The server is running in an Alpine container where /etc/nsswitch.conf does not exist, and Go's resolver selection logic is choosing the cgo resolver, which calls getaddrinfo() through a single-threaded C library.
Go ships two DNS resolvers. The pure-Go resolver reads /etc/resolv.conf directly, sends DNS packets over UDP/TCP, and parses responses -- entirely in Go, fully async, no cgo overhead. The cgo resolver calls getaddrinfo() from libc, which respects nsswitch.conf, mDNS, LDAP, and other system resolution mechanisms but requires a real OS thread per lookup.
Go chooses which resolver to use at startup. On Linux, it prefers the pure-Go resolver when /etc/resolv.conf has only standard directives and /etc/nsswitch.conf shows a simple "hosts: files dns" configuration. If nsswitch.conf is missing, contains unfamiliar modules, or if CGO_ENABLED=1 with custom nsswitch entries, Go falls back to cgo. Force the choice with GODEBUG=netdns=go (pure Go) or GODEBUG=netdns=cgo (libc). For containers resolving only DNS names, GODEBUG=netdns=go eliminates the cgo dependency entirely and scales to thousands of concurrent lookups without OS thread exhaustion.
A Node.js server handling 10,000 concurrent connections slows to a crawl. Event loop latency spikes from 2ms to 500ms. The bottleneck is dns.lookup() -- every new outbound HTTP connection triggers a getaddrinfo() call on the libuv thread pool, and with only 4 threads by default, DNS lookups queue behind each other.
Node.js has two DNS APIs with fundamentally different behavior. dns.lookup() calls getaddrinfo() through libuv's thread pool, blocking one of 4 default threads per lookup. It respects /etc/hosts, nsswitch.conf, and all system resolution mechanisms, but under load the 4-thread pool becomes a bottleneck. dns.resolve() uses the c-ares library, which sends DNS queries asynchronously without blocking any thread -- but it ignores /etc/hosts and only does real DNS queries.
The fix depends on the use case. For high-throughput servers, increase the thread pool with UV_THREADPOOL_SIZE=64 (max 1024) to handle more concurrent getaddrinfo() calls. Or switch to dns.resolve4() / dns.resolve6() for pure DNS lookups that bypass the thread pool entirely. The http/https modules use dns.lookup() by default -- override this with a custom lookup function in the agent options. Some applications use the cacheable-lookup npm package to add a TTL-aware DNS cache on top of dns.lookup(), reducing redundant system calls from thousands to single digits per minute.
Same Concept Across Tech
| Concept | Docker | Kubernetes | Go | Node.js |
|---|---|---|---|---|
| DNS server | Embedded 127.0.0.11 | CoreDNS ClusterIP (10.96.0.10) | OS resolver or pure-Go | OS resolver (dns.lookup) or c-ares (dns.resolve) |
| resolv.conf injection | Auto-generated from host + --dns flags | Kubelet generates from cluster DNS config | Reads /etc/resolv.conf at startup | Reads via libuv (lookup) or c-ares (resolve) |
| Name resolution scope | Container names on same user-defined network | service.namespace.svc.cluster.local | Whatever resolv.conf provides | Whatever OS provides (lookup) or DNS-only (resolve) |
| Caching | None (relies on upstream) | CoreDNS has 30s default cache | None (glibc has none, pure-Go has none) | None by default; cacheable-lookup package adds it |
| Search domain control | --dns-search flag | dnsConfig.searches in pod spec | Reads search from resolv.conf | Reads via OS (lookup) or ignores (resolve) |
Stack Layer Mapping
| Layer | DNS Mechanism |
|---|---|
| Application | getaddrinfo(), dns.lookup(), net.LookupHost() |
| C library | glibc stub resolver reads resolv.conf, sends UDP/TCP |
| Local cache | systemd-resolved (127.0.0.53), nscd, dnsmasq |
| Container DNS | Docker 127.0.0.11, K8s CoreDNS |
| Recursive resolver | ISP resolver, 8.8.8.8, 1.1.1.1 |
| Authoritative server | Holds the actual zone records |
| Root servers | 13 root server clusters (a.root-servers.net through m.root-servers.net) |
Design Rationale
DNS resolution was designed as a hierarchical distributed database because no single server can hold every name in the world. The stub resolver is intentionally simple -- it asks a recursive resolver and trusts the answer. Caching was left out of glibc deliberately to avoid stale records and TTL-tracking complexity in a library that every process links. The nsswitch.conf layer exists because name resolution is not always DNS -- /etc/hosts, LDAP, NIS, mDNS all predate or complement DNS, and the resolution order must be configurable. Kubernetes chose ndots:5 to make in-cluster service names (which have 4 dots in their FQDN) resolve without trailing dots, at the cost of multiplying external lookups.
If You See This, Think This
| Symptom | Likely Cause | First Check |
|---|---|---|
| "Name or service not known" from getaddrinfo() | DNS server unreachable or name does not exist | dig example.com to test direct resolution |
| 5-second delay before connection | DNS timeout to first nameserver, falls through to second | cat /etc/resolv.conf for nameserver order, dig @<first-ns> example.com |
| 30-second delay before failure | All 3 nameservers timing out with 2 attempts each (3 * 5s * 2) | ss -ulnp | grep :53 to verify DNS listener, check network connectivity |
| Resolution works on host but fails in container | Container resolv.conf points to wrong nameserver or missing search domain | docker exec <ctr> cat /etc/resolv.conf |
| "redis" resolves in one pod but not another | Pods in different namespaces, search domain mismatch | Compare /etc/resolv.conf across pods |
| Go service DNS slower than expected | Using cgo resolver instead of pure-Go | GODEBUG=netdns=2 to log resolver choice |
| Node.js event loop stalls under load | dns.lookup() saturating 4-thread libuv pool | UV_THREADPOOL_SIZE=64 or switch to dns.resolve() |
| Intermittent SERVFAIL responses | CoreDNS or upstream resolver overloaded | kubectl logs -n kube-system -l k8s-app=kube-dns |
When to Use / Avoid
Use when:
- Debugging slow or failing network connections in containers
- Optimizing DNS query volume in Kubernetes clusters
- Configuring DNS for Docker containers or Compose services
- Choosing between Go's pure-Go and cgo DNS resolvers
- Tuning Node.js applications that make many outbound connections
- Diagnosing intermittent connection timeouts caused by DNS failures
Avoid when:
- Using service mesh with sidecar proxies that handle DNS (Istio, Linkerd -- they intercept at the network level)
- Working with hardcoded IP addresses that bypass DNS entirely
- Testing on localhost where /etc/hosts resolves everything without network queries
Try It Yourself
1 # Show current DNS configuration
2
3 cat /etc/resolv.conf
4
5 # Show name resolution order
6
7 grep "^hosts:" /etc/nsswitch.conf
8
9 # Basic DNS lookup with dig (shows TTL, server, query time)
10
11 dig example.com +noall +answer +stats
12
13 # Query a specific nameserver directly
14
15 dig @8.8.8.8 example.com A +short
16
17 # Trace full DNS delegation chain from root servers
18
19 dig +trace example.com
20
21 # Look up both A and AAAA records
22
23 dig example.com A +short && dig example.com AAAA +short
24
25 # Strace getaddrinfo to see actual resolution steps
26
27 strace -e trace=openat,connect,sendto,recvfrom -f getent hosts example.com 2>&1 | head -40
28
29 # Measure DNS resolution time
30
31 time getent hosts example.com
32
33 # Check what a container sees for DNS
34
35 docker run --rm alpine cat /etc/resolv.conf 2>/dev/null || echo 'Docker not available'
36
37 # Test DNS from inside a Kubernetes pod
38
39 kubectl run dns-test --rm -it --restart=Never --image=busybox:1.36 -- nslookup kubernetes.default.svc.cluster.local 2>/dev/null || echo 'kubectl not available'Debug Checklist
- 1
cat /etc/resolv.conf -- check nameserver, search, ndots, timeout - 2
cat /etc/nsswitch.conf -- verify hosts resolution order (files dns) - 3
dig +short example.com -- test basic DNS resolution - 4
dig +trace example.com -- follow the full delegation chain from root - 5
strace -e trace=network -f curl http://example.com 2>&1 | grep -E 'connect|sendto|recvfrom' -- watch actual DNS packets - 6
nslookup example.com 8.8.8.8 -- test against a specific nameserver - 7
cat /etc/hosts -- check for local overrides - 8
ss -ulnp | grep :53 -- check if a local DNS resolver is listening - 9
time getent hosts example.com -- measure actual resolution time including nsswitch
Key Takeaways
- ✓glibc does NOT cache DNS results. Every getaddrinfo() call for a name that is not in /etc/hosts sends a UDP packet to the nameserver. A web server making 1000 requests/second to api.example.com generates 1000 DNS queries/second unless something external caches: systemd-resolved, nscd, dnsmasq, or application-level caching.
- ✓The ndots option controls when search domains are appended. With ndots:5 (Kubernetes default), any name with fewer than 5 dots gets each search domain appended first. "redis.default.svc" has 2 dots, which is less than 5, so glibc appends all search domains before trying the name as-is. Append a trailing dot ("redis.default.svc.") to force absolute resolution and skip the search list entirely.
- ✓DNS queries go over UDP by default (port 53). If the response has the TC (truncated) flag set, the resolver retries over TCP. EDNS0 (RFC 6891) extends UDP payload size beyond 512 bytes -- modern resolvers negotiate up to 4096 bytes. DNS over TCP uses a persistent connection for multiple queries. DNS over TLS (port 853) and DNS over HTTPS (port 443) encrypt queries but require a resolver that supports them.
- ✓/etc/hosts is checked before DNS (default nsswitch.conf order). Entries in /etc/hosts bypass DNS entirely -- no network query, no TTL, instant response. Container runtimes inject entries here for the container's own hostname and for linked containers. Kubernetes adds pod IP and hostAliases entries.
- ✓IPv6 AAAA lookups happen alongside IPv4 A lookups. When AI_ADDRCONFIG is set (default in most resolvers), AAAA queries are skipped if the system has no IPv6 address configured. Happy Eyeballs (RFC 8305) races A and AAAA queries in parallel and connects to whichever responds first, with a 250ms head start for IPv6.
Common Pitfalls
- ✗Mistake: Assuming DNS results are cached by the operating system. Reality: glibc has zero DNS caching. Every getaddrinfo() call that reaches the DNS path sends a fresh UDP query. Without nscd, systemd-resolved, or application-level caching, an application making 100 requests/second to the same hostname generates 100 DNS queries/second. Musl libc (Alpine Linux) also does not cache.
- ✗Mistake: Not understanding ndots in Kubernetes. Reality: The default ndots:5 means any name with fewer than 5 dots gets the entire search list appended. Resolving "api.example.com" (2 dots) generates 6 DNS queries before the actual name is tried. For pods that mostly resolve external names, set ndots:1 in the pod's dnsConfig or append a trailing dot to every external hostname.
- ✗Mistake: Using dns.lookup() in Node.js for high-throughput workloads. Reality: dns.lookup() calls getaddrinfo() on libuv's thread pool, which defaults to 4 threads. Under load, DNS lookups queue behind each other, stalling the event loop. Use dns.resolve() with c-ares for async DNS, or increase UV_THREADPOOL_SIZE up to 1024.
- ✗Mistake: Running Go services in Alpine containers without setting GODEBUG=netdns=go. Reality: Alpine uses musl libc and lacks /etc/nsswitch.conf by default. Go may fall back to the cgo resolver, requiring CGO and a C library, adding latency and complexity. Setting GODEBUG=netdns=go forces the pure-Go resolver, which reads /etc/resolv.conf directly and scales better in containers.
- ✗Mistake: Adding more than 3 nameservers to /etc/resolv.conf. Reality: glibc ignores any nameserver directive beyond the third. The resolver tries them in order with the configured timeout (default 5s). With 3 nameservers and 2 attempts each, the worst-case total timeout is 30 seconds before getaddrinfo() returns an error.
Reference
In One Line
getaddrinfo() reads nsswitch.conf and resolv.conf on every call, glibc never caches, and ndots controls how many search-domain queries fire before the real name is tried.