728x90

Dev.to에 올라온 "Two Bugs Later: What It Actually Took to Replace a DNS Library"를 읽고 한참 동안 예전 장애 티켓들이 떠올랐다. 글쓴이는 Go로 재귀 DNS 리졸버 hollow를 만들면서 사실상 표준인 github.com/miekg/dns를 안 쓰고 코덱을 직접 짰다. 그 과정에서 네트워크에서 도달 가능한 보안 버그 두 개를 스스로 만들어냈고, 그 얘기를 솔직하게 풀어놨다.

핵심 문장은 이거다. "라이브러리는 코드가 아니다. 누군가 이미, 올바르게, 그리고 당신에게 결정 중이라는 사실조차 알려주지 않고 내린 서른~마흔 개의 결정이다." 인프라 하는 사람이라면 이 문장이 왜 아픈지 안다. 우리가 매년 하는 일이 그거니까 — 라이브러리 갈아끼우기, 사이드카 교체, 리졸버 변경.

왜 지금 이 글인가: 의존성 교체는 코드 교체가 아니다

miekg/dns는 known importer가 16,234개다. CoreDNS가 이걸로 만들어졌다. 인기 있는 선택지가 아니라 유일한 선택지다. 그걸 지우고 나니 프로젝트는 이렇게 됐다.

  • 코덱 구현 1,341줄 / 코덱 테스트 1,925줄
  • 전체 9,984줄 Go / 11,033줄 테스트 / -race 하에서 342개 테스트
  • go.modrequire 블록 없음, go.sum 없음, vendor/ 없음

구현보다 테스트가 더 많다는 게 첫 번째 힌트다. 저자 말대로 "DNS는 1987년 포맷이고 문서는 넘치도록 있다. 코덱 쓰는 건 어렵지 않다. 소켓에 붙여도 되는 코덱을 쓰는 건 다른 일이다."

실무로 옮겨보자. 우리가 DNS 관련 스택을 건드릴 때 실제로 바뀌는 건 API 시그니처가 아니다. 리졸버 순서(/etc/nsswitch.conf), 타임아웃과 재시도 횟수, UDP 512바이트 초과 시 TCP 폴백 여부, EDNS0 버퍼 크기 협상, search domain 처리, 캐시 TTL 해석 — 이 전부가 기존 라이브러리가 대신 내려주던 결정이다. 갈아끼우는 순간 전부 내 책상 위로 돌아온다.

동작 원리: 재귀 리졸버가 실제로 걷는 길

hollow는 8.8.8.8한테 물어보고 답을 앵무새처럼 반복하는 forwarder가 아니라, 루트 서버부터 시작해 referral을 따라 내려가는 재귀 리졸버다. 원문의 trace 출력이 이 구조를 잘 보여준다.

$ hollow trace www.github.com
. (root)
+- 193.0.14.129:53  17ms udp, referral, 839 B, 13 NS + 26 glue,
   asked as WWW.GitHUB.com.
com.
+- c.gtld-servers.net. (192.26.92.30:53)  85ms udp, referral, 310 B,
   asked as wWw.gItHuB.cOm.
github.com.
+- ns-421.awsdns-52.com. (205.251.193.165:53)  35ms udp, answer, 296 B,
   asked as www.gIthuB.coM.

www.github.com. 3600 IN CNAME github.com.
github.com.       60 IN A     20.207.73.82

3 queries, 3 zones, 0 answers from cache, 136ms

여기서 눈여겨볼 게 asked as 줄이다. 쿼리 이름의 대소문자를 매 쿼리마다 랜덤화해서 nonce로 쓴다(0x20 인코딩). 응답이 그 대소문자를 정확히 그대로 되돌려주지 않으면 버린다. 스푸핑된 응답을 막는 값싼 방어다.

같은 걸 여러분 환경에서 확인하고 싶으면 dig로 루트부터 따라가 보면 된다. 쿠버네티스 노드나 아무 리눅스 박스에서 바로 실행된다.

$ dig +trace +nodnssec www.github.com A | head -20

; <<>> DiG 9.18.x <<>> +trace +nodnssec www.github.com A
.                       518400  IN      NS      a.root-servers.net.
...
;; Received 239 bytes from 198.41.0.4#53(a.root-servers.net) in 12 ms

