한 번 쓰고 영원히 본다 — watch/list가 조정 루프를 어떻게 지탱하는가

kubectl get pods를 1초마다 돌리는 모니터링 스크립트를 본 적이 있다. 잘 동작했지만 apiserver에 1초마다 전체 Pod 목록을 요청했다 — 클러스터에 Pod가 5000개면 매초 5000개를 직렬화해 보내는 셈이다. 어느 순간 apiserver가 CPU 100%에 도달했고, 모니터링이 클러스터를 죽이는 역설이 벌어졌다. 해결책은 폴링을 멈추는 게 아니라 watch로 바꾸는 것이었다.

이 글이 푸는 질문: Kubernetes에서 수십 개의 컨트롤러가 동시에 돌면서 매번 "상태가 바뀌었나?"를 확인하는데, 왜 apiserver는 붕괴하지 않는가? 답은 list-watch 패턴리소스 버전(resourceVersion)이라는 두 장치에 있다. 이 둘이 조정 루프를 대규모로 가능하게 하는 기계장치다.

모든 것은 객체다 — API 객체의 구조

먼저 기계장치를 보기 전에, 그것이 다루는 대상을 짚자. Kubernetes에서 모든 것API 객체다. Pod, Deployment, Service, ConfigMap — 전부 apiserver에 저장되는 구조화된 레코드다. 각 객체는 공통 구조를 갖는다.

# Kubernetes 1.36
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: default
  uid: a1b2c3d4-...
  resourceVersion: "1234567"      # 이 객체의 버전 (핵심)
  creationTimestamp: 2026-07-12T...
spec:                             # 사용자가 원하는 상태
  replicas: 3
status:                           # 시스템이 관측한 현재 상태
  readyReplicas: 3
  availableReplicas: 3

이 구조에서 가장 중요한 분리는 specstatus다.

  • spec: 사용자(또는 다른 컨트롤러)가 "이렇게 되길 원한다"고 쓴 원하는 상태.
  • status: 시스템이 "지금 이렇다"고 관측해 쓴 현재 상태.

조정 루프는 정확히 이 둘을 비교한다 — spec(원함) vs status(지금). Deployment 컨트롤러는 Deployment의 spec.replicas: 3status.readyReplicas를 비교해 행동을 결정한다. 모든 Kubernetes의 자가 복구·확장·복구는 결국 이 두 필드의 간극을 메우는 일이다.

status는 사용자가 직접 쓰면 안 된다. 컨트롤러(시스템)가 관측 결과로 쓴다. 사용자는 spec만 쓴다. 이 역할 분담이 Kubernetes의 "선언 + 관측" 모델을 완성한다.

resourceVersion — 변경의 단조 증가 일련번호

객체마다 있는 resourceVersion은 핵심 기계장치다. apiserver는 객체가 바뀔 때마다 resourceVersion을 증가시킨다. 정확히는, etcd의 revision(전체 키스페이스 단조 증가 번호)을 그대로 쓴다. 즉 resourceVersion은 객체 자체의 버전이 아니라, "이 객체를 마지막으로 바꾼 etcd의 전체 revision"이다.

왜 이것이 중요한가? 컨트롤러가 watch를 시작할 때 "내가 본 마지막 resourceVersion은 1234567이야, 그 이후의 변화만 보내줘"라고 말할 수 있기 때문이다. apiserver는 그 버전 이후의 변경만 스트림으로 보낸다. 이것이 증분 전송을 가능하게 한다.

폴링(잘못된 방식) watch(올바른 방식)
매번 전체 목록 요청 처음 한 번 list, 이후 변경만 스트림
객체 5000개 → 매번 5000개 직렬화 변경 1개 → 1개만 전송
apiserver 부하 선형 증가 부하가 변경 빈도에 비례
놓친 변경 재탐지 불가 resourceVersion으로 정확한 재개 지점

list-watch 패턴 — 한 번 full list, 이후 영구 스트림

컨트롤러가 상태를 감시하는 표준 방식을 list-watch라 부른다. 두 단계로 동작한다.

flowchart LR
    C["컨트롤러 시작"] --> L["1. LIST<br/>전체 객체를 한 번 가져온다<br/>(각 객체의 resourceVersion 포함)"]
    L --> W["2. WATCH<br/>가장 높은 resourceVersion 이후의<br/>변경만 스트림으로 받는다"]
    W -->|"연결 끊김"| R{"마지막 resourceVersion을<br/>apiserver가 기억하는가?"}
    R -->|"예"| W
    R -->|"아니오(너무 옛날)"| L
  1. LIST: 컨트롤러는 시작 시 전체 객체를 한 번 가져온다. 이때 각 객체의 resourceVersion을 받는다. 이것이 컨트롤러의 현재 인식이 된다.
  2. WATCH: 그 중 가장 높은 resourceVersion 이후의 변경(추가/수정/삭제)만 스트림으로 받는다. apiserver가 변경이 생길 때마다 이벤트를 밀어 넣는다.

