728x90

Dev.to에 올라온 "Two Bugs Later: What It Actually Took to Replace a DNS Library"를 읽고 한참 동안 예전 장애 티켓들이 떠올랐다. 글쓴이는 Go로 재귀 DNS 리졸버 hollow를 만들면서 사실상 표준인 github.com/miekg/dns를 안 쓰고 코덱을 직접 짰다. 그 과정에서 네트워크에서 도달 가능한 보안 버그 두 개를 스스로 만들어냈고, 그 얘기를 솔직하게 풀어놨다.

핵심 문장은 이거다. "라이브러리는 코드가 아니다. 누군가 이미, 올바르게, 그리고 당신에게 결정 중이라는 사실조차 알려주지 않고 내린 서른~마흔 개의 결정이다." 인프라 하는 사람이라면 이 문장이 왜 아픈지 안다. 우리가 매년 하는 일이 그거니까 — 라이브러리 갈아끼우기, 사이드카 교체, 리졸버 변경.

왜 지금 이 글인가: 의존성 교체는 코드 교체가 아니다

miekg/dns는 known importer가 16,234개다. CoreDNS가 이걸로 만들어졌다. 인기 있는 선택지가 아니라 유일한 선택지다. 그걸 지우고 나니 프로젝트는 이렇게 됐다.

  • 코덱 구현 1,341줄 / 코덱 테스트 1,925줄
  • 전체 9,984줄 Go / 11,033줄 테스트 / -race 하에서 342개 테스트
  • go.modrequire 블록 없음, go.sum 없음, vendor/ 없음

구현보다 테스트가 더 많다는 게 첫 번째 힌트다. 저자 말대로 "DNS는 1987년 포맷이고 문서는 넘치도록 있다. 코덱 쓰는 건 어렵지 않다. 소켓에 붙여도 되는 코덱을 쓰는 건 다른 일이다."

실무로 옮겨보자. 우리가 DNS 관련 스택을 건드릴 때 실제로 바뀌는 건 API 시그니처가 아니다. 리졸버 순서(/etc/nsswitch.conf), 타임아웃과 재시도 횟수, UDP 512바이트 초과 시 TCP 폴백 여부, EDNS0 버퍼 크기 협상, search domain 처리, 캐시 TTL 해석 — 이 전부가 기존 라이브러리가 대신 내려주던 결정이다. 갈아끼우는 순간 전부 내 책상 위로 돌아온다.

동작 원리: 재귀 리졸버가 실제로 걷는 길

hollow는 8.8.8.8한테 물어보고 답을 앵무새처럼 반복하는 forwarder가 아니라, 루트 서버부터 시작해 referral을 따라 내려가는 재귀 리졸버다. 원문의 trace 출력이 이 구조를 잘 보여준다.

$ hollow trace www.github.com
. (root)
+- 193.0.14.129:53  17ms udp, referral, 839 B, 13 NS + 26 glue,
   asked as WWW.GitHUB.com.
com.
+- c.gtld-servers.net. (192.26.92.30:53)  85ms udp, referral, 310 B,
   asked as wWw.gItHuB.cOm.
github.com.
+- ns-421.awsdns-52.com. (205.251.193.165:53)  35ms udp, answer, 296 B,
   asked as www.gIthuB.coM.

www.github.com. 3600 IN CNAME github.com.
github.com.       60 IN A     20.207.73.82

3 queries, 3 zones, 0 answers from cache, 136ms

여기서 눈여겨볼 게 asked as 줄이다. 쿼리 이름의 대소문자를 매 쿼리마다 랜덤화해서 nonce로 쓴다(0x20 인코딩). 응답이 그 대소문자를 정확히 그대로 되돌려주지 않으면 버린다. 스푸핑된 응답을 막는 값싼 방어다.

같은 걸 여러분 환경에서 확인하고 싶으면 dig로 루트부터 따라가 보면 된다. 쿠버네티스 노드나 아무 리눅스 박스에서 바로 실행된다.

$ dig +trace +nodnssec www.github.com A | head -20

; <<>> DiG 9.18.x <<>> +trace +nodnssec www.github.com A
.                       518400  IN      NS      a.root-servers.net.
...
;; Received 239 bytes from 198.41.0.4#53(a.root-servers.net) in 12 ms

com.                    172800  IN      NS      a.gtld-servers.net.
;; Received 1170 bytes from 192.5.5.241#53(f.root-servers.net) in 35 ms

github.com.             172800  IN      NS      ns-421.awsdns-52.com.
;; Received 640 bytes from 192.26.92.30#53(c.gtld-servers.net) in 28 ms

중요한 개념 세 개만 짚자. referral은 에러가 아니라 정상 응답이다. glue 레코드는 최적화가 아니라 부트스트랩 문제 때문에 존재한다(ns1.github.com의 주소를 알려면 github.com을 물어봐야 하는데 그러려면 ns1이 필요한 순환). resolution은 테이블 조회가 아니라 트리를 걸어 내려가는 행위다. 이 그림이 머릿속에 없으면 hex dump 앞에서 RFC는 아무 도움이 안 된다.

버그 #1 — 이름 압축 키를 잘못 잡아서 자기 방어를 깨먹다

DNS 메시지 안의 이름은 앞서 나온 동일 suffix를 가리키는 2옥텟 포인터로 대체할 수 있다. 인코더는 "이미 쓴 suffix"를 맵에 들고 있다가 나중에 가리킨다. 문제는 맵 키였다.