com.                    172800  IN      NS      a.gtld-servers.net.
;; Received 1170 bytes from 192.5.5.241#53(f.root-servers.net) in 35 ms

github.com.             172800  IN      NS      ns-421.awsdns-52.com.
;; Received 640 bytes from 192.26.92.30#53(c.gtld-servers.net) in 28 ms

중요한 개념 세 개만 짚자. referral은 에러가 아니라 정상 응답이다. glue 레코드는 최적화가 아니라 부트스트랩 문제 때문에 존재한다(ns1.github.com의 주소를 알려면 github.com을 물어봐야 하는데 그러려면 ns1이 필요한 순환). resolution은 테이블 조회가 아니라 트리를 걸어 내려가는 행위다. 이 그림이 머릿속에 없으면 hex dump 앞에서 RFC는 아무 도움이 안 된다.

버그 #1 — 이름 압축 키를 잘못 잡아서 자기 방어를 깨먹다

DNS 메시지 안의 이름은 앞서 나온 동일 suffix를 가리키는 2옥텟 포인터로 대체할 수 있다. 인코더는 "이미 쓴 suffix"를 맵에 들고 있다가 나중에 가리킨다. 문제는 맵 키였다.

버전 1은 대소문자를 접었다(case-fold). DNS 이름은 대소문자 구분 없이 매칭되니 맞는 것 같지만 틀렸다. 포인터는 이름을 가리키는 게 아니라 특정 오프셋에 있는 바이트를 가리킨다. 두 철자를 같은 키로 취급하면 두 번째 이름이 첫 번째를 가리키게 되고, 결과적으로 다른 철자가 와이어로 나간다. 대부분의 프로그램에선 그냥 미관 문제지만, 이 프로젝트에선 위에서 말한 0x20 nonce 방어가 통째로 무너진다. 자기가 만든 방어를 자기 인코더가 조용히 깨는 것이다.

버전 2는 라벨을 점으로 이었다. 이건 더 나쁘다. DNS 라벨은 점을 포함할 수 있다(presentation form에선 \.로 이스케이프). 그래서 단일 라벨 a.b와 두 라벨 a, b가 같은 문자열로 납작해진다. 키는 같은데 이름은 다르다. 최종 해법은 인코딩된 옥텟 자체를 키로 쓰는 것.

func suffixKey(labels [][]byte) string {
    var b strings.Builder
    for _, l := range labels {
        b.WriteByte(byte(len(l))) // 길이 프리픽스 포함이 핵심
        b.Write(l)
    }
    return b.String()
}

이러면 두 suffix가 충돌하는 경우는 정확히 "둘 사이 포인터가 올바른 경우"뿐이다. 처음부터 기대고 있던 그 속성 그대로다.

버그 #2 — bailiwick 체크를 한 줄로 짜서 캐시 포이즈닝 구멍을 내다

이건 코덱이 아니라 "이 네임서버 말을 믿을 것인가"를 판단하는 로직이다. referral과 함께 오는 glue 주소를 전부 믿으면 안 된다. com 서버는 com 안쪽 이름에 대해서만 말할 자격이 있지, bank.example.org의 주소를 알려줄 자격은 없다. 이 검사가 bailiwick이고, 틀리면 교과서적인 캐시 포이즈닝이다. 누구나 처음 쓰는 버전은 이거다.

strings.HasSuffix(name, zone)   // 읽기엔 멀쩡하다. 구멍이다.

또 이스케이프된 점이다. evil\.com단일 라벨이고 com의 자식이 아니라 형제인데, 바이트로 보면 어쨌든 com.으로 끝난다. suffix 검사는 com referral 안에서 ns1.evil\.com의 glue를 기꺼이 받아들인다. 정확히 이 검사가 걸러내라고 존재하는 입력을 통과시킨다. 제대로 된 버전은 언이스케이프해서 라벨 단위로 오른쪽부터 비교한다.

for i := range zl {
    if !strings.EqualFold(string(nl[len(nl)-len(zl)+i]), string(zl[i])) {
        return false
    }
}

호출당 할당 하나가 더 든다. 대신 한 줄짜리 버전에 대해 실패하는 테스트가 있다. 저자의 정리가 좋다. "한 줄짜리는 누구나 처음 쓰는 버전이다. 그걸 잡아내는 건 모든 응답은 신뢰할 수 없는 입력이라고 결정한 다음, 그 가정이 실제로 강제되지 않는 지점을 코드에서 다시 찾아 나서는 일이다."

