IT/Kubernets

ndots:5 한 줄이 클러스터를 무릎 꿇린 새벽

gfrog 2026. 6. 26. 09:44
SMALL

지난주 화요일 새벽 2시 47분, 슬랙 알림이 무더기로 떴다. P99 레이턴시 그래프가 평소 80ms 근처에서 600ms 위로 튕겨 올라가더니, API 5xx 비율이 0.1%에서 4%까지 치솟았다. 결제 트래픽이 한창 몰리는 시간대였다.

처음엔 또 어디서 메모리가 새는 건가 싶었다. 근데 컨테이너 메모리는 멀쩡했고, CPU도 평소 수준이었다. 그러다 어떤 마이크로서비스 로그를 까보니 거의 모든 요청에 dial tcp: lookup api.stripe.com on 10.96.0.10:53: i/o timeout 같은 줄이 박혀 있었다. DNS였다.

첫 의심: CoreDNS 파드가 죽었나

당연한 수순으로 kubectl -n kube-system get pods -l k8s-app=kube-dns 부터 쳐봤다. 다 Running. CPU도 limit 근처는 아니었다. 근데 kubectl top pod 로 보니 평소 50m 정도 쓰던 CoreDNS 파드들이 1500m을 다 빨아먹고 있었다. 그게 limit이었다.

CoreDNS 메트릭(coredns_dns_request_count_total)을 Grafana에서 열어보니 그래프가 산처럼 솟아 있었다. 평소 초당 8천 QPS 정도였는데 그 시점에 6만 QPS를 넘기고 있었다. 트래픽이 갑자기 7배가 늘 리는 없는데.

진짜 원인을 찾기까지

처음엔 누가 reverse DNS lookup이라도 폭주시킨 건가 의심했다. CoreDNS 로그 레벨을 올려 보고 싶었지만 운영 클러스터에서 그러기 부담스러워서, dnstap을 켜둔 카나리 노드에서 패킷을 떴다.

쿼리 패턴이 이상했다. api.stripe.com 한 번 부르려고 보내는 쿼리가 이렇게 생겼다.

api.stripe.com.payment.svc.cluster.local. → NXDOMAIN
api.stripe.com.svc.cluster.local.          → NXDOMAIN
api.stripe.com.cluster.local.              → NXDOMAIN
api.stripe.com.ap-northeast-2.compute.internal. → NXDOMAIN
api.stripe.com.                            → A 레코드

다섯 번이다. 하나 부르는데 다섯 번. 그리고 모든 마이크로서비스가 외부 API를 부를 때마다 이 짓을 하고 있었다. 결제 트래픽이 두 배쯤 늘어난 게 트리거였고, 거기에 5배 증폭이 더해져 CoreDNS QPS가 7배로 뛴 거다.

원흉은 /etc/resolv.confndots:5. 이게 쿠버네티스의 기본값인데, "도메인에 점이 5개 미만이면 search 도메인을 먼저 붙여서 시도하라"는 의미다. api.stripe.com 은 점이 2개니까, 4개의 search 도메인을 다 돌고 나서야 진짜 도메인으로 시도한다.

알고는 있었던 이슈다. CKA 공부할 때 본 적 있다. 근데 그동안 트래픽이 적어서 묻혀 있다가, 결제 피크 시간에 터진 거다.

무엇이 잘못 굴러갔나

여기서 황당한 건, CoreDNS의 negative cache가 작동하긴 했다는 점이다. 기본 설정에 cache 30 이 있어서 NXDOMAIN을 30초 캐싱한다. 근데 우리 환경에선 이게 거의 효과가 없었다.

이유는 두 가지였다.

첫째, 우리는 NodeLocal DNSCache를 쓰지 않고 있었다. 모든 파드가 cluster-wide CoreDNS로 직접 쿼리를 보내고 있었고, conntrack 테이블을 거치고, 네트워크 한 홉을 더 탔다. 노드가 60대쯤 되니 한 노드에서 같은 쿼리가 캐시되어도 옆 노드는 또 한 번 CoreDNS를 친다.

