CRD 와 오퍼레이터 · 오퍼레이터 패턴과 리컨사일 루프 · 이론
리컨사일 — 관측·비교·행동·보고의 네 박자
한 줄 요약
리컨사일은 "무슨 일이 일어났는가"를 묻지 않는다. 오직 "지금 원하는 상태는 무엇이고 실제는 무엇인가" 만 묻고, 그 차이를 멱등하게 좁힌다.
왜 이게 필요했나
명령형 자동화 스크립트는 "이걸 만들고, 저걸 설정하고, 그다음 이걸 실행하라"의 나열이다. 중간에 실패하면 시스템이 어중간한 상태로 남고, 다시 실행하면 "이미 존재함" 오류가 나거나 중복이 생긴다. 그래서 사람들은 재실행을 무서워하게 되고, 무서운 자동화는 결국 쓰이지 않는다.
리컨사일은 이 문제를 뒤집는다. 어느 지점에서 실패하든 다음 호출이 처음부터 다시 돌면서 끝나지 않은 부분만 마저 한다. 컨트롤러가 중간에 죽었다 살아나도 상관없다. "어디까지 했는지"를 메모리에 들고 있을 필요가 없기 때문이다. 진짜 상태는 언제나 클러스터에 있다.
어떻게 동작하나
실제 컨트롤러에서 요청이 도달하는 경로는 세 단계다.
API Server --watch--> Informer --> WorkQueue --> Reconciler (로컬 캐시) (중복 제거, (사용자 코드) 속도 제어)여기서 가장 중요한 통찰은 리컨사일에 전달되는 것은 오브젝트가 아니라 오브젝트의 키(네임스페이스/이름)뿐 이라는 점이다. 무엇이 바뀌었는지, 생성이었는지 수정이었는지는 전달되지 않는다. 리컨사일은 그 키로 최신 상태를 다시 읽는다. 이 설계가 멱등성을 강제한다.
리컨사일 한 번은 여섯 개의 질문으로 요약된다.
1. 지금 이 오브젝트의 원하는 상태는 무엇인가 (desired)
2. 실제 클러스터의 상태는 무엇인가 (observed)
3. 둘의 차이는 무엇인가 (diff)
4. 그 차이를 멱등하게 메우려면 (action)
5. 아직 못 메운 부분이 있나 (requeue 판단)
6. 관찰한 결과를 status 에 어떻게 반영하나 (status)
4번의 멱등성. "생성하라"가 아니라 "원하는 모습으로 존재하는지 확인하고 아니면 맞춰라"로 써야 한다. 이미 맞으면 아무것도 하지 않는 것이 정답이고, 그때 하위 오브젝트의 resourceVersion 이 그대로여야 한다. 내용이 같은데 다시 쓰면 새 watch 이벤트가 생기고, 그 이벤트가 다시 리컨사일을 부른다.
5번의 재큐. "아직 준비 안 됨"을 전부 에러로 처리하면 안 된다. 에러로 반환하면 워크큐가 지수 백오프로 재시도하며 에러 로그가 쌓이고, 그 객체의 백오프가 길어져 응답성이 떨어진다. 의존 대상이 아직 없는 것은 실패가 아니라 "조금 뒤에 다시 보자"다.
| 구분 | 트리거 | 대기 시간 | 용도 |
| --- | --- | --- | --- |
| 에러 백오프 | 에러 반환 | 지수적으로 증가(자동) | 진짜 실패의 재시도 |
| 지연 재큐 | 결과에 명시 | 내가 지정한 값 | 의도적 주기 점검 |
백오프가 지수적인 이유는 한 객체의 반복 실패가 전체 큐를 굶기지 않게 하기 위해서다. 그리고 성공하면 그 키의 백오프 카운터는 리셋된다.
6번의 status 와 무한 루프. status 를 쓰면 그 자체가 새 watch 이벤트가 되어 리컨사일을 다시 부를 수 있다. 실제 컨트롤러는 GenerationChangedPredicate 로 이것을 막는다. generation 은 spec 이 바뀔 때만 오르므로, 이 필터를 걸면 자기 status 쓰기로 인한 재호출이 걸러진다. 다만 주기 점검이 필요한 컨트롤러라면 이 필터가 그 점검까지 막지 않도록 주의해야 한다.
삭제 경로와 파이널라이저. 소유자 참조로 이어진 클러스터 안의 자식은 가비지 컬렉터가 정리하지만, 클러스터 밖의 자원(클라우드 로드밸런서, 외부 DB 계정)은 쿠버네티스가 모른다. 파이널라이저는 그 정리를 보장하는 장치다.
삭제 요청 -> deletionTimestamp 설정 (오브젝트는 아직 존재) -> 리컨사일 재호출: 정리 수행 -> 파이널라이저 제거 -> 그제서야 실제 삭제핵심은 "삭제 = 즉시 사라짐"이 아니라 "삭제 = 삭제 시각이 찍히고 리컨사일이 한 번 더 불린다" 는 것이다. 그 한 번이 정리할 마지막 기회다. 그리고 정리 함수도 멱등해야 한다 — 이미 없는 것을 지우려는 시도가 오류를 던지면 파이널라이저가 영원히 안 떨어져 삭제 교착이 생긴다.
이 실습 환경에 대한 솔직한 설명
이 랩에는 컴파일된 Go 컨트롤러를 실행할 수단이 없다. 그래서 리컨사일 루프를 셸 스크립트 리컨사일러로 만든다. informer 캐시와 워크큐는 시뮬레이션하지 않고, 그 대신 리컨사일 함수의 본질인 관측 → 비교 → 행동 → status 보고와 재큐 판단, 파이널라이저 흐름을 손으로 구현한다. 채점되는 것은 컨트롤러 바이너리가 아니라 판단의 정확성과 멱등성 이다. 실제 컨트롤러를 Go 로 옮기더라도 이 네 박자는 그대로다.
현장에서 만나는 모습
첫째, 무한 리컨사일. 거의 모든 오퍼레이터 개발자가 한 번은 겪는 통과의례다. 리컨사일이 status 를 쓰면 그 쓰기가 새 watch 이벤트를 만들고, 그 이벤트가 다시 리컨사일을 부른다. CPU 가 한 코어를 붙잡고 로그가 초당 수백 줄씩 쌓인다. 해법은 두 가지다 — spec 이 바뀔 때만 증가하는 metadata.generation 을 보는 predicate 를 걸어 status 변경으로 인한 자가 트리거를 끊거나, 애초에 값이 달라졌을 때만 status 를 쓰는 것이다.
둘째, status 충돌. 캐시에서 읽은 낡은 객체로 status 를 갱신하려다 conflict 를 맞는다. 컨트롤러가 리소스를 동시에 여러 개 처리하면 확률이 올라간다. 최신 객체를 다시 읽어 갱신하거나 서버사이드 apply 로 필드 단위 병합에 맡긴다.
셋째, 방금 만든 객체가 안 보인다. 읽기는 informer 캐시에서, 쓰기는 API 서버로 간다. 그래서 Create 직후 Get 하면 아직 "없음"이 나올 수 있다. 여기서 sleep 을 넣는 것이 가장 흔한 오답이다. 올바른 답은 기다리지 않는 것 — 멱등하게 짜 두고 다음 리컨사일에서 자연스럽게 보게 두면 된다. 리컨사일 루프의 설계 철학이 그대로 드러나는 지점이다.
넷째, 파이널라이저에 걸린 오브젝트. 컨트롤러가 죽은 채로 CR 을 지우면 deletionTimestamp 만 찍힌 채 영원히 Terminating 에 남는다. 파이널라이저를 떼 줄 주체가 없기 때문이다. 그래서 오퍼레이터를 제거할 때는 컨트롤러보다 CR 을 먼저 정리하는 순서를 지켜야 하고, 급하면 파이널라이저를 손으로 떼어 내는 탈출구를 런북에 적어 둔다.
다음 실습에서 할 것
/root/op/reconcile/reconcile.sh 를 작성해 CR 의 spec 을 desired 로, 하위 ConfigMap 을 actual 로 읽어 비교하고, 없으면 만들고, 이미 맞으면 아무것도 하지 않는 것을 resourceVersion 이 그대로임을 근거로 증명한다. status 를 서브리소스 경로로 되쓰고, 의존 대상이 없을 때의 재큐 판단을 JSON 으로 남기고, 파이널라이저로 정리 후 삭제되는 흐름을 만든 뒤, 마지막에 세 개의 CR 을 한 바퀴 돌린 조정 보고서를 만든다.