두 버그를 잡은 건 리뷰가 아니라 퍼저였다

둘 다 네트워크에서 도달 가능했고, 코드 리뷰도 못 잡았고, 이미 작성해둔 20개의 malformed 메시지 테스트도 못 잡았다. 잡은 건 Go 내장 testing.F다. 디코더에 3,840만 회 실행을 돌렸는데 크래시가 없었다. 근데 이 퍼저는 크래시를 보는 게 아니다. 라운드트립 불변식을 검증한다.

  • 디코드되는 것은 반드시 재인코딩되어야 한다
  • 다시 디코드하면 동일한 메시지여야 한다
  • 두 번째 인코딩은 첫 번째 인코딩과 바이트 단위로 동일해야 한다

첫 인코딩이 입력과 달라지는 건 허용된다(인코더가 압축 대상을 스스로 고르니까). 두 번째부터는 인코더 자신의 출력이 되돌아 들어가는 거라 허용 안 된다. 이 fixed point가 압축 키 충돌을 잡았다. 이름이 재작성되면 round-trip이 깨지니까.

포인터 루프 종료 증명 — visited set 없이

압축 포인터는 아무 데나 가리킬 수 있다. 자기 자신도, 루프도. 잘못 다루면 40바이트 패킷 하나가 리졸버를 멈춰 세운다. 흔한 방어는 "포인터는 뒤로만 가야 한다"인데 이것으로는 부족하다. 포인터가 뒤로 점프해도 라벨 워크가 커서를 다시 앞으로 밀기 때문이다. 오프셋 20의 포인터가 15를 가리키고, 15의 라벨을 걷다 보니 다시 20에 도착하는 구성은 매 스텝 "뒤로"를 만족하면서 영원히 돈다.

해법은 현재 읽기 위치가 아니라 직전 포인터의 타깃과 비교하는 것. 타깃들이 음이 아닌 정수의 순감소 수열이 되므로 워크는 반드시 종료한다. visited set도, 점프 예산도, 튜닝할 값도 없다. 그리고 정당한 입력을 하나도 거부하지 않는다. 유효한 포인터는 같은 메시지에서 앞서 나온 이름만 참조할 수 있으니까. 저자의 마무리가 정확하다. "miekg/dns는 이걸 공짜로 준다. 그리고 여기에 논쟁거리가 있었다는 사실조차 모르고 지나간다."

실무 관점: 라이브러리가 대신 내려주던 결정들과 흔한 함정

원문에서 가장 실무적인 부분은 "라이브러리가 나 대신 결정해줬을 것들" 목록이다. 전부 README의 한 줄이 됐다고 한다. 우리 환경으로 번역해보면 이렇다.

결정 지점 선택과 이유
UDP 수신 큐가 찼을 때 블로킹하지 말고 드롭. 블로킹하면 커널 수신 큐를 못 비워서 느린 쿼리 하나가 그 박스의 모든 클라이언트를 멈춘다. UDP는 원래 드롭을 허용한다.
response 비트가 켜진 채 도착한 메시지 절대 응답하지 않는다. 응답하면 서로를 가리킨 두 서버가 패킷 하나를 영원히 주고받고, source에 피해자 주소를 넣은 쪽은 트래픽 발사기를 얻는다.
드롭 로그 첫 건만 로그, 나머지는 카운트. 안 그러면 패킷 플러드가 디스크 플러드로 바뀐다. 타깃만 바뀐 같은 공격이다.
레이트 리밋 초과 refuse가 아니라 드롭. 에러도 응답이고, 응답이야말로 앰프 공격이 원한 것. 다만 두 번째마다 truncated로 응답해서 진짜 클라이언트는 TCP로 재시도해 성공하고 스푸핑된 소스는 핸드셰이크를 못 끝내게 한다.
캐시 TTL 내보낼 때 남은 초로 매번 재작성. 안 하면 1분 간격 두 조회가 같은 카운트다운을 보고한다. 눈에 보이는 거짓말이고 제일 먼저 들킨다. 측정값: example.com 콜드 268ms, 웜 0ms.

