Skip to content

Split View: dig는 되는데 애플리케이션은 실패한다 — DNS 해석 순서부터 확인하는 법

✨ Learn with Quiz
|

dig는 되는데 애플리케이션은 실패한다 — DNS 해석 순서부터 확인하는 법

들어가며 — dig는 되는데 애플리케이션만 이름을 못 찾습니다

장애 상황에서 가장 사람을 헷갈리게 만드는 조합이 있습니다.

dig +short api.internal.example.com
10.20.4.31

DNS는 정상으로 보입니다. 그런데 같은 호스트에서 애플리케이션은 이렇게 죽습니다.

java.net.UnknownHostException: api.internal.example.com

여기서 대부분 "DNS 서버는 멀쩡한데 애플리케이션이 이상하다"는 결론으로 넘어갑니다. 그리고 애플리케이션 코드와 라이브러리를 뒤지기 시작합니다. 이 방향은 거의 항상 헛수고입니다.

원인은 단순합니다. dig와 애플리케이션은 애초에 다른 경로로 이름을 풉니다. dig가 성공했다는 사실은 애플리케이션이 성공한다는 근거가 되지 못합니다. 반대 방향도 마찬가지입니다. dig가 실패해도 애플리케이션은 잘 도는 경우가 있습니다.

이 글은 애플리케이션이 실제로 밟는 해석 경로를 순서대로 따라가면서, 각 단계에서 무엇이 어긋날 수 있는지를 정리합니다.

애플리케이션이 이름을 푸는 실제 경로

C, 파이썬, 루비, PHP, Node의 기본 해석기, 그리고 리눅스의 자바까지 대부분은 결국 glibc의 getaddrinfo()를 호출합니다. glibc는 다음 순서를 밟습니다.

  1. /etc/nsswitch.conf의 hosts 행을 읽어 어떤 소스를 어떤 순서로 쓸지 결정합니다.
  2. files 모듈이면 /etc/hosts를 봅니다.
  3. dns 모듈이면 /etc/resolv.conf를 읽어 nameserver, search, options를 적용하고 질의를 보냅니다.
  4. resolve 모듈이면 D-Bus로 systemd-resolved에 물어봅니다.
  5. myhostname, mdns4_minimal 같은 특수 모듈이 중간에 끼어 있을 수 있습니다.

첫 단계부터 확인합니다.

grep '^hosts' /etc/nsswitch.conf
hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname

이 한 줄에 함정이 두 개 있습니다.

mdns4_minimal [NOTFOUND=return]은 이름이 .local로 끝날 때 mDNS로만 해석하고, 실패하면 그 자리에서 멈추라는 뜻입니다. 뒤에 있는 dns 모듈까지 가지 않습니다. 사내 도메인을 cluster.local이나 svc.local처럼 지어 쓰는 조직이 이 함정에 자주 걸립니다. dig는 nsswitch를 보지 않으므로 정상 응답을 돌려주고, 애플리케이션만 실패합니다.

또 하나는 resolve 모듈입니다.

hosts: files resolve [!UNAVAIL=return] dns myhostname

이 설정이면 애플리케이션은 D-Bus를 통해 systemd-resolved에 물어봅니다. resolv.conf의 nameserver 값을 직접 쓰지 않습니다. 즉 /etc/resolv.conf를 고쳐도 애플리케이션 동작이 바뀌지 않을 수 있습니다.

resolv.conf도 확인합니다.

cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search ap-northeast-2.compute.internal

dig와 nslookup은 /etc/hosts를 보지 않습니다

이 글에서 가장 실용적인 사실입니다. dig, nslookup, host는 DNS 프로토콜 전용 도구입니다. nsswitch.conf도, /etc/hosts도, systemd-resolved의 링크별 설정도 보지 않습니다. resolv.conf에서 nameserver 주소만 가져다가 UDP 53으로 질의를 던집니다.

그래서 다음과 같은 상황이 아무 모순 없이 성립합니다.

# /etc/hosts에만 있는 이름 — dig는 못 찾는다
dig +short legacy-billing.internal
(빈 출력)
# 애플리케이션과 같은 경로 — 정상적으로 찾는다
getent hosts legacy-billing.internal
10.20.9.14      legacy-billing.internal

반대 방향도 있습니다. 누군가 디버깅하다 /etc/hosts에 임시로 넣어 둔 낡은 IP가 남아 있으면, dig는 새 IP를 돌려주는데 애플리케이션은 계속 낡은 IP로 접속합니다. 이 경우 증상은 DNS 오류가 아니라 connection refused나 타임아웃으로 나타나므로 원인을 DNS에서 찾지 않게 됩니다.

따라서 진단의 기본 도구를 바꿔야 합니다.

