- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- reconcile 요청 흐름: informer → workqueue → reconcile
- Result와 requeue로 재시도 제어
- 멱등 구현 패턴
- status 업데이트와 conditions
- 이벤트 필터: predicate
- rate limiting과 동시성
- 캐시와 클라이언트: get from cache vs API
- 관측: metrics와 로깅
- 흔한 버그
- 성능 튜닝 체크리스트
- watch 대상 확장: 무엇이 reconcile를 트리거하는가
- 테스트로 reconcile 검증하기
- 워커 큐 내부와 백오프 동작 자세히
- 실전 시나리오: 단계적 reconcile 설계
- reconcile 멘탈 모델 정리
- 리더 선출과 고가용성
- 프로덕션 운영 체크리스트
- 마치며
- 참고 자료
들어가며
앞 글에서 Kubebuilder로 동작하는 Operator를 만들어 봤습니다. 거기서 reconcile 함수는 "원하는 상태를 만들고 멱등하게 맞춘다"는 단순한 골격이었습니다. 하지만 실제 프로덕션에서 Operator를 운영하다 보면, reconcile 루프의 미묘한 동작이 안정성과 성능을 좌우한다는 것을 깨닫게 됩니다.
이 글은 reconcile 루프의 내부 동작을 깊이 파고듭니다. 요청이 어떻게 informer에서 workqueue를 거쳐 reconcile에 도달하는지, Result로 어떻게 재시도를 제어하는지, 멱등성을 어떻게 견고하게 구현하는지, status 충돌을 어떻게 피하는지, 그리고 동시성과 캐시를 어떻게 튜닝하는지를 다룹니다. controller-runtime v0.24.x 기준입니다.
reconcile 요청 흐름: informer → workqueue → reconcile
reconcile 함수가 호출되기까지의 경로를 이해하는 것이 모든 것의 출발점입니다. controller-runtime은 다음과 같은 파이프라인을 구성합니다.
API Server
| watch (변경 스트림)
v
+-----------+ +-------------+ +------------+
| Informer |----->| WorkQueue |----->| Reconciler |
| (캐시 동기)| | (속도제어/ | | (사용자 코드)|
+-----------+ | 중복제거) | +-----+------+
^ +-------------+ |
| 로컬 캐시 읽기 | 재시도 시
+----------------------------------------+ requeue
각 단계의 역할은 이렇습니다.
- Informer: API Server를 watch하면서 객체를 로컬 캐시에 동기화합니다. 변경이 생기면 이벤트를 발생시킵니다. 이 캐시 덕분에 reconcile은 매번 API Server를 때리지 않고도 객체를 읽을 수 있습니다.
- WorkQueue: 이벤트를 받아 "조정해야 할 객체 키(namespace/name)"를 큐에 넣습니다. 이 큐는 중복을 제거하고(같은 객체가 여러 번 들어와도 한 번만 처리), 속도를 제어합니다.
- Reconciler: 큐에서 키를 꺼내 reconcile 함수를 호출합니다. reconcile은 그 키에 해당하는 객체의 원하는 상태를 맞춥니다.
핵심 통찰은 reconcile에 전달되는 것은 객체 자체가 아니라 객체의 키뿐이라는 점입니다. reconcile은 그 키로 최신 객체를 다시 읽어야 합니다. 이것이 멱등성과 깊이 연결됩니다. 이벤트가 무엇이었는지(생성/수정/삭제)는 중요하지 않고, 오직 "지금 원하는 상태가 무엇이고 실제는 무엇인가"만 봅니다.
Result와 requeue로 재시도 제어
reconcile 함수는 (Result, error)를 반환합니다. 이 두 값이 다음에 무슨 일이 일어날지 결정합니다.
return ctrl.Result{}, nil
-> 성공. 다시 큐에 넣지 않음(다음 watch 이벤트까지 대기)
return ctrl.Result{}, err
-> 에러. workqueue가 지수 백오프로 자동 재시도
return ctrl.Result{Requeue: true}, nil
-> 에러는 아니지만 다시 처리 요청(즉시 재큐)
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
-> 30초 후 다시 reconcile (주기적 확인에 유용)
이 구분이 중요한 이유는, 모든 "아직 안 됨" 상황을 에러로 처리하면 로그가 에러로 도배되기 때문입니다. 예를 들어 외부 의존성이 아직 준비되지 않았다면, 그것은 에러가 아니라 "조금 뒤에 다시 보자"는 상황입니다. 이럴 때는 에러 대신 RequeueAfter를 씁니다.
잘못된 패턴:
if 의존성_준비안됨:
return Result{}, fmt.Errorf("아직 준비 안 됨") # 에러 로그 폭발
올바른 패턴:
if 의존성_준비안됨:
return Result{RequeueAfter: 10초}, nil # 조용히 다시 확인
RequeueAfter는 또한 "watch만으로는 잡히지 않는 변화"를 주기적으로 점검하는 데도 유용합니다. 예를 들어 인증서 만료처럼 시간에 따라 변하는 조건은 RequeueAfter로 일정 주기마다 다시 확인합니다.
멱등 구현 패턴
멱등성은 reconcile의 절대 원칙입니다. 같은 객체에 대해 reconcile이 수십 번 불려도 결과가 같아야 합니다. 이를 견고하게 구현하는 핵심 패턴 두 가지를 봅니다.
1. 서버사이드 apply
전통적인 "get 후 없으면 create, 있으면 update" 패턴은 동작하지만, 여러 컨트롤러가 같은 객체를 건드릴 때 충돌이 생기기 쉽습니다. 서버사이드 apply(SSA) 는 "이 필드들에 대해 내가 원하는 값은 이것이다"를 선언하면 API Server가 필드 단위로 병합해 주는 방식입니다.
서버사이드 apply의 장점:
- 필드 소유권(field ownership)을 API Server가 추적
- 내가 관리하는 필드만 갱신, 남이 관리하는 필드는 보존
- "현재 상태를 먼저 읽고 비교"하는 보일러플레이트 감소
- 멱등성이 자연스럽게 보장됨
SSA를 쓰면 reconcile은 "원하는 객체 전체를 선언적으로 apply"하는 형태가 되어 코드가 단순해지고, 동시 수정에 강해집니다.
2. owner reference로 소유권 명확화
앞 글에서도 강조했듯, 컨트롤러가 만든 하위 리소스에는 owner reference를 답니다. 이렇게 하면 두 가지 이점이 있습니다. 첫째, 부모 삭제 시 자식이 자동 정리됩니다(가비지 컬렉션). 둘째, 컨트롤러는 "내가 소유한 것"만 관리하므로 reconcile 범위가 명확해집니다. owner reference가 없으면 컨트롤러가 무엇을 정리해야 할지 직접 추적해야 해서 finalizer 로직이 복잡해집니다.
status 업데이트와 conditions
spec과 status는 분리해서 다룬다
쿠버네티스 API 컨벤션에서 spec은 사용자/컨트롤러가 원하는 것, status는 컨트롤러가 관찰한 것입니다. 이 둘은 서로 다른 업데이트 경로를 씁니다. status 서브리소스가 활성화되어 있으면, status는 Status().Update()로만 갱신해야 합니다. spec과 status를 한 번의 Update로 같이 쓰려고 하면 충돌이나 무시가 발생합니다.
잘못됨:
obj.Spec.X = ...
obj.Status.Y = ...
Update(obj) # status 서브리소스가 켜져 있으면 status는 무시됨
올바름:
obj.Status.Y = ...
Status().Update(obj) # status만 별도 경로로 갱신
conditions로 표준화된 상태 표현
status에는 단순한 phase 문자열보다 conditions 배열을 쓰는 것이 권장됩니다. condition은 type, status(True/False/Unknown), reason, message, lastTransitionTime을 가진 표준 구조입니다. 예를 들어 Ready, Progressing, Degraded 같은 타입을 두면, 관측 도구와 사용자가 일관된 방식으로 상태를 해석할 수 있습니다.
conditions 예시:
- type: Ready, status: "True", reason: AllReplicasReady
- type: Progressing, status: "False", reason: Stable
conditions는 누적·갱신되며, 같은 type의 condition은 status가 바뀔 때만 lastTransitionTime을 갱신합니다. 이 관례를 지키면 Capability Level 4(Deep Insights)의 토대가 됩니다.
이벤트 필터: predicate
기본적으로 컨트롤러는 watch 대상의 모든 변경에 대해 reconcile합니다. 그런데 모든 변경이 의미 있는 것은 아닙니다. 예를 들어 status만 바뀐 이벤트나, 관심 없는 필드만 바뀐 이벤트로 reconcile을 도는 것은 낭비입니다. predicate는 어떤 이벤트가 reconcile을 트리거할지 필터링합니다.
자주 쓰는 predicate:
- GenerationChangedPredicate
: metadata.generation이 바뀐 경우만(= spec 변경만) 처리.
status 변경으로 인한 reconcile 자가 트리거를 막는 데 유용.
- LabelChangedPredicate / AnnotationChangedPredicate
: 특정 메타데이터 변경만 관심.
- 사용자 정의 predicate
: 임의의 조건으로 필터.
특히 GenerationChangedPredicate는 중요합니다. reconcile이 status를 갱신하면 그 자체가 새 watch 이벤트를 만들어 다시 reconcile을 부를 수 있습니다. generation은 spec이 바뀔 때만 증가하므로, 이 predicate를 쓰면 status 업데이트로 인한 불필요한 reconcile 폭주를 막을 수 있습니다. (단, RequeueAfter로 주기적 점검이 필요한 컨트롤러라면 이 predicate가 그 점검까지 막지 않도록 주의해야 합니다.)
rate limiting과 동시성
MaxConcurrentReconciles
기본적으로 컨트롤러는 한 번에 하나의 reconcile만 돌립니다. 처리량이 부족하면 동시성을 올릴 수 있습니다.
컨트롤러 옵션:
MaxConcurrentReconciles: 5
-> reconcile을 최대 5개 워커가 병렬 실행
주의할 점은, workqueue가 같은 객체 키는 동시에 두 워커에 주지 않는다는 것입니다. 즉 같은 객체에 대한 reconcile은 직렬화되므로, 동시성을 올려도 같은 객체에 대한 경쟁 조건은 생기지 않습니다. 다만 서로 다른 객체는 병렬 처리되므로 처리량이 올라갑니다.
rate limiter
workqueue는 rate limiter를 통해 재시도 속도를 제어합니다. 기본은 지수 백오프와 전체 처리율 제한을 조합한 것입니다. 에러가 반복되는 객체는 점점 긴 간격으로 재시도되어, 한 객체의 실패가 전체 큐를 굶기지 않게 합니다. 폭주하는 외부 API를 호출하는 컨트롤러라면 rate limiter를 조정해 다운스트림을 보호할 수 있습니다.
캐시와 클라이언트: get from cache vs API
controller-runtime의 기본 클라이언트는 읽기는 캐시에서, 쓰기는 API Server로 보냅니다. 이 구분을 이해하지 못하면 미묘한 버그에 빠집니다.
| 작업 | 기본 동작 | 주의점 |
|---|---|---|
| Get/List | informer 캐시에서 읽음 | 캐시는 약간 지연될 수 있음(eventually consistent) |
| Create/Update/Delete | API Server로 직접 | 즉시 반영되지만 캐시는 잠시 뒤 갱신 |
여기서 흔한 함정이 있습니다. 방금 Create한 객체를 곧바로 Get하면, 캐시가 아직 갱신되지 않아 "없음"이 나올 수 있습니다. 따라서 reconcile은 "방금 만든 것이 캐시에 보일 때까지 기다린다"가 아니라, "다음 reconcile에서 자연스럽게 보게 된다"는 전제로 멱등하게 작성해야 합니다. 정말 최신 값이 필요한 드문 경우에는 캐시를 우회하는 직접 읽기 클라이언트를 쓸 수 있지만, 성능상 기본은 캐시 읽기입니다.
관측: metrics와 로깅
프로덕션 Operator는 자신의 상태를 노출해야 합니다.
- 메트릭: controller-runtime은 기본적으로 reconcile 횟수, 처리 시간, 큐 깊이, 에러 수 같은 Prometheus 메트릭을 노출합니다. 큐 깊이가 계속 쌓이면 처리량이 부족하다는 신호이고, reconcile 시간이 길면 외부 호출이 병목이라는 신호입니다.
- 구조화 로깅:
log.FromContext(ctx)로 얻은 로거는 객체 키 같은 컨텍스트를 자동으로 포함합니다. 키-값 형태의 구조화 로그를 남기면 특정 객체의 reconcile 흐름을 추적하기 쉽습니다. - 이벤트: 중요한 조정 결과는 쿠버네티스 Event로 발행해
kubectl describe에서 보이게 합니다.
2026년 기준으로 메트릭 엔드포인트는 별도 사이드카 없이 controller-runtime의 인증·인가 미들웨어(WithAuthenticationAndAuthorization)로 보호하는 것이 표준입니다.
흔한 버그
reconcile 루프에서 반복적으로 나타나는 버그를 정리합니다.
| 버그 | 원인 | 해결 |
|---|---|---|
| 무한 reconcile | status 업데이트가 새 이벤트를 만들어 자가 트리거 | GenerationChangedPredicate 적용, 불필요한 status 쓰기 제거 |
| status 충돌 (conflict) | 캐시의 낡은 객체로 Status().Update | 최신 객체로 다시 Get 후 갱신, 또는 SSA |
| "이미 존재" 에러 | 비멱등 create | get 후 분기, 또는 서버사이드 apply |
| 방금 만든 객체가 안 보임 | 캐시 지연 | 멱등 설계로 다음 reconcile에 맡김 |
| 한 객체 실패가 전체 지연 | rate limiter/동시성 부족 | MaxConcurrentReconciles 조정 |
| 외부 API 폭주 | requeue 과다 | RequeueAfter로 간격 조절 |
특히 무한 reconcile과 status 충돌은 거의 모든 Operator 개발자가 한 번씩 겪는 통과의례입니다. 둘 다 "status를 어떻게 다루는가"에서 비롯되므로, status 경로를 신중히 설계하는 것이 핵심입니다.
성능 튜닝 체크리스트
성능 문제가 생기면 다음 순서로 점검합니다.
- 메트릭 먼저 본다: 큐 깊이, reconcile 시간, 에러율을 확인해 병목이 어디인지 특정합니다.
- 불필요한 reconcile 줄이기: predicate로 의미 없는 이벤트를 거릅니다. 특히 status 자가 트리거를 차단합니다.
- 동시성 올리기: 처리량이 부족하면 MaxConcurrentReconciles를 올립니다. 같은 객체는 직렬화되므로 안전합니다.
- 외부 호출 최소화: reconcile마다 느린 외부 API를 호출하면 그게 병목입니다. 캐싱하거나 RequeueAfter로 빈도를 낮춥니다.
- 캐시 활용: 읽기는 캐시에서 하고, 꼭 필요할 때만 직접 읽기를 씁니다.
- status 쓰기 최소화: 실제로 바뀐 경우에만 status를 갱신해 불필요한 이벤트와 충돌을 줄입니다.
watch 대상 확장: 무엇이 reconcile를 트리거하는가
reconcile가 언제 호출되는지는 컨트롤러가 무엇을 watch하느냐로 결정됩니다. controller-runtime은 watch 대상을 선언하는 여러 방법을 제공합니다.
For(&MyKind{})
: 주 리소스. 이 종류의 변경이 직접 reconcile를 트리거.
Owns(&appsv1.Deployment{})
: 내가 소유한(owner reference) 하위 리소스.
이 리소스가 바뀌면 그 소유자(주 리소스)의 reconcile를 트리거.
Watches(&otherKind{}, handler)
: 소유 관계가 아닌 임의의 리소스를 watch.
핸들러로 "이 변경이 어떤 주 리소스의 reconcile를 부를지" 매핑.
For와 Owns만으로 대부분의 컨트롤러가 커버됩니다. 하지만 소유 관계가 아닌 리소스의 변경에 반응해야 할 때가 있습니다. 예를 들어 어떤 ConfigMap이 바뀌면 그것을 참조하는 모든 CR을 reconcile해야 한다면, Watches와 매핑 함수를 씁니다.
매핑 함수의 동작
매핑 함수(EnqueueRequestsFromMapFunc)는 "변경된 객체"를 입력받아 "reconcile해야 할 주 리소스 키들의 목록"을 반환합니다. 즉 하나의 외부 변경이 여러 주 리소스의 reconcile를 큐에 넣을 수 있습니다.
ConfigMap "shared-config" 변경
-> 매핑 함수 호출
-> 이 ConfigMap을 참조하는 CR 목록 반환: [cr-a, cr-b, cr-c]
-> 3개의 reconcile 요청이 큐에 들어감
이 패턴은 강력하지만 주의가 필요합니다. 매핑 함수가 너무 많은 주 리소스를 반환하면, 하나의 변경이 reconcile 폭풍을 일으킬 수 있습니다. 매핑 함수는 가볍고 정확하게 유지해야 합니다.
테스트로 reconcile 검증하기
reconcile의 미묘한 동작은 테스트로 잡는 것이 가장 확실합니다. envtest 기반 테스트의 전형적인 흐름을 봅시다.
테스트 시나리오:
1. CR을 생성한다
2. 잠시 폴링하며 기대하는 하위 리소스가 생겼는지 확인
3. CR의 spec을 바꾼다
4. 하위 리소스가 새 값으로 갱신됐는지 확인
5. 하위 리소스를 손으로 변경한다
6. reconcile가 다시 원하는 값으로 되돌리는지 확인 (자가 치유 검증)
7. CR을 삭제한다
8. finalizer/owner reference 정리가 동작하는지 확인
핵심은 폴링(eventually 패턴) 입니다. reconcile는 비동기로 도므로, 테스트는 "지금 당장"이 아니라 "잠시 안에" 기대 상태가 되는지를 확인해야 합니다. 캐시 지연과 비동기 처리를 고려하지 않고 즉시 단언하면 테스트가 불안정(flaky)해집니다.
단위 테스트 vs 통합 테스트
| 종류 | 도구 | 검증 대상 |
|---|---|---|
| 단위 | fake client | 순수 로직(빌더 함수 등) |
| 통합 | envtest | API Server 차원 동작(검증, status, GC) |
순수 함수(예: desired Deployment를 만드는 빌더)는 fake client나 일반 단위 테스트로 빠르게 검증합니다. 반면 status 서브리소스, CRD 검증, owner reference 가비지 컬렉션처럼 API Server가 개입하는 동작은 envtest로 검증해야 현실적입니다. 두 종류를 적절히 섞는 것이 좋은 테스트 전략입니다.
워커 큐 내부와 백오프 동작 자세히
앞에서 workqueue를 간단히 소개했지만, 백오프가 실제로 어떻게 동작하는지 알아 두면 디버깅에 큰 도움이 됩니다. controller-runtime의 기본 rate limiter는 두 가지 메커니즘을 결합합니다.
기본 rate limiter = 항목별 지수 백오프 + 전체 버킷 제한
항목별 지수 백오프:
같은 키가 실패할 때마다 대기 시간이 2배씩 증가
예: 5ms -> 10ms -> 20ms -> 40ms -> ... -> 최대 상한
전체 버킷 제한(token bucket):
초당 처리 가능한 항목 수에 전역 상한
폭주하는 큐가 시스템 전체를 마비시키지 않게 함
이 두 메커니즘이 합쳐져, 특정 객체가 계속 실패해도 그 객체만 점점 뜸하게 재시도되고, 전체 처리율은 안정적으로 유지됩니다. 중요한 점은 reconcile가 성공(에러 없이 반환)하면 그 키의 백오프 카운터가 리셋된다는 것입니다. 따라서 일시적 실패 후 성공하면 다음 실패는 다시 짧은 대기부터 시작합니다.
백오프와 RequeueAfter의 차이
여기서 헷갈리기 쉬운 부분이 있습니다. 에러 반환으로 인한 백오프와 RequeueAfter는 서로 다른 메커니즘입니다.
| 구분 | 트리거 | 대기 시간 | 용도 |
|---|---|---|---|
| 에러 백오프 | error 반환 | 지수 증가(자동) | 진짜 실패의 재시도 |
| RequeueAfter | Result에 명시 | 내가 지정한 고정값 | 의도적 주기 점검 |
에러 백오프는 "뭔가 잘못됐으니 점점 천천히 다시 시도"이고, RequeueAfter는 "정상이지만 일정 시간 뒤 다시 확인"입니다. 이 둘을 혼동해 정상 상황을 에러로 처리하면, 로그가 오염되고 백오프가 쌓여 응답성이 떨어집니다.
실전 시나리오: 단계적 reconcile 설계
복잡한 Operator의 reconcile는 보통 여러 단계로 나뉩니다. 한 번에 모든 것을 끝내려 하지 말고, 각 단계를 멱등하게 쪼개는 것이 좋은 설계입니다. 데이터베이스 클러스터 Operator를 예로 들어 봅시다.
reconcile(cluster):
1. finalizer 보장 (없으면 추가)
2. deletionTimestamp 있으면 -> 정리 분기로
3. Secret(비밀번호) 보장
4. ConfigMap(설정) 보장
5. StatefulSet 보장
6. Service 보장
7. 부트스트랩 완료 여부 확인
아직이면 return RequeueAfter(10s)
8. 프라이머리 선출/확인
9. status.conditions 갱신
return Result{}
각 "보장(ensure)" 단계는 멱등합니다. 이미 있으면 비교 후 필요시 갱신, 없으면 생성합니다. 핵심은 단계가 순서를 가진다는 점입니다. Secret 없이 StatefulSet을 만들 수 없으므로, 앞 단계가 끝나야 다음으로 진행합니다. 아직 준비 안 된 단계를 만나면 에러가 아니라 RequeueAfter로 "조금 뒤에 이어서"를 표현합니다.
단계별 분기와 조기 반환
이 패턴의 장점은 reconcile가 항상 같은 진입점에서 시작해 "현재 어디까지 됐는지"를 매번 다시 판단한다는 것입니다. 컨트롤러가 중간에 죽었다 살아나도, 다음 reconcile가 처음부터 다시 돌면서 이미 끝난 단계는 건너뛰고 안 끝난 단계부터 이어 갑니다. 이것이 바로 reconcile가 "어디까지 했는지" 상태를 메모리에 들고 있을 필요가 없는 이유입니다. 진짜 상태는 항상 클러스터(observed state)에 있습니다.
조기 반환의 예:
if 부트스트랩_미완료:
return Result{RequeueAfter: 10s}, nil # 여기서 끝, 다음에 이어감
# 여기 아래는 부트스트랩이 끝났을 때만 실행
이렇게 설계하면 reconcile 함수가 길어 보여도 각 부분이 독립적이고 멱등하므로, 추론과 테스트가 쉬워집니다.
reconcile 멘탈 모델 정리
지금까지 다룬 내용을 하나의 멘탈 모델로 압축해 봅시다. reconcile를 작성하거나 디버깅할 때 머릿속에 항상 떠올려야 할 질문들입니다.
reconcile를 호출받았을 때 스스로 묻기:
1. "지금 이 객체의 원하는 상태는 무엇인가?" (desired)
2. "실제 클러스터의 상태는 무엇인가?" (observed)
3. "둘의 차이는 무엇인가?" (diff)
4. "그 차이를 멱등하게 메우려면?" (action)
5. "아직 못 메운 부분이 있나?" (requeue 판단)
6. "관찰한 결과를 status에 어떻게 반영하나?" (status)
이 여섯 질문이 reconcile의 골격입니다. 이벤트가 무엇이었는지(생성/수정/삭제)는 의도적으로 묻지 않습니다. reconcile는 항상 "지금 이 순간의 진실"에서 출발하기 때문입니다. 이 멘탈 모델을 체화하면, 새로운 Operator 코드를 읽거나 버그를 추적할 때 어디를 봐야 할지 자연스럽게 알게 됩니다.
디버깅할 때의 사고 흐름
reconcile가 이상하게 동작할 때는 보통 위 여섯 단계 중 하나가 깨진 것입니다.
| 증상 | 의심할 단계 |
|---|---|
| 아무 일도 안 일어남 | desired 계산 또는 watch 설정 |
| 계속 같은 일을 반복 | 멱등성(action) 또는 status 자가 트리거 |
| 차이가 안 메워짐 | diff 비교 로직 또는 RBAC 권한 |
| status가 안 맞음 | status 갱신 경로 또는 캐시 지연 |
증상에서 의심 단계로 곧장 좁혀 들어가는 이 습관이, 막연한 디버깅을 체계적인 추적으로 바꿔 줍니다.
리더 선출과 고가용성
프로덕션에서는 컨트롤러 매니저를 여러 복제본으로 띄워 가용성을 확보합니다. 그런데 같은 객체를 두 복제본이 동시에 reconcile하면 충돌이 납니다. 이를 막는 것이 리더 선출(leader election) 입니다.
leader election:
여러 매니저 복제본 중 단 하나만 "리더"가 되어 reconcile 실행
리더는 Lease 객체로 리더십을 주기적으로 갱신
리더가 죽으면 Lease가 만료되고 다른 복제본이 리더가 됨
controller-runtime은 리더 선출을 옵션 하나로 켤 수 있습니다. 활성화하면 평소에는 하나의 복제본만 실제로 일하고, 나머지는 대기(standby)합니다. 리더가 장애로 사라지면 빠르게 다른 복제본이 인계받아 reconcile를 이어 갑니다. 이로써 단일 장애점 없이 컨트롤러를 운영할 수 있습니다.
주의할 점은, 리더 선출이 켜져 있어도 reconcile 자체의 멱등성은 여전히 중요하다는 것입니다. 리더 전환 순간에 같은 객체가 두 번 처리될 수 있는 경계 케이스가 있기 때문입니다. 멱등성은 모든 안전장치의 토대입니다.
프로덕션 운영 체크리스트
reconcile 루프를 프로덕션에 올리기 전에 다음을 점검하면 흔한 사고를 예방할 수 있습니다.
[ ] reconcile가 멱등한가? (같은 입력 반복 실행이 안전한가)
[ ] 모든 하위 리소스에 owner reference가 설정됐는가?
[ ] 외부 리소스를 다룬다면 finalizer가 있는가?
[ ] status는 Status().Update로만 갱신하는가?
[ ] status 자가 트리거를 막는 predicate가 있는가?
[ ] 에러와 "아직 안 됨"을 구분해 처리하는가? (RequeueAfter 활용)
[ ] 메트릭과 로깅으로 동작을 관측할 수 있는가?
[ ] 리더 선출로 다중 복제본 안전성을 확보했는가?
[ ] 외부 API 호출에 타임아웃과 재시도 한계가 있는가?
[ ] RBAC 권한이 최소 권한 원칙을 따르는가?
이 체크리스트는 앞에서 다룬 모든 개념의 요약이기도 합니다. 멱등성, owner reference, finalizer, status 경로, predicate, 에러 처리, 관측, 고가용성, 보안 — 견고한 Operator는 이 모든 것을 빠짐없이 챙긴 결과물입니다.
마치며
reconcile 루프는 겉보기엔 단순한 함수 하나지만, 그 뒤에는 informer, workqueue, rate limiter, 캐시가 정교하게 맞물려 돌아갑니다. 견고한 Operator를 만들려면 이 파이프라인을 이해하고, reconcile을 멱등하게 쓰고, status 경로를 신중히 다루고, predicate로 불필요한 일을 줄이고, 메트릭으로 동작을 관측해야 합니다.
핵심 원칙을 한 줄로 요약하면 이렇습니다. "reconcile은 이벤트가 무엇이었는지 묻지 않는다. 오직 지금의 원하는 상태와 실제 상태를 비교해 멱등하게 좁힐 뿐이다." 이 마음가짐을 지키면 무한 루프도, status 충돌도, 성능 함정도 대부분 자연스럽게 피해 갈 수 있습니다.