Post

Kubernetes DNS 쿼리 증폭과 ndots 튜닝의 숨은 위험

Kubernetes DNS 쿼리 증폭과 ndots 튜닝의 숨은 위험

들어가며

운영 중인 Kubernetes 클러스터에서 외부 도메인 1건을 resolve하는 데 5번의 DNS 쿼리가 발생하고 있었다. ndots:5 기본값이 만든 증폭이다.

해결책은 ndots를 낮추는 것. AI에게 검토 요청하니 “내부 서비스 해석이 깨질 수 있다”고 경고했지만, search 알고리즘을 읽어본 바로는 장애가 날 이유가 없었다. 더 파보니 답은 libc에 있었다. glibc는 안전하고, musl(Alpine)은 실제로 깨진다.

libc는 C언어 표준 라이브러리의 약칭으로, 입출력, 메모리 관리, 문자열 처리 등 C 프로그램의 핵심 기능을 제공하는 기본 라이브러리이다. 리눅스 환경에서는 보통 GNU에서 구현한 glibc를 의미하며, 애플리케이션과 OS 커널 사이에서 시스템 호출을 처리하는 핵심적인 역할을 한다.

이 글에서는 CoreDNS의 search 동작을 따라가며 glibc와 musl의 resolver 차이를 분석하고, DNS 성능 개선 시 검토해야 할 트레이드오프를 정리한다.


CoreDNS란

CoreDNS는 Kubernetes에서 클러스터 내부 DNS를 담당하는 컴포넌트로, kube-system 네임스페이스에 존재한다. 자체 DNS 서버가 아니라 플러그인 체인 기반의 미들웨어이다.

1
[App Pod] - UDP > [CoreDNS] - UDP > [UpstreamDNS]

DNS Query를 수신하면 DNS Zone을 매칭하고, 해당 Zone에 정의된 Plugin Chain을 순서대로 실행하여 적절한 플러그인이 응답을 생성한다.


CoreDNS 동작 과정을 뜯어보기

CoreDNS의 핵심 설정 파일 3가지

#파일내용위치변경 시점
1CorefileZone, Plugin Chain, fallthrough 등 CoreDNS 전체 동작 정의coredns ConfigMap → /etc/coredns/Corefile런타임 (reload 가능)
2CoreDNS의 resolv.confCoreDNS가 바라보는 Upstream DNS 정의CoreDNS Pod의 /etc/resolv.confPod 생성 시 (reload 불가)
3App Pod의 resolv.confsearch 순회 동작 정의 (ndots, search)App Pod의 /etc/resolv.confPod 생성 시 (reload 불가)

Default Corefile (kubeadm 기준)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.:53 { # .:53 == Zone
    # 하위에 정의된 것들이 각각 Plugin들이고, 이 조합을 Plugin Chain이라 부른다.
    # 정의한 순서와 무관, 정해진 순서대로 실행된다.
    errors
    health {
       lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
       pods insecure
       fallthrough in-addr.arpa ip6.arpa
       ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
       max_concurrent 1000
    }
    cache 30 {
       disable success cluster.local
       disable denial cluster.local
    }
    loop
    reload
    loadbalance
}

CoreDNS와 App Pod의 resolv.conf

kubernetes Pod의 dns 설정은 spec.dnsPolicy에 존재하고, 기본값은 ClusterFirst이다. node의 resolv.conf를 그대로 가져감을 의미한다. 기본 값은 다음과 같다.

1
2
3
4
# cat /run/systemd/resolve/resolv.conf
nameserver 10.96.0.10
search <namespace>.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
  • nameserver: kube-dns Service의 ClusterIP
  • search: 자동 부여된 3개 도메인 (Pod의 namespace 기준)
  • ndots: 5 (Kubernetes 기본값)


Pod의 DNS Query 처리 흐름을 최종 정리하면…

google.com이라는 dns query를 날린다고 가정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[1. App Pod]
    │
    │ 1. 앱에서 DNS 이름 사용 (ex. "google.com")
    │
    │ 2. glibc/musl이 /etc/resolv.conf의 ndots + search 기반으로 **FQDN 생성**"."으로 끝나는 도메인 → 원본 도메인으로 시도 (glibc)