버전 1은 대소문자를 접었다(case-fold). DNS 이름은 대소문자 구분 없이 매칭되니 맞는 것 같지만 틀렸다. 포인터는 이름을 가리키는 게 아니라 특정 오프셋에 있는 바이트를 가리킨다. 두 철자를 같은 키로 취급하면 두 번째 이름이 첫 번째를 가리키게 되고, 결과적으로 다른 철자가 와이어로 나간다. 대부분의 프로그램에선 그냥 미관 문제지만, 이 프로젝트에선 위에서 말한 0x20 nonce 방어가 통째로 무너진다. 자기가 만든 방어를 자기 인코더가 조용히 깨는 것이다.

버전 2는 라벨을 점으로 이었다. 이건 더 나쁘다. DNS 라벨은 점을 포함할 수 있다(presentation form에선 \.로 이스케이프). 그래서 단일 라벨 a.b와 두 라벨 a, b가 같은 문자열로 납작해진다. 키는 같은데 이름은 다르다. 최종 해법은 인코딩된 옥텟 자체를 키로 쓰는 것.

func suffixKey(labels [][]byte) string {
    var b strings.Builder
    for _, l := range labels {
        b.WriteByte(byte(len(l))) // 길이 프리픽스 포함이 핵심
        b.Write(l)
    }
    return b.String()
}

이러면 두 suffix가 충돌하는 경우는 정확히 "둘 사이 포인터가 올바른 경우"뿐이다. 처음부터 기대고 있던 그 속성 그대로다.

버그 #2 — bailiwick 체크를 한 줄로 짜서 캐시 포이즈닝 구멍을 내다

이건 코덱이 아니라 "이 네임서버 말을 믿을 것인가"를 판단하는 로직이다. referral과 함께 오는 glue 주소를 전부 믿으면 안 된다. com 서버는 com 안쪽 이름에 대해서만 말할 자격이 있지, bank.example.org의 주소를 알려줄 자격은 없다. 이 검사가 bailiwick이고, 틀리면 교과서적인 캐시 포이즈닝이다. 누구나 처음 쓰는 버전은 이거다.

strings.HasSuffix(name, zone)   // 읽기엔 멀쩡하다. 구멍이다.

또 이스케이프된 점이다. evil\.com단일 라벨이고 com의 자식이 아니라 형제인데, 바이트로 보면 어쨌든 com.으로 끝난다. suffix 검사는 com referral 안에서 ns1.evil\.com의 glue를 기꺼이 받아들인다. 정확히 이 검사가 걸러내라고 존재하는 입력을 통과시킨다. 제대로 된 버전은 언이스케이프해서 라벨 단위로 오른쪽부터 비교한다.

for i := range zl {
    if !strings.EqualFold(string(nl[len(nl)-len(zl)+i]), string(zl[i])) {
        return false
    }
}

호출당 할당 하나가 더 든다. 대신 한 줄짜리 버전에 대해 실패하는 테스트가 있다. 저자의 정리가 좋다. "한 줄짜리는 누구나 처음 쓰는 버전이다. 그걸 잡아내는 건 모든 응답은 신뢰할 수 없는 입력이라고 결정한 다음, 그 가정이 실제로 강제되지 않는 지점을 코드에서 다시 찾아 나서는 일이다."

두 버그를 잡은 건 리뷰가 아니라 퍼저였다

둘 다 네트워크에서 도달 가능했고, 코드 리뷰도 못 잡았고, 이미 작성해둔 20개의 malformed 메시지 테스트도 못 잡았다. 잡은 건 Go 내장 testing.F다. 디코더에 3,840만 회 실행을 돌렸는데 크래시가 없었다. 근데 이 퍼저는 크래시를 보는 게 아니다. 라운드트립 불변식을 검증한다.

  • 디코드되는 것은 반드시 재인코딩되어야 한다
  • 다시 디코드하면 동일한 메시지여야 한다
  • 두 번째 인코딩은 첫 번째 인코딩과 바이트 단위로 동일해야 한다

첫 인코딩이 입력과 달라지는 건 허용된다(인코더가 압축 대상을 스스로 고르니까). 두 번째부터는 인코더 자신의 출력이 되돌아 들어가는 거라 허용 안 된다. 이 fixed point가 압축 키 충돌을 잡았다. 이름이 재작성되면 round-trip이 깨지니까.

포인터 루프 종료 증명 — visited set 없이

압축 포인터는 아무 데나 가리킬 수 있다. 자기 자신도, 루프도. 잘못 다루면 40바이트 패킷 하나가 리졸버를 멈춰 세운다. 흔한 방어는 "포인터는 뒤로만 가야 한다"인데 이것으로는 부족하다. 포인터가 뒤로 점프해도 라벨 워크가 커서를 다시 앞으로 밀기 때문이다. 오프셋 20의 포인터가 15를 가리키고, 15의 라벨을 걷다 보니 다시 20에 도착하는 구성은 매 스텝 "뒤로"를 만족하면서 영원히 돈다.

해법은 현재 읽기 위치가 아니라 직전 포인터의 타깃과 비교하는 것. 타깃들이 음이 아닌 정수의 순감소 수열이 되므로 워크는 반드시 종료한다. visited set도, 점프 예산도, 튜닝할 값도 없다. 그리고 정당한 입력을 하나도 거부하지 않는다. 유효한 포인터는 같은 메시지에서 앞서 나온 이름만 참조할 수 있으니까. 저자의 마무리가 정확하다. "miekg/dns는 이걸 공짜로 준다. 그리고 여기에 논쟁거리가 있었다는 사실조차 모르고 지나간다."

실무 관점: 라이브러리가 대신 내려주던 결정들과 흔한 함정

원문에서 가장 실무적인 부분은 "라이브러리가 나 대신 결정해줬을 것들" 목록이다. 전부 README의 한 줄이 됐다고 한다. 우리 환경으로 번역해보면 이렇다.

