Skip to main content
Networking · lesson 4 of 8
Networking beginner Lesson 4 of 8

DNS: From a Name to an Address, and the Ways It Fails

Query a local dnsmasq with dig: an A record and its TTL, NXDOMAIN vs SERVFAIL vs no answer, what Python sees for each, and /etc/hosts beating DNS.

By SkillByExample · Updated

networking dns dig dnsmasq docker

Every connection in the earlier lessons used a name like server, and something turned it into an address before the first packet. That something is DNS. This lesson runs a small DNS server, dnsmasq, in Docker, queries it with dig, and breaks resolution in three different ways. Each failure looks different in dig and in Python, and telling them apart saves hours. Tested with Docker Engine 29.8.2, Alpine 3.22.6, dnsmasq 2.91, dig from BIND 9.20.29 and Python 3.12.15 (python:3.12-slim).

A resolver for your own zone

One image holds both the server and the client tools:

FROM alpine:3.22
RUN apk add --no-cache dnsmasq bind-tools

The config answers for a private zone, lab.test, from its own records, and forwards one other domain, broken.test, to an upstream server at 172.30.0.99.

# dnsmasq.conf
no-resolv
no-hosts
log-queries
log-facility=-
local=/lab.test/
local-ttl=300
host-record=api.lab.test,172.30.0.10
host-record=db.lab.test,172.30.0.11
server=/broken.test/172.30.0.99

no-resolv stops dnsmasq from forwarding anything else to your real resolver, and the network is --internal, so no query can leave the lab. The .test top-level domain is reserved for exactly this kind of testing.

docker build -t dns-lab .
docker network create --internal --subnet 172.30.0.0/24 dns-lab
docker run -d --name dns --network dns-lab --ip 172.30.0.53 -v "$PWD/dnsmasq.conf":/etc/dnsmasq.conf:ro dns-lab dnsmasq -k

A successful lookup

$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 api.lab.test

; <<>> DiG 9.20.29 <<>> @172.30.0.53 api.lab.test
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 56099
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;api.lab.test.			IN	A

;; ANSWER SECTION:
api.lab.test.		300	IN	A	172.30.0.10

;; Query time: 0 msec
;; SERVER: 172.30.0.53#53(172.30.0.53) (UDP)
;; WHEN: Tue Oct 13 19:09:06 UTC 2026
;; MSG SIZE  rcvd: 57

Three parts matter. status: NOERROR in the header is the outcome. The answer line reads “the name api.lab.test has an Internet (IN) address record (A) of 172.30.0.10, and you may cache it for 300 seconds”. That number is the TTL. The aa flag means authoritative answer: this server owns the zone and isn’t repeating something it heard elsewhere. @172.30.0.53 told dig which server to ask, and the SERVER line confirms it went over UDP port 53.

Failure 1: NXDOMAIN, the name doesn’t exist

$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 nope.lab.test

; <<>> DiG 9.20.29 <<>> @172.30.0.53 nope.lab.test
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 49872
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;nope.lab.test.			IN	A

;; Query time: 0 msec
;; SERVER: 172.30.0.53#53(172.30.0.53) (UDP)
;; WHEN: Tue Oct 13 19:09:07 UTC 2026
;; MSG SIZE  rcvd: 42

The server answered, instantly, and the answer is “no such name”. NXDOMAIN is a fact about the zone, and resolvers cache it like any other answer. Retrying won’t help. The fix is a record or a spelling correction.

Failure 2: no answer at all

broken.test is forwarded to 172.30.0.99, and nothing runs there yet:

$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 www.broken.test
;; communications error to 172.30.0.53#53: timed out
;; communications error to 172.30.0.53#53: timed out
;; communications error to 172.30.0.53#53: timed out

; <<>> DiG 9.20.29 <<>> @172.30.0.53 www.broken.test
; (1 server found)
;; global options: +cmd
;; no servers could be reached
$ docker logs dns 2>&1 | tail -3
dnsmasq[1]: forwarded www.broken.test to 172.30.0.99
dnsmasq[1]: query[A] www.broken.test from 172.30.0.2
dnsmasq[1]: forwarded www.broken.test to 172.30.0.99