# 애플리케이션과 동일한 경로를 그대로 재현한다
getent hosts api.internal.example.com

# IPv4와 IPv6를 모두, 그리고 시도 순서까지 보여준다
getent ahosts api.internal.example.com
10.20.4.31       STREAM api.internal.example.com
10.20.4.31       DGRAM
10.20.4.31       RAW

getent ahosts의 출력 순서는 애플리케이션이 실제로 시도할 순서와 같습니다. AAAA가 먼저 나오는데 IPv6 경로가 없다면 첫 연결 시도가 실패한다는 뜻이고, 이는 앞서 말한 "Network is unreachable"의 원인이 됩니다.

정리하면 이렇게 씁니다. dig는 DNS 서버의 답을 보는 도구이고, getent는 애플리케이션이 받게 될 답을 보는 도구입니다. 둘의 결과가 다르면 그 차이 자체가 원인의 위치를 알려줍니다.

systemd-resolved와 127.0.0.53이 만드는 혼란

우분투를 비롯한 최근 배포판에서 resolv.conf의 nameserver는 보통 127.0.0.53입니다. 이것은 systemd-resolved가 띄운 스텁 리졸버이고, 실제 상위 서버는 따로 있습니다.

resolvectl status
Global
         Protocols: -LLMNR -mDNS +DNSOverTLS DNSSEC=no/unsupported
  resolv.conf mode: stub

Link 2 (ens5)
    Current Scopes: DNS
         Protocols: +DefaultRoute +DNSSEC=no/unsupported
Current DNS Server: 10.0.0.2
       DNS Servers: 10.0.0.2
        DNS Domain: ap-northeast-2.compute.internal

Link 5 (tun0)
    Current Scopes: DNS
         Protocols: +DNSOverTLS
Current DNS Server: 10.60.0.53
       DNS Servers: 10.60.0.53
        DNS Domain: ~corp.example.com ~internal.example.com

여기서 두 가지가 중요합니다.

첫째, 링크마다 DNS 서버와 도메인이 따로 있습니다. 위 예시에서 corp.example.com으로 끝나는 이름은 VPN 인터페이스의 10.60.0.53으로 가고, 나머지는 10.0.0.2로 갑니다. 이것을 스플릿 DNS라고 합니다. 그런데 dig @10.0.0.2 vault.corp.example.com처럼 상위 서버를 직접 지정하면 이 라우팅을 건너뛰므로 실패합니다. VPN을 켠 상태에서 "dig는 실패하는데 브라우저는 열린다"는 상황이 여기서 나옵니다.

둘째, dig의 기본 대상은 127.0.0.53이므로 스텁의 캐시를 보게 됩니다. 상위 서버가 이미 고쳐졌는데도 스텁 캐시 때문에 옛 답이 계속 나올 수 있습니다.

세 층위를 각각 나눠서 확인하는 것이 정확합니다.

# 1) 애플리케이션 경로 그대로
resolvectl query vault.corp.example.com

# 2) 스텁 리졸버에 직접
dig @127.0.0.53 vault.corp.example.com

# 3) 상위 서버에 직접
dig @10.60.0.53 vault.corp.example.com
vault.corp.example.com: 10.60.4.7                    -- link: tun0

-- Information acquired via protocol DNS in 2.1ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: yes

resolvectl query가 링크 이름까지 알려주는 점이 유용합니다. 어느 인터페이스의 DNS로 갔는지가 그대로 나옵니다.

캐시가 의심되면 층위별로 비웁니다.

resolvectl flush-caches
resolvectl statistics
DNSSEC verdicts
Secure: 0
Insecure: 0
Bogus: 0
Indeterminate: 0

Transactions
Current Transactions: 0
Total Transactions: 184213

Cache
Current Cache Size: 0
Cache Hits: 156402
Cache Misses: 27811

캐시 계층은 여기서 끝나지 않습니다. 애플리케이션 내부 캐시가 별도로 있습니다. 자바가 대표적입니다. 보안 관리자가 설치되지 않은 기본 환경에서 JVM은 성공한 해석 결과를 30초간 캐시하고, 실패한 해석은 networkaddress.cache.negative.ttl 기본값인 10초간 캐시합니다. 보안 관리자를 쓰는 오래된 배포에서는 성공 결과를 영원히 캐시합니다. 페일오버로 IP가 바뀌었는데 특정 서비스만 계속 옛 IP를 붙잡고 있다면 이 값을 먼저 봐야 합니다.

# 자바 실행 시 명시적으로 지정
java -Dsun.net.inetaddr.ttl=30 -Dsun.net.inetaddr.negative.ttl=0 -jar app.jar

# 또는 java.security 파일에서
# networkaddress.cache.ttl=30
# networkaddress.cache.negative.ttl=0