결정 지점 선택과 이유
UDP 수신 큐가 찼을 때 블로킹하지 말고 드롭. 블로킹하면 커널 수신 큐를 못 비워서 느린 쿼리 하나가 그 박스의 모든 클라이언트를 멈춘다. UDP는 원래 드롭을 허용한다.
response 비트가 켜진 채 도착한 메시지 절대 응답하지 않는다. 응답하면 서로를 가리킨 두 서버가 패킷 하나를 영원히 주고받고, source에 피해자 주소를 넣은 쪽은 트래픽 발사기를 얻는다.
드롭 로그 첫 건만 로그, 나머지는 카운트. 안 그러면 패킷 플러드가 디스크 플러드로 바뀐다. 타깃만 바뀐 같은 공격이다.
레이트 리밋 초과 refuse가 아니라 드롭. 에러도 응답이고, 응답이야말로 앰프 공격이 원한 것. 다만 두 번째마다 truncated로 응답해서 진짜 클라이언트는 TCP로 재시도해 성공하고 스푸핑된 소스는 핸드셰이크를 못 끝내게 한다.
캐시 TTL 내보낼 때 남은 초로 매번 재작성. 안 하면 1분 간격 두 조회가 같은 카운트다운을 보고한다. 눈에 보이는 거짓말이고 제일 먼저 들킨다. 측정값: example.com 콜드 268ms, 웜 0ms.

이 표를 보면서 든 생각: 우리가 쿠버네티스에서 CoreDNS 튜닝할 때 만지는 값들이 정확히 이 목록이다. cache 플러그인의 TTL 처리, forwardmax_concurrent, prefer_udp/TCP 폴백, loop 플러그인. 우리는 이 결정들을 안 내리는 게 아니라 CoreDNS가 이미 내려준 걸 쓰고 있는 것이다.

흔한 함정 1 — hosts 파일/블록리스트 파서가 localhost를 막는다

원문에서 제일 실용적인 경고다. 진짜 hosts 파일은 이렇게 시작한다.

127.0.0.1       localhost
127.0.0.1       localhost.localdomain
127.0.0.1       local
255.255.255.255 broadcasthost
::1             localhost

두 번째 필드를 그냥 집어삼키는 파서는 localhost를 블록리스트에 넣고, 자기가 돌고 있는 머신을 죽인다. 문자열 localhost만 필터링하는 것도 부족하다. localbroadcasthost가 그대로 통과한다. 컨테이너 안에서 이게 터지면 이런 에러를 보게 된다.

$ curl http://localhost:8080/healthz
curl: (6) Could not resolve host: localhost

$ getent hosts localhost
(출력 없음, exit code 2)

헬스체크가 localhost로 되어 있는 파드라면 CrashLoopBackOff로 직행이다. 원인이 "블록리스트 파서"라는 걸 떠올리기까지가 오래 걸린다.

흔한 함정 2 — bufio.Scanner가 조용히 파일을 잘라먹는다

블록리스트를 읽는 가장 뻔한 방법이 bufio.Scanner인데, 버퍼 한도를 넘는 라인을 만나면 에러를 반환하고 더 이상 진행하지 않는다. 생성된 리스트에 말도 안 되게 긴 줄 하나가 섞여 있으면 나머지 파일 전체가 잘리는데 로드는 성공으로 보고된다. 절반짜리 블록리스트가 자기를 완전하다고 믿는 상태다. 실제 에러 메시지는 이거다.

bufio.Scanner: token too long

Go 코드에서 이걸 놓치지 않으려면 Scan() 루프가 끝난 뒤 Err()를 반드시 확인해야 한다. 대부분의 예제 코드가 이 부분을 생략하고 있다는 게 함정이다.

sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 상한을 명시적으로 올린다
for sc.Scan() {
    line := strings.TrimSpace(sc.Text())
    if line == "" || strings.HasPrefix(line, "#") {
        continue
    }
    // ... 파싱
}
if err := sc.Err(); err != nil {
    return fmt.Errorf("blocklist truncated: %w", err) // 여기를 빼먹으면 조용히 반쪽
}

쿠버네티스 환경에서 좁혀 들어가기

DNS 동작이 의심될 때 실제로 쓰는 순서. 파드 안에서 /etc/resolv.conf부터 본다.

$ kubectl exec -it mypod -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

$ kubectl exec -it mypod -- dig +short api.example.com
# 응답이 느리거나 비어 있으면, search 도메인이 먼저 붙어 나가는지 확인
$ kubectl exec -it mypod -- dig +search +trace api.example.com | head

ndots:5는 점이 5개 미만인 이름에 대해 search 도메인을 먼저 다 붙여본다는 뜻이다. api.example.com은 점이 2개니까 api.example.com.default.svc.cluster.local부터 시도한다. NXDOMAIN 왕복이 여러 번 발생하고, 이게 지연과 CoreDNS 부하로 나타난다. 외부 도메인을 많이 호출하는 워크로드라면 파드 스펙의 dnsConfigndots를 낮추거나 FQDN 끝에 점을 붙이는 게 흔한 대응이다. 다만 클러스터 내부 서비스 디스커버리에 영향이 가니 서비스별로 판단해야 한다.

여기서도 원문의 교훈이 그대로 적용된다. ndots:5도, search 도메인 순서도, UDP 우선도 누군가 이미 내려놓은 결정이고, 우리는 그게 결정이었다는 걸 장애가 나야 알게 된다.

그래서 직접 만들어야 하나?