이 표를 보면서 든 생각: 우리가 쿠버네티스에서 CoreDNS 튜닝할 때 만지는 값들이 정확히 이 목록이다. cache 플러그인의 TTL 처리, forwardmax_concurrent, prefer_udp/TCP 폴백, loop 플러그인. 우리는 이 결정들을 안 내리는 게 아니라 CoreDNS가 이미 내려준 걸 쓰고 있는 것이다.

흔한 함정 1 — hosts 파일/블록리스트 파서가 localhost를 막는다

원문에서 제일 실용적인 경고다. 진짜 hosts 파일은 이렇게 시작한다.

127.0.0.1       localhost
127.0.0.1       localhost.localdomain
127.0.0.1       local
255.255.255.255 broadcasthost
::1             localhost

두 번째 필드를 그냥 집어삼키는 파서는 localhost를 블록리스트에 넣고, 자기가 돌고 있는 머신을 죽인다. 문자열 localhost만 필터링하는 것도 부족하다. localbroadcasthost가 그대로 통과한다. 컨테이너 안에서 이게 터지면 이런 에러를 보게 된다.

$ curl http://localhost:8080/healthz
curl: (6) Could not resolve host: localhost

$ getent hosts localhost
(출력 없음, exit code 2)

헬스체크가 localhost로 되어 있는 파드라면 CrashLoopBackOff로 직행이다. 원인이 "블록리스트 파서"라는 걸 떠올리기까지가 오래 걸린다.

흔한 함정 2 — bufio.Scanner가 조용히 파일을 잘라먹는다

블록리스트를 읽는 가장 뻔한 방법이 bufio.Scanner인데, 버퍼 한도를 넘는 라인을 만나면 에러를 반환하고 더 이상 진행하지 않는다. 생성된 리스트에 말도 안 되게 긴 줄 하나가 섞여 있으면 나머지 파일 전체가 잘리는데 로드는 성공으로 보고된다. 절반짜리 블록리스트가 자기를 완전하다고 믿는 상태다. 실제 에러 메시지는 이거다.

bufio.Scanner: token too long

Go 코드에서 이걸 놓치지 않으려면 Scan() 루프가 끝난 뒤 Err()를 반드시 확인해야 한다. 대부분의 예제 코드가 이 부분을 생략하고 있다는 게 함정이다.

sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 상한을 명시적으로 올린다
for sc.Scan() {
    line := strings.TrimSpace(sc.Text())
    if line == "" || strings.HasPrefix(line, "#") {
        continue
    }
    // ... 파싱
}
if err := sc.Err(); err != nil {
    return fmt.Errorf("blocklist truncated: %w", err) // 여기를 빼먹으면 조용히 반쪽
}

쿠버네티스 환경에서 좁혀 들어가기

DNS 동작이 의심될 때 실제로 쓰는 순서. 파드 안에서 /etc/resolv.conf부터 본다.

$ kubectl exec -it mypod -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

$ kubectl exec -it mypod -- dig +short api.example.com
# 응답이 느리거나 비어 있으면, search 도메인이 먼저 붙어 나가는지 확인
$ kubectl exec -it mypod -- dig +search +trace api.example.com | head

ndots:5는 점이 5개 미만인 이름에 대해 search 도메인을 먼저 다 붙여본다는 뜻이다. api.example.com은 점이 2개니까 api.example.com.default.svc.cluster.local부터 시도한다. NXDOMAIN 왕복이 여러 번 발생하고, 이게 지연과 CoreDNS 부하로 나타난다. 외부 도메인을 많이 호출하는 워크로드라면 파드 스펙의 dnsConfigndots를 낮추거나 FQDN 끝에 점을 붙이는 게 흔한 대응이다. 다만 클러스터 내부 서비스 디스커버리에 영향이 가니 서비스별로 판단해야 한다.

여기서도 원문의 교훈이 그대로 적용된다. ndots:5도, search 도메인 순서도, UDP 우선도 누군가 이미 내려놓은 결정이고, 우리는 그게 결정이었다는 걸 장애가 나야 알게 된다.

그래서 직접 만들어야 하나?