스트림이 끊기면(네트워크 장애 등), 컨트롤러는 끊긴 시점의 마지막 resourceVersion을 가지고 watch를 재개한다. apiserver가 그 revision을 아직 기억하면 그 이후의 변경만 받고, 너무 옛날 revision이라 이미 버렸으면 full LIST로 다시 동기화한다. 이 자동 복구가 컨트롤러가 장애에 강한 이유다.

이 패턴은 Google Borg/Omega의 핵심 설계에서 왔다. "변경을 알려면 전체를 다시 읽지 말고, revision 기반으로 증분만 받아라"는 통찰. Kubernetes 설계자들이 (Borg, Omega, and Kubernetes)에서 직접 설명한 대규모 동기화의 기본 패턴이다.

informer — list-watch를 캡슐화한 캐시

직접 list-watch를 짜면 코드가 복잡해진다 — 끊김 복구, 캐싱, 인덱싱, 이벤트 핸들러를 매번 구현해야 한다. Kubernetes는 이것을 informer라는 라이브러리 패턴으로 캡슐화했다. client-go의 Informer가 표준 구현이다.

informer는:

  1. list-watch를 돌려 로컬 캐시(메모리 저장소)를 유지한다.
  2. 객체가 추가·수정·삭제될 때마다 등록된 핸들러(콜백)를 부른다.
  3. 캐시에 인덱스를 만들어 빠른 조회를 지원한다.

컨트롤러 작성자는 informer에게 "이 리소스를 감시하고, 변하면 이 함수를 불러줘"라고만 등록하면 된다. 거의 모든 Kubernetes 컨트롤러(내장이든 오퍼레이터든)가 informer 위에 서 있다. 그래서 컨트롤러는 apiserver에 반복 요청을 보내지 않고, 로컬 캐시에서 객체를 읽어 조정 루프를 돌린다 — apiserver 부하를 낮게 유지하는 핵심.

# Kubernetes 1.36 — apiserver의 watch/list 요청 부하 관찰
kubectl get --raw '/metrics' 2>/dev/null | grep -E 'apiserver_request_total.*(LIST|WATCH)' | head

metrics 서버 활성화 시 관찰 가능. 미검증 환경(기본 kind)에서는 metrics가 제한적일 수 있음. "미검증(1차 출처 인용)".

level-triggered — 왜 이벤트가 아니라 상태를 보는가

01장에서 짧게 언급한 level-triggered를 여기서 정확히 짚자. informer가 이벤트(추가/수정/삭제)를 콜백으로 전달하므로, "그럼 edge-triggered(이벤트 기반) 아니냐"고 반문할 수 있다. 미묘한 차이가 있다.

  • 컨트롤러가 이벤트 자체에 의존해 행동하면 edge-triggered다. "Pod가 생성됐다"는 이벤트를 놓치면 행동을 안 한다.
  • 컨트롤러가 이벤트를 '신호'로만 쓰고, 실제 결정은 캐시(현재 상태)를 읽어서 한다면 level-triggered다.

올바른 Kubernetes 컨트롤러는 후자다. informer가 "Pod 생성 이벤트"를 알려주면, 컨트롤러는 캐시에서 "지금 replicas가 몇 개고 원하는 게 몇 개인가"를 다시 읽고 결정한다. 이벤트를 놓쳐도, 다음 이벤트(또는 주기적 resync)에서 캐시를 다시 비교하므로 결국 상태로 수렴한다. 이것이 "이벤트를 잃어도 안전한" 이유고, Kubernetes가 신뢰성을 얻는 핵심 설계 원칙이다.

informer에는 주기적으로 캐시 전체를 다시 동기화하는 resync 주기가 있다. 이때 아무 변경이 없어도 모든 객체의 핸들러가 다시 불린다 — 이벤트를 놓친 컨트롤러가 상태를 재점검하는 안전망이다.

etcd — revision 기반 저장의 근원

watch가 resourceVersion을 기준으로 동작할 수 있는 건, etcd가 revision 기반 저장을 하기 때문이다. etcd는 모든 키-값 변경에 대해 전역적으로 단조 증가하는 revision을 부여한다. Kubernetes의 resourceVersion은 이 revision을 그대로 노출한다.