정리하면 캐시는 네 층입니다. 애플리케이션 내부, 스텁 리졸버, 재귀 리졸버, 그리고 권한 서버가 알려준 TTL입니다. 어느 층을 비웠는지 명확히 하지 않으면 "캐시를 지웠는데 그대로다"라는 보고가 반복됩니다.

search 도메인과 ndots — 쿠버네티스가 느려지는 진짜 이유

쿠버네티스 파드 안의 resolv.conf는 이렇게 생겼습니다.

kubectl exec -it deploy/api -- cat /etc/resolv.conf
search prod.svc.cluster.local svc.cluster.local cluster.local ap-northeast-2.compute.internal
nameserver 10.96.0.10
options ndots:5

ndots:5는 "질의할 이름에 점이 5개보다 적으면, 절대 이름으로 시도하기 전에 search 목록을 먼저 붙여 보라"는 뜻입니다. 이 설정 덕분에 파드 안에서 apiapi.prod처럼 짧은 이름으로 서비스를 부를 수 있습니다.

문제는 외부 도메인입니다. api.stripe.com은 점이 2개이므로 5보다 적습니다. 그래서 다음 순서로 질의가 나갑니다.

kubectl exec -it deploy/api -- sh -c 'tcpdump -nn -i any port 53' &
kubectl exec -it deploy/api -- curl -sS -o /dev/null https://api.stripe.com/v1/charges
10:11:02.512331 IP 10.244.1.9.41822 > 10.96.0.10.53: 41955+ A? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.512402 IP 10.244.1.9.41822 > 10.96.0.10.53: 8271+ AAAA? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.513880 IP 10.96.0.10.53 > 10.244.1.9.41822: 41955 NXDomain 0/1/0 (148)
10:11:02.513991 IP 10.96.0.10.53 > 10.244.1.9.41822: 8271 NXDomain 0/1/0 (148)
10:11:02.514552 IP 10.244.1.9.55110 > 10.96.0.10.53: 12043+ A? api.stripe.com.svc.cluster.local. (50)
10:11:02.514601 IP 10.244.1.9.55110 > 10.96.0.10.53: 39218+ AAAA? api.stripe.com.svc.cluster.local. (50)
10:11:02.516001 IP 10.96.0.10.53 > 10.244.1.9.55110: 12043 NXDomain 0/1/0 (143)
10:11:02.516120 IP 10.96.0.10.53 > 10.244.1.9.55110: 39218 NXDomain 0/1/0 (143)
10:11:02.516644 IP 10.244.1.9.39004 > 10.96.0.10.53: 55129+ A? api.stripe.com.cluster.local. (46)
10:11:02.516702 IP 10.244.1.9.39004 > 10.96.0.10.53: 21008+ AAAA? api.stripe.com.cluster.local. (46)
10:11:02.518233 IP 10.96.0.10.53 > 10.244.1.9.39004: 55129 NXDomain 0/1/0 (139)
10:11:02.518344 IP 10.96.0.10.53 > 10.244.1.9.39004: 21008 NXDomain 0/1/0 (139)
10:11:02.518881 IP 10.244.1.9.60122 > 10.96.0.10.53: 3311+ A? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.518944 IP 10.244.1.9.60122 > 10.96.0.10.53: 61190+ AAAA? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.523115 IP 10.96.0.10.53 > 10.244.1.9.60122: 3311 NXDomain 0/1/0 (157)
10:11:02.523240 IP 10.96.0.10.53 > 10.244.1.9.60122: 61190 NXDomain 0/1/0 (157)
10:11:02.523774 IP 10.244.1.9.44911 > 10.96.0.10.53: 9042+ A? api.stripe.com. (32)
10:11:02.523821 IP 10.244.1.9.44911 > 10.96.0.10.53: 55403+ AAAA? api.stripe.com. (32)
10:11:02.531002 IP 10.96.0.10.53 > 10.244.1.9.44911: 9042 3/0/0 A 34.111.87.9 ... (98)

외부 도메인 하나를 풀기 위해 질의 10개가 나갔고, 그중 8개는 NXDOMAIN을 받으려고 나간 것입니다. 파드 하나가 아니라 수백 개가 초당 수십 번씩 이 일을 하면 CoreDNS의 부하 대부분이 실패할 것이 확정된 질의로 채워집니다. 그리고 UDP 패킷 손실이 조금이라도 있으면, 손실된 질의마다 5초 재시도 타임아웃이 붙어 지연이 눈에 띄게 튑니다.

여기서 흔히 도는 처방을 정정해야 합니다. "ndots를 1로 낮추면 된다"는 조언은 클러스터를 깨뜨립니다. ndots가 1이면 점이 1개 이상인 이름은 search를 붙이지 않고 절대 이름으로 먼저 질의합니다. 그러면 payments.prod처럼 네임스페이스를 붙여 부르는 크로스 네임스페이스 호출이 전부 깨집니다. 점이 1개라서 search가 적용되지 않기 때문입니다.