대부분의 경우 아니다. 원문 저자도 miekg/dns를 깎아내리지 않는다. 오히려 "그건 인기 있는 선택이 아니라 유일한 선택"이라고 인정하고 시작한다. 판단 기준을 정리하면 이렇다.

  • 직접 짤 만한 경우: 의존성 제로가 요구사항이거나(공급망/감사 이슈), DNS 자체가 제품의 핵심이고 라이브러리의 결정을 다르게 내려야 할 때. 그리고 퍼징으로 불변식을 검증할 역량과 시간이 있을 때. 테스트가 구현보다 많아지는 게 정상이라는 걸 받아들일 수 있어야 한다.
  • 쓰지 말아야 할 경우: 나머지 전부. 특히 bailiwick 같은 보안 경계를 다시 구현해야 한다면 재고하는 게 맞다. 원문 저자조차 한 줄짜리 HasSuffix로 구멍을 냈다.

정리

한 줄 요약: 라이브러리를 걷어낸다는 건 코드를 다시 쓰는 게 아니라, 누군가 대신 내려줬던 수십 개의 결정을 하나씩 내 이름으로 다시 내리는 일이다.

실무자에게 남는 건 이 세 가지다.

  1. 불변식 기반 퍼징은 지금 당장 도입할 값어치가 있다. Go에선 build tag도, 별도 코퍼스 저장소도, 두 번째 툴체인도 필요 없다. 시드 코퍼스는 캡처한 패킷 두 개면 시작된다. 파서를 다루는 코드가 있다면 "라운드트립하면 동일해야 한다" 같은 불변식 하나만 걸어도 리뷰가 못 잡는 걸 잡는다.
  2. 모든 외부 응답은 적대적 입력이라고 선언하고, 그 가정이 강제되지 않는 지점을 코드에서 되짚어라. HasSuffix 한 줄이 캐시 포이즈닝이 될 수 있다.
  3. 의존성을 바꿀 땐 API가 아니라 동작 계약을 먼저 문서화하라. 큐가 차면 드롭인가 블로킹인가, 레이트 리밋 초과는 refuse인가 드롭인가, TTL은 재작성하는가. 새 라이브러리가 이걸 다르게 결정했다면 그게 다음 장애다. 가능하면 섀도 트래픽으로 양쪽 응답을 비교해보고 점진적으로 롤아웃하는 게 안전하다.

마지막으로 저자가 언급한 Kurose & Ross의 Computer Networking: A Top-Down Approach. 팩트는 RFC에 있다. 책이 주는 건 모양(shape)이다. hex dump 깊숙한 곳에서 RFC는 안 구해준다. 그게 뭘 묘사하는지 이미 그림을 갖고 있는 상태가 구해준다.

참고 자료

728x90
728x90

kubebuilder로 컨트롤러 한번 만들어본 사람이라면 다들 겪는 순간이 있다. r.Get()으로 방금 r.Update() 친 오브젝트를 다시 읽었는데 옛날 값이 나온다. "어? 방금 바꿨는데?" 하고 로그 찍어보고, 재현 안 되고, 그러다 넘어간다. 근데 이게 부하 올라가면 실서비스에서 터진다.

이 글은 그 "왜 옛날 값이 나오지?"의 근본 원인인 controller-runtime 캐시를 뜯어본다. 원문은 Kubernetes 공식 블로그의 How the controller-runtime Cache Actually Works(Andrei Kvapil, Timofei Larkin)를 기반으로 하고, 실무에서 실제로 밟는 지뢰 위주로 재구성했다.

1. 왜 지금 이걸 알아야 하는가

요즘 사내 플랫폼팀에서 CRD + 컨트롤러 조합으로 자동화 짜는 게 거의 표준이 됐다. kubebuilder 스캐폴딩 돌리면 몇 시간 만에 동작하는 컨트롤러가 나온다. 문제는 그다음이다. 트래픽 없을 땐 잘 돌던 게, 오브젝트 수만 개 규모에서 갑자기 이상하게 군다.

대부분의 사람이 갖고 있는 잘못된 멘탈 모델은 이렇다:

  • r.Get()은 kube-apiserver를 직접 조회한다
  • r.List()는 실시간 상태를 돌려준다
  • r.Update() 직후 다시 읽으면 새 값이 바로 보인다

셋 다 틀렸다. 실제로는 정반대다. controller-runtime은 list + watch로 채워둔 로컬 인메모리 복사본을 상대로 읽는다. 그래서 reconcile 안에서 초당 수백 번 읽어도 컨트롤 플레인에 부하가 거의 안 간다. 대신 대가로 조용히 메모리를 수 GB씩 먹고, 숨은 O(n) 스캔을 돌리고, stale read(오래된 값 읽기)에 걸려 넘어진다.