flowchart LR
    W["watch 요청<br/>resourceVersion=1000"] --> ETCD["etcd"]
    ETCD --> KV["revision=1001 PodA 추가"]
    ETCD --> KV2["revision=1002 PodB 수정"]
    KV --> APIS["apiserver"]
    KV2 --> APIS
    APIS -->|"1000 이후 변경 스트림"| W

이 설계가 주는 보장: watch는 revision 기반으로 정확한 지점에서 재개된다. 중복도 누락도 없다. apiserver는 etcd의 watch API를 그대로 감싸서 클라이언트에게 노출하는 셈이다.

etcd의 revision 모델과 MVCC(다중 버전)는 etcd 공식 문서의 glossarydata model에 정확히 기술돼 있다. Kubernetes의 resourceVersion이 "etcd revision"이라는 사실은 이 1차 출처에서 확인된다.

컨트롤러의 실제 동작 흐름

지금까지의 부품을 Deployment 컨트롤러에 연결해 보자.

flowchart TD
    IN["informer<br/>(Deployment 캐시 + Pod 캐시)"] -->|"Deployment 변경 신호"| Q{"작업 큐<br/>(workqueue)"}
    Q --> PROC["조정 함수(reconcile)"]
    PROC --> READ["캐시에서 읽기:<br/>Deployment.spec.replicas vs 실제 Pod 수"]
    READ --> ACT{"불일치?"}
    ACT -->|"Pod 부족"| MAKE["Pod 생성 (apiserver에 POST)"]
    ACT -->|"Pod 과다"| DEL["Pod 삭제"]
    ACT -->|"일치"| WAIT["대기"]
    MAKE --> IN
    DEL --> IN

주목할 점: 컨트롤러는 캐시에서 읽고(informer), 결정하고, apiserver에 쓴다(POST/DELETE). 그리고 쓰면 그것이 다시 이벤트로 informer에 들어와 다음 루프를 유발한다. 이 폐루프가 "영구적으로 spec과 status를 맞추는" 기계장치다. 사이에 작업 큐(workqueue)가 있어 같은 객체에 대한 처리를 직렬화하고 중복을 합친다.

직접 확인하기

# Kubernetes 1.36 — watch 동작 직접 보기 (5초만)
kubectl get pods -A -w --request-timeout=5s 2>/dev/null | head

확인할 것: -w(watch)는 한 번 출력하고 끝나지 않고 변경을 계속 스트리밍한다. (시간 초과로 종료됨.)

# 객체의 resourceVersion 직접 관찰
kubectl get deploy nginx -o=jsonpath='{.metadata.resourceVersion}{"\n"}'

확인할 것: 숫자 하나. 이 객체를 마지막으로 바꾼 etcd revision이다.

# 객체를 수정한 뒤 resourceVersion이 어떻게 변하는지
kubectl scale deploy nginx --replicas=5
kubectl get deploy nginx -o=jsonpath='{.metadata.resourceVersion}{"\n"}'

확인할 것: resourceVersion이 증가한다 — 객체가 바뀔 때마다 단조 증가하는 증거.

실제 출력의 정확한 값은 클러스터 상태에 따라 다르다. 핵심은 증가한다는 사실.

shared informer의 내부 — 캐시 일관성과 resync

informer가 로컬 캐시를 유지하지만, 그 캐시가 apiserver와 항상 일치하는가? 미묘하다:

  • eventual consistency: watch 스트림이 끊기면 캐시가 뒤쳐질 수 있다. 재연결 시 resourceVersion으로 복구하지만, 찰나의 불일치 가능.
  • resync: informer는 주기적(기본 10시간)으로 전체 캐시를 재동기화. 이때 모든 객체의 핸들러가 다시 불림(변경이 없어도). 이것이 "이벤트를 놓친 컨트롤러가 상태를 재점검"하는 안전망.
flowchart LR
    CACHE["informer 캐시"] -->|"resync 주기마다"| HANDLER["모든 객체 핸들러 재호출"]
    HANDLER --> WQ["workqueue"]
    WQ --> RECONCILE["조정 함수: 실제 상태 다시 비교"]

resync는 변경이 아니어도 핸들러를 부르므로 — 컨트롤러는 핸들러에서 "이 객체가 정말 뭔가 해야 하나?"를 다시 판단. 이것이 level-triggered 원칙의 구현 — "이벤트를 잃어도, 다음 resync에서 상태 불일치를 잡는다."

resync 주기가 너무 길면 놓친 변경이 오래 방치. 너무 짧으면 불필요한 재조정이 컨트롤러 부하. 기본 10시간은 보수적 균형이지만, 민감한 컨트롤러는 더 짧게 튜닝하기도.

workqueue — 중복 제거와 rate limiting

