Skip to content

How DNS works — why one name lookup sent 8 queries in Kubernetes

Contents9 sections

When code calls http.Get("http://api.example.com"), the name has to become an IP address before any request goes out. That’s DNS’s job. I’d always taken this step for granted, so when I went back over CS fundamentals I worked through it from the start. With the same setup as a Kubernetes pod, one name lookup sent 8 queries to a tiny DNS server I wrote to count them.

On a machine whose /etc/resolv.conf has a single nameserver line, a Go program looks up api.example.com once. How many queries does the DNS server get?

  1. 1
  2. 3
  3. 4
  4. 2
Show answer

2 It asks for the IPv4 address (A) and the IPv6 address (AAAA) separately.

Take a guess before you read. Being wrong is fine.

Why do we need DNS?

Computers find each other by IP address, a number like 203.0.113.10. People find names like api.example.com far easier to remember than numbers. DNS (the Domain Name System) is the system that turns a name into an IP address, and browsers and server code alike go through it before they connect to anything.

Name first, then connectComputers only find each other by IP address1The app wants to reach this URLhttp://api.example.com2It asks DNSWhat is the IP of api.example.com?3DNS answers203.0.113.104The app connects to that IP203.0.113.10:80
Steps 2 and 3 are DNS's job; the connection only starts once the answer is back

Asking once, as in step 2, “what’s the IP address for this name?” is called a query. This whole post is about counting how many of those queries go out.

It works a lot like directory assistance on the phone. You give a name, they give you a number, and you call that number. The analogy breaks in one place, though. No single operator knows every number; you get passed along with “ask over there” a few times until you reach the server that knows. That handing-off is what lets DNS work at the scale of the whole internet.

Ask the nearest place first

To see who knows what, we need to take the name apart. api.example.com looks like three labels separated by dots, but there’s one more dot at the very end that we usually leave out, so the full name is api.example.com.. Labels further to the right are bigger units.

Read a domain name from the rightEach label has its own servers1api.example.com.RootThe final dot, usually left out←2api.example.com.TLDTop-level labels like com, net, uk3api.example.com.DomainThe name someone registers4api.example.com.HostOne server inside that domain
Starting from the final dot (the root), each label to the left is a smaller unit

Each label has its own servers. A root server knows which server handles com, and the TLD (top-level domain) server for com knows which server handles example.com. Only the authoritative server, run by whoever registered example.com, knows the actual IP of api.example.com. The “ask over there” from earlier is this walk from the rightmost label to the left.

A lookup doesn’t start at the root every time, though. It starts at the nearest place.

  • First it checks my own machine: the cache, which holds answers received recently, and the /etc/hosts file, where names and IPs are written down by hand. On Linux, the hosts: files dns line in /etc/nsswitch.conf sets this order.
  • If neither has it, it asks a resolver. A resolver is a DNS server that looks up names it doesn’t know on your behalf. At home that’s usually your ISP’s DNS; in Kubernetes it’s CoreDNS. The nameserver line in /etc/resolv.conf says which resolver to ask.
Ask the nearest place firstThe app asks once; the resolver does the restOn my machine1Cache · /etc/hostsIf an answer is written down, doneif not2ResolverA DNS server that looks it up for youAsked by the resolver3Root server“Ask that server about com”4TLD server · com“Ask that one about example.com”5Authoritative · example.com“It’s 203.0.113.10. Trust it for 30s”Each hop keeps the answer for 30s (TTL)
The app asks the resolver once, and the resolver makes trips 3 to 5 on its behalf

A name you’ve never looked up has to go through all those trips, so it’s slow. Ask for the same name again and some hop along the way has the answer remembered, so it’s fast.

On Linux, which file decides whether /etc/hosts or DNS is checked first?

  1. /etc/resolv.conf
  2. /etc/hosts
  3. /etc/hostname
  4. /etc/nsswitch.conf
Show answer

/etc/nsswitch.conf Its hosts: files dns line says to check the hosts file (files) first and DNS after it. Which resolver to ask is set in /etc/resolv.conf.

Pick an answer

How many queries go out?

To see how many queries really go out, I wrote a tiny DNS server that prints each query it receives, one line per query. It sits in the resolver’s seat at 127.0.0.1:53, answers the two names I gave it, and says “no such name” for everything else. It never goes on to a root or TLD server. The server code is at the end of section 8.

🧪 Try it

One name, two queries

