[CS300 #151] DNS 동작 원리 — 이름 하나가 주소가 되기까지의 질문 사슬
컴퓨터공학 300 주제 시리즈의 151번째 글이다. 전체 지도는 여기.
한 줄 요약
DNS 는 계층형으로 나뉜 분산 데이터베이스다. 재귀 리졸버가 루트 → TLD → 권한 서버 순으로 물어 이름의 답을 찾고, 답은 TTL 동안 곳곳에 캐시된다.
왜 필요한가
모든 네트워크 요청의 첫 단계가 이름 풀이다. 그래서 DNS 가 느리면 모든 요청이 느리고, DNS 가 틀리면 모든 요청이 엉뚱한 곳으로 간다. “배포 후 일부 사용자만 옛 서버로 간다”(TTL 캐시), “쿠버네티스 파드에서 외부 도메인 조회가 유난히 느리다”(검색 도메인과 ndots), “도메인 이전 뒤 메일만 안 온다”(MX 레코드)처럼 원인이 DNS 인 장애는 흔하고, 대개 처음엔 다른 문제로 오해받는다.
핵심 개념
이름 공간의 계층
DNS 이름은 오른쪽에서 왼쪽으로 내려가는 트리다. 기본 설계는 RFC 1034(개념)와 RFC 1035(구현·메시지 형식)에 있다.
. (루트)
┌─────────┼──────────┐
com org kr
│ │
example co.kr
│
www → www.example.com.
- 각 점(.)으로 나뉜 조각이 레이블이다. 레이블은 최대 63바이트, 전체 이름은 최대 255바이트다.
- 트리의 일부를 맡아 관리하는 단위가 영역(zone)이다.
example.com영역을 가진 권한 서버(authoritative server)가 그 안의 레코드에 대해 최종 답을 준다. - 상위 영역은 하위 영역을 위임(delegation)한다.
com서버는example.com의 답을 직접 갖지 않고 “그건 이 NS 들에게 물어라”라고 알려 준다.
누가 누구에게 묻는가
앱 → 스텁 리졸버(OS 라이브러리) → 재귀 리졸버(ISP·사내·공용 DNS)
│ ① . 에게: www.example.com? → "com 은 저 서버들"
│ ② com 에게 → "example.com 은 저 서버들"
│ ③ example.com 권한 서버에게 → "A 192.0.2.80, TTL 300"
▼
답을 캐시하고 스텁에 전달
- 스텁 리졸버는
/etc/resolv.conf의 네임서버에게 “알아서 끝까지 찾아 달라”(재귀 질의, RD 비트)고 묻는다. - 재귀 리졸버는 루트부터 반복 질의(iterative query)로 내려간다. 루트 서버는 a 부터 m 까지 13개 이름으로 운영되며, 각 이름 뒤에는 애니캐스트로 퍼진 많은 인스턴스가 있다.
- 각 단계의 답은 TTL 동안 캐시된다. 두 번째 질의부터는 대부분 캐시에서 끝난다. 존재하지 않는다는 답(NXDOMAIN)도 일정 시간 캐시된다(부정 캐싱, RFC 2308).
주요 레코드 타입
| 타입 | 의미 | 예 |
|---|---|---|
| A / AAAA | IPv4 / IPv6 주소 (AAAA 는 RFC 3596) | www A 192.0.2.80 |
| CNAME | 다른 이름의 별칭 | blog CNAME example.github.io. |
| NS | 이 영역의 권한 서버 | example.com NS ns1.example.net. |
| MX | 메일 서버와 우선순위 | example.com MX 10 mail.example.com. |
| TXT | 임의 문자열(SPF, 도메인 소유 확인 등) | |
| SOA | 영역의 메타데이터(일련번호, 부정 캐시 TTL 등) | |
| PTR | 주소 → 이름 역방향 조회 | |
| SRV | 서비스의 호스트와 포트 | 쿠버네티스 named port 조회에 쓰임 |
CNAME 은 같은 이름에 다른 레코드와 함께 둘 수 없다. 그래서 영역 꼭대기(example.com 자체)에는 CNAME 을 못 쓴다.
전송과 확장
- 기본은 UDP 53 이다. 질의·응답이 작아 한 번 왕복으로 끝나기 때문이다.
- 응답이 커서 잘리면 TC(truncated) 비트를 켜고, 클라이언트는 TCP 53 으로 다시 묻는다. EDNS(0)(RFC 6891)은 UDP 로 더 큰 응답을 받을 수 있게 확장한다.
- 전통적 DNS 는 평문이다. 경로 위에서 엿보거나 변조할 수 있어 DNS over TLS, DNS over HTTPS(DoH, RFC 8484)가 나왔다. 응답 위조는 DNSSEC 서명으로 검증한다.
메시지 형식
DNS 메시지는 12바이트 헤더(ID, 플래그, 질문·답·권한·추가 섹션 개수) 다음에 질문과 레코드가 온다. 이름은 “길이 바이트 + 레이블”의 연속으로 쓰고 0 으로 끝낸다. www.example.com 은 3www7example3com0 이 된다. 응답에서 같은 이름이 반복되면 앞에 나온 위치를 가리키는 2바이트 포인터로 압축한다.
직접 해 보기
질의 패킷을 만들고, 권한 서버가 돌려줄 법한 응답을 직접 조립한 뒤 파싱해 보자. 네트워크로 보내지는 않는다. 주소는 문서용 대역이다.
import struct, socket
def encode_name(name):
out = b""
for label in name.rstrip(".").split("."):
out += bytes([len(label)]) + label.encode()
return out + b"\x00"
def build_query(qid, name, qtype=1):
header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) # RD=1, 질문 1개
return header + encode_name(name) + struct.pack("!HH", qtype, 1) # IN 클래스
q = build_query(0x1234, "www.example.com")
print("질의", len(q), "바이트:", q.hex())
# 응답 조립: 헤더(QR=1,RD=1,RA=1) + 질문 + 답 1개(이름은 오프셋 12 를 가리키는 압축 포인터)
answer = struct.pack("!HHHIH", 0xC00C, 1, 1, 300, 4) + socket.inet_aton("192.0.2.80")
resp = struct.pack("!HHHHHH", 0x1234, 0x8180, 1, 1, 0, 0) + q[12:] + answer
def read_name(msg, off):
labels, jumped, end = [], False, None
while True:
n = msg[off]
if n & 0xC0 == 0xC0: # 압축 포인터
if not jumped: end = off + 2
off = struct.unpack("!H", msg[off:off+2])[0] & 0x3FFF; jumped = True
continue
if n == 0:
return ".".join(labels), (end if jumped else off + 1)
labels.append(msg[off+1:off+1+n].decode()); off += 1 + n
def parse(msg):
qid, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", msg[:12])
off = 12
for _ in range(qd):
name, off = read_name(msg, off); off += 4
print(f"ID=0x{qid:04x} QR={flags>>15} RCODE={flags & 0xF} 질문={name}")
for _ in range(an):
name, off = read_name(msg, off)
rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", msg[off:off+10]); off += 10
rdata = msg[off:off+rdlen]; off += rdlen
if rtype == 1:
print(f" {name} A {socket.inet_ntoa(rdata)} TTL={ttl}")
parse(resp)
print("로컬 이름 풀이:", sorted({ai[4][0] for ai in socket.getaddrinfo("localhost", 80, socket.AF_INET)}))
질의 33 바이트: 12340100000100000000000003777777076578616d706c6503636f6d0000010001
ID=0x1234 QR=1 RCODE=0 질문=www.example.com
www.example.com A 192.0.2.80 TTL=300
로컬 이름 풀이: ['127.0.0.1']
질의 패킷이 고작 33바이트다. UDP 한 번 왕복으로 충분한 이유다. 답 레코드의 이름 자리에는 c00c 2바이트만 들어갔다. “오프셋 12 에 있는 이름과 같다”는 압축 포인터다. 마지막 줄의 getaddrinfo 는 DNS 만이 아니라 /etc/hosts 등 시스템 설정 순서를 따라 이름을 푼다.
현업에서는
dig www.example.com +trace로 루트부터 권한 서버까지의 위임 사슬을,dig +norecurse로 특정 서버의 캐시만 볼 수 있다. 응답의 TTL 이 줄어드는 걸 보면 캐시에서 온 답이다.- 레코드를 바꾸기 전에 TTL 을 미리 낮춰 두면 전환이 빨라진다. TTL 3600 인 레코드를 바꾸면 최대 한 시간 동안 일부 클라이언트는 옛 주소로 간다.
- 쿠버네티스 파드의
/etc/resolv.conf에는<네임스페이스>.svc.cluster.local같은 검색 도메인과ndots:5옵션이 들어간다. 점이 5개 미만인 이름(api.example.com처럼)은 검색 도메인을 먼저 붙여 보므로 외부 조회 하나가 여러 질의로 불어난다. 외부 이름 끝에 점을 붙이거나(api.example.com.) 파드의dnsConfig로 조정한다. - 홈랩 k3s 클러스터에서도 서비스 이름은 CoreDNS 가
<서비스>.<네임스페이스>.svc.cluster.local로 답한다. 파드 안에서 이름이 안 풀리면 CoreDNS 파드 상태와 로그부터 본다.
확인 문제
- 재귀 질의와 반복 질의의 차이는 무엇이고, 각각 누가 하는가?
www.example.com의 A 레코드 TTL 이 300 이다. 레코드를 바꾼 직후 전 세계 사용자가 새 주소를 보기까지 최대 얼마나 걸릴 수 있는가(다른 캐시 정책은 무시)?- DNS 응답이 UDP 로 다 들어가지 않으면 어떻게 되는가?
- 영역 꼭대기(
example.com)에 CNAME 을 둘 수 없는 이유는? - 파드 안에서
ndots:5때문에 외부 이름 조회가 느려지는 이유와 해결책 하나를 들라.
풀이
- 재귀 질의는 “끝까지 찾아 답을 달라”는 요청으로 스텁 리졸버가 재귀 리졸버에게 한다. 반복 질의는 “아는 만큼 답하거나 다음에 물을 곳을 알려 달라”는 요청으로 재귀 리졸버가 루트·TLD·권한 서버에게 한다.
- 300초(5분). 각 캐시는 받은 시점부터 TTL 만큼 옛 답을 쓸 수 있다.
- 서버가 TC 비트를 켜서 보내고, 클라이언트는 TCP 로 다시 질의한다. EDNS(0)로 더 큰 UDP 응답을 허용할 수도 있다.
- CNAME 은 같은 이름에 다른 레코드와 공존할 수 없는데, 영역 꼭대기에는 SOA 와 NS 레코드가 반드시 있어야 하기 때문이다.
- 점이 5개 미만인 이름은 검색 도메인을 하나씩 붙여 먼저 조회하고 실패한 뒤에야 원래 이름을 묻기 때문이다. 이름 끝에 점을 붙여 완전한 이름(FQDN)으로 쓰거나
dnsConfig로 ndots 를 낮춘다.
더 읽을거리 (References)
- RFC 1034, Domain Names – Concepts and Facilities: https://www.rfc-editor.org/rfc/rfc1034.html
- RFC 1035, Domain Names – Implementation and Specification: https://www.rfc-editor.org/rfc/rfc1035.html
- Root Server Technical Operations Association: https://root-servers.org/
- Kubernetes 문서, DNS for Services and Pods: https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/