대부분의 경우 아니다. 원문 저자도 miekg/dns를 깎아내리지 않는다. 오히려 "그건 인기 있는 선택이 아니라 유일한 선택"이라고 인정하고 시작한다. 판단 기준을 정리하면 이렇다.

  • 직접 짤 만한 경우: 의존성 제로가 요구사항이거나(공급망/감사 이슈), DNS 자체가 제품의 핵심이고 라이브러리의 결정을 다르게 내려야 할 때. 그리고 퍼징으로 불변식을 검증할 역량과 시간이 있을 때. 테스트가 구현보다 많아지는 게 정상이라는 걸 받아들일 수 있어야 한다.
  • 쓰지 말아야 할 경우: 나머지 전부. 특히 bailiwick 같은 보안 경계를 다시 구현해야 한다면 재고하는 게 맞다. 원문 저자조차 한 줄짜리 HasSuffix로 구멍을 냈다.

정리

한 줄 요약: 라이브러리를 걷어낸다는 건 코드를 다시 쓰는 게 아니라, 누군가 대신 내려줬던 수십 개의 결정을 하나씩 내 이름으로 다시 내리는 일이다.

실무자에게 남는 건 이 세 가지다.

  1. 불변식 기반 퍼징은 지금 당장 도입할 값어치가 있다. Go에선 build tag도, 별도 코퍼스 저장소도, 두 번째 툴체인도 필요 없다. 시드 코퍼스는 캡처한 패킷 두 개면 시작된다. 파서를 다루는 코드가 있다면 "라운드트립하면 동일해야 한다" 같은 불변식 하나만 걸어도 리뷰가 못 잡는 걸 잡는다.
  2. 모든 외부 응답은 적대적 입력이라고 선언하고, 그 가정이 강제되지 않는 지점을 코드에서 되짚어라. HasSuffix 한 줄이 캐시 포이즈닝이 될 수 있다.
  3. 의존성을 바꿀 땐 API가 아니라 동작 계약을 먼저 문서화하라. 큐가 차면 드롭인가 블로킹인가, 레이트 리밋 초과는 refuse인가 드롭인가, TTL은 재작성하는가. 새 라이브러리가 이걸 다르게 결정했다면 그게 다음 장애다. 가능하면 섀도 트래픽으로 양쪽 응답을 비교해보고 점진적으로 롤아웃하는 게 안전하다.

마지막으로 저자가 언급한 Kurose & Ross의 Computer Networking: A Top-Down Approach. 팩트는 RFC에 있다. 책이 주는 건 모양(shape)이다. hex dump 깊숙한 곳에서 RFC는 안 구해준다. 그게 뭘 묘사하는지 이미 그림을 갖고 있는 상태가 구해준다.

참고 자료

728x90
728x90

내부 DNS는 인프라 하는 사람들에게 오랫동안 애매하게 방치된 영역이었다. 공개 DNS는 Cloudflare나 Route53 같은 걸 쓰고, 사내망은 CoreDNS나 BIND, 클라우드 안에서는 각 벤더의 Private Hosted Zone... 이렇게 서너 개 시스템이 각자 놀고 있는 경우가 대부분이다. 2026년 7월 GA된 Cloudflare Internal DNS는 바로 이 파편화를 하나의 제어 평면으로 묶겠다는 이야기다. 실무자 입장에서 이게 진짜 쓸만한 건지, 어디서 삽질하게 될지를 정리해봤다.

1. 왜 지금 이게 화제인가 — 내부 DNS 파편화라는 오래된 통증

혼합 환경을 굴려본 사람이라면 이 그림이 익숙할 거다.

  • 공개 도메인(example.com)은 외부 DNS 제공자
  • 사내망(*.corp.internal)은 온프레미스 CoreDNS 또는 BIND
  • AWS 안쪽은 Route53 Private Hosted Zone
  • GCP는 Cloud DNS의 private zone
  • 보안 정책은 또 별도 방화벽/프록시 계층에서

문제는 이 시스템들이 공통 제어 평면을 공유하지 않는다는 점이다. 특히 Split-horizon DNS(같은 호스트명을 내부/외부 사용자에게 다르게 응답하는 구성)를 하면 지옥문이 열린다. 내부용 뷰와 외부용 뷰를 각각 별도 시스템에서 관리하다가, 어느 한쪽만 레코드를 수정하는 순간 두 시스템이 어긋나기(drift) 시작한다. 그리고 그 drift는 꼭 금요일 저녁에 장애로 터진다.

Cloudflare가 미는 메시지는 명확하다. "공개 DNS, 내부 DNS, Zero Trust 정책을 같은 글로벌 네트워크와 하나의 API에서 다루자." 원문에 따르면 Enterprise 고객은 Cloudflare Gateway에 추가 비용 없이 포함된다고 한다. 이 부분은 계약 형태에 따라 다를 수 있으니 영업/공식 문서 확인은 필요하다.

2. 동작 원리 — Gateway Resolver + Internal Authoritative DNS

구조를 단순화하면 두 개의 컴포넌트로 나뉜다.

  • Gateway Resolver: 재귀 해석(recursive)과 정책 평가를 담당. 2020년에 나온 그 Gateway 맞고, 공개 해석은 1.1.1.1 인프라를 그대로 탄다. 표현식(expression) 기반으로 쿼리를 필터링하거나 다른 업스트림으로 리다이렉트할 수 있다.
  • Internal Authoritative DNS: 내부 존의 권한 있는(authoritative) 레코드를 서빙. Cloudflare가 10년 넘게 굴려온 그 authoritative 플랫폼 위에 올라간다.

여기서 다루는 객체가 세 가지인데, 이걸 이해하면 절반은 끝난다.

  • Internal Zones: 내부 리소스의 실제 레코드(앱, 서비스 엔드포인트, DB 등)를 담는 존
  • DNS Views: 특정 사용자/디바이스 집합이 봐야 할 "해석 컨텍스트". Split-horizon이 별도 시스템 없이 되는 핵심.
  • Resolver Policies: Gateway 안에 존재하며, 매칭되는 쿼리를 특정 View로 라우팅

