ndots:1 한 줄 바꿨다가 클러스터 내부 DNS가 깨진 이야기

ndots가 뭐길래
지난주 화요일이었다. 외부 API 호출이 많은 워크로드 하나가 P99 레이턴시가 갑자기 700ms를 넘기 시작했다. APM 그래프를 보니 외부 API 자체는 멀쩡한데 우리 쪽 클라이언트에서 응답을 받기 전까지의 시간이 길었다. 처음엔 또 NAT Gateway냐 싶었는데, 그건 아니었다.
원인은 결국 DNS였다. 정확히는 ndots:5 였다. 그리고 그걸 ndots:1로 내리는 한 줄짜리 패치를 만들었다가, 다음날 아침에 멘탈이 나갔다.
쿠버네티스에서 파드가 뜨면 /etc/resolv.conf에 기본적으로 이런 게 들어간다.
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
ndots:5의 의미는 단순하다. 도메인에 점(.)이 5개 미만이면 일단 search domain을 다 붙여본 다음, 그래도 안 풀리면 마지막에 절대 도메인으로 시도한다.
문제는 우리가 호출하는 외부 API가 api.stripe.com 처럼 점이 2개짜리라는 거다. 그러면 CoreDNS는 이렇게 동작한다.
api.stripe.com.default.svc.cluster.local→ NXDOMAINapi.stripe.com.svc.cluster.local→ NXDOMAINapi.stripe.com.cluster.local→ NXDOMAINapi.stripe.com.→ 드디어 성공
한 번 부르는데 쿼리 4번. 그것도 3번은 다 실패. 이 워크로드는 초당 외부 API를 수십 번 호출하는 녀석이었고, CoreDNS 큐가 천천히 밀리면서 P99가 튀고 있었다.
일단 ndots:1 박아봤다
해법은 명확해 보였다. ndots를 1로 내리면 api.stripe.com은 점이 이미 1개 이상이니까 search 도메인 안 붙이고 바로 외부로 나간다. 디플로이먼트에 이렇게 넣었다.
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "1"
배포하고 30분 정도 지났을 때 P99는 80ms 대로 떨어졌다. 야 이거 깔끔하네, 하고 칼퇴했다.
다음날 아침에 슬랙이 켜졌다
다음날 아침 9시 좀 넘어서, 같은 네임스페이스에 있는 다른 마이크로서비스에서 에러가 터지기 시작했다. 사내 서비스간 통신 일부가 실패하고 있었다. 로그를 보니 payment-gateway 같은 short name으로 부르던 호출이 no such host로 떨어지고 있었다.
이 서비스는 우리가 ndots:1을 박은 워크로드와 같은 디플로이먼트 템플릿을 공유하고 있었다. 이게 함정이었다. 그쪽은 외부 API를 거의 안 부르고, 대신 같은 네임스페이스의 payment-gateway 라는 짧은 이름으로 다른 서비스를 호출했다.
ndots:1로 바꾸면서 payment-gateway 도 점이 0개니까 search domain을 붙여서 풀어준다... 까지는 좋은데, payment-gateway 처럼 점이 0개인 경우는 여전히 search 다 돈다. 여기까진 문제 없다. 진짜 문제는 다른 데 있었다.
$ kubectl exec test -- nslookup grafana-monitoring.observability
grafana-monitoring.observability 처럼 점이 1개인 cross-namespace 호출. ndots:1이라서 search 안 붙이고 바로 절대 도메인으로 쏜다. 당연히 외부에는 그런 도메인이 없으니까 NXDOMAIN. 그리고 CoreDNS의 negative cache(기본 30초)에 잡혔다. 30초간 계속 실패.
이게 다른 서비스들 호출에도 다 영향을 줬다. 같은 네임스페이스 안에서만 통신하는 서비스는 멀쩡한데, 다른 네임스페이스를 짧게 부르는 코드가 어디 한 군데에라도 있으면 거기서 다 깨졌다.
그래서 어떻게 풀었나
급한 불은 ndots:1 패치를 롤백해서 껐다. P99가 다시 튀었지만 일단 다른 서비스 죽는 것보단 낫다.
그 다음에 한 일은 두 가지였다.
첫째, ndots는 워크로드별로 다르게 잡았다. 외부 API 많이 부르는 워크로드만 ndots:2로 내렸다. api.stripe.com은 점 2개라서 ndots:2면 search 안 돌고, 같은 네임스페이스 호출은 여전히 search로 처리된다. cross-namespace는 짧게 부르는 코드를 다 찾아서 FQDN으로 바꿨다. 코드 리뷰 PR이 좀 컸지만 어쩔 수 없었다.
spec:
template:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
둘째, NodeLocal DNSCache를 도입했다. 이건 진작 했어야 하는 건데 미루고 있었다. NodeLocal이 노드 단위로 NXDOMAIN을 캐시하기 때문에, search 도메인 돌 때 발생하는 실패 쿼리가 노드 안에서 끝난다. CoreDNS까지 안 올라간다.
# nodelocaldns ConfigMap 발췌
cluster.local:53 {
errors
cache {
success 9984 30
denial 9984 5
}
forward . __PILLAR__CLUSTER_DNS__
}
denial 9984 5 가 NXDOMAIN을 5초간 캐시하는 부분이다. 우리는 이걸 좀 더 늘려서 30초로 잡았다. 외부 도메인이 갑자기 만들어질 일은 거의 없으니까.
사후 회고 같은 것
며칠 지나고 팀 내부 회고에서 몇 가지 정리했다.
DNS는 평소엔 안 보이다가 문제 터지면 다 같이 무너진다. 그래서 한 워크로드에만 영향 가는 줄 알았던 변경이 클러스터 전체로 번질 수 있다. 같은 디플로이먼트 템플릿을 공유한다거나, 같은 네임스페이스에 있다거나 하는 이유로.
그리고 ndots 같은 것은 환경별로 영향이 달라서, dev에서 잘 돌아도 prod에서 깨질 수 있다. dev는 외부 호출 별로 없는 경우가 많으니까. 다음부터는 dev에서 검증할 때 짧은 이름 호출을 일부러 섞어서 테스트해야 할 것 같다.
P99가 800ms에서 80ms로 떨어진 건 결국 NodeLocal DNSCache 효과가 컸다. ndots 패치는 보조였다. 처음부터 NodeLocal부터 깔았으면 ndots는 안 건드려도 됐을지도 모르겠다. 근데 그건 또 모르는 일이다. 둘 다 해놓으니까 마음이 편하긴 하다.
혹시 비슷한 상황 겪으신 분 있으면 댓글로 어떻게 푸셨는지 알려주시면 좋겠다. 우리도 아직 ndots:2가 정답인지 확신은 없다.