The lookup side is a short program built on net.Resolver from Go’s standard library. Below, I call the compiled binary dnslookup.

dnslab/lookup/main.go
package main
import (
"context"
"fmt"
"net"
"os"
"time"
)
// Look up each name given as an argument and print the result and how long it took.
func main() {
r := &net.Resolver{PreferGo: true} // the Go resolver, not cgo
for _, name := range os.Args[1:] {
start := time.Now()
addrs, err := r.LookupHost(context.Background(), name)
took := time.Since(start).Round(100 * time.Microsecond)
if err != nil {
fmt.Printf("%-20s failed %v (%v)\n", name, err, took)
continue
}
fmt.Printf("%-20s %v (%v)\n", name, addrs, took)
}
}

I put a single line in /etc/resolv.conf, pointing at the lab server, and looked up api.example.com.

Output
$ cat /etc/resolv.conf
nameserver 127.0.0.1
$ dnslookup api.example.com
api.example.com [203.0.113.10] (1.5ms)
--- queries the DNS server received
1 A api.example.com. → NOERROR (A)
2 AAAA api.example.com. → NOERROR (empty)

I asked for one name and the server got two queries. There are two kinds of IP address: IPv4 (four numbers, like 203.0.113.10) and IPv6 (a longer address, like 2001:db8::10), and Go’s resolver asks for both. An A query asks for the IPv4 address; an AAAA query asks for the IPv6 one.

NOERROR means the server answered without a problem. The A query came back with an IP, and the AAAA query came back empty, meaning “the name exists, but it has no IPv6 address.” The two queries go out at the same time, so the order they arrive in can change from run to run.1

Each piece of information a DNS server stores is called a record, and A and AAAA are also the names of record types. When you’re starting out, these four are enough.

Record Holds Example value
A An IPv4 address 203.0.113.10
AAAA An IPv6 address 2001:db8::10
CNAME The real name an alias points to example.com
NS The server in charge of a label ns1.example.com

When the root and TLD servers in figure 3 say “ask that server,” what they hand back is an NS record.

🧪 Try it

Names in hosts never reach the DNS server

I added a line 203.0.113.99 db.local to /etc/hosts and looked up db.local.

Output
$ dnslookup db.local
db.local [203.0.113.99] (800µs)
--- queries the DNS server received

The answer came back, and the DNS server didn’t get a single query. The lookup ended at step 1 in figure 3. For the same reason, a name you’ve put in hosts for a test environment ignores any change to your DNS settings.

You look up a name that is in /etc/hosts. How many queries does the DNS server get?

  1. 1
  2. 0
  3. 2
  4. 4
Show answer

0 If the hosts file has the answer, the lookup never reaches the resolver. In the lab, the DNS server got not a single query.

Pick an answer

Answers are remembered for a TTL

Along with the IP, the authoritative server sends a TTL (time to live). It means “you can trust this answer for 30 seconds,” and the lab server attaches a 30-second TTL to every answer too. Most places an answer passes through, such as resolvers, CoreDNS, the OS, and browsers, hold on to it for the TTL and hand it back without asking again when the same question comes in. Remembering an answer like this for a while is called caching. It’s also why changing a server’s IP doesn’t reach everyone at once; the change spreads as the TTLs run out.

My Go program isn’t one of those places that remember, though. Let’s check with numbers.

🧪 Try it

Ask Go for the same name twice, and it asks twice

The same program looked up api.example.com twice in a row, with the same settings as the previous lab.

Output
$ dnslookup api.example.com api.example.com
api.example.com [203.0.113.10] (4.4ms)
api.example.com [203.0.113.10] (12.2ms)
--- queries the DNS server received
1 AAAA api.example.com. → NOERROR (empty)
2 A api.example.com. → NOERROR (A)
3 AAAA api.example.com. → NOERROR (empty)
4 A api.example.com. → NOERROR (A)

Lines 3 and 4 are the second lookup. The TTL was 30 seconds, yet it sent both queries again just like the first time, and it wasn’t any faster. Go’s net package has no DNS cache. Caching happens outside it, in the OS’s caching daemon or a resolver like CoreDNS, so code that opens a new connection for every request also looks the name up for every request.

DNS answers are cached for the TTL, but the resolver and the OS do the caching. A Go program asks again on every lookup.