안전한 하한선은 2입니다. ndots가 2이면 apipayments.prod는 여전히 search를 거치고, 점이 2개 이상인 외부 도메인은 곧바로 절대 이름으로 질의합니다.

spec:
  dnsConfig:
    options:
      - name: ndots
        value: '2'

애플리케이션을 고칠 수 있다면 더 확실한 방법이 있습니다. 외부 도메인 끝에 점을 붙여 절대 이름으로 만들면 search 목록을 통째로 건너뜁니다.

# 뒤의 점 하나가 search 도메인 4개를 없앤다
curl -sS -o /dev/null -w '%{time_namelookup}\n' https://api.stripe.com./v1/charges

NodeLocal DNSCache를 쓰는 것도 좋은 완화책입니다. 실패 질의가 노드 로컬 캐시에서 끝나 CoreDNS까지 가지 않고, UDP 손실로 인한 5초 재시도도 크게 줄어듭니다.

NXDOMAIN, SERVFAIL, 그리고 빈 NOERROR

응답 코드를 뭉뚱그려 "DNS가 안 된다"고 보고하면 원인 범위가 좁혀지지 않습니다. dig 출력의 status 필드를 반드시 확인해야 합니다.

dig api.internal.example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 51204
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

ANSWER가 0인데 status는 NOERROR입니다. 이 조합이 가장 자주 오진됩니다.

응답 코드dig에서 보이는 모습실제 의미흔한 원인다음에 할 일
NOERROR + ANSWER 1개 이상정상 레코드 출력이름과 레코드 타입이 모두 존재정상다음 계층으로 이동
NOERROR + ANSWER 0개AUTHORITY만 있고 답이 없음이름은 있으나 그 타입의 레코드가 없음A는 있고 AAAA는 없음, CNAME만 있고 대상 미생성질의 타입을 바꿔 재확인
NXDOMAINstatus: NXDOMAIN권한 서버가 그 이름 자체가 없다고 확정오타, 미생성 레코드, search 도메인 붙은 이름권한 서버에 직접 질의해 확정
SERVFAILstatus: SERVFAIL리졸버가 답을 만들지 못함DNSSEC 검증 실패, 상위 서버 무응답, 존 로드 실패리졸버 로그와 DNSSEC 검사
REFUSEDstatus: REFUSED서버가 이 질의를 처리할 의사가 없음재귀 미허용, ACL 차단, 존을 갖고 있지 않음질의 대상 서버가 맞는지 확인

빈 NOERROR가 나오는 대표적 상황은 IPv6입니다. AAAA 레코드만 없는데 애플리케이션이 AAAA를 먼저 찾는 경우, 로그에는 "이름을 찾을 수 없음"으로 남을 수 있습니다. 질의 타입을 명시해 확인합니다.

dig +noall +answer api.internal.example.com A AAAA CNAME
api.internal.example.com. 300 IN CNAME api-lb.internal.example.com.
api-lb.internal.example.com. 60 IN A 10.20.4.31

SERVFAIL은 리졸버 자신의 문제입니다. DNSSEC 검증 실패가 원인인지 빠르게 가르는 방법이 있습니다.

# 검증을 끄고 다시 물어본다. 이때 성공하면 DNSSEC 문제다
dig +cd api.internal.example.com

# 권한 서버까지 위임 경로를 따라간다
dig +trace api.internal.example.com

NXDOMAIN을 봤을 때는 질의한 이름을 그대로 읽어야 합니다. tcpdump에서 api.stripe.com.prod.svc.cluster.local처럼 search 도메인이 붙은 이름에 대한 NXDOMAIN이라면 그것은 정상 동작이며 문제의 원인이 아닙니다.

512바이트, TCP 폴백, 그리고 큰 응답만 실패하는 경우

DNS는 원래 UDP 512바이트를 넘는 응답을 보낼 수 없었습니다. 초과하면 서버가 TC(truncated) 플래그를 세우고, 클라이언트는 같은 질의를 TCP 53으로 다시 보냅니다. EDNS0는 클라이언트가 더 큰 UDP 버퍼를 받을 수 있다고 알려 이 왕복을 줄이는 확장입니다.

# EDNS0를 끄고 512바이트로 제한해 본다
dig +notcp +bufsize=512 TXT _dmarc-report.example.com
;; Truncated, retrying in TCP mode.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 40912
;; flags: qr tc rd ra; QUERY: 1, ANSWER: 8, AUTHORITY: 0, ADDITIONAL: 0

플래그에 tc가 보이는 것이 잘림의 증거입니다.