중요한 개념이 Zone reference다. intranet.local 같은 공용 존을 한 번만 정의하고 여러 View에서 참조(reference)한다. 기존 Split-horizon이 강요하던 "레코드 복붙 → drift 발생" 패턴을 DRY하게 바꾸는 게 이 참조 구조다. 개인적으로 이 부분이 제일 실용적이라고 본다. CoreDNS로 뷰 나눌 때 존 파일 두 벌 관리하다가 한쪽만 고쳐서 사고 나는 걸 실제로 몇 번 봤기 때문이다.

쿼리 한 방이 흘러가는 경로

클라이언트 쿼리가 들어오면 이렇게 처리된다.

  1. 먼저 Gateway Resolver에 도착해서 정책이 평가된다.
  2. Resolver policy가 매칭되고 내부 View를 가리키면 → Internal Authoritative DNS로 라우팅, 해당 View의 존에서 응답.
  3. 정책이 차단(block)하면 → resolver 단에서 드롭.
  4. 둘 다 아니면 → 공개 경로로, 1.1.1.1이 공개 DNS 계층에서 해석.

여기서 편한 게, View는 내부에서 이름을 못 찾으면 공개 해석으로 폴백(fallback)할 수 있다. 즉 클라이언트는 이게 내부 이름인지 공개 이름인지 몰라도 되고, 하나의 리졸버가 둘 다 처리한다. 이걸 CoreDNS로 하려면 forwardfallthrough 조합을 손으로 짜야 하는데, 그걸 정책으로 추상화한 셈이다.

변경 전파 경로

레코드 변경은 대시보드든 Terraform이든 직접 API 호출이든 전부 동일한 DNS Records API를 탄다. 쓰기 경로가 하나라 감사(audit)와 추론이 단순해진다. 변경은 코어 데이터센터에 저장·검증된 뒤 글로벌 네트워크로 복제되고, 영향받은 캐시 엔트리가 무효화되어 TTL 만료를 기다리지 않고 몇 초 안에 반영된다고 한다. 이건 온프레미스 BIND에서 TTL 걸어놓고 "왜 아직 옛날 값 나와?" 하던 경험을 아는 사람에겐 꽤 매력적인 부분이다.

3. 기존 솔루션과 비교 — CoreDNS / Route53 PHZ / Pi-hole

실무에서 자주 쓰는 것들과 성격을 비교해보면 이렇다. (수치성 벤치마크는 원문에 없어 생략, 성격 위주로만 정리한다.)

항목Cloudflare Internal DNSCoreDNSRoute53 Private Hosted ZonePi-hole
주 용도내부/공개 통합 + Zero Trust 정책K8s/자체 내부 DNSAWS VPC 내부 DNS가정/소규모 광고차단 DNS
제어 평면Cloudflare 단일 평면직접 운영AWS 콘솔/API직접 운영
Split-horizonView + Zone reference로 네이티브view 플러그인/수동 구성VPC 단위로 사실상 horizon사실상 미지원
Zero Trust 연동Gateway 정책과 통합없음(별도 계층)없음없음
클라우드 종속없음(어느 연결이든)없음AWS 락인없음
운영 부담매니지드본인이 다 짊어짐매니지드(AWS 한정)직접 운영

정리하면, CoreDNS는 K8s 클러스터 내부용으로는 여전히 최고지만 조직 전체의 사용자·디바이스 정책까지 다루지는 않는다. Route53 PHZ는 AWS 안에서는 훌륭하지만 온프레미스와 다른 클라우드를 아우르는 순간 벽에 부딪힌다. Cloudflare Internal DNS의 차별점은 "DNS 해석 + 누가 접근 가능한가"를 같은 정책 엔진에서 결정한다는 지점이다. Pi-hole은 애초에 결이 다르니 비교표에 넣긴 했지만 엔터프라이즈 대안은 아니다.

4. 실전 구성 — Split-horizon DNS 세 단계로 세우기

원문 기준으로 설정은 크게 세 단계다. 존 생성 → 뷰 생성 → 리졸버 정책 정의. API로 가보자.

4-1. 내부 존과 첫 레코드 생성

# 1) 내부 존 생성
curl -X POST "https://api.cloudflare.com/client/v4/accounts/zones" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account": { "id": "'"$ACCOUNT_ID"'" },
    "name": "corp.internal",
    "type": "internal"
  }'

기대 출력(예시):

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": {
    "id": "023e105f4ecef8ad9ca31a8372d0c353",
    "name": "corp.internal",
    "type": "internal",
    "status": "active"
  }
}
# 2) 내부 A 레코드 추가 (예: DB 엔드포인트)
curl -X POST "https://api.cloudflare.com/client/v4/zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "A",
    "name": "db.corp.internal",
    "content": "10.0.1.50",
    "ttl": 300
  }'

4-2. DNS View 생성 후 존 연결

curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/internal_dns/views" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }'

기대 출력(예시):

{
  "success": true,
  "result": {
    "id": "9f7b3c1a55d24e6f8a0b1c2d3e4f5a6b",
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }
}

4-3. Gateway Resolver Policy로 라우팅

마지막은 Zero Trust 대시보드에서 Gateway location을 만들고, 조건을 정한 뒤 해석 방식(resolution method)을 Internal DNS View로 지정, 위에서 만든 production-view를 선택하면 된다. 여기서부터 정책에 매칭되는 쿼리는 내부 존에서 해석된다.

4-4. Terraform으로 IaC 관리