A name has a 30-second TTL. A Go program looks it up twice, one second apart. What happens on the second lookup?

  1. It asks the configured nameserver again
  2. Go reuses the answer it remembered
  3. It asks the root server again
  4. It waits for the 30-second TTL to run out, then asks
Show answer

It asks the configured nameserver again Go's net package has no DNS cache. The nameserver (a resolver like CoreDNS) or the OS is what remembers answers for the TTL.

Pick an answer

Short names work inside a Kubernetes pod

From here on, this is about Kubernetes. In Kubernetes, you can call a service in the same namespace by a short name like api. What DNS knows, though, is a longer name like api.demo.svc.cluster.local: the service name api, the namespace demo, and svc.cluster.local, which means “a service inside the cluster,” joined together.

Short names work because of the pod’s /etc/resolv.conf. When a pod starts, Kubernetes fills this file in like the one below. I added the comments.

A pod's /etc/resolv.conf
# Resolver to ask: CoreDNS's service IP (differs per cluster)
nameserver 10.96.0.10
# Suffixes to try after a short name, in order
search demo.svc.cluster.local svc.cluster.local cluster.local
# Names with fewer than 5 dots try the suffixes first
options ndots:5

The search line is the list of suffixes. When you look up api inside the pod, the resolver tries each suffix in turn and uses the answer for the first name that exists.

Short names get a search suffixLooking up api inside a podName looked upapiSuffixes are tried in search order1api.demo.svc.cluster.local✓Service api in namespace demo · 10.96.0.202api.svc.cluster.localnot asked3api.cluster.localnot asked
The first suffix gave a name that exists, so the other two were never asked

🧪 Try it

The short name api was found on the first suffix

I registered api.demo.svc.cluster.local on the lab server, used the same settings as a pod with only nameserver pointed at the lab server, and looked up api.

Output
$ cat /etc/resolv.conf
search demo.svc.cluster.local svc.cluster.local cluster.local
nameserver 127.0.0.1
options ndots:5
$ dnslookup api
api [10.96.0.20] (1.2ms)
--- queries the DNS server received
1 AAAA api.demo.svc.cluster.local. → NOERROR (empty)
2 A api.demo.svc.cluster.local. → NOERROR (A)

api.demo.svc.cluster.local, with the first suffix added, matched right away, so it took two queries (AAAA and A), the same as the first lab.

Why one outside name sent 8 queries

So what happens if the same pod looks up an outside name, api.example.com? The resolver can’t tell whether it’s a short name or an outside one, so it decides by counting dots, and ndots sets the threshold. With ndots:5, a name with fewer than five dots might be a short name, so the resolver asks for the suffixed names before the name itself. api.example.com has two dots, so the suffixes go first.

Under 5 dots, suffixes go firstapi.example.com, 2 dots · suffixes in redCandidate 1 · 2 queries✕ no such nameapi.example.com.demo.svc.cluster.localCandidate 2 · 2 queries✕ no such nameapi.example.com.svc.cluster.localCandidate 3 · 2 queries✕ no such nameapi.example.com.cluster.localCandidate 4 · 2 queries✓ 203.0.113.10api.example.com4 candidates × 2 queries (A, AAAA) = 86 of them confirmed names that don’t exist
The three suffixed candidates don't exist, so the name itself is only asked fourth

🧪 Try it

One outside name sent 8 queries

Open the function in Go’s resolver that builds the list of candidates, and the rule is right there. I left out the length checks and edge cases and rewrote the comments.

net/dnsclient_unix.go · nameList (Go 1.22, trimmed)
func (conf *dnsConfig) nameList(name string) []string {
rooted := len(name) > 0 && name[len(name)-1] == '.'
if rooted { // trailing dot: ask for this name only
return []string{name}
}
hasNdots := bytealg.CountString(name, '.') >= conf.ndots
name += "."
var names []string
if hasNdots { // enough dots: the name itself goes first
names = append(names, name)
}
for _, suffix := range conf.search { // a candidate per search suffix
names = append(names, name+suffix)
}
if !hasNdots { // too few dots: the name itself goes last
names = append(names, name)
}
return names
}

With the same settings, I looked up api.example.com.

Output
$ dnslookup api.example.com
api.example.com [203.0.113.10] (2.2ms)
--- queries the DNS server received
1 AAAA api.example.com.demo.svc.cluster.local. → NXDOMAIN
2 A api.example.com.demo.svc.cluster.local. → NXDOMAIN
3 AAAA api.example.com.svc.cluster.local. → NXDOMAIN
4 A api.example.com.svc.cluster.local. → NXDOMAIN
5 AAAA api.example.com.cluster.local. → NXDOMAIN
6 A api.example.com.cluster.local. → NXDOMAIN
7 AAAA api.example.com. → NOERROR (empty)
8 A api.example.com. → NOERROR (A)

