DNS는 왜 UDP를 쓰는가 (그리고 안 쓸 때는 언제인가)

DNS는 인터넷에서 가장 바쁜 요청-응답 시스템을, 패킷이 도착한다고 보장하지 않는 전송 위에 올려놨다. 꼼수가 아니었다. 옳은 선택이었다 — 답이 너무 커지기 전까지는.

DNS는 하루에 2조 건쯤 되는 쿼리를 처리한다. 그런데 그걸, 패킷이 도착한다고 대놓고 보장하지 않는 유일한 전송 위에 올려놨다. UDP. 던지고, 잘되길 빌고, 확인 응답도 없고, 재전송도 없고, 연결도 없다. 인터넷 전체가 뭔가를 찾을 때 의존하는 프로토콜치고는 미친 선택처럼 들린다.

옳은 결정이었다. 그리고 이게 더 이상 충분하지 않게 된 지점의 이야기가, 부하 상태에서 DNS가 실제로 어떻게 동작하는지에 대한 이야기다.

대화 한 번의 비용

DNS 조회는 가능한 가장 작은 트랜잭션이다. 질문 하나 — “이 이름의 A 레코드가 뭐냐?” — 그리고 초기 설계에서 패킷 하나에 들어가던 답 하나. 질문도 패킷 하나에 들어간다.

여기에 TCP를 깔아보자. 뭘 묻기도 전에 TCP는 3-웨이 핸드셰이크를 요구한다. SYN, SYN-ACK, ACK. 실제 질문의 첫 바이트가 나가기 전에 순수하게 설정만 하는 왕복이 한 번 깔린다. 그리고 끝나면 연결 해제. 작은 질문 하나 보내고 작은 답 하나 받자고, 패킷 수를 세 배로 늘리고 모든 조회마다 왕복 지연을 하나씩 더 얹는 셈이다.

이걸 모든 기기가 해석하는 모든 이름에 곱하고, 그 전부를 떠안는 바쁜 리졸버에 다시 곱해보라. TCP는 서버에 연결 상태도 들고 있게 만든다. 클라이언트마다 제어 블록 하나가 핸드셰이크와 타임아웃 내내 메모리에 박혀 있다. UDP는 아무것도 안 든다. 패킷이 들어오고, 답이 나가고, 서버는 당신을 즉시 잊는다. 압도적으로 작고, 독립적이고, 멱등인 요청으로 이루어진 작업에서 상태 없음(statelessness)은 타협이 아니다. 그게 핵심 기능이다.

그래서 DNS는 UDP를 골랐고, 쿼리가 증발할 수도 있음을 받아들였고, 신뢰성은 클라이언트로 떠넘겼다. 몇 초 안에 답이 안 오면? 다시 물어라. 매번 연결을 세우는 것보다 가끔 다시 묻는 게 싸다.

512바이트 천장

UDP에는 원래 명세에 박힌 대가가 따라왔다. RFC 1035, 1987년. UDP 위의 DNS 메시지는 IP와 UDP 헤더를 빼고 512바이트로 제한된다. 전부 — 질문, 답, 추가 레코드까지 — 512바이트에 들어가야 했고, 안 들어가면 못 나갔다.

그 숫자는 임의가 아니었다. 512바이트는 어떤 IP 경로든 단편화(fragmentation) 없이 실어 나를 거라고 모두가 확신하던 크기였다. 이 밑으로만 있으면 답은 패킷 하나에 정확히 도착한다. 재조립 따위 없이.

A 레코드와 PTR 레코드뿐이던 1987년 인터넷에서 512바이트는 넉넉했다. 그 가정은 험하게 늙었다.

탈출구는 첫날부터 있었다

설계자들이 천장에 대해 순진했던 건 아니다. 그래서 폴백을 프로토콜 자체에 박아뒀다. 모든 DNS 응답 헤더에는 TC — truncated(잘림) — 라는 1비트 플래그가 있다. 답이 UDP 예산에 안 맞으면 서버는 들어갈 만큼만 보내고 TC=1을 세운다. 뜻은 이렇다. “더 있다. TCP로 다시 물어라.”

클라이언트는 잘림 비트를 보고, TCP 연결을 열고, 다시 묻는다. 이제 핸드셰이크 비용을 낸다 — 하지만 데이터그램에 안 맞는 드문 답에 대해서만이지, 잘 들어가는 수백만 건에 대해서는 아니다. 흔한 경우엔 UDP, 넘칠 때만 TCP.

한 가지 작업만은 늘 TCP를 썼다. 존 전송(zone transfer)이다. 네임서버 사이에 존 전체를 복사하는 AXFR은 모든 바이트가 순서대로 필요한 대량 데이터라, TCP로 가고 늘 그래왔다. 분업은 의도된 것이었다. 조회엔 데이터그램, 대량엔 스트림.

그러다 답이 커졌다

세 가지가 512바이트를 넘겼다.

IPv6가 AAAA 레코드를 더하면서 이름들이 v4와 v6 주소를 함께 달기 시작했다. 이메일 인증이 TXT 레코드를 존에 쌓았다. SPF, 그다음 DKIM 키, 그다음 DMARC. 그리고 보안 확장인 DNSSEC가 레코드에 암호 서명을 붙인다. 서명은 작지 않다. DNSSEC 서명 응답은 흔히 수 킬로바이트 — 옛 천장의 몇 배 — 까지 간다.

TC 비트 폴백은 여전히 동작했지만, 이제 끊임없이 발동했다. 즉 쿼리의 상당수에 TCP 재시도가 붙었다는 뜻이고, 그건 정확히 UDP를 고른 이유였던 그 오버헤드다. 천장을 올려야 했다.

EDNS0: 천장을 올리고, 벽에 부딪히다