여기서 실무 사고가 생깁니다. 오래된 방화벽 설정은 UDP 53만 열고 TCP 53을 막아 둔 경우가 많습니다. 평소에는 아무 문제가 없습니다. 응답이 작으니까요. 그런데 레코드가 늘어나거나 DNSSEC 서명이 붙어 응답이 512바이트를 넘는 순간, 그 이름만 해석되지 않습니다. "특정 도메인 하나만 안 된다"는 신고의 원인이 여기인 경우가 있습니다.

# TCP 53이 열려 있는지 직접 확인
dig +tcp @10.0.0.2 example.com

# EDNS0 경로가 미들박스에 막히는지 확인
dig +bufsize=4096 @10.0.0.2 dnssec-failed.org

# 응답 크기 자체를 확인
dig +noall +stats example.com
;; Query time: 12 msec
;; SERVER: 10.0.0.2#53(10.0.0.2) (UDP)
;; MSG SIZE  rcvd: 512

MSG SIZE rcvd가 512나 1232 근처에 딱 붙어 있고 그 이름만 간헐적으로 실패한다면 경로상의 크기 제한을 의심할 근거가 충분합니다. 참고로 요즘 리졸버들은 IP 프래그멘테이션을 피하기 위해 EDNS 버퍼 권고값을 1232바이트로 낮춰 씁니다.

마치며 — 이름 해석은 dig로 끝나지 않습니다

기억할 것은 한 문장입니다. dig는 DNS 서버의 답을 보여주고 getent는 애플리케이션이 받을 답을 보여줍니다. 두 결과가 다르다면 그 차이가 곧 원인의 위치입니다.

실전에서는 다음 순서로 좁히면 대부분 몇 분 안에 끝납니다. 먼저 getent hostsdig +short를 나란히 실행해 결과가 갈리는지 봅니다. 갈린다면 nsswitch.conf의 hosts 행과 /etc/hosts, 그리고 systemd-resolved의 링크별 설정을 확인합니다. 결과가 같다면 문제는 리졸버 위쪽이므로 dig의 status 필드로 NXDOMAIN, SERVFAIL, 빈 NOERROR를 갈라내고, 필요하면 +trace로 위임 경로를 따라갑니다. 답은 맞는데 오래 걸린다면 tcpdump로 실제로 나가는 질의 개수를 세어 봅니다. search 도메인과 ndots가 만들어 내는 불필요한 질의가 거기서 그대로 드러납니다.

DNS 장애의 상당수는 서버가 아니라 클라이언트 측 해석 순서에서 생깁니다. 순서를 먼저 확인하면 서버를 뒤질 필요가 없어집니다.

dig works but the application fails — start by checking the DNS resolution order

Introduction — dig works, but only the application cannot find the name

There is one combination that confuses people more than anything else during an incident.

dig +short api.internal.example.com
10.20.4.31

DNS looks healthy. Yet on the same host the application dies like this.

java.net.UnknownHostException: api.internal.example.com

At this point most people jump to the conclusion that "the DNS server is fine, so something is wrong with the application", and start digging through application code and libraries. That direction is almost always wasted effort.

The cause is simple. dig and the application resolve names along different paths to begin with. The fact that dig succeeded is not evidence that the application will succeed. The reverse holds too: dig can fail while the application runs perfectly well.

This post walks the resolution path the application actually takes, step by step, and lays out what can go wrong at each stage.

The real path an application takes to resolve a name

C, Python, Ruby, PHP, the default resolver in Node, and even Java on Linux mostly end up calling glibc getaddrinfo(). glibc then walks the following order.

  1. It reads the hosts line in /etc/nsswitch.conf to decide which sources to use and in what order.
  2. For the files module it looks at /etc/hosts.
  3. For the dns module it reads /etc/resolv.conf, applies nameserver, search and options, and sends the query.
  4. For the resolve module it asks systemd-resolved over D-Bus.
  5. Special modules such as myhostname or mdns4_minimal may sit somewhere in the middle.

Start with the first step.

grep '^hosts' /etc/nsswitch.conf
hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname

This single line contains two traps.

mdns4_minimal [NOTFOUND=return] means that when a name ends in .local it is resolved via mDNS only, and if that fails, resolution stops right there. It never reaches the dns module that follows. Organisations that name internal domains something like cluster.local or svc.local fall into this trap often. dig does not consult nsswitch, so it returns a normal answer while only the application fails.

The other trap is the resolve module.

hosts: files resolve [!UNAVAIL=return] dns myhostname

With this configuration the application asks systemd-resolved over D-Bus. It does not use the nameserver values in resolv.conf directly. In other words, editing /etc/resolv.conf may not change application behaviour at all.

Check resolv.conf as well.

cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search ap-northeast-2.compute.internal