이 트레이드오프를 이해하고 있느냐 아니냐가 실서비스에서 컨트롤러 굴려본 사람과 예제만 돌려본 사람의 차이다.

2. 핵심: 캐시가 실제로 어떻게 채워지고 읽히는가

한 줄 요약부터

reconcile 안의 r.Get(), r.List()API 서버를 안 읽는다. 매니저가 시작할 때 list로 워밍업한 뒤 watch로 계속 최신 상태를 유지하는 로컬 캐시에서 읽는다. 나머지 성질들은 전부 이 한 줄에서 파생된다.

  • 읽기는 싸다. 하지만 쓰기 직후 강한 일관성(strong consistency)은 보장 안 된다.
  • 쓰기는 캐시를 우회해서 API 서버로 직행한다.
  • 캐시 크기와 인덱스 개수가 곧 메모리 사용량이다.
  • 잘못 짠 List()는 수만 개 오브젝트에 대한 선형 스캔으로 조용히 변한다.

파이프라인: API 서버에서 이벤트 핸들러까지

sigs.k8s.io/controller-runtime/pkg/cache는 사실 k8s.io/client-go/tools/cache를 얇게 감싼 것뿐이다. Kubernetes 전체를 굴리는 것과 똑같은 프리미티브가 밑에 깔려 있다.

API 서버
   │  (list 1회 + watch 지속)
   ▼
Reflector      ← API 서버와 직접 대화하는 유일한 컴포넌트
   │  (delta 스트림)
   ▼
DeltaFIFO      ← key별로 변경 이력을 순서대로 누적
   │  (Pop)
   ▼
Indexer(Store) ← 실제 오브젝트가 사는 인메모리 저장소 + 인덱스
   │
   ▼
Event Handlers ← 당신 컨트롤러가 여기 붙는다 (OnAdd/OnUpdate/OnDelete)

각 링크를 실무자 관점으로 풀어보면:

Reflector는 시작할 때 딱 한 번 list를 친다. API 서버는 오브젝트 목록과 함께 그 스냅샷이 찍힌 시점의 resourceVersion을 돌려준다. 그러면 Reflector가 "버전 X부터 watch 열어줘"라고 요청하고, 그 이후에 일어난 모든 이벤트 스트림을 받는다. list와 watch 사이에 이벤트가 새는 위험이 없는 이유가 이거다. watch가 list가 끝난 지점에서 정확히 이어붙는다.

연결이 끊기면 마지막으로 알던 resourceVersion으로 재연결한다. API 서버가 410 Gone("그 버전은 이미 히스토리에서 밀려났어, 너무 뒤처졌어")을 돌려주면 그때 fresh list를 다시 친다. 이걸 relist라고 하는데, 스케줄로 도는 게 아니라 이런 실패 시나리오에서만 발생한다. "주기적으로 API 서버 긁는 거 아니냐"는 오해가 여기서 깨진다.

DeltaFIFO는 Reflector와 informer 사이의 버퍼다. 세 가지를 해결한다:

  • 순서 보존: default/my-deploy에 대해 흘러온 변경 순서를 소비자도 똑같이 본다.
  • key별 그룹핑: 같은 namespace/name의 delta가 한 슬롯에 쌓인다. Pop()은 delta 하나가 아니라 마지막 호출 이후 그 key에 쌓인 delta 전체를 슬라이스로 돌려준다.
  • 선택적 중복 제거: 연속된 Deleted delta는 합친다. 근데 연속된 Added나 Updated는 안 합친다. 중간 상태를 최종 하나로 뭉개는 건 원칙적으로 DeltaFIFO의 일이 아니다.

비유하자면

Git으로 생각하면 편하다. Reflector가 git fetch로 원격 변경을 계속 당겨오고, Indexer는 로컬 워킹 카피다. 당신의 r.Get()은 로컬 파일을 읽는 것이지 매번 GitHub에 요청 날리는 게 아니다. 그래서 빠르지만, 방금 push한(=Update한) 내용이 아직 fetch되기 전이면 로컬에선 옛날 상태가 보인다. 이게 stale read의 정체다.

3. 실무 관점: 트레이드오프와 흔한 함정

함정 1: Update 직후 Get 하면 옛날 값이 나온다