둘째, 우리가 자체적으로 빌드해서 쓰던 Helm chart 의 CoreDNS Corefile 에서 캐시 라인이 cache 30 으로만 적혀 있었다. 양성/음성 분리가 없었다. 30초로는 같은 NXDOMAIN을 분당 두 번씩은 다시 묻는 셈이다.

일단 멈춰 세운 방법

새벽 3시 반쯤, 가장 빠르게 할 수 있는 응급조치는 두 가지였다.

(1) Deployment의 dnsConfig로 ndots 낮추기: 외부 API를 많이 부르는 서비스에 한해 ndots를 2로 내렸다.

spec:
  template:
    spec:
      dnsConfig:
        options:
          - name: ndots
            value: "2"

이거 하나만으로도 CoreDNS QPS가 3만대로 떨어졌다. 절반 가까이 사라진 거다.

(2) CoreDNS replica를 임시로 늘림: HPA 안 걸어둔 채로 운영하고 있어서 수동으로 4 → 8 로 올렸다. 결제 피크 끝날 때까지만 살리려고.

이걸로 P99는 평소 수준으로 돌아왔다. 5xx도 0.2%까지 떨어졌다. 새벽 4시쯤 일단 잤다.

다음날 제대로 손본 것들

아침에 다시 모여서 한 작업.

NodeLocal DNSCache를 그제서야 깔았다. 이게 노드마다 link-local IP(169.254.20.10)로 DNS 캐시를 띄워놓고, 파드는 그쪽으로 먼저 묻는 구조다. 캐시 히트면 CoreDNS까진 안 간다. conntrack도 안 탄다.

Corefile도 손봤다. 양성 1시간, 음성 5분으로 분리했다.

cache {
  success 9984 3600
  denial 9984 300
}

음성 5분은 운영 환경에서 좀 공격적인 편이긴 한데, 외부 API 호스트가 갑자기 NXDOMAIN에서 A로 바뀌는 경우는 거의 없으니 감수하기로 했다. 만약 인프라 작업으로 외부 호스트가 막 바뀐다면 음성 30~60초로 다시 내릴 생각이다.

그리고 결제 서비스, 외부 결제 게이트웨이 호출이 많은 서비스 몇 개는 dnsConfig로 ndots:2 박았다. 다른 일반 서비스는 그냥 기본값으로 뒀다. 클러스터 내부 통신은 ndots:5가 더 편하기도 하고.

NodeLocal DNSCache 깐 다음 측정해 보니 클러스터 전체 DNS 쿼리가 70% 가까이 줄었다. 캐시 효과가 생각보다 컸다.

회고하면서 든 생각

ndots:5 이슈는 워낙 유명해서, 책에서나 컨퍼런스에서나 다 다루는 주제다. 작년 KubeCon 세션에서도 누가 발표했던 기억이 난다. 근데 "알고 있다"와 "우리 클러스터에 적용해뒀다"는 다른 얘기였다.

NodeLocal DNSCache는 진작에 깔았어야 했다. 클러스터 규모가 30대를 넘는 순간부터 사실상 의무에 가까운데, 우리는 그동안 운 좋게 큰 사고 없이 굴러왔다. CoreDNS HPA도 마찬가지. 평상시 QPS 8천이라고 안심하면 안 됐다.

다음에는 외부 API 호출이 많은 워크로드를 자동으로 식별해서 dnsConfig를 mutating webhook으로 주입하는 방안을 검토해보려고 한다. 매번 Deployment마다 손으로 박는 건 누가 봐도 지속 가능한 방식이 아니다.

혹시 비슷한 새벽을 보내신 분이 있으면, NodeLocal DNSCache 먼저 깔고 Corefile에 denial 캐시 분리부터 챙기시길. 그것만 해도 절반은 막을 수 있다.


태그: CoreDNS, Kubernetes, DNS, ndots, NodeLocalDNSCache, 트러블슈팅

BIG