들어가며
운영 중인 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가지
| # | 파일 | 내용 | 위치 | 변경 시점 |
|---|
| 1 | Corefile | Zone, Plugin Chain, fallthrough 등 CoreDNS 전체 동작 정의 | coredns ConfigMap → /etc/coredns/Corefile | 런타임 (reload 가능) |
| 2 | CoreDNS의 resolv.conf | CoreDNS가 바라보는 Upstream DNS 정의 | CoreDNS Pod의 /etc/resolv.conf | Pod 생성 시 (reload 불가) |
| 3 | App Pod의 resolv.conf | search 순회 동작 정의 (ndots, search) | App Pod의 /etc/resolv.conf | Pod 생성 시 (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의 ClusterIPsearch: 자동 부여된 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 ✓
|
핵심 코드 - glibc/resolv/res_query.c __res_context_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
| /*
* 이름에 점(.)이 충분히 많으면, 일단 '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 시도 안 함.
|
핵심 코드 - musl/src/network/lookup_name.c name_from_dns_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종
| Pod | libc | ndots |
|---|
alpine-ndots5 | musl | 5 (기본) |
alpine-ndots2 | musl | 2 |
debian-ndots5 | glibc | 5 (기본) |
debian-ndots2 | glibc | 2 |
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)비교 요약
| 조건 | glibc | musl |
|---|
| 점 < ndots | search 먼저 → 실패 시 원본 | search 먼저 → 실패 시 원본 |
| 점 ≥ ndots | 원본 먼저 → 실패 시 search fallback | 원본만 시도 → 실패 시 그대로 종료 |
ndots:5 기본값에서는 대부분의 도메인이 점 < 5라 두 libc 모두 search를 먼저 돌리므로 동작 차이가 드러나지 않는다. 하지만 ndots를 낮추는 순간 점 ≥ ndots가 되는 도메인이 늘어나면서 musl의 fallback 부재가 실제 장애로 이어진다.
musl 환경에서도 ndots를 낮추고 싶다면 다음을 함께 적용해야 한다.
- 베이스 이미지 교체 검토:
alpine → debian-slim, distroless 등 - 앱 레벨에서 FQDN 사용:
my-svc.default.svc.cluster.local. (끝의 . 포함 시 search 자체를 건너뜀) - 점진적 적용: 클러스터 전체가 아닌 특정 워크로드의
dnsConfig로 먼저 시도 - NodeLocal DNSCache 병행: ndots와 무관하게 캐시 계층을 추가하면 CoreDNS 부하를 크게 줄일 수 있다
참고 자료
Appendix) musl search fallback 논의 이후 영향 받은 프로젝트들
- musl 본질 이슈 발생
- Alpine → Debian 마이그레이션 PR/이슈
- 다운스트림 프로젝트에 영향
- 참고 문서