dig and nslookup do not look at /etc/hosts

This is the most practical fact in the whole post. dig, nslookup and host are DNS-protocol-only tools. They do not consult nsswitch.conf, they do not consult /etc/hosts, and they do not consult the per-link configuration of systemd-resolved. They take only the nameserver address out of resolv.conf and fire a query at UDP 53.

That is why the following holds without any contradiction.

# A name that exists only in /etc/hosts — dig cannot find it
dig +short legacy-billing.internal
(empty output)
# The same path as the application — it resolves normally
getent hosts legacy-billing.internal
10.20.9.14      legacy-billing.internal

The reverse direction happens too. If a stale IP that someone put into /etc/hosts while debugging is still sitting there, dig returns the new IP while the application keeps connecting to the old one. In that case the symptom is not a DNS error but connection refused or a timeout, which keeps you from looking for the cause in DNS at all.

So the basic diagnostic tool has to change.

# Reproduce exactly the same path the application takes
getent hosts api.internal.example.com

# Show both IPv4 and IPv6, and the order they will be tried in
getent ahosts api.internal.example.com
10.20.4.31       STREAM api.internal.example.com
10.20.4.31       DGRAM
10.20.4.31       RAW

The output order of getent ahosts is the same order the application will actually try. If AAAA comes first but there is no IPv6 route, that means the first connection attempt will fail, and this becomes the cause of the "Network is unreachable" mentioned earlier.

To put it simply: dig is the tool for seeing the DNS server answer, and getent is the tool for seeing the answer the application will receive. When the two results differ, that difference itself tells you where the cause lives.

The confusion created by systemd-resolved and 127.0.0.53

On Ubuntu and other recent distributions, the nameserver in resolv.conf is usually 127.0.0.53. This is a stub resolver run by systemd-resolved, and the real upstream servers are somewhere else.

resolvectl status
Global
         Protocols: -LLMNR -mDNS +DNSOverTLS DNSSEC=no/unsupported
  resolv.conf mode: stub

Link 2 (ens5)
    Current Scopes: DNS
         Protocols: +DefaultRoute +DNSSEC=no/unsupported
Current DNS Server: 10.0.0.2
       DNS Servers: 10.0.0.2
        DNS Domain: ap-northeast-2.compute.internal

Link 5 (tun0)
    Current Scopes: DNS
         Protocols: +DNSOverTLS
Current DNS Server: 10.60.0.53
       DNS Servers: 10.60.0.53
        DNS Domain: ~corp.example.com ~internal.example.com

Two things matter here.

First, each link has its own DNS servers and domains. In the example above, names ending in corp.example.com go to 10.60.0.53 on the VPN interface, and everything else goes to 10.0.0.2. This is called split DNS. But if you specify an upstream server directly, as in dig @10.0.0.2 vault.corp.example.com, you bypass that routing and the query fails. The "dig fails but the browser opens the page" situation while the VPN is up comes from exactly this.

Second, the default target of dig is 127.0.0.53, so you are looking at the stub cache. Even after the upstream server has been fixed, the stub cache can keep handing you the old answer.

Checking the three layers separately is the accurate approach.

# 1) Exactly the application path
resolvectl query vault.corp.example.com

# 2) Straight to the stub resolver
dig @127.0.0.53 vault.corp.example.com

# 3) Straight to the upstream server
dig @10.60.0.53 vault.corp.example.com
vault.corp.example.com: 10.60.4.7                    -- link: tun0

-- Information acquired via protocol DNS in 2.1ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: yes

What makes resolvectl query useful is that it tells you the link name too. Which interface the DNS query went out on is right there in the output.

If you suspect the cache, flush it layer by layer.

resolvectl flush-caches
resolvectl statistics
DNSSEC verdicts
Secure: 0
Insecure: 0
Bogus: 0
Indeterminate: 0

Transactions
Current Transactions: 0
Total Transactions: 184213

Cache
Current Cache Size: 0
Cache Hits: 156402
Cache Misses: 27811

The cache layers do not end there. Applications have their own internal caches. Java is the classic example. In a default environment with no security manager installed, the JVM caches successful resolutions for 30 seconds and caches failed resolutions for the networkaddress.cache.negative.ttl default of 10 seconds. In older deployments that do use a security manager, successful results are cached forever. If an IP changed during failover and only one particular service keeps clinging to the old IP, this is the value to look at first.

# Specify explicitly when launching Java
java -Dsun.net.inetaddr.ttl=30 -Dsun.net.inetaddr.negative.ttl=0 -jar app.jar

# Or in the java.security file
# networkaddress.cache.ttl=30
# networkaddress.cache.negative.ttl=0

