IT/Kubernets

CoreDNS 죽지도 않았는데 DNS가 느려서 새벽에 깬 이야기

gfrog 2026. 7. 29. 15:19

지난 화요일 새벽 3시쯤이었다. 알람이 울렸다. P99 latency가 400ms를 넘겼다는 알림. 자다 깨서 노트북을 열었을 때 제일 먼저 든 생각은 "또 뭐가 문제냐"였다.

우리 EKS 클러스터는 노드 40대 정도 되고, 트래픽이 특별히 튀지도 않았다. Grafana에서 애플리케이션 CPU도 여유롭고, DB 지표도 정상. 근데 왜 느려졌지? 한참 파고 나서야 원인이 나왔다. DNS였다. CoreDNS 파드는 CPU 20%도 안 쓰고 있는데 DNS 응답이 느렸다. 좀 어이가 없었다.

처음엔 CoreDNS 스케일업을 의심했다

당연히 첫 반응은 "CoreDNS 파드 수 늘려야겠다"였다. 그런데 grafana를 보니 CoreDNS 자체는 문제가 없었다. coredns_dns_request_duration_seconds P99가 4ms 수준. 파드 CPU도 여유. 로그에도 별다른 에러가 없었다.

혼란스러웠다. 클라이언트(애플리케이션 파드)에서는 DNS lookup에 30초 넘게 걸리는 경우가 있는데 서버 쪽은 멀쩡하다? 그럼 요청이 도착도 안 하고 있다는 얘긴데.

여기서부터 conntrack을 의심하기 시작했다. conntrack -S로 확인해보니 insert_failed 카운터가 초당 수백 개씩 늘고 있었다. UDP DNS 요청이 kube-proxy DNAT을 거치면서 SNAT race condition으로 드랍되고, 클라이언트는 5초 timeout 후 재시도, 최악의 경우 30초. 이거였다.

ndots:5, 오래된 함정

conntrack 문제를 잡으려고 자료를 뒤지다가 다시 마주친 게 ndots:5였다. 알고는 있었지만 그냥 넘겼던 부분. 우리 클러스터도 기본값이라 /etc/resolv.confndots:5가 박혀 있다.

문제는 이게 외부 도메인 조회할 때 다섯 개의 search suffix를 다 한번씩 붙여서 물어본다는 거다. 예를 들어 api.stripe.com을 조회하면 실제로는 이런 순서로 물어본다.

api.stripe.com.default.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                            -> 정답

한 번의 외부 DNS 조회가 다섯 번의 왕복을 만든다. 사내 서비스처럼 자주 부르는 도메인이면 문제 안 되는데, 외부 API를 많이 부르는 배치 잡이 있으면 상황이 나빠진다. 우리 팀엔 마침 스토리지 프로바이더 API를 계속 호출하는 워커가 있었다.

NodeLocal DNSCache를 붙였다. 그런데.

해결책은 NodeLocal DNSCache로 정해졌다. 각 노드에 DaemonSet으로 caching agent를 띄우고, 169.254.20.10 같은 링크로컬 IP를 통해 파드가 로컬 캐시로 붙게 한다. iptables DNAT을 안 거치니 conntrack race도 없고, NXDOMAIN도 캐싱되니 ndots 폭탄도 완화된다. 이론상 완벽.

manifest를 준비했다. 대충 이런 식이다.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-local-dns
  namespace: kube-system
spec:
  template:
    spec:
      hostNetwork: true
      tolerations:
        - operator: "Exists"
      containers:
        - name: node-cache
          image: registry.k8s.io/dns/k8s-dns-node-cache:1.24.0
          resources:
            requests:
              cpu: 25m
              memory: 5Mi
          args:
            - -localip
            - 169.254.20.10,10.100.0.10
            - -conf
            - /etc/Corefile
            - -upstreamsvc
            - kube-dns-upstream

그리고 kubelet의 --cluster-dns 값을 169.254.20.10으로 바꿔야 파드가 로컬 캐시로 붙는데, EKS AL2023 노드에서 이거 바꾸는 게 생각보다 애매했다. bootstrap script를 손대야 하는데 매니지드 노드그룹에선 그게 불편하다.