가장 흔하고 가장 오래 헤매는 케이스다. 쓰기는 API 서버로 직행하지만, 그 변경이 watch를 타고 로컬 캐시에 반영되기까지는 시간이 걸린다. 코드로 보면:

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var cm corev1.ConfigMap
    if err := r.Get(ctx, req.NamespacedName, &cm); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    cm.Data["updated"] = "true"
    if err := r.Update(ctx, &cm); err != nil {   // API 서버로 직행
        return ctrl.Result{}, err
    }

    // 함정: 여기서 다시 Get 하면 캐시에서 읽으므로
    // 방금 쓴 값이 아직 안 보일 수 있다
    var again corev1.ConfigMap
    _ = r.Get(ctx, req.NamespacedName, &again)
    // again.Data["updated"] 가 "true"가 아닐 수 있음!

    return ctrl.Result{}, nil
}

여기서 "그럼 재조회해서 값 맞을 때까지 루프 돌리자"는 최악의 대응이다. 캐시가 갱신될 때까지 busy loop를 도는 순간 CPU를 태운다. 올바른 접근은 reconcile을 멱등하게(idempotent) 짜는 것이다. Update 직후 재조회에 의존하지 말고, 다음 reconcile에서 다시 원하는 상태로 맞추면 된다. watch가 변경을 감지해서 어차피 다시 큐에 넣어준다.

정말로 방금 쓴 최신 값을 즉시 읽어야 하는 예외적 상황(예: optimistic lock 충돌 재시도, 상태 머신에서 순서가 중요한 경우)에서만 APIReader를 쓴다. 이건 캐시를 우회해서 API 서버를 직접 읽는다. 대신 값이 비싸니 남발하면 처음에 캐시 만든 의미가 없어진다.

// 캐시 우회 직접 읽기 - 정말 필요할 때만
if err := r.APIReader.Get(ctx, req.NamespacedName, &cm); err != nil {
    return ctrl.Result{}, err
}

함정 2: RBAC 누락 → 캐시 워밍업 자체가 실패

이건 검색으로 제일 많이 유입되는 에러다. 새 타입을 watch에 등록했는데 ServiceAccount에 해당 리소스 list/watch 권한이 없으면, informer가 캐시를 못 채우고 시작 단계에서 이런 로그가 뜬다:

W0729 10:23:41.882314   1 reflector.go:539] pkg/mod/k8s.io/client-go/tools/cache/reflector.go:229:
  failed to list *v1.ConfigMap: configmaps is forbidden:
  User "system:serviceaccount:my-ns:my-controller" cannot list resource "configmaps"
  in API group "" at the cluster scope

E0729 10:23:41.882390   1 reflector.go:147] pkg/mod/k8s.io/client-go/tools/cache/reflector.go:229:
  Failed to watch *v1.ConfigMap: failed to list *v1.ConfigMap: configmaps is forbidden

핵심 포인트: 컨트롤러는 크래시하지 않고 이 로그만 계속 토해낸다. Reconcile은 호출되긴 하는데 캐시가 비어 있어서 r.Get()이 NotFound를 돌려주고, 왜 아무 일도 안 일어나는지 한참 헤매게 된다. 로그를 안 보면 진짜 잡기 어렵다.

kubebuilder를 쓴다면 리컨사일러 위에 marker 주석을 달고 make manifests로 RBAC를 재생성하는 게 정석이다:

//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete

여기서 listwatch가 빠지면 캐시가 안 채워진다. get만 있어도 informer 워밍업은 실패한다는 걸 기억하자.

함정 3: List()가 조용히 선형 스캔으로 변한다

필드 셀렉터로 필터링한다고 생각하고 짠 List()가 인덱스가 없으면 전체 스토어를 훑는 O(n) 스캔이 된다. 오브젝트 수천 개까진 티도 안 나다가, 수만 개 규모에서 reconcile 지연이 확 늘어난다. 특정 필드로 자주 조회한다면 매니저 셋업 시점에 인덱서를 미리 등록하자:

// main.go 또는 SetupWithManager 근처
err := mgr.GetFieldIndexer().IndexField(
    ctx, &corev1.Pod{}, "spec.nodeName",
    func(o client.Object) []string {
        pod := o.(*corev1.Pod)
        return []string{pod.Spec.NodeName}
    },
)
if err != nil {
    return err
}