Terraform 지원이 있고, Terraform 역시 동일한 DNS Records API를 타기 때문에 전파 경로가 대시보드/직접 API와 완전히 같다. 즉 IaC로 바꿔도 별도 반영 경로를 따로 신경 쓸 필요가 없다. 아래는 개념 예시이며, 실제 리소스명·인자는 공식 프로바이더 문서 확인이 필요하다.

# 개념 예시 (실제 스키마는 provider 문서 확인 필요)
resource "cloudflare_dns_record" "db_internal" {
  zone_id = "023e105f4ecef8ad9ca31a8372d0c353"
  type    = "A"
  name    = "db.corp.internal"
  content = "10.0.1.50"
  ttl     = 300
}

연결 방식은 뭐든 된다

Gateway Resolver로 DNS 트래픽이 흐르기만 하면 된다. Cloudflare One Client(구 WARP), DoH, DoT, 표준 53번 포트 DNS, PAC 파일, Cloudflare WAN 모두 지원한다. 특히 Cloudflare WAN을 쓰면 연결된 네트워크의 모든 디바이스가 개별 클라이언트 설치 없이 내부 호스트명을 해석할 수 있다고 한다. 레거시 장비가 깔린 지점망/공장망처럼 에이전트 설치가 곤란한 환경에서 이 부분이 꽤 유용할 것으로 보인다.

5. 운영 관점 — 트레이드오프와 흔한 함정

흔한 함정 1: 폴백을 믿고 존을 어설프게 나눴을 때

View가 내부에서 못 찾으면 공개로 폴백한다는 게 편하긴 한데, 이게 함정이 될 수도 있다. 내부 존에 존재해야 할 레코드가 오타나 누락으로 빠지면, 차단되지 않고 조용히 공개 해석으로 넘어가 엉뚱한 외부 IP를 물어오는 경우가 생긴다. 이런 건 에러가 안 나서 더 위험하다. 배포 후 내부 이름은 반드시 dig로 응답 소스를 검증하는 습관을 들이는 게 좋다.

dig db.corp.internal +short
# 기대: 10.0.1.50
# 만약 공인 IP가 나온다면 → 폴백으로 새어나간 것. 존/뷰/정책 매칭 재점검

흔한 함정 2: 존 타입/이름 충돌 에러

같은 이름의 존을 중복 생성하거나, 이미 공개 존으로 등록된 이름을 내부 존으로 다시 만들려 할 때 대략 이런 형태의 응답을 만나게 된다(에러 형식은 실제와 다를 수 있으니 참고용):

{
  "success": false,
  "errors": [
    {
      "code": 1061,
      "message": "zone with name 'corp.internal' already exists"
    }
  ],
  "result": null
}

이럴 땐 새로 만들지 말고 기존 존을 여러 View에서 참조(Zone reference)하는 방향으로 가야 한다. Split-horizon을 한다고 존을 복제 생성하는 순간 Cloudflare가 굳이 없애려던 drift를 스스로 다시 만드는 꼴이다.

흔한 함정 3: 정책이 매칭 안 돼서 전부 공개로 나가는 경우

Resolver Policy의 location 조건(소스 IP 범위, 디바이스 상태 등)이 실제 클라이언트와 안 맞으면, 쿼리는 정책에 걸리지 않고 그냥 공개 경로로 간다. "내부 이름이 하나도 안 잡혀요" 증상의 대부분은 존/레코드 문제가 아니라 정책 매칭 조건 문제다. Gateway의 DNS 로그(단일 대시보드)를 열어서 해당 쿼리가 어떤 정책에 매칭됐는지부터 확인하는 게 순서다. 이게 로그가 한 군데 모인다는 통합 제어 평면의 실질적 이점이기도 하다.

트레이드오프

  • 종속성: 결국 Cloudflare 제어 평면에 DNS 해석 경로가 묶인다. 멀티 벤더 전략을 중시하는 조직은 이 점을 감안해야 한다.
  • Enterprise 중심: 원문상 Enterprise + Gateway 사용자가 대상이다. 소규모 팀이 가볍게 쓰기엔 결이 안 맞을 수 있다.
  • 클러스터 내부 DNS는 여전히 CoreDNS: K8s 내부의 서비스 디스커버리까지 이걸로 대체하는 그림은 아니다. 조직 레벨 이름 해석과 클러스터 내부 해석은 계층이 다르다.

6. 정리 — 누가 언제 써야 하나

한 줄 요약: 공개 DNS·내부 DNS·Zero Trust 정책이 각자 따로 놀아서 drift와 장애에 시달려온 조직이라면, 이 셋을 하나의 API/정책 엔진/감사 로그로 합쳐준다는 게 Cloudflare Internal DNS의 핵심 가치다.

이런 팀에 잘 맞는다.

  • 온프레미스 + 여러 클라우드가 섞여 있고, 내부 DNS가 환경마다 파편화된 조직
  • Split-horizon을 두 벌 존 관리로 버티다가 drift 장애를 겪어본 팀
  • 이미 Cloudflare Zero Trust / Gateway / WAN을 쓰고 있어 제어 평면을 하나로 모으고 싶은 곳
  • 레거시 DNS 어플라이언스 리프레시 주기와 스케일링 병목을 걷어내고 싶은 경우

반대로 K8s 클러스터 내부 해석만 필요하거나, 소규모여서 CoreDNS/자체 리졸버로 충분한 팀이라면 굳이 지금 갈아탈 이유는 크지 않다. 다만 원문 마지막이 예고하듯 "이름 해석 → 서비스 도달 → 접근 인가"를 하나의 플랫폼에서 결정하는 방향으로 통합이 더 진행될 걸로 보이니, Cloudflare 생태계를 이미 쓰는 조직이라면 흐름은 지켜볼 가치가 있다.

참고 자료