UDP │    점 < ndots → search 도메인을 먼저 붙여서 시도, 원본은 마지막 (glibc/musl)
    │    점 ≥ ndots → 원본 먼저 시도, 실패 시 search 시도 (glibc)
    │    ※ 외부 도메인 1회 질의 시 최대 3~4회 NXDOMAIN 발생 (search 순회) (glibc/musl)
    │
    │ 3. 각 FQDN마다 CoreDNS에 UDP 전송 (NOERROR 받으면 중단)
    │
    ▼
[2. iptables DNAT + conntrack]
  ClusterIP(가상) → CoreDNS Pod IP(실제) 변환
    │
UDP │
    │
    ▼
[3. CoreDNS]
  1. 유효성 검증
  2. Zone 매칭 (현재 "." 하나만 존재)
  --** 중요 **--
  3. Plugin Chain 실행
     - cache      → 적중 시 즉시 반환 / 미스 시 다음
     - kubernetes → cluster.local 매칭 시 K8s API Watch 기반 인메모리 인덱스에서 해석 → NOERROR 또는 NXDOMAIN
                  cluster.local 아닌 경우 → 다음
                  (kube-apiserver의 watch API로 Service / Endpoints / EndpointSlice 객체를 구독해서 메모리에 캐시)
     - forward    → Upstream DNS로 전달 → NOERROR 또는 NXDOMAIN 또는 SERVFAIL
  4. 응답을 cache에 저장 후 App Pod에 반환
    │
UDP │
    │
    ▼
[4. App Pod]
  NOERROR → 성공, 연결 시작
  NXDOMAIN → glibc가 다음 FQDN으로 **재시도** (search 순회 계속)

이때, NXDOMAIN을 받고 다음 FQDN으로 재시도하는 반복 로직으로 search 쿼리 증폭이 발생한다.


ndots 조정으로 CoreDNS 성능 개선하기

ndots:2로 낮추면 점이 2개 이상인 도메인은 search 순회 없이 원본부터 시도한다. 대부분의 외부 도메인(google.com, api.example.com 등)이 여기에 해당하므로, 외부 도메인의 비율이 높은 클러스터의 경우 ndots를 조정하는 것으로 큰 개선 효과를 볼 수 있다.

ndots 조정 방법 - Pod의 dnsConfig 수정

1
2
3
4
5
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"


그러나 AI한테 물어보면 장애 가능성이 있다고 한다. 왜 장애 가능성이 있다는걸까?

ndots를 낮추는 변경을 AI에게 검토 요청했을 때, “클러스터 내부 서비스 해석이 실패할 수 있다”고 경고했다. 이 경고의 논리는 다음과 같다.

  • ndots:5에서 my-svc.default(점 1개)를 질의하는 경우:
1
2
3
점 1 < ndots 5 → search 먼저
my-svc.default.<ns>.svc.cluster.local  → NXDOMAIN
my-svc.default.svc.cluster.local       → NOERROR  ✓
  • ndots:1로 낮춘 경우 동일 도메인을 질의하면:
1
2
3
점 1 ≥ ndots 1 → 원본 먼저
my-svc.default                         → NXDOMAIN (외부에 없음)
... 그 다음은?

그러나 알아본 search 알고리즘은 점 1 ≥ ndots 1의 경우 원본 먼저 query 후 fallback 시 search 알고리즘을 이어서 수행하여 장애가 발생하지 않는 구조 d였다 !!! 그래서 더 알아보니 컨테이너 이미지의 libc에 따라 search 알고리즘이 다른 것을 확인했다.


glibc와 musl

search/ndots를 해석하는 주체는 컨테이너 이미지의 libc(resolver)이다. 어떤 libc를 쓰느냐에 따라 실제 질의 패턴이 달라진다.

1. glibc (Debian, Ubuntu, CentOS, RHEL, Amazon Linux 등)

  • 원본 먼저 시도 → NXDOMAIN → search fallback 수행 → 결국 성공
  • 쿼리가 1~2회 추가 발생하지만 해석 자체는 성공한다
1
2
3
4
my-svc.default            → NXDOMAIN
↓ search fallback
my-svc.default.<ns>.svc.cluster.local  → NXDOMAIN
my-svc.default.svc.cluster.local       → NOERROR  ✓
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
 * 이름에 점(.)이 충분히 많으면, 일단 'as is'(원형 그대로) 시도해본다.
 */