해법은 EDNS0(RFC 6891)였다. 클라이언트가 쿼리에서, 자기가 받아들일 수 있는 UDP 응답 크기를 광고하게 해준다. 512를 한참 넘는 숫자, 흔히 4096. 그걸 본 서버는 훨씬 큰 답을 UDP에 욱여넣고 TCP 춤을 건너뛸 수 있다.

몇 년을 벌었다. 그리고 더 미묘한 문제를 만들었다. 그 큰 UDP 데이터그램들은 네트워크를 IP 단편으로 건너야 하는데, IP 단편화는 깨지기 쉽다. 미들박스와 방화벽이 단편을 버린다. UDP 포트 번호는 첫 단편에만 실려서 필터가 헷갈린다. 더 나쁜 건, 단편이 위조 통로라는 점이다. 공격자가 위조한 두 번째 단편을 응답에 끼워 넣는 경쟁을 걸 수 있다. 2020년 IETF는 RFC 8900을 냈고, 제목은 보기 드물게 직설적이다. “IP 단편화는 깨지기 쉬운 것으로 간주된다(IP Fragmentation Considered Fragile).” 단편화된 UDP에 기대 DNS를 나르는 건 모래 위에 짓는 일이었다.

DNS Flag Day 2020: UDP를 다시 작게

운영자 커뮤니티의 답은 직관에 반했다. 큰 답을 UDP로 밀어 넣으려는 시도를 그만두자는 것. DNS Flag Day 2020은 EDNS 버퍼를 1232바이트로 제한할 것을 권고했다.

이 숫자는 깔끔하게 유도된다. IPv6는 최소 MTU 1280바이트를 보장한다. 여기서 IPv6 헤더 40, UDP 헤더 8을 빼면 1232 — 사실상 어떤 경로에서든 IPv6 패킷 하나에 들어가는, 단편화 없는 가장 큰 DNS 페이로드다. UDP 응답은 1232 밑으로 두고, 그보다 크면 잘림 비트를 세워 TCP로 폴백하라. 원래 설계로의 회귀다. 더 똑똑한 천장만 달고.

이건 TCP가 실제로 거기 있어야만 동작한다.

TCP는 더 이상 선택이 아니다

수십 년간 많은 구현이 DNS-over-TCP를 있으면 좋은 것 정도로 취급했고, 어떤 네트워크는 “DNS는 UDP니까”라는 논리로 53번 포트의 TCP를 통째로 막았다. RFC 7766이 2016년에 그 논쟁을 끝냈다. 범용 DNS 구현은 UDP와 TCP를 둘 다 지원해야 한다(MUST). 그 근거는 거의 냉소적이다. 인터넷 코어의 MTU가 DNSSEC 서명 응답에 의해 일상적으로 초과되고 있으며 “RFC 1123에서 예견했던 미래가 도래했다”고 적었다. 번역하면 이렇다. DNS가 정말로 TCP를 필요로 하게 될 거라고 늘 알고 있던 그날이, 왔다.

방화벽이 아직도 DNS over TCP를 막고 있다면, 당신의 DNS는 동작하는 게 아니다. 안 맞는 첫 답이 오기 전까지만 동작하는 거다 — 그리고 아무도 확인할 생각을 안 하는 방식으로 망가진다.

비연결의 청구서

UDP의 상태 없음에는 보안 세금이 붙고, DNS는 오랫동안 그걸 내왔다. 핸드셰이크가 없으니 UDP 패킷의 출발지 주소는 위조가 식은 죽 먹기고, 그게 두 가지 고전적 공격을 가능하게 한다.

캐시 포이즈닝. 공격자가 리졸버에 위조 응답을 퍼붓고, 쿼리의 트랜잭션 ID를 추측하며, 가짜 답이 진짜보다 먼저 도착하길 노린다. 댄 카민스키(Dan Kaminsky)의 2008년 작업이 이게 얼마나 현실적인지 보여줬고, 그 임시방편 — 출발지 포트를 무작위화해 공격자가 추측해야 할 ID 공간을 넓히는 것 — 은 이제 표준이다. DNS 쿠키(RFC 7873)가 또 하나의 가벼운 검증을 더했다. DNSSEC는 답에 서명함으로써 이걸 제대로 고친다. 배포만 한다면.

반사와 증폭. 출발지에 피해자 주소를 위조해 넣고, 작은 쿼리로 큰 답을 끌어낸 뒤, 리졸버가 그 답을 피해자에게 쏘게 한다. 짧은 요청이 홍수가 된다. DNS는 바로 이 이유로 대용량 DDoS의 단골 엔진이었다. UDP를 효율적으로 만드는 바로 그 작은-쿼리-큰-답 비대칭이, 그걸 무기로 만든다.

최근의 암호화 DNS 전송 — DNS-over-TLS와 DNS-over-HTTPS — 은 TCP 위에서 돌지만, 크기가 아니라 프라이버시를 푸는 것이다. 그 과정에서 진짜 연결을 덤으로 물려받았을 뿐이다.

옳은 선택, 그리고 한참 늦은 정정

UDP는 1987년에 옳았고, 압도적 다수의 조회에 대해 지금도 옳다. 비연결 데이터그램은 한 방 질문에 한 방 답이라는 모양에 가장 잘 맞고, 현대 트래픽의 그 무엇도 흔한 경우에 대해선 이걸 바꾸지 않는다. 잘못은 결코 UDP가 아니었다. 잘못은 첫 초안부터 프로토콜에 박혀 있던 TCP 폴백을 장식품 취급한 것, 그리고 512바이트 가정이 “DNS엔 TCP가 필요 없다”로 굳어지게 둔 것이다.

언제나 필요했다. 답이 무거워질 때까지 벽에 부딪히지 않았을 뿐이다.

토론 참여

← 블로그로 돌아가기