※ 본문의 API 응답·에러 메시지 일부는 형식 이해를 돕기 위한 예시이며, 실제 응답 스키마와 다를 수 있다. 정확한 스키마·리소스명·요금 조건은 공식 문서 확인이 필요하다.

728x90
728x90

Hacker News에 Bunny DNS가 무료화됐다는 글이 올라왔다. "또 무료 떡밥이냐" 싶을 수 있는데, DNS는 인프라 비용에서 의외로 뒤통수 맞기 쉬운 영역이라 한 번 짚고 갈 만하다. 5년차 굴린 입장에서 실무 관점으로 풀어본다.

1. 도입: 왜 지금 화제이고 어떤 문제를 푸는지

DNS 비용은 평소엔 존재감이 없다. 월 청구서 보면 CDN, 컴퓨트, 스토리지가 압도적이고 DNS는 몇 달러 수준이라 신경도 안 쓴다. 문제는 트래픽이 튀거나 공격받을 때 터진다.

AWS Route53을 예로 들면 쿼리당 과금이다. 공식 요금 기준으로 표준 쿼리는 100만 건당 $0.40 (첫 10억 쿼리까지). 평소엔 푼돈인데, 봇이나 DDoS성 DNS 증폭 쿼리가 들어오면 이 숫자가 갑자기 뛴다. 실제로 잘못 설정한 헬스체크나 짧은 TTL 때문에 쿼리량이 폭증해서 청구서 보고 놀라는 케이스가 있다.

Bunny가 이번에 한 건 DNS 쿼리 과금 자체를 없앤 것이다. 원문 기준 계정당 500개 도메인까지 무료 호스팅, 쿼리 제한·건당 과금 없음, smart record와 헬스 모니터링도 포함. 단 bunny.net 공통 정책인 월 $1 최소 사용료는 적용된다(DNS 자체엔 사용량 과금이 없을 뿐).

요금 구조를 거칠게 정리하면 이렇다. (수치는 각 사 공식 요금이 시점에 따라 바뀌니 직접 확인 필요)

서비스호스팅 비용쿼리 과금비고
Route53호스팅 존당 월 $0.50쿼리당 과금 있음존·쿼리 많아지면 누적
Cloudflare무료 플랜 존재기본 무료고급 기능은 유료 플랜
Bunny DNS500도메인까지 무료없음(쿼리 과금 폐지)계정당 월 $1 최소 사용료

핵심은 "쿼리 폭증 = 비용 폭증"이라는 불안 요소를 제거했다는 점이다. 비용 예측 가능성은 인프라 운영에서 생각보다 큰 가치다.

2. 핵심: Anycast와 스마트 라우팅, 어떻게 동작하는가

Bunny DNS의 출발점은 자기네 CDN을 위한 내부 라우팅 엔진이었다고 한다. 원문 표현대로 "단순 레코드 조회 테이블을 글로벌 분산 스마트 라우팅 엔진으로 업그레이드"한 것이 핵심이다.

Anycast가 뭐길래 빠른가

비유하자면 이렇다. 일반 Unicast는 "서울 본사 전화번호 하나"라서 부산 사람도 서울로 전화해야 한다. Anycast는 같은 IP(전화번호)를 전 세계 119개 지점이 동시에 광고해서, 네트워크가 알아서 "가장 가까운 지점"으로 연결해준다. 부산 사람은 부산 지점이 받는다.

실제로 Anycast가 도는지는 같은 도메인의 권한 네임서버를 다른 지역에서 traceroute 떠보면 경로가 달라지는 걸로 간접 확인할 수 있다. dig로 응답 지연을 보는 게 가장 간단하다.

$ dig @8.8.8.8 example.com A +stats

;; ANSWER SECTION:
example.com.		300	IN	A	93.184.216.34

;; Query time: 12 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Tue Jun 24 14:03:21 KST 2026
;; MSG SIZE  rcvd: 56

여기서 Query time이 권한 서버까지 포함한 응답 시간이다. Anycast가 잘 깔린 DNS는 한국에서 쏴도 보통 한 자릿수~수십 ms 안에 들어온다. 캐시 미스 상태에서 권한 서버를 직접 때려봐야 진짜 레이턴시가 나온다.

Smart record / 헬스체크

원문에서 강조하는 건 latency 데이터, 헬스체크, 심지어 JavaScript로 응답을 동적 결정한다는 점이다. 쉽게 말해 "이 사용자가 어디서 왔고 어느 오리진이 살아있는지 보고 A 레코드를 즉석에서 골라준다"는 거다. Route53의 Latency-based routing + Health check 조합과 개념적으로 비슷한데, Bunny는 이걸 무료 티어에 포함시켰다는 게 차별점이다.

3. 실무 관점: 마이그레이션, 트레이드오프, 흔한 함정

마이그레이션 체크리스트

DNS 이전은 잘못하면 도메인 전체가 죽는 작업이다. 순서가 중요하다.

  1. 현재 존 레코드 전량 백업 — 이게 1순위다.
  2. 새 DNS(Bunny)에 동일 레코드 전부 입력 (Bunny는 자동 존 스캔 + BIND 파일 업로드 지원)
  3. 이전 전에 양쪽 응답이 일치하는지 검증
  4. 이전 며칠 전부터 SOA/NS의 TTL을 짧게 낮춰두기
  5. 레지스트라에서 네임서버 변경
  6. 전 세계 전파 모니터링 (보통 수십 분~48시간)

기존 존을 통째로 뽑는 가장 확실한 방법은 AXFR(존 전송)이지만, 대부분 매니지드 DNS는 보안상 AXFR을 막아둔다. 막혀있으면 콘솔에서 BIND 파일 export를 쓰거나, 주요 레코드를 직접 dig로 긁어야 한다.

