DNS 심화와 실무
여러 주소로 트래픽을 나누는 라운드 로빈과 위치에 따라 답을 바꾸는 GeoDNS, 장애 조치가 캐시와 TTL에 묶이는 방식을 정리합니다. DoT와 DoH가 보호하는 구간과 DNSSEC이 검증하는 대상을 구별하고, RFC 8484의 질의 URL을 직접 만들어 본 뒤, DNS 장애를 계층별로 나누어 찾는 순서를 봅니다.
DNS는 이름을 주소로 바꾸는 데서 그치지 않습니다. 같은 이름에 누가 묻느냐에 따라 다른 답을 주어 트래픽을 나누고, 서버가 죽으면 다른 주소를 알려 주는 장애 조치에도 쓰입니다. 동시에 거의 모든 통신의 첫 단계이기 때문에, 엿보거나 위조하려는 공격의 대상이 되기도 합니다. 이 글에서는 DNS로 트래픽을 다루는 방법과 그 한계, DNS를 지키는 기술, 그리고 DNS 장애를 찾는 순서를 정리합니다.
여러 주소로 나누는 라운드 로빈
한 이름에 A 레코드를 여러 개 두면 응답에 주소가 여러 개 담기고, 권한 서버나 리졸버는 응답할 때마다 순서를 돌립니다. 클라이언트가 대개 첫 주소를 쓰므로 요청이 여러 서버로 나뉘는데, 이를 DNS 라운드 로빈(round robin)이라고 합니다. 설정이 간단하지만, 로드 밸런서처럼 연결 단위로 고르게 나누지는 못합니다. 리졸버가 받은 순서를 TTL 동안 캐시하기 때문입니다.
다음 코드는 사용자가 700명, 200명, 100명인 리졸버 세 곳이 차례로 묻고, 권한 서버가 물을 때마다 순서를 돌리는 상황입니다. 각 리졸버의 사용자는 TTL 동안 캐시된 답의 첫 주소로 접속한다고 가정합니다.
from collections import Counter
addresses = ["192.0.2.10", "192.0.2.11"]
resolver_users = {"리졸버 A": 700, "리졸버 B": 200, "리졸버 C": 100}
load = Counter()
rotation = 0
for resolver, users in resolver_users.items():
answer = addresses[rotation:] + addresses[:rotation] # 권한 서버가 질의마다 순서를 돌린다
rotation = (rotation + 1) % len(addresses)
load[answer[0]] += users # TTL 동안 이 리졸버의 사용자는 첫 주소로 간다
print(dict(load)){'192.0.2.10': 800, '192.0.2.11': 200}권한 서버는 순서를 공평하게 돌렸지만, 사용자가 많은 리졸버가 받은 순서가 TTL 동안 그대로 쓰여 한 서버에 80%가 몰렸습니다. 실제로는 클라이언트가 주소를 고르는 규칙(RFC 6724)에 따라 순서를 다시 정렬하기도 하고, 한 번 맺은 연결을 오래 재사용하기도 하므로 비율은 더 예측하기 어렵습니다. 게다가 라운드 로빈은 서버가 죽었는지 모르므로, 죽은 서버의 주소도 계속 돌려줍니다. 그래서 실무에서는 라운드 로빈을 상태 확인(health check)을 갖춘 DNS 서비스나 로드 밸런서, CDN과 함께 씁니다.
묻는 곳에 따라 답을 바꾸는 GeoDNS
GeoDNS는 질문이 어디서 왔는지에 따라 다른 답을 돌려줍니다. 한국에서 물으면 서울 리전의 주소를, 미국에서 물으면 버지니아 리전의 주소를 주는 식입니다. 그런데 권한 서버가 보는 것은 사용자의 주소가 아니라 사용자를 대신해 묻는 재귀 리졸버의 주소입니다. 한국의 사용자가 미국에 있는 공개 리졸버를 쓰면 미국 리전의 주소를 받을 수 있습니다.
이를 보완하는 것이 EDNS Client Subnet(ECS, RFC 7871)으로, 리졸버가 사용자 주소의 앞부분(예를 들어 /24)을 질문에 실어 권한 서버에 전합니다. 위치는 정확해지지만 사용자의 네트워크 정보가 권한 서버에 전해지고, 리졸버는 서브넷마다 답을 따로 캐시해야 합니다. 개인정보 보호를 이유로 ECS를 보내지 않는 공개 리졸버도 있으므로, GeoDNS의 결과가 사용자의 실제 위치와 다를 수 있다는 점을 전제로 설계합니다.
라우팅 정책과 장애 조치
클라우드의 DNS 서비스는 응답을 고르는 여러 정책을 제공합니다. 모두 DNS 응답을 바꾸는 방식이므로 캐시와 TTL의 영향을 받습니다.
| 정책 | 고르는 기준 | 쓰는 곳 | 주의할 점 |
|---|---|---|---|
| 단순 | 정해 둔 레코드 | 서버가 하나인 서비스 | 장애를 알아채지 못한다 |
| 가중치 | 정해 둔 비율 | 새 버전으로 조금씩 옮기기, A/B 시험 | 캐시 때문에 순간의 비율은 흔들린다 |
| 지리적 위치 | 리졸버나 ECS의 위치 | 지역별 콘텐츠, 데이터 보관 규제 | 사용자의 실제 위치와 다를 수 있다 |
| 지연 시간 | 미리 잰 리전별 지연 | 전 세계 사용자를 받는 API | 지금 이 순간의 경로 상태와는 다를 수 있다 |
| 장애 조치 | 상태 확인의 결과 | 주 리전과 예비 리전 | TTL 동안은 예전 답이 남는다 |
| 다중 값 | 상태 확인을 통과한 여러 주소 | DNS 수준의 간단한 가용성 | 로드 밸런서를 대신하지는 못한다 |
장애 조치에 걸리는 시간은 장애를 알아채는 시간과 캐시가 만료되는 시간을 더한 것입니다. 상태 확인을 30초마다 하고 세 번 연속 실패해야 장애로 판정한다면 알아채는 데 최대 90초쯤 걸리고, 그 뒤에도 TTL 동안은 예전 주소를 캐시한 사용자가 죽은 서버로 갑니다. 장애 조치에 쓰는 레코드의 TTL을 짧게 두는 것은 이 때문인데, TTL을 너무 짧게 하면 리졸버가 자주 물어 권한 서버의 부담과 첫 조회의 지연이 늘어납니다. TTL을 지키지 않고 더 오래 캐시하는 클라이언트도 있으므로, 예전 주소로 오는 요청을 받을 방법도 함께 마련해 둡니다.
질문을 엿보지 못하게 하는 DoT와 DoH
전통적인 DNS는 UDP나 TCP의 53번 포트로 평문을 주고받습니다. 같은 와이파이에 있는 사람이나 경로의 통신사는 내가 어떤 이름을 묻는지 볼 수 있고, 응답을 바꿔치기할 수도 있습니다. 이를 막으려고 DNS 메시지를 암호화된 연결에 싣는 방법이 나왔습니다. DoT(DNS over TLS, RFC 7858)는 TCP 853번 포트의 TLS 연결로, DoH(DNS over HTTPS, RFC 8484)는 HTTPS 요청으로 DNS 메시지를 보냅니다. QUIC 위에서 보내는 DoQ(RFC 9250)도 있습니다.
DoH는 DNS 메시지를 그대로 HTTP 요청에 담습니다. GET 요청이라면 메시지를 base64url로 바꿔 dns 매개변수에 넣고, 같은 질문이 HTTP 캐시에서도 같은 URL이 되도록 메시지의 ID를 0으로 둡니다. 다음 코드는 www.example.com의 A 레코드를 묻는 메시지를 만들어 그 URL을 찍습니다.
import base64
import struct
def dns_query(name, qtype=1):
header = struct.pack("!HHHHHH", 0, 0x0100, 1, 0, 0, 0) # ID 0, 재귀 요청(RD), 질문 1개
qname = b"".join(bytes([len(label)]) + label.encode() for label in name.rstrip(".").split(".")) + b"\x00"
return header + qname + struct.pack("!HH", qtype, 1) # 타입 A, 클래스 IN
message = dns_query("www.example.com")
param = base64.urlsafe_b64encode(message).rstrip(b"=").decode() # base64url, 끝의 = 는 뺀다
print(f"{len(message)}바이트")
print("GET /dns-query?dns=" + param)33바이트
GET /dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB헤더 12바이트, 8장 1편에서 본 형태의 이름 17바이트, 타입과 클래스 4바이트로 33바이트이고, 만들어진 URL은 RFC 8484 §4.1.1의 예와 같습니다. 서버는 application/dns-message 형식의 DNS 응답을 HTTP 본문으로 돌려줍니다. 그래서 HTTP 상태가 200이어도 본문의 DNS 응답은 NXDOMAIN이나 SERVFAIL일 수 있습니다. HTTP가 성공했다는 것과 이름을 해석했다는 것은 따로 확인해야 합니다. 일부 사업자는 이와 별도로 JSON 형식의 조회 API를 제공하는데, 이것은 RFC 8484의 형식이 아닌 사업자별 형식입니다.
DoT와 DoH가 지키는 것은 내 컴퓨터와 내가 고른 리졸버 사이의 구간뿐입니다. 리졸버는 질문을 풀어서 읽어야 답을 찾을 수 있으므로 리졸버 운영자는 여전히 내 질문을 봅니다. 리졸버가 권한 서버에 묻는 구간은 따로 보호해야 하고, 그 뒤 실제로 접속하는 서버의 IP 주소나 TLS의 SNI처럼 어디에 접속하는지 드러내는 정보도 남습니다. 지금 쓰는 리졸버는 Linux에서는 resolvectl status, Windows에서는 ipconfig /all로 확인할 수 있고, BIND 9.18 이후의 dig는 +tls와 +https 옵션으로 DoT와 DoH 질의도 보낼 수 있습니다.
위조를 막는 DNSSEC
캐시 포이즈닝(cache poisoning)은 재귀 리졸버가 묻고 있는 동안 공격자가 위조한 응답을 진짜보다 먼저 보내, 리졸버가 그것을 믿고 캐시하게 만드는 공격입니다. 리졸버는 응답의 질문 이름과 타입, 16비트 ID가 질의와 맞는지 확인하는데, ID만으로는 65536가지뿐이어서 위조 응답 1000개를 보내면 1000 ÷ 65536 ≈ 1.5%의 확률로 맞힐 수 있습니다. 2008년에 이 약점을 대규모로 이용하는 방법이 알려진 뒤, 리졸버들은 질의마다 출발지 포트도 무작위로 고르게 되었습니다. 포트 28232개 가운데 하나를 고른다면 같은 1000개로 맞힐 확률은 1000 ÷ (65536 × 28232) ≈ 5.4 × 10⁻⁷로 떨어집니다.
하지만 확률을 낮추는 것과 위조를 검출하는 것은 다릅니다. DNSSEC(DNS security extensions, RFC 4033)은 레코드 집합에 전자 서명(RRSIG)을 붙여, 받은 데이터가 그 영역의 주인이 만든 것이고 바뀌지 않았음을 검증하게 합니다. 서명을 확인할 공개 키는 그 영역의 DNSKEY 레코드에 있고, 그 키가 진짜라는 것은 상위 영역에 등록된 DS 레코드가 보증합니다. 이렇게 example.com의 키는 .com이, .com의 키는 루트가 보증하는 사슬을 따라 올라가면, 리졸버에 미리 설정해 둔 루트의 키, 곧 신뢰 앵커(trust anchor)에 닿습니다.
DNS · 서로 다른 보호
전송 암호화는 구간을, DNSSEC은 데이터의 출처와 무결성을 확인한다
DoH·DoT는 클라이언트와 선택한 리졸버 사이를 암호화하지만 그 리졸버는 질의를 읽습니다. DNSSEC은 신뢰 체인에 따른 서명 검증이며 질의 내용을 숨기는 기능은 아닙니다.
위 영역의 실선은 질의와 응답의 교환이고, 아래 점선은 검증 입력입니다. 서명된 영역을 검증하는 예시이며 서명되지 않은 영역을 모두 위조로 분류하지 않습니다. 기존 표에서 각 방식의 남는 경계를 함께 읽습니다.
| 방식 | 처리하는 범위 | 남는 경계 |
|---|---|---|
| 전통 DNS | 전송 방식: UDP/TCP 53의 DNS 교환 | 기본 DNS에는 전송 암호화 없음 |
| DoT | 보통 TCP 853, TLS로 연결 구간 보호 | 선택한 리졸버는 내용을 읽음; 다른 조회 구간은 별도 |
| DoH | 보통 HTTPS 443의 DNS 교환 | DoH 서버는 내용을 읽음; 앱별 리졸버 정책 확인 |
| DNSSEC | 신뢰 앵커에서 검증한 RRset의 출처·무결성 | 서명은 질의 기밀성을 제공하지 않음; 신뢰 체인 필요 |
- 전통 DNS
- 처리하는 범위: 전송 방식: UDP/TCP 53의 DNS 교환남는 경계: 기본 DNS에는 전송 암호화 없음
- DoT
- 처리하는 범위: 보통 TCP 853, TLS로 연결 구간 보호남는 경계: 선택한 리졸버는 내용을 읽음; 다른 조회 구간은 별도
- DoH
- 처리하는 범위: 보통 HTTPS 443의 DNS 교환남는 경계: DoH 서버는 내용을 읽음; 앱별 리졸버 정책 확인
- DNSSEC
- 처리하는 범위: 신뢰 앵커에서 검증한 RRset의 출처·무결성남는 경계: 서명은 질의 기밀성을 제공하지 않음; 신뢰 체인 필요
전송 암호화와 DNSSEC 검증은 서로 다른 보호를 제공하므로 함께 사용할 수 있습니다.
검증하는 리졸버는 서명이 맞으면 응답에 ad 플래그를 붙이고, 서명된 영역인데 검증에 실패하면 위조로 보고 SERVFAIL을 돌려줍니다. 서명이 없는 영역의 답은 검증할 수 없을 뿐 위조로 취급하지는 않습니다. DNSSEC은 데이터의 출처와 무결성을 지킬 뿐 질문을 숨기지 않으므로, 질문을 엿보지 못하게 하는 DoT나 DoH와 함께 쓸 수 있습니다.
dig +dnssec example.com A # RRSIG까지 함께 받는다
dig +short DS example.com # 상위 영역에 등록된 DS
dig +short DNSKEY example.com # 영역의 공개 키
delv example.com A # 직접 검증하고 결과를 알려 준다dig +dnssec은 서명 데이터를 함께 달라는 요청일 뿐 검증은 하지 않습니다. 응답의 ad 플래그는 그 리졸버가 검증했다는 표시이므로, 그 리졸버와 리졸버까지의 경로를 믿을 수 있을 때만 의미가 있습니다. 직접 검증한 결과를 보려면 BIND에 포함된 delv를 씁니다. DNSSEC 장애의 흔한 원인은 운영자의 실수입니다. 키를 바꾸면서 상위 영역의 DS를 함께 바꾸지 않거나 서명의 유효 기간을 넘기면, 검증하는 리졸버를 쓰는 사용자에게만 SERVFAIL이 나서 일부 사용자만 접속하지 못하는 장애가 됩니다.
더 깊이: 서명으로 이름이 없다는 것을 보여 주는 방법
DNSSEC은 있는 레코드에 서명하는 것으로 끝나지 않습니다. 공격자가 NXDOMAIN을 위조해 있는 이름을 없는 것처럼 만들지 못하게 하려면, 없다는 답도 검증할 수 있어야 합니다. RFC 4034의 NSEC 레코드는 영역의 이름을 정렬했을 때 바로 다음에 오는 이름을 가리키고, 그 사이에 다른 이름이 없다는 사실에 미리 서명해 둡니다. 예를 들어 a.example.com의 NSEC가 다음 이름으로 c.example.com을 가리킨다면, b.example.com이 없다는 것을 서명으로 보여 줄 수 있습니다. 대신 NSEC를 차례로 따라가면 영역의 모든 이름을 알아낼 수 있는데, 이를 영역 걷기(zone walking)라고 합니다.
NSEC3(RFC 5155)는 이름 대신 이름의 해시를 정렬해 이어서 이름이 그대로 드러나지 않게 합니다. 원래는 해시를 여러 번 반복하고 솔트를 붙여 추측을 어렵게 하도록 했지만, RFC 9276(BCP 236)은 흔한 이름은 사전으로 쉽게 맞힐 수 있어 반복이 별 도움이 되지 않고 리졸버의 CPU만 쓴다고 보았습니다. 그래서 NSEC3를 쓴다면 추가 반복 횟수를 0으로 하고 솔트를 쓰지 않으며, NSEC3의 기능이 꼭 필요하지 않다면 NSEC를 쓰라고 정했습니다. 반복 횟수가 큰 영역은 리졸버가 서명이 없는 영역처럼 다루거나 SERVFAIL을 돌려줄 수 있으므로, 오래된 설정 예제를 그대로 따라 하면 검증하는 리졸버의 사용자만 접속하지 못하는 일이 생길 수 있습니다.
DNS 장애를 나누어 찾기
"사이트가 안 열린다"는 하나의 증상 뒤에는 여러 원인이 있습니다. 바깥쪽부터 차례로 나누어 확인합니다.
dig @ns1.example.com www.example.com A +norecurse # 1. 권한 서버의 원본은 무엇인가
dig @1.1.1.1 www.example.com A # 2. 리졸버는 무엇을, 몇 초 더 캐시하는가
getent hosts www.example.com # 3. 내 컴퓨터의 프로그램은 무엇을 받는가(Linux)1번의 답이 틀렸다면 영역의 설정이나 위임이 문제입니다. 1번은 맞는데 2번이 다르다면 리졸버의 캐시가 예전 값이므로 남은 TTL을 확인합니다. 1번과 2번은 같은데 3번이 다르다면, hosts 파일이나 운영체제의 캐시처럼 내 컴퓨터 안의 문제입니다. getent는 /etc/nsswitch.conf의 순서대로 hosts 파일과 DNS를 보므로, 애플리케이션이 받는 답과 같은 결과를 보여 줍니다. 셋이 모두 같은 주소라면 DNS가 아니라 그 주소의 서버나 경로를 봐야 합니다.
| 증상 | 흔한 원인 | 먼저 확인할 것 |
|---|---|---|
| 서버를 옮긴 뒤 예전 주소로 간다 | 캐시가 남아 있다 | 리졸버 응답의 남은 TTL, 운영체제와 브라우저의 캐시 |
| NXDOMAIN | 이름의 오타, 도메인 만료, 위임 누락 | 등록 상태, 상위 영역의 NS |
| SERVFAIL | 권한 서버 장애, DNSSEC 검증 실패 | 권한 서버의 응답, DS와 DNSKEY의 짝, 서명 기간 |
| 첫 접속만 느리다 | 리졸버의 응답 지연, 긴 CNAME 사슬 | 다른 리졸버와 비교한 응답 시간 |
| 엉뚱한 서버로 간다 | 레코드 오타, hosts 파일, 위조 | 권한 서버에 직접 물은 결과, hosts 파일 |
정리
- 라운드 로빈은 한 이름에 여러 주소를 두어 요청을 나누지만, 리졸버의 캐시 때문에 비율이 고르지 않고 죽은 서버도 알아채지 못합니다.
- GeoDNS는 묻는 쪽의 위치로 답을 고르는데, 권한 서버가 보는 것은 리졸버의 위치이며 ECS는 정확도와 개인정보를 맞바꿉니다.
- DNS의 장애 조치에 걸리는 시간은 장애를 알아채는 시간에 TTL을 더한 것이므로, 장애 조치용 레코드는 TTL을 짧게 둡니다.
- DoT와 DoH는 내 컴퓨터와 리졸버 사이의 질문을 암호화할 뿐이고, 리졸버는 질문을 읽으며, HTTP의 성공과 이름 해석의 성공은 따로 봐야 합니다.
- DNSSEC은 서명과 DS의 사슬로 데이터의 출처와 무결성을 검증하지만 질문을 숨기지는 않으며, 키 교체의 실수는 일부 사용자만 접속하지 못하는 장애를 만듭니다.
- DNS 장애는 권한 서버, 리졸버, 내 컴퓨터의 순서로 같은 이름을 물어 어느 계층이 다른지 찾습니다.
연습 문제
1. 주소 두 개, 요청 100개
어느 이름의 응답에 A 레코드가 두 개 들어 있습니다. 한 사용자의 브라우저가 이 사이트에 HTTP 요청을 100번 보낸다면 두 서버가 50번씩 처리한다고 말할 수 있나요?
해설 보기
말할 수 없습니다. 브라우저는 주소 하나를 골라 연결을 맺고, 그 연결을 계속 재사용하므로 100번의 요청이 모두 한 서버로 갈 수 있습니다. 응답의 순서도 리졸버와 운영체제의 캐시, 주소를 고르는 규칙에 따라 정해지므로 사용자마다 다릅니다. DNS가 정하는 것은 후보 목록일 뿐이어서, 실제로 어느 서버가 요청을 처리했는지는 서버의 접속 기록으로 따로 확인해야 합니다.
2. 장애 조치가 끝나기까지
상태 확인을 30초마다 하고 세 번 연속 실패하면 예비 서버로 답을 바꾸는 장애 조치 레코드가 있습니다. TTL이 60초라면, 주 서버가 죽은 뒤 TTL을 지키는 모든 사용자가 예비 서버로 옮겨 가기까지 최대 얼마나 걸리나요? TTL이 3600초였다면 어떻게 되나요?
해설 보기
주 서버가 확인 직후에 죽었다면 그 뒤 세 번의 확인이 실패해야 하므로 장애로 판정하기까지 최대 90초쯤 걸리고, 판정 직전에 예전 답을 캐시한 리졸버는 그 뒤로 60초 동안 예전 주소를 돌려줍니다. 그래서 최대 150초쯤 걸립니다. TTL이 3600초라면 90초에 3600초를 더해 한 시간이 넘게 일부 사용자가 죽은 서버로 가게 되어, 장애 조치를 해 둔 의미가 거의 없어집니다. 상태 확인의 간격과 실패 횟수도 이 시간에 들어가므로 TTL만 줄여서는 충분하지 않습니다.
3. 암호화와 서명은 서로를 대신하지 않는다
DNSSEC을 검증하는 리졸버를 DoH로 쓰고 있습니다. 서명된 영역의 응답이 검증에 실패했는데 DoH의 HTTPS 연결은 정상이었다면, 그 데이터를 믿어도 되나요? 반대로 DNSSEC 검증에 성공한 답을 평문 DNS로 받았다면, 같은 와이파이의 다른 사람에게서 내 질문이 숨겨졌다고 말할 수 있나요?
해설 보기
첫 경우의 데이터는 믿으면 안 됩니다. HTTPS 연결이 정상이라는 것은 내 컴퓨터와 리졸버 사이의 구간이 보호되었다는 뜻이고, 리졸버가 권한 서버 쪽에서 받은 데이터가 진짜인지는 DNSSEC의 검증으로만 알 수 있습니다. 검증에 실패했다면 리졸버는 SERVFAIL을 돌려주는 것이 맞습니다. 두 번째 경우도 숨겨지지 않았습니다. DNSSEC은 데이터가 바뀌지 않았음을 확인할 뿐 메시지를 암호화하지 않으므로, 평문으로 오간 질문은 같은 망의 누구나 볼 수 있습니다. 두 기술은 서로 다른 것을 지키므로 함께 써야 합니다.
다음 장 HTTP 기본 구조에서는 웹의 거의 모든 통신이 쓰는 HTTP의 요청과 응답이 어떻게 생겼고, 메서드와 URL이 무엇을 뜻하는지 살펴봅니다.