To sum up, there are four cache layers: inside the application, the stub resolver, the recursive resolver, and the TTL handed down by the authoritative server. Unless you are explicit about which layer you flushed, you will keep getting reports of "I cleared the cache and nothing changed".

search domains and ndots — the real reason Kubernetes gets slow

The resolv.conf inside a Kubernetes pod looks like this.

kubectl exec -it deploy/api -- cat /etc/resolv.conf
search prod.svc.cluster.local svc.cluster.local cluster.local ap-northeast-2.compute.internal
nameserver 10.96.0.10
options ndots:5

ndots:5 means "if the name being queried has fewer than 5 dots, try the search list first, before trying it as an absolute name". That setting is what lets you call services from inside a pod with short names like api or api.prod.

The problem is external domains. api.stripe.com has 2 dots, which is fewer than 5. So the queries go out in the following order.

kubectl exec -it deploy/api -- sh -c 'tcpdump -nn -i any port 53' &
kubectl exec -it deploy/api -- curl -sS -o /dev/null https://api.stripe.com/v1/charges
10:11:02.512331 IP 10.244.1.9.41822 > 10.96.0.10.53: 41955+ A? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.512402 IP 10.244.1.9.41822 > 10.96.0.10.53: 8271+ AAAA? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.513880 IP 10.96.0.10.53 > 10.244.1.9.41822: 41955 NXDomain 0/1/0 (148)
10:11:02.513991 IP 10.96.0.10.53 > 10.244.1.9.41822: 8271 NXDomain 0/1/0 (148)
10:11:02.514552 IP 10.244.1.9.55110 > 10.96.0.10.53: 12043+ A? api.stripe.com.svc.cluster.local. (50)
10:11:02.514601 IP 10.244.1.9.55110 > 10.96.0.10.53: 39218+ AAAA? api.stripe.com.svc.cluster.local. (50)
10:11:02.516001 IP 10.96.0.10.53 > 10.244.1.9.55110: 12043 NXDomain 0/1/0 (143)
10:11:02.516120 IP 10.96.0.10.53 > 10.244.1.9.55110: 39218 NXDomain 0/1/0 (143)
10:11:02.516644 IP 10.244.1.9.39004 > 10.96.0.10.53: 55129+ A? api.stripe.com.cluster.local. (46)
10:11:02.516702 IP 10.244.1.9.39004 > 10.96.0.10.53: 21008+ AAAA? api.stripe.com.cluster.local. (46)
10:11:02.518233 IP 10.96.0.10.53 > 10.244.1.9.39004: 55129 NXDomain 0/1/0 (139)
10:11:02.518344 IP 10.96.0.10.53 > 10.244.1.9.39004: 21008 NXDomain 0/1/0 (139)
10:11:02.518881 IP 10.244.1.9.60122 > 10.96.0.10.53: 3311+ A? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.518944 IP 10.244.1.9.60122 > 10.96.0.10.53: 61190+ AAAA? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.523115 IP 10.96.0.10.53 > 10.244.1.9.60122: 3311 NXDomain 0/1/0 (157)
10:11:02.523240 IP 10.96.0.10.53 > 10.244.1.9.60122: 61190 NXDomain 0/1/0 (157)
10:11:02.523774 IP 10.244.1.9.44911 > 10.96.0.10.53: 9042+ A? api.stripe.com. (32)
10:11:02.523821 IP 10.244.1.9.44911 > 10.96.0.10.53: 55403+ AAAA? api.stripe.com. (32)
10:11:02.531002 IP 10.96.0.10.53 > 10.244.1.9.44911: 9042 3/0/0 A 34.111.87.9 ... (98)

Ten queries went out to resolve a single external domain, and eight of them went out only to collect an NXDOMAIN. When it is not one pod but hundreds doing this dozens of times per second, most of the load on CoreDNS consists of queries that are guaranteed to fail. And if there is even a little UDP packet loss, every lost query picks up a 5-second retry timeout and the latency spikes become visible.

There is a widely circulated prescription that needs correcting here. The advice "just lower ndots to 1" breaks the cluster. With ndots at 1, any name with one or more dots is queried as an absolute name first, without the search list. That breaks every cross-namespace call written with a namespace suffix, such as payments.prod, because with one dot the search list no longer applies.

The safe lower bound is 2. With ndots at 2, api and payments.prod still go through search, while external domains with two or more dots are queried immediately as absolute names.

spec:
  dnsConfig:
    options:
      - name: ndots
        value: '2'

If you can change the application, there is an even more definitive method. Adding a trailing dot to an external domain makes it an absolute name and skips the entire search list.

# That one trailing dot removes four search domains
curl -sS -o /dev/null -w '%{time_namelookup}\n' https://api.stripe.com./v1/charges

Running NodeLocal DNSCache is another good mitigation. Failed queries terminate at the node-local cache instead of reaching CoreDNS, and the 5-second retries caused by UDP loss drop sharply.