# 기존 네임서버에서 존 전체를 BIND 형식으로 받아보기 (AXFR 허용 시)
$ dig @ns-old.example.com example.com AXFR > zone_backup.txt

# AXFR이 막혀있으면 이런 에러를 만난다 ↓
$ dig @ns-old.example.com example.com AXFR

; <<>> DiG 9.18.18 <<>> @ns-old.example.com example.com AXFR
;; global options: +cmd
; Transfer failed.

; Transfer failed.가 떴다면 AXFR이 거부된 거다. 당황하지 말고 콘솔 export로 우회하면 된다.

흔한 함정 1: 네임서버를 바꿨는데 안 바뀐다

가장 자주 보는 증상. 레지스트라에서 NS를 바꿨는데 한참 옛날 IP가 돌아온다. 이건 보통 리졸버 캐시거나, 존 안에 박혀있는 NS 레코드레지스트라(상위 위임)의 NS 레코드가 불일치할 때 생긴다. 두 곳을 다 맞춰야 한다.

위임이 제대로 됐는지는 상위(부모) 존에 직접 물어봐야 한다.

# .com 권한 서버에 위임 정보를 직접 물어보기
$ dig +trace example.com NS

example.com.		172800	IN	NS	ns1.bunny.net.
example.com.		172800	IN	NS	ns2.bunny.net.
;; Received 100 bytes from 192.5.6.30#53(a.gtld-servers.net) in 145 ms

여기서 a.gtld-servers.net(상위 서버)이 알려주는 NS가 Bunny 것으로 나오면 위임은 성공한 거다. 그래도 내 PC에서 옛날 값이 나온다면 십중팔구 로컬/사내 리졸버 캐시다.

흔한 함정 2: DNSSEC 켜고 SERVFAIL

DNSSEC은 켜는 순간 사고가 잘 난다. 가장 흔한 건 DS 레코드와 DNSKEY 불일치다. DNS 사업자를 옮기면서 한쪽엔 DNSSEC이 켜져있고 DS는 옛날 키를 가리키면 전체 도메인이 검증 실패로 죽는다.

$ dig example.com A +dnssec

;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 41552
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

status: SERVFAIL이 DNSSEC 체인 깨졌을 때 전형적으로 보는 화면이다. DNS 사업자 마이그레이션 시에는 옮기기 전에 기존 쪽 DNSSEC을 끄고(레지스트라의 DS도 제거), TTL 기다린 뒤 옮기고, 새 쪽에서 다시 켜는 순서가 안전하다.

참고로 Bunny는 DNSSEC을 NSEC Black Lies 방식으로 구현했다고 한다. 전통 NSEC은 존 전체 레코드를 추측(zone walking)당할 수 있는데, Black Lies는 그걸 막으면서 검증은 유지한다. 보안에 민감한 조직이면 의미 있는 디테일이다.

트레이드오프: 무료의 대가

  • SLA 보장 수준 — DNS는 다운되면 서비스 전체가 죽는다. 무료 티어에 명시적 가용성 SLA가 어떻게 걸리는지는 계약서/약관 직접 확인 필요. "무료니까 보장 안 해도 할 말 없다"는 리스크는 항상 있다.
  • 벤더 락인 — 1-Click Acceleration으로 DNS에서 CDN을 바로 켜는 구조는 편한 만큼 Bunny 생태계에 묶인다. CDN까지 같이 쓸 거면 장점, DNS만 떼서 쓸 거면 굳이일 수 있다.
  • 지원 — 장애 났을 때 응답 속도. 미션 크리티컬이면 유료 엔터프라이즈 지원이 깔린 곳이 마음 편하다.

대안은 명확하다. AWS에 다 몰빵돼 있으면 Route53이 통합 면에서 편하고, 글로벌 무료 + 안정성 실적이면 Cloudflare가 검증돼 있다. Bunny는 CDN을 Bunny로 쓰거나, 쿼리 과금 불안에서 벗어나고 싶은 경우에 매력적이다.

TTL 설계 한 줄 팁

평상시엔 A/AAAA TTL을 300~3600초로 적당히 길게(쿼리 줄여 안정·비용↓), 마이그레이션이나 페일오버 직전 며칠은 60~300초로 낮춰서 빠른 전파를 확보하는 게 정석이다. Bunny는 쿼리 과금이 없으니 TTL을 짧게 가져가는 부담이 상대적으로 적다는 게 이번 변화의 실무적 의미다.

4. 정리: 누가 언제 써야 하나

한 줄 요약: Bunny DNS 무료화는 "쿼리 폭증 = 비용 폭증" 공포를 없앤 변화이고, CDN까지 Bunny로 묶을 거면 특히 합이 좋다.

  • 쓰면 좋은 경우: Bunny CDN/Shield를 이미 쓰거나 검토 중, 트래픽 변동성이 커서 쿼리 과금이 부담, 짧은 TTL로 빠른 페일오버가 필요한 스타트업~스케일업.
  • 굳이 안 옮겨도 되는 경우: AWS에 모든 게 묶여 Route53 통합이 더 가치 있을 때, 이미 Cloudflare로 잘 돌고 있고 옮길 이유가 없을 때, 계약상 명시적 DNS SLA가 반드시 필요한 금융/엔터프라이즈.

결론적으로 "무료니까 일단 테스트 존 하나 올려서 dig로 응답 시간 재보고 판단하라"가 현실적인 답이다. 메인 도메인부터 옮기지 말고, 안 중요한 도메인으로 먼저 굴려보자.

참고 자료

※ 본문의 요금·수치는 각 사 정책 변경에 따라 달라질 수 있으니 도입 전 공식 페이지에서 반드시 재확인하세요.

728x90

+ Recent posts