dig blames 172.30.0.53, which is the server it asked, and it got nothing back after three tries of five seconds each. The server log shows the real story: dnsmasq was alive and forwarding, but waiting on an upstream that never answered, so it had nothing to send back. “No servers could be reached” means no DNS response arrived, not that the name is wrong. It’s the DNS version of the timeout from the sockets lesson.

Failure 3: SERVFAIL, an answer that says “I failed”

Now put a server at 172.30.0.99 that is up but broken. It replies to every query with response code 2, SERVFAIL, which is what a real upstream sends when its own backend or DNSSEC validation fails.

# broken_upstream.py
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 53))
while True:
    query, addr = s.recvfrom(512)
    # same ID and question, flags = response, RCODE 2 (SERVFAIL)
    header = query[:2] + b"\x81\x82" + query[4:6] + b"\x00\x00" * 3
    end = query.index(b"\x00", 12) + 5      # end of the question section
    s.sendto(header + query[12:end], addr)
$ docker run -d --name upstream --network dns-lab --ip 172.30.0.99 -v "$PWD":/work -w /work python:3.12-slim python broken_upstream.py
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 www.broken.test

; <<>> DiG 9.20.29 <<>> @172.30.0.53 www.broken.test
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 61628
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;www.broken.test.		IN	A

;; Query time: 0 msec
;; SERVER: 172.30.0.53#53(172.30.0.53) (UDP)
;; WHEN: Tue Oct 13 19:09:27 UTC 2026
;; MSG SIZE  rcvd: 33
$ docker logs dns 2>&1 | tail -3
dnsmasq[1]: query[A] www.broken.test from 172.30.0.2
dnsmasq[1]: forwarded www.broken.test to 172.30.0.99
dnsmasq[1]: reply error is SERVFAIL

The whole DNS message format is visible in those few lines. A response reuses the query’s 2-byte ID so the client can match them up, sets flag bits that mark it as a response, and puts the outcome in the low 4 bits of the flags (0x82 ends in 2). SERVFAIL means “I couldn’t get an answer”, not “there’s no such name”. The name may well exist, so you look at the servers in the chain, not at the record.

What your application sees

Programs don’t run dig. They call the system resolver, which in Python is getaddrinfo:

# resolve.py
import socket, sys

for name in sys.argv[1:]:
    try:
        infos = socket.getaddrinfo(name, 80, type=socket.SOCK_STREAM)
        print(f"{name:<16} {infos[0][4][0]}")
    except socket.gaierror as e:
        print(f"{name:<16} gaierror {e.errno}: {e.strerror}")

--dns makes Docker’s embedded resolver in the container forward to the lab’s dnsmasq:

$ docker run --rm --network dns-lab --dns 172.30.0.53 -v "$PWD":/work -w /work python:3.12-slim python resolve.py api.lab.test nope.lab.test www.broken.test
api.lab.test     172.30.0.10
nope.lab.test    gaierror -2: Name or service not known
www.broken.test  gaierror -3: Temporary failure in name resolution

The two failures arrive as two different error numbers. -2 is NXDOMAIN, which is permanent. -3 is SERVFAIL, which is “temporary”, so a retry might succeed. A log line that only says “could not resolve host” has thrown that difference away. Log the error number.

/etc/hosts wins, but only for some tools

--add-host writes an entry into the container’s /etc/hosts, pointing api.lab.test somewhere else:

$ docker run --rm --network dns-lab --dns 172.30.0.53 --add-host api.lab.test:10.9.9.9 dns-lab sh -c "grep lab.test /etc/hosts; getent hosts api.lab.test; dig +short api.lab.test"
10.9.9.9	api.lab.test
10.9.9.9          api.lab.test  api.lab.test
172.30.0.10

getent hosts asks the system resolver, the same path curl, Python and almost every program take. That path reads /etc/hosts before DNS, so it returns 10.9.9.9. dig only speaks DNS, so it never reads the file and returns the real record. When “dig says one thing and my app does another”, look in /etc/hosts. A stale entry left over from testing is a classic cause. Container-name DNS on Docker networks is covered in Docker networking. Clean up with docker rm -f dns upstream and docker network rm dns-lab.

Practice

  1. Add an alias. Give api.lab.test a second name, www.lab.test, with a CNAME record, and query it with +noall +answer to see only the answer section.
Solution

dnsmasq takes extra settings on the command line as well as from the file:

