지난 화요일 새벽 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.conf에 ndots: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에서 이거 깔끔하게 해결한 사례 있으신 분 있으면 공유 부탁드립니다.
'IT > Kubernets' 카테고리의 다른 글
| kubectl debug로 distroless 파드에 셸 붙이기 (0) | 2026.08.03 |
|---|---|
| Karpenter vs Cluster Autoscaler, 우리 팀은 왜 Karpenter로 갔나 (0) | 2026.07.30 |
| HPA scale-down이 너무 공격적이라면, behavior 필드부터 보자 (0) | 2026.07.28 |
| KEDA로 Kafka consumer lag 오토스케일링, 이것만은 알고 시작하자 (0) | 2026.07.24 |
| kubectl debug --profile, 이거 모르면 매번 privileged 파드 만들고 있는 거다 (0) | 2026.07.20 |