NXDOMAIN, SERVFAIL, and the empty NOERROR

Reporting response codes as a blurred "DNS is broken" narrows nothing down. You have to check the status field in the dig output.

dig api.internal.example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 51204
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

ANSWER is 0, yet the status is NOERROR. This combination is the one most often misdiagnosed.

Response codeHow it looks in digWhat it actually meansCommon causesWhat to do next
NOERROR with 1 or more ANSWERNormal records printedBoth the name and the record type existNormalMove on to the next layer
NOERROR with 0 ANSWERAUTHORITY only, no answerThe name exists but has no record of that typeA exists but AAAA does not, only a CNAME with no target createdRe-check with a different query type
NXDOMAINstatus: NXDOMAINThe authoritative server confirms the name does not existTypo, record never created, a name with a search domain appendedQuery the authoritative server directly to confirm
SERVFAILstatus: SERVFAILThe resolver could not produce an answerDNSSEC validation failure, unresponsive upstream, zone load failureResolver logs and a DNSSEC check
REFUSEDstatus: REFUSEDThe server is unwilling to handle this queryRecursion not allowed, ACL block, does not host the zoneConfirm you are querying the right server

The classic situation that produces an empty NOERROR is IPv6. When only the AAAA record is missing and the application looks up AAAA first, the log can end up saying "name not found". Confirm it by naming the query type explicitly.

dig +noall +answer api.internal.example.com A AAAA CNAME
api.internal.example.com. 300 IN CNAME api-lb.internal.example.com.
api-lb.internal.example.com. 60 IN A 10.20.4.31

SERVFAIL is a problem in the resolver itself. There is a quick way to separate out whether DNSSEC validation failure is the cause.

# Ask again with validation off. If it succeeds now, it is a DNSSEC problem
dig +cd api.internal.example.com

# Follow the delegation path down to the authoritative server
dig +trace api.internal.example.com

When you see an NXDOMAIN, read the queried name exactly as written. If tcpdump shows an NXDOMAIN for a name with a search domain appended, such as api.stripe.com.prod.svc.cluster.local, that is normal behaviour and not the cause of your problem.

512 bytes, TCP fallback, and the case where only large responses fail

DNS originally could not send a response larger than 512 bytes over UDP. If it went over, the server set the TC (truncated) flag and the client resent the same query over TCP 53. EDNS0 is the extension that reduces this round trip by letting the client announce that it can accept a larger UDP buffer.

# Turn EDNS0 off and constrain it to 512 bytes
dig +notcp +bufsize=512 TXT _dmarc-report.example.com
;; Truncated, retrying in TCP mode.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 40912
;; flags: qr tc rd ra; QUERY: 1, ANSWER: 8, AUTHORITY: 0, ADDITIONAL: 0

Seeing tc in the flags is the evidence of truncation.

This is where a real-world incident is born. Older firewall configurations frequently open UDP 53 only and leave TCP 53 blocked. Normally there is no problem at all, because the responses are small. But the moment records grow or DNSSEC signatures push a response past 512 bytes, that one name stops resolving. Reports of "only this one domain does not work" sometimes have exactly this cause.

# Check directly whether TCP 53 is open
dig +tcp @10.0.0.2 example.com

# Check whether the EDNS0 path is blocked by a middlebox
dig +bufsize=4096 @10.0.0.2 dnssec-failed.org

# Check the response size itself
dig +noall +stats example.com
;; Query time: 12 msec
;; SERVER: 10.0.0.2#53(10.0.0.2) (UDP)
;; MSG SIZE  rcvd: 512

If MSG SIZE rcvd is pinned right at 512 or near 1232 and only that one name fails intermittently, you have enough grounds to suspect a size limit on the path. For reference, modern resolvers lower the advertised EDNS buffer value to 1232 bytes to avoid IP fragmentation.

Closing — name resolution does not end with dig

There is one sentence to remember. dig shows you the DNS server answer and getent shows you the answer the application will receive. If the two results differ, that difference is the location of the cause.

In practice, narrowing it down in the following order usually finishes the job in a few minutes. First run getent hosts and dig +short side by side and see whether the results diverge. If they do, check the hosts line in nsswitch.conf, /etc/hosts, and the per-link configuration of systemd-resolved. If they agree, the problem is upstream of the resolver, so use the status field in dig to separate NXDOMAIN, SERVFAIL and the empty NOERROR, and follow the delegation path with +trace if needed. If the answer is right but takes too long, count the queries that actually go out with tcpdump. The unnecessary queries generated by search domains and ndots show up there directly.

A large share of DNS incidents originate not in the server but in the client-side resolution order. Check the order first and you will not need to dig through the server at all.