컨트롤러가 핸들러에서 바로 apiserver에 쓰면 — 같은 객체에 짧은 시간에 여러 변경이 여러 번 쓰기를 유발(비효율). workqueue가 이것을 중재:

  • 중복 제거(dedup): 같은 객체에 대한 처리를 합침. 1초 안에 같은 Pod가 5번 변경되도 workqueue는 한 번 처리.
  • rate limiting: 처리 속도에 상한. apiserver에 쓰기가 너무 빠르면 apiserver 부하.
  • 지수 백오프: 실패한 처리를 재시도할 때 점점 느리게.

이것이 "event 폭주 → 컨트롤러 폭주 → apiserver 폭주" 연쇄를 끊는 장치. informer + workqueue의 조합이 대규모 컨트롤러를 안정적으로.

캐시 vs apiserver — 언제 캐시를 믿고 언제 직접 보나

informer 캐시는 읽기*에 쓴다. 하지만 *쓰기(생성/수정)는 캐시가 아니라 apiserver에 직접. 그리고 방금 쓴 것을 다시 읽을 때 — 캐시가 아직 갱신 안 됐을 수 있다(비동기 watch 전파). 그래서 "create 직후 get"이 캐시에서 못 찾는 경우가 발생(stale read). 이것이 컨트롤러 설계의 미묘함 — 방금 쓴 객체는 캐시를 안 믿고(또는 잠시 대기).

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"컨트롤러는 매초 apiserver에 객체를 요청한다" informer의 로컬 캐시에서 읽는다. apiserver에 반복 요청 안 함
"status는 사용자가 설정한다" 시스템(컨트롤러)이 관측해 쓴다. 사용자는 spec만
"watch가 끊기면 변경을 잃는다" resourceVersion으로 정확한 지점에서 재개. 잃지 않는다(너무 옛날 revision이면 full LIST로 복구)
"이벤트만 처리하면 충분하다" edge-triggered 위험. 이벤트를 신호로 쓰되 결정은 상태(캐시)로 — level-triggered
"resourceVersion은 객체의 버전이다" etcd 전체 revision이다. 객체 고유 번호가 아니다
"컨트롤러를 직접 짜려면 informer가 필수는 아니다" 사실상 필수. 안 쓰면 캐싱·재개·resync를 직접 구현해야 한다

요약 — 이 글의 결론

  • 모든 것은 API 객체이며, spec(원하는 상태)과 status(관측한 현재 상태)의 분리가 조정 루프의 기준이 된다. 사용자는 spec만, 시스템은 status만 쓴다.
  • resourceVersion은 etcd의 전역 revision으로, 객체가 바뀔 때마다 단조 증가한다. 이것이 watch의 정확한 재개 지점이다.
  • list-watch 패턴이 컨트롤러의 표준 — 한 번 full LIST 후, resourceVersion 기반으로 변경만 스트림. 매번 전체를 폴링하지 않아 apiserver가 대규모에서도 버틴다.
  • informer가 list-watch를 캡슐화해 로컬 캐시+콜백+인덱스를 제공. 거의 모든 컨트롤러가 informer 위에 서고, apiserver 부하를 낮게 유지한다.
  • level-triggered 원칙: 이벤트는 '신호'일 뿐, 결정은 항상 상태(캐시)를 다시 읽어서. 이벤트를 놓쳐도 주기적 resync로 수렴한다 — 신뢰성의 근원.
  • etcd의 revision 기반 MVCC가 이 전부의 물리적 토대. Kubernetes의 자가 복구·확장·일관성은 결국 etcd의 버전 모델 위에 서 있다.

생각해 볼 문제

  1. 컨트롤러 없이 Deployment만 etcd에 적혀 있다면 무슨 일이 벌어지나? (spec과 status는 각각 어떻게 되나?)
  2. watch 재개 시 apiserver가 그 resourceVersion을 이미 버렸다면 어떻게 되는가? 정확한 복구 경로를 설명하라.
  3. resourceVersion이 etcd의 전역 revision이라면, 다른 객체가 바뀌어도 내 객체의 resourceVersion이 변하는가?
  4. 폴링 기반 모니터링(1초마다 get)이 클러스터를 죽인 사례를 watch 관점에서 진단하라. 구체적 병목은?
  5. informer의 resync 주기가 길면 어떤 장단점이 생기나? 짧으면?
  6. etcd revision이 32비트 정수에 가까운 증가다. 아주 오래 돈 클러스터에서 이 값이 넘치면(overflow) 어떤 일이 벌어질까? (고급 — 공식 문서에서 확인)

참고

'Tech Artifacts > Kubernetes' 카테고리의 다른 글

K8s - 02. architecture  (1) 2026.07.13
K8s - 01. overview  (1) 2026.07.13