우선 dnsConfig로 문제 있는 워크로드만 로컬캐시를 쓰게 해서 테스트했다.

spec:
  dnsPolicy: None
  dnsConfig:
    nameservers:
      - 169.254.20.10
    searches:
      - default.svc.cluster.local
      - svc.cluster.local
      - cluster.local
    options:
      - name: ndots
        value: "2"

여기서 슬쩍 ndots도 2로 낮췄다. 우리 워크로드에서 3단계 이상 도메인 조회할 일이 거의 없어서.

배포하고 30분쯤 관찰. P99 latency가 원래대로 30ms 대로 내려왔다. insert_failed 카운터도 안정. 됐다 싶었는데.

3일 뒤, 다시 문제

주말이 지나고 월요일 오전. 다시 알람. 이번엔 특정 노드에서만 DNS lookup이 실패한다는 알림이었다. node-local-dns 파드가 CrashLoopBackOff.

로그를 보니 이런 게 찍혀 있었다.

[ERROR] plugin/errors: 2 kube-dns-upstream.kube-system.svc.cluster.local.
  A: read udp 10.100.24.15:59371->10.100.0.10:53: i/o timeout

kube-dns-upstream이라는 별도 헤드리스 서비스로 CoreDNS로 upstream 하게 되어 있는데, 이게 응답을 안 하고 있었다. CoreDNS 파드가 다른 AZ에 있어서 그런지, 특정 노드에서만 유독 timeout이 났다.

우인 파악에 반나절 걸렸다. 결국은 그 노드에 있는 kube-proxy의 conntrack 테이블이 꽉 차서(hashsize 부족) UDP 흐름이 잘리고 있었다. NodeLocal DNSCache가 conntrack을 우회한다는 건 파드->로컬캐시 구간이지, 로컬캐시->CoreDNS upstream 구간은 여전히 서비스 IP 거치서 DNAT을 탄다.

TCP로 upstream 바꾸는 옵션(force_tcp)이 있어서 그걸로 우회했다. Corefile에 이렇게.

cluster.local:53 {
    errors
    cache {
            success 9984 30
            denial 9984 5
    }
    reload
    loop
    bind 169.254.20.10 10.100.0.10
    forward . __PILLAR__CLUSTER__DNS__ {
            force_tcp
    }
    prometheus :9253
    health 169.254.20.10:8080
}

TCP로 바꾸니 conntrack race 자체가 사라졌다. UDP처럼 요청 하나에 conntrack 항목 하나가 아니라, 연결이 살아있는 동안 재사용되니 훨첄 안정적이다. 그 뒤로는 조용해졌다.

그래서 배운 것

솔직히 이번 삽질에서 얻은 건 세 가지다.

첫째, DNS는 응답 서버가 멀쩡해도 느려질 수 있다. conntrack, ndots, resolver retry 세 개가 겹치면 서버 지표만 봐선 절대 못 잡는다. conntrack -S, 클라이언트 쪽 strace로 실제 lookup 시간 재기, 이런 저수준 관측이 필요하다.

둘째, NodeLocal DNSCache는 "붙이면 다 해결" 아니다. 파드->캐시 구간은 좋아지지만 캐시->CoreDNS 구간은 여전히 kube-proxy 세계다. force_tcp를 켜서 upstream을 TCP로 만드는 게 사실상 필수 옵션이라고 본다. 매뉴얼에는 옵션이라고 되어 있지만 프로덕션에선 안 켜면 언젠가 물린다.

셋째, ndots는 진짜 손봐야 한다. 우리 팀 워크로드처럼 외부 API 호출이 많으면 ndots를 2 정도로 낮추거나, FQDN으로 조회하도록 코드 레벨에서 수정하는 게 낫다. 인프라만 만지면 근본은 안 풀린다.

지금은 dnsConfig 방식으로 우선 문제 워크로드만 커버해두고, 클러스터 전체에 적용하는 건 다음 노드 이미지 리빌드할 때 반영하려고 한다. 매니지드 노드그룹에서 --cluster-dns 바꾸는 방법은 아직 좀 실험 중이다. 혹시 EKS AL2023에서 이거 깔끔하게 해결한 사례 있으신 분 있으면 공유 부탁드립니다.