saved_herrno = -1;
if (dots >= statp->ndots || trailing_dot) {
    // dots가 ndots 보다 많거나 trailing dot이 있으면, search list 적용 없이 입력된 이름 그대로 질의 시도
    // trailing dot이란 맨 마지막에 붙는 `.`으로 FQDN을 의미
    ret = __res_context_querydomain (ctx, name, NULL, class, type, ...);

    // 성공했거나(ret > 0), trailing dot이 있는 경우 즉시 결과 반환
    // (trailing dot은 명시적 FQDN이므로 추가 search 시도를 하지 않음)
    if (ret > 0 || trailing_dot ...)
        return (ret);

    // 실패한 경우 h_errno 값을 저장해 두었다가, search 시도까지 모두 실패하면 이 값으로 복원하기 위함 (원래 'as is' 시도의 에러를 보존)
    saved_herrno = h_errno;
    tried_as_is++;  // 'as is' 시도 횟수 카운트 (중복 질의 방지용)
    ...
}

/*
 * 다음 조건 중 하나라도 만족하면 최소 한 번의 search를 수행한다:
 *  - 점이 하나도 없고 RES_DEFNAMES 옵션이 켜져 있는 경우
 *    (예: "myhost" -> "myhost.example.com" 으로 default domain 부착)
 *  - 점이 하나 이상 있고, trailing dot은 없으며, RES_DNSRCH 옵션이 켜진 경우
 *    (예: "host.sub" -> search list의 각 도메인을 순회하며 부착)
 */
if ((!dots && (statp->options & RES_DEFNAMES) != 0) ||
    (dots && !trailing_dot && (statp->options & RES_DNSRCH) != 0)) {
    int done = 0;

    // search list의 각 도메인을 순회 (resolv.conf의 search 지시자)
    for (size_t domain_index = 0; !done; ++domain_index) {
        // 현재 인덱스에 해당하는 search domain을 가져옴
        const char *dname = __resolv_context_search_list (ctx, domain_index);

        // search list 끝에 도달하면 루프 종료
        if (dname == NULL) break;

        searched = 1;  // search를 한 번이라도 시도했음을 표시
        ...
        // name + "." + dname 형태로 결합하여 질의
        // (예: name="host", dname="example.com" -> "host.example.com")
        ret = __res_context_querydomain (ctx, name, dname, class, type, ...);
    }
}


2. musl libc (Alpine)

  • 경량 설계로 resolv.conf 옵션 지원이 제한적이다
  • dots ≥ ndots이면 내부적으로 *search = 0으로 set하여 search list를 아예 건너뛴다
  • 원본만 시도 → NXDOMAIN → ★ 해석 실패 (재시도 없음)
1
2
my-svc.default            → NXDOMAIN
                          ✗ 끝. search 시도 안 함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
static int name_from_dns_search(struct address buf[static MAXADDRS],
                                char canon[static 256],
                                const char *name, int family)
{
    char search[256];        // resolv.conf의 search list 버퍼 (공백 구분 도메인 목록)
    struct resolvconf conf;  // resolv.conf 파싱 결과 (ndots, nameserver 등)
    size_t l, dots;
    char *p, *z;

    if (__get_resolv_conf(&conf, search, sizeof search) < 0) return -1;

    /* 이름의 점(.) 개수를 센다.
     * ndots 임계값 이상이거나 trailing dot이 있는 경우 search를 건너뛴다. */
    for (dots=l=0; name[l]; l++) if (name[l]=='.') dots++;
    if (dots >= conf.ndots || name[l-1]=='.') *search = 0;
    // 이 경우 별도 분기 없이 search를 빈 문자열로 만들어서 아래 for 루프의 첫 검사에서 즉시 탈출하도록 만든다.

    ...
    // canon 버퍼에 "name." 형태로 복사 (뒤에 search domain을 이어붙이기 위함)
    // 예: name="host", l=4 -> canon = "host."
    memcpy(canon, name, l);
    canon[l] = '.';

    /* search list를 공백 단위로 순회하며 각 도메인을 시도.
     *
     * ★ 핵심 동작: 위에서 *search = 0 으로 만든 경우,
     *   첫 반복에서 *p == 0 이므로 isspace 루프도 통과하고
     *   z==p 조건에 걸려 즉시 break → search 시도 0회.
     */
    for (p=search; *p; p=z) {
        // 선행 공백 스킵 (search list는 공백/탭으로 구분됨)
        for (; isspace(*p); p++);
        // 현재 토큰의 끝(z)을 찾음 — 다음 공백 또는 문자열 끝까지
        for (z=p; *z && !isspace(*z); z++);
        // 빈 토큰이면 종료 (공백만 남았거나 search 끝 도달)
        if (z==p) break;

        // 결합 결과가 canon 버퍼(256바이트)에 들어가는지 확인
        // l+1: "name." 부분, z-p: search domain 길이, +1: NUL 종결자
        if (z-p < 256 - l - 1) {
            // canon = "name." + "<search domain>"
            // 예: "host." + "example.com" -> "host.example.com"
            memcpy(canon+l+1, p, z-p);
            canon[z-p+1+l] = 0;

            // 결합된 FQDN으로 DNS 질의 시도
            // 성공(cnt > 0)하면 즉시 반환 — 첫 매칭 도메인에서 끝
            int cnt = name_from_dns(buf, canon, canon, family, &conf);
            if (cnt) return cnt;
        }
    }

    // search list가 비어있었거나 모두 실패한 경우의 fallback.
    // canon[l] = 0 으로 trailing dot을 NUL로 덮어 원래 이름으로 복원
    // 예: "host." -> "host"
    canon[l] = 0;

    // search 없이 입력된 이름 그대로 한 번 질의하고 종료.
    // ★ glibc와 달리 absolute(as-is) 질의는 정확히 1회만 수행됨.
    return name_from_dns(buf, canon, name, family, &conf);
}