// 이후 이렇게 조회하면 인덱스를 탄다
var pods corev1.PodList
err = r.List(ctx, &pods,
    client.MatchingFields{"spec.nodeName": "node-1"},
)

인덱스 없이 client.MatchingFields를 쓰면 에러가 나거나(등록 안 된 필드일 때) 전체 스캔으로 떨어진다. 자주 쓰는 조회 패턴은 반드시 인덱스로 뒷받침해야 한다.

함정 4: 캐시 스코프를 안 좁혀서 메모리 폭발

기본값은 클러스터 전체의 해당 타입을 전부 캐시에 올린다. Secret이나 ConfigMap처럼 개수가 많은 리소스를 무심코 watch하면 메모리가 순식간에 GB 단위로 뛴다. 특히 대규모 클러스터에서 Pod 전체를 캐시하면 OOMKilled로 재시작 루프에 빠지기도 한다.

매니저 생성 시 캐시 범위를 좁히자. 특정 네임스페이스만, 혹은 특정 라벨만:

mgr, err := ctrl.NewManager(cfg, ctrl.Options{
    Cache: cache.Options{
        // 특정 네임스페이스로 제한
        DefaultNamespaces: map[string]cache.Config{
            "my-ns": {},
        },
        // 타입별로 필터를 다르게
        ByObject: map[client.Object]cache.ByObject{
            &corev1.Secret{}: {
                Label: labels.SelectorFromSet(labels.Set{
                    "managed-by": "my-controller",
                }),
            },
        },
    },
})

이렇게 ByObject로 라벨 셀렉터를 걸면 애초에 캐시에 안 올라온다. 다만 셀렉터에 안 걸린 오브젝트는 캐시에서 안 보이므로, 그걸 r.Get()하면 NotFound가 난다는 점을 팀에 공유해야 한다. 스코프 좁히기와 "필요한 걸 못 읽는 버그"는 종이 한 장 차이다.

확인 명령어: watch가 어떻게 도는지 눈으로 보기

컨트롤러가 소비하는 것과 같은 이벤트 스트림을 kubectl로도 볼 수 있다. 이걸 한번 돌려보면 "단일 최종 오브젝트가 아니라 상태의 연쇄"라는 감이 온다:

$ kubectl get pods --watch
NAME          READY   STATUS              RESTARTS   AGE
my-pod        0/1     Pending             0          0s
my-pod        0/1     ContainerCreating   0          1s
my-pod        0/1     Running             0          4s
my-pod        1/1     Running             0          8s

스케줄러가 노드를 배정하고, kubelet이 status를 갱신하고, 여러 컨트롤러가 각자 변경을 얹으면서 한 오브젝트가 여러 상태를 거쳐 간다. 컨트롤러도 정확히 이 스트림을 받아서 로컬 캐시를 최신으로 유지한다.

4. 정리

한 줄 요약: reconcile 안의 읽기는 API 서버가 아니라 list+watch로 채운 로컬 인메모리 캐시에서 나온다. 그래서 읽기는 싸지만 쓰기 직후 강한 일관성이 없고, 캐시 크기·인덱스가 곧 메모리이며, 잘못된 List는 조용히 선형 스캔이 된다.

누가 언제 신경 써야 하나:

  • 이미 controller-runtime으로 컨트롤러를 짜고 있고, "왜 방금 쓴 값이 안 보이지?"를 겪은 사람 → 함정 1을 멱등 설계로 해결하라.
  • 대규모 클러스터(오브젝트 수만 개 이상)에 컨트롤러를 배포할 사람 → 함정 3, 4를 배포 전에 반드시 점검하라. 메모리와 reconcile 지연이 걸린다.
  • 새 CRD/리소스를 watch에 추가하는 사람 → RBAC의 list/watch부터 확인하라. 함정 2는 크래시 없이 조용히 아무것도 안 하게 만든다.

반대로 소규모 클러스터에서 몇 개 오브젝트만 다루는 컨트롤러라면 기본값으로도 충분하다. 캐시 커스터마이징은 실제로 메모리나 일관성 문제를 겪은 뒤에 손대도 늦지 않다. 다만 멘탈 모델만큼은 처음부터 정확히 갖고 있어야 프로덕션에서 비싼 놀라움을 피한다.

참고 자료

728x90

+ Recent posts