들어가며 — 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는 다음 순서를 밟습니다.
/etc/nsswitch.conf의 hosts 행을 읽어 어떤 소스를 어떤 순서로 쓸지 결정합니다.- files 모듈이면
/etc/hosts를 봅니다. - dns 모듈이면
/etc/resolv.conf를 읽어 nameserver, search, options를 적용하고 질의를 보냅니다. - resolve 모듈이면 D-Bus로 systemd-resolved에 물어봅니다.
- 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 목록을 먼저 붙여 보라"는 뜻입니다. 이 설정 덕분에 파드 안에서 api나 api.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이면 api와 payments.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만 있고 대상 미생성 | 질의 타입을 바꿔 재확인 |
| NXDOMAIN | status: NXDOMAIN | 권한 서버가 그 이름 자체가 없다고 확정 | 오타, 미생성 레코드, search 도메인 붙은 이름 | 권한 서버에 직접 질의해 확정 |
| SERVFAIL | status: SERVFAIL | 리졸버가 답을 만들지 못함 | DNSSEC 검증 실패, 상위 서버 무응답, 존 로드 실패 | 리졸버 로그와 DNSSEC 검사 |
| REFUSED | status: 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 hosts와 dig +short를 나란히 실행해 결과가 갈리는지 봅니다. 갈린다면 nsswitch.conf의 hosts 행과 /etc/hosts, 그리고 systemd-resolved의 링크별 설정을 확인합니다. 결과가 같다면 문제는 리졸버 위쪽이므로 dig의 status 필드로 NXDOMAIN, SERVFAIL, 빈 NOERROR를 갈라내고, 필요하면 +trace로 위임 경로를 따라갑니다. 답은 맞는데 오래 걸린다면 tcpdump로 실제로 나가는 질의 개수를 세어 봅니다. search 도메인과 ndots가 만들어 내는 불필요한 질의가 거기서 그대로 드러납니다.
DNS 장애의 상당수는 서버가 아니라 클라이언트 측 해석 순서에서 생깁니다. 순서를 먼저 확인하면 서버를 뒤질 필요가 없어집니다.
현재 단락 (1/164)
장애 상황에서 가장 사람을 헷갈리게 만드는 조합이 있습니다.