$ docker rm -f dns
$ docker run -d --name dns --network dns-lab --ip 172.30.0.53 -v "$PWD/dnsmasq.conf":/etc/dnsmasq.conf:ro dns-lab dnsmasq -k --cname=www.lab.test,api.lab.test
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 +noall +answer www.lab.test
www.lab.test.		300	IN	CNAME	api.lab.test.
api.lab.test.		300	IN	A	172.30.0.10

A CNAME says “this name is really that name”, and the server helpfully includes the A record for the target in the same answer. Two hops, one round trip.

  1. Go backwards. Look up the name for 172.30.0.10 with dig -x. What name does the query actually ask for?
Solution
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 +noall +answer -x 172.30.0.10
10.0.30.172.in-addr.arpa. 300	IN	PTR	api.lab.test.

A reverse lookup asks for a PTR record under in-addr.arpa, with the address’s octets reversed, so the most general part comes last, as in a normal name. host-record created the PTR record automatically.

  1. Watch a TTL count down. Start a second dnsmasq at 172.30.0.54 that caches and forwards everything to 172.30.0.53. Ask it for api.lab.test twice, about ten seconds apart, then ask the authoritative server.
Solution
$ docker run -d --name cache --network dns-lab --ip 172.30.0.54 dns-lab dnsmasq -k --no-resolv --server=172.30.0.53
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.54 +noall +answer api.lab.test
api.lab.test.		300	IN	A	172.30.0.10
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.54 +noall +answer api.lab.test
api.lab.test.		289	IN	A	172.30.0.10
$ docker run --rm --network dns-lab dns-lab dig @172.30.0.53 +noall +answer api.lab.test
api.lab.test.		300	IN	A	172.30.0.10

The authoritative server always says 300. The cache says how much of the 300 is left. If you changed the record now, clients of this cache would keep getting the old address for 289 more seconds. That’s why you lower a TTL before a migration, not during it.

  1. Name each failure. For each case, say what you’d check first: dig prints status: NXDOMAIN; dig prints status: SERVFAIL; dig prints no servers could be reached; dig is right but getent isn’t.
Solution
SymptomMeaningCheck first
NXDOMAINthe zone says the name doesn’t existspelling, then whether the record was created
SERVFAILthe resolver got no usable answerthe upstream servers for that domain, then DNSSEC
no servers could be reachedno DNS response arrivedthat the resolver is up and reachable on port 53
getent disagrees with digsomething before DNS answered/etc/hosts

Frequently Asked Questions

What is the difference between NXDOMAIN and SERVFAIL?
NXDOMAIN is a definite answer: the server responsible for the name says it does not exist, and resolvers may cache that. SERVFAIL means the resolver could not get any answer, for example because the upstream server for that domain is failing or DNSSEC validation failed. NXDOMAIN usually points to a typo or a missing record; SERVFAIL points to a broken server along the way.
What does the number before IN A in dig output mean?
It is the TTL, the time to live in seconds. It tells resolvers how long they may cache the answer before asking again. An authoritative server always returns the full TTL, while a caching resolver returns the time remaining, so the number counts down between queries. A long TTL is why a changed DNS record can take time to reach everyone.
Why does getent show a different IP than dig?
getent hosts goes through the system resolver, which reads /etc/hosts before asking DNS, as configured in nsswitch.conf. dig is a DNS tool and only asks DNS servers, so it never sees /etc/hosts. If the two disagree, an /etc/hosts entry is overriding DNS for every normal program on that machine, which is a common cause of 'it works with dig but not in my app'.
What Python error do I get when a hostname doesn't resolve?
socket.getaddrinfo raises socket.gaierror. On Linux with glibc, errno -2 'Name or service not known' means the name does not exist (NXDOMAIN), and errno -3 'Temporary failure in name resolution' means no usable answer came back, such as SERVFAIL or an unreachable resolver. Retrying can help for -3 but not for -2.
What does 'communications error ... timed out' from dig mean?
dig sent its query and got no reply at all, so it retried and finally printed 'no servers could be reached'. Either the DNS server itself is down or unreachable, or it is silently waiting on an upstream that never answers. Unlike NXDOMAIN or SERVFAIL, no DNS response was received, so check connectivity to the server before looking at records.