musl에서도 search 알고리즘의 fallback을 지원하면 안 되는걸까 ?

관련 논의는 musl 메일링 리스트에 여러 번 올라왔고, 메인테이너 Rich Felker는 일관되게 거절했다. 대표 사례는 2019년 Andrey Arapov의 문제 제기.

  • 요청: DNS 서버 설정이 조금만 잘못돼도 musl 리졸버는 SERVFAIL 한 번에 멈춘다. FQDN을 직접 시도하지도 않는다. “이거 의도된 동작인가? 고칠 수 없나?”
  • Rich Felker의 답변: “한 lookup이 SERVFAIL로 끝나면 결과가 indeterminate하다. caller에게 에러로 보고해야지, fallback하면 안 된다. 그렇지 않으면 nameserver의 transient failure에 따라 lookup 결과가 달라진다.”

핵심은 결과의 결정성(determinism). fallback을 허용하는 순간 같은 쿼리가 매번 다른 답을 줄 수 있고, 이는 공격자가 transient failure를 유도해 결과를 조작할 여지를 만든다. musl이 search 기능을 도입할 때부터 “다른 구현의 위험한 동작은 재현하지 않는다”가 전제 조건이었다.

  • 결론: 이 문제 안 만나려면 ndots=1로 두고 짧은 이름 의존하지 마라


kind로 glibc vs musl 차이 직접 확인 해보기

1. 클러스터 생성

1
2
3
4
5
6
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: dns-poc
nodes:
  - role: control-plane
1
kind create cluster --config kind-config.yaml


2. CoreDNS 로그 활성화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# `coredns-log-patch.yaml`
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns
  namespace: kube-system