The first six lines all failed. The resolver tried the name with each of the three suffixes, got NXDOMAIN (no such name) every time, and only then asked for api.example.com. itself. Four candidates, each with an A and an AAAA query, makes eight.

In the lab even the failed queries came back in under a millisecond, but in a real cluster each failed query is a round trip between the pod and CoreDNS. CoreDNS answers names ending in cluster.local with NXDOMAIN itself and keeps them in its 30-second cache, but the round trip still happens for every query. For a service that calls outside APIs often, that adds load on CoreDNS and latency to every lookup.

In the same pod (ndots:5, three search suffixes), how many DNS queries does looking up api.example.com send?

  1. 8
  2. 2
  3. 4
  4. 6
Show answer

8 Four candidates (three suffixed names plus the name itself), each with an A and an AAAA query: 4 × 2 = 8.

Pick an answer

Cutting queries for outside names

The simplest fix is a trailing dot on the name. That final dot from figure 2 marks a fully qualified domain name (FQDN), written all the way to the root, so the resolver doesn’t add any suffix. It’s the rooted branch in the code above. The other fix is lowering ndots. With ndots:1, a name with at least one dot is asked as is first.

🧪 Try it

A trailing dot or a lower ndots brings it down to 2

Output · with a trailing dot
$ dnslookup api.example.com.
api.example.com. [203.0.113.10] (2.8ms)
--- queries the DNS server received
1 AAAA api.example.com. → NOERROR (empty)
2 A api.example.com. → NOERROR (A)

Only the name itself was asked, with no suffixed candidates. Next, I left the name without a dot and lowered only ndots in the settings, to 1.

Output · with ndots:1
$ dnslookup api.example.com
api.example.com [203.0.113.10] (1ms)
--- queries the DNS server received
1 AAAA api.example.com. → NOERROR (empty)
2 A api.example.com. → NOERROR (A)

Two queries again. The name has at least one dot, so it was asked as is first, and it matched.

Queries the DNS server received for one lookup
  • api (short name)2
  • api.example.com8
  • api.example.com. (trailing dot)2
  • with ndots:12

Same lab server · Go 1.22 resolver (A and AAAA together) · 3 search suffixes · ndots:5 unless noted

Neither one is free.

Add a trailing dot

config.yaml
upstream: "api.example.com."

Queries drop to two and the cluster settings stay as they are. In exchange, every place that uses the name needs the dot. Go’s TLS strips the trailing dot for SNI and certificate checks, but the HTTP Host header carries api.example.com. with the dot. A server or proxy that compares host names character by character may treat it as a different host, so check yours.

Lower ndots with dnsConfig

deployment.yaml (example)
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "2"

The code stays the same and the change applies per pod. In exchange, cluster names with two or more dots, like api.demo.svc, get asked as is first, so their query count goes up instead. On the lab server, api.demo.svc took six queries with ndots:5 and eight with ndots:2. With the same settings, api.example.com dropped from eight to two.

What you gain is fewer queries for outside names; what you give up is a consistent way of writing names, or efficient lookups for internal ones. If, like my service, a pod mostly calls services in its own namespace by short name and makes only a few outside calls, adding a dot to those few names has the smaller impact. If a pod mainly calls outside APIs, lowering ndots works better.

What is the simplest way to skip the suffixes and ask for the name itself first?

  1. Raise the TTL
  2. Turn off AAAA queries
  3. Raise ndots to 10
  4. Add a trailing dot to the name (api.example.com.)
Show answer

Add a trailing dot to the name (api.example.com.) The trailing dot marks a fully qualified domain name (FQDN), written all the way to the root, so the resolver adds no suffix. Raising ndots does the opposite: more names get the suffixes.

Pick an answer

Lab setup and limits

I ran the labs with Go 1.22’s resolver (PreferGo) and a DNS server I wrote myself. There was no Kubernetes cluster; I only matched the pod’s resolv.conf format.

