CoreDNS ndots:5 때문에 P99가 800ms 튀던 이야기
지난주에 진짜 며칠을 날렸다. 결론부터 말하면 범인은 ndots:5 였는데, 이 얘기 어디선가 다들 한 번씩은 들어봤을 거다. 근데 막상 우리 클러스터에서 실제로 터지니까 원인 찾는 데 이틀이 걸렸다. 이번엔 그 과정에 대한 기록이다.
증상: 새벽 3시에 켜진 페이지
노드 40대 규모의 EKS 클러스터에서 결제 관련 서비스 P99 레이턴시가 갑자기 튀기 시작했다. 평소엔 60ms 언저리였는데, 어느 순간부터 800ms까지 스파이크가 찍힌다. 새벽 3시에 폰이 울려서 잠이 확 깼다.
처음 든 생각은 "또 RDS인가"였다. 요즘 우리 팀에선 뭐만 느려지면 일단 DB부터 의심하는 게 습관이 됐거든. 근데 커넥션 풀도, slow query 로그도, RDS CloudWatch도 다 깔끔했다. 애플리케이션 로그를 뒤져봐도 특별한 에러가 없었다. 근데 요청 하나하나가 이상하게 느리다.
당황스러운 건, 튀는 게 완전히 랜덤해 보였다는 점이다. 어떤 요청은 60ms, 어떤 건 800ms, 특정 pod에 몰리지도 않고, 특정 인스턴스 타입에 몰리지도 않고.
이틀 동안 삽질한 것들
일단 의심스러운 것들을 다 걸어봤다.
첫날 오전 — 애플리케이션 프로파일링. JVM heap dump 뜨고 GC 로그 봤다. Full GC는 안 돈다. Thread dump에서 뭔가 blocking되는 것도 안 보인다. 그럼 애플리케이션 문제는 아니다.
첫날 오후 — 네트워크. VPC flow log 봤다. NAT gateway 대역폭도 정상. 인스턴스 간 latency도 문제없다. Retransmit rate도 낮다.
첫날 저녁 — 아니 근데 잠깐. 요청이 튀는 pod에 kubectl exec 로 들어가서 time curl https://payment-provider.example.com 을 여러 번 돌려봤다. 대부분 200ms인데, 가끔 900ms가 나온다. 어? 아 뭐야 이거.
둘째날 오전 — DNS 의심 시작. dig 로 같은 도메인 계속 찔러봤다. 정상적일 땐 3ms, 튀는 순간 5000ms. 5초 딱 걸린다. 이 5초라는 숫자를 보는 순간 뭔가 데자뷔가 왔다. UDP DNS 타임아웃이 5초다.
여기서부터 이야기가 풀리기 시작했다.
진짜 원인: ndots + search domain 조합
Pod 안에서 /etc/resolv.conf 를 봤다. 이거다.
search default.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 도메인 붙여서 먼저 시도해봐" 라는 뜻이다. 그러니까 payment-provider.example.com (점 2개) 을 조회하려고 하면 resolver 가 이런 순서로 쿼리를 날린다.
payment-provider.example.com.default.svc.cluster.local→ NXDOMAINpayment-provider.example.com.svc.cluster.local→ NXDOMAINpayment-provider.example.com.cluster.local→ NXDOMAINpayment-provider.example.com.ap-northeast-2.compute.internal→ NXDOMAINpayment-provider.example.com→ 정답
한 번의 이름 조회를 위해 5번의 왕복이 발생한다. 각 쿼리는 A, AAAA 둘 다 보내니까 실질적으로는 10번이다. 클러스터 전체가 이런 트래픽을 CoreDNS 한테 쏟아붓고 있었다.
왜 하필 지금 터졌나
ndots:5 는 K8s 기본값이다. 몇 년째 그대로였다. 근데 왜 지금 갑자기 튀었을까.
트래픽을 다시 봤더니 지난주에 결제 관련 마이크로서비스 하나를 새로 배포했다. 이 서비스가 초당 수백 건씩 외부 API 를 호출한다. 그리고 여기 코드에 HTTP 클라이언트 재사용이 제대로 안 돼 있었다. 매 요청마다 새 커넥션을 열고, 매 커넥션마다 DNS 조회를 새로 한다. 그 결과 CoreDNS 로 가는 쿼리 수가 배포 전 대비 40배로 늘었다.
CoreDNS pod 3개가 쿼리를 다 못 받아내면서, 일부 UDP 패킷이 drop 되고, resolver 가 5초 타임아웃 후 재시도 하면서 P99 가 튀는 그림이었다. kubectl top pod -n kube-system 봤을 때 CoreDNS CPU 가 750m 까지 올라가 있었다. 평소엔 150m 인데.
시도해본 것들과 결정
세 가지 방향을 놓고 고민했다.
첫 번째: CoreDNS 스케일 아웃. 가장 쉬운 방법. HPA 로 늘리면 된다. 근데 이건 근본 원인 해결이 아니다. 트래픽 늘면 또 늘려야 하고, 무엇보다 매 요청마다 5번씩 쿼리 날리는 낭비 자체를 못 없앤다.
두 번째: 애플리케이션에서 FQDN 사용. 코드에서 payment-provider.example.com. (뒤에 점) 으로 바꾸면 resolver 가 "이건 완전한 이름이다" 로 인식해서 search 도메인을 안 붙인다. 이론적으론 깨끗한 해결책이지만, 팀 여러 개가 각자 서비스 코드 고쳐야 한다. 그리고 서드파티 라이브러리에서 도메인 조립하는 경우엔 우리가 못 건드린다.
세 번째: NodeLocal DNSCache 도입. 각 노드에 DNS 캐시 데몬셋을 띄우고, pod 는 노드로컬 캐시에 쿼리를 보낸다. NXDOMAIN 응답도 캐시되니까 다음번 같은 쿼리는 노드 밖으로 안 나간다. 게다가 conntrack 을 태우지 않는 NOTRACK 규칙까지 덤으로 붙는다.
결국 세 번째를 고랐다. ndots 자체를 건드릴 수도 있는데(ndots:2 로 줄이는 게 흔한 선택지), 이건 내부 서비스 이름(my-service 처럼 점이 없는 이름) resolve 에 영향이 있어서, 우리는 기존 코드 호환성을 유지하는 쪽을 택했다.
NodeLocal DNSCache 도입 후
Deploy 하고 30분쯤 지나니까 그래프가 확 안정됐다.
- CoreDNS 로 가는 QPS: 40k → 6k (약 85% 감소)
- P99 레이턴시: 800ms 스파이크 → 다시 60ms 대역
- CoreDNS CPU: 750m → 200m
특히 인상적이었던 건 conntrack table 사용률이 30% 넘게 떨어진 거다. NodeLocal DNSCache 의 NOTRACK 규칙 덕분에 pod → 로컬 DNS 트래픽이 conntrack 을 안 태우니까. 나중에 알았는데 이게 원래 이 컴포넌트의 주요 설계 목적 중 하나였다고 한다.
GKE 는 요즘 버전(1.34.1-gke.3720000 이상) 에선 기본으로 켜져 있고, Autopilot 에선 아예 끌 수도 없다는데, EKS 는 여전히 수동이다. 왜 기본이 아닌지 항상 의아하다.
그래서 배운 것
DNS 는 평소엔 있는지도 모르다가 터지면 정말 안 잡힌다. 그리고 대부분 애플리케이션 개발자는 자기 코드에서 . 하나 있고 없고가 얼마나 큰 차이인지 모른다. 배포 후 CoreDNS 메트릭을 항상 대시보드에 켜두는 게 정답인 것 같다.
한 가지 아직 검증 중인 게 있는데, NodeLocal DNSCache 를 쓰면서 pod 재시작 순간에 잠깐 DNS 가 안 잡히는 케이스가 있다는 이슈 리포트를 봤다. 우리 클러스터에선 아직 이 문제를 못 재현했는데, 혹시 같은 겪은 분 있으면 댓글 남겨주면 좋겠다.
다음엔 CoreDNS 자체 튜닝(캐시 TTL, forward 옵션) 도 한 번 다뤄보려고 한다. 이건 좀 더 깊게 파야 해서 시간이 걸릴 것 같다.