data:
  Corefile: |
    .:53 {
        log # log plugin 활성화
        errors
        health {
           lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        prometheus :9153
        forward . /etc/resolv.conf {
           max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }
1
2
kubectl apply -f coredns-log-patch.yaml
kubectl rollout restart deployment/coredns -n kube-system


3. 테스트 Pod 4종

Podlibcndots
alpine-ndots5musl5 (기본)
alpine-ndots2musl2
debian-ndots5glibc5 (기본)
debian-ndots2glibc2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# pods.yaml
---
apiVersion: v1
kind: Pod
metadata:
  name: alpine-ndots5
  labels: { app: dns-poc, libc: musl, ndots: "5" }
spec:
  containers:
    - name: shell
      image: alpine:3.20
      command: ["sh", "-c"]
      args:
        - |
          apk add --no-cache bind-tools musl-utils >/dev/null 2>&1 || true
          tail -f /dev/null
---
apiVersion: v1
kind: Pod
metadata:
  name: alpine-ndots2
  labels: { app: dns-poc, libc: musl, ndots: "2" }
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"
  containers:
    - name: shell
      image: alpine:3.20
      command: ["sh", "-c"]
      args:
        - |
          apk add --no-cache bind-tools musl-utils >/dev/null 2>&1 || true
          tail -f /dev/null
---
apiVersion: v1
kind: Pod
metadata:
  name: debian-ndots5
  labels: { app: dns-poc, libc: glibc, ndots: "5" }
spec:
  containers:
    - name: shell
      image: debian:12-slim
      command: ["sh", "-c"]
      args:
        - |
          apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq dnsutils >/dev/null 2>&1 || true
          tail -f /dev/null
---
apiVersion: v1
kind: Pod
metadata:
  name: debian-ndots2
  labels: { app: dns-poc, libc: glibc, ndots: "2" }
spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"
  containers:
    - name: shell
      image: debian:12-slim
      command: ["sh", "-c"]
      args:
        - |
          apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq dnsutils >/dev/null 2>&1 || true
          tail -f /dev/null
1
2
3
kubectl apply -f pods.yaml
# Pod가 Ready 상태가 될 때까지 대기
kubectl wait --for=condition=Ready pod -l app=dns-poc --timeout=120s


4. kubernetes.default.svc 조회 (dots = 2)

이 도메인은 ndots:5에선 점 < ndots → search 우선, ndots:2에선 점 ≥ ndots → 원본 우선이다. 즉 ndots:2 환경에서만 libc 차이가 드러난다.

1
2
3
4
5
NAME=kubernetes.default.svc
for p in alpine-ndots5 alpine-ndots2 debian-ndots5 debian-ndots2; do
  printf "===== [%s] =====\n" "$p"
  kubectl exec "$p" -- sh -c "getent hosts $NAME; echo exit=\$?"
done

결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
===== [alpine-ndots5] =====
10.96.0.1       kubernetes.default.svc.cluster.local
exit=0

===== [alpine-ndots2] =====
exit=2                          # ← musl, search 폴백 없음 → 실패

===== [debian-ndots5] =====
10.96.0.1       kubernetes.default.svc.cluster.local
exit=0

===== [debian-ndots2] =====
10.96.0.1       kubernetes.default.svc.cluster.local
exit=0                          # ← glibc, NXDOMAIN 후 search 폴백 → 성공


5. CoreDNS 로그로 검증

1
kubectl logs -n kube-system -l k8s-app=kube-dns -f --tail=20 --prefix

alpine-ndots2의 source IP:

1
2
... kubernetes.default.svc.   AAAA  NXDOMAIN
... kubernetes.default.svc.   A     NXDOMAIN
  • search list로 확장된 쿼리가 아예 들어오지 않는다. musl이 *search = 0으로 만들어 search loop 자체를 진입하지 않은 결과다.

debian-ndots2의 source IP:

1
2
3
4
... kubernetes.default.svc.                                A  NXDOMAIN
... kubernetes.default.svc.default.svc.cluster.local.      A  NXDOMAIN
... kubernetes.default.svc.svc.cluster.local.              A  NXDOMAIN
... kubernetes.default.svc.cluster.local.                  A  NOERROR  10.96.0.1
  • 원본 NXDOMAIN 후 search list 3개를 차례로 시도하여 마지막에 성공한다.

6. 클러스터 정리

1
kind delete cluster --name dns-poc


결론

libc(resolver)비교 요약

조건glibcmusl
점 < ndotssearch 먼저 → 실패 시 원본search 먼저 → 실패 시 원본
점 ≥ ndots원본 먼저 → 실패 시 search fallback원본만 시도 → 실패 시 그대로 종료

ndots:5 기본값에서는 대부분의 도메인이 점 < 5라 두 libc 모두 search를 먼저 돌리므로 동작 차이가 드러나지 않는다. 하지만 ndots를 낮추는 순간 점 ≥ ndots가 되는 도메인이 늘어나면서 musl의 fallback 부재가 실제 장애로 이어진다.

musl 환경에서도 ndots를 낮추고 싶다면 다음을 함께 적용해야 한다.

  1. 베이스 이미지 교체 검토: alpinedebian-slim, distroless
  2. 앱 레벨에서 FQDN 사용: my-svc.default.svc.cluster.local. (끝의 . 포함 시 search 자체를 건너뜀)
  3. 점진적 적용: 클러스터 전체가 아닌 특정 워크로드의 dnsConfig로 먼저 시도
  4. NodeLocal DNSCache 병행: ndots와 무관하게 캐시 계층을 추가하면 CoreDNS 부하를 크게 줄일 수 있다


참고 자료


Appendix) musl search fallback 논의 이후 영향 받은 프로젝트들

  1. musl 본질 이슈 발생
  1. Alpine → Debian 마이그레이션 PR/이슈
  1. 다운스트림 프로젝트에 영향
  1. 참고 문서
This post is licensed under CC BY 4.0 by the author.