Checked What it shows What it doesn’t
One lookup Go’s resolver asks for A and AAAA together How other language runtimes or glibc ask
hosts lab If hosts has the answer, the DNS server isn’t asked Behavior with a different lookup order in nsswitch.conf
Twice lab Go’s resolver has no cache and asks every time How much latency CoreDNS’s cache saves in a real cluster
ndots lab Names with fewer than five dots try search suffixes first Real CoreDNS load and latency. The lab server answered instantly on the same machine
Resolver figure The order: cache, hosts, resolver, root, TLD, authoritative How many times a resolver actually asks root and TLD servers. Outside network access was blocked, so I didn’t reproduce it

This is the whole server that prints the queries. It reads only the name and type (A or AAAA) from each query, answers names it knows, and returns NXDOMAIN for the rest. Since it listens on port 53, on Linux it usually runs as root.

dnslab/server/main.go
package main
import (
"encoding/binary"
"fmt"
"net"
"strings"
)
// A tiny DNS server that only knows a few names. It prints every query it receives, in order.
var records = map[string]net.IP{
"api.example.com.": net.ParseIP("203.0.113.10"),
"api.demo.svc.cluster.local.": net.ParseIP("10.96.0.20"),
}
func main() {
conn, err := net.ListenPacket("udp", "127.0.0.1:53")
if err != nil {
panic(err)
}
buf := make([]byte, 512)
for n := 1; ; n++ {
size, addr, _ := conn.ReadFrom(buf)
name, qtype, qend := parseQuestion(buf[:size])
ip, ok := records[name]
rcode := "NXDOMAIN"
var resp []byte
switch {
case ok && qtype == 1: // A
resp, rcode = answer(buf[:qend], ip.To4()), "NOERROR (A)"
case ok: // AAAA etc.: the name exists but has no record of that type
resp, rcode = answer(buf[:qend], nil), "NOERROR (empty)"
default:
resp = header(buf[:qend], 3, 0) // 3 = NXDOMAIN
}
fmt.Printf("%2d %-4s %-40s → %s\n", n, typeName(qtype), name, rcode)
conn.WriteTo(resp, addr)
}
}
func parseQuestion(m []byte) (string, uint16, int) {
var labels []string
i := 12
for m[i] != 0 {
l := int(m[i])
labels = append(labels, string(m[i+1:i+1+l]))
i += 1 + l
}
i++
return strings.Join(labels, ".") + ".", binary.BigEndian.Uint16(m[i:]), i + 4
}
// Copy the query as is and turn only the header into a response.
func header(q []byte, rcode byte, ancount uint16) []byte {
r := append([]byte(nil), q...)
r[2] = 0x81 // QR=1, RD=1
r[3] = 0x80 | rcode // RA=1
binary.BigEndian.PutUint16(r[6:], ancount)
binary.BigEndian.PutUint16(r[8:], 0)
binary.BigEndian.PutUint16(r[10:], 0)
return r
}
func answer(q []byte, ip4 net.IP) []byte {
if ip4 == nil {
return header(q, 0, 0)
}
r := header(q, 0, 1)
r = append(r, 0xc0, 0x0c, 0, 1, 0, 1) // name pointer, TYPE A, CLASS IN
r = binary.BigEndian.AppendUint32(r, 30) // TTL 30 seconds
r = append(r, 0, 4)
return append(r, ip4...)
}
func typeName(t uint16) string {
switch t {
case 1:
return "A"
case 28:
return "AAAA"
}
return fmt.Sprint(t)
}

Instead of memorizing CS fundamentals one at a time, I decided to follow the path a single request takes in and out of a server. It finds the address, connects, sends the request, and then the server receives it, uses CPU and memory, finds the data, and responds. Each stop on that path has a CS topic, and next to it sits how Go and Kubernetes behave in everyday use.

The path of one requestOne CS topic per stop · this post is stop 11Find the addressDNS2ConnectTCP · TLS3SendHTTP4ReceiveI/O multiplexing5Run on a CPUProcesses · scheduling6Use memoryVirtual memory7ShareLocks · sync8Find the dataData structures · disks9When it breaksSignals · debugging
This post is stop 1, finding the address; the next one is stop 2, connecting

With the name turned into an IP, the next stop is opening an actual connection to that IP. Next, I plan to write up how TCP opens and closes a connection, and what TLS checks on top of it.

References

Footnotes

  1. With options single-request in resolv.conf, both Go’s resolver and glibc ask for A and AAAA one after the other. ↩

Edit on GitHub

Comments

esc

Recent posts

↑↓ move↵ openOpen search page ›