12.4 컨트롤러와 Operator 패턴
12.3의 Backup CR은 저장될 뿐 아무 일도 일으키지 않았다. 이것은 결함이 아니라 Kubernetes의 일관된 설계다. Deployment 객체도 그 자체로는 데이터일 뿐이고, 그것을 ReplicaSet과 Pod로 만들어 내는 것은 controller manager 안의 컨트롤러였다. 선언을 현실로 만드는 주체는 언제나 컨트롤러다. 커스텀 리소스에도 같은 원리가 적용된다. CRD로 도메인 어휘를 정의하고, 그 어휘를 알아듣는 커스텀 컨트롤러를 함께 배포하는 조합을 Operator 패턴이라고 부른다.
reconciliation loop 일반형
컨트롤러의 동작은 Part II. 클러스터 아키텍처에서 본 모양 그대로다. 현재 상태를 관찰하고, 선언된 상태와 비교하고, 차이가 있으면 조치한다. 커스텀 컨트롤러는 자신의 CR과 자신이 만들어 낸 하위 리소스를 watch하다가, 어느 쪽에 변화가 생기든 이 루프를 다시 실행한다.
flowchart TD
W["CR과 하위 리소스 watch"] --> O["현재 상태 관찰"]
O --> C{"spec과 일치?"}
C -->|"일치"| S["대기"]
C -->|"불일치"| A["생성, 수정, 삭제 조치"]
A --> ST["status에 결과 기록"]
ST --> W
S --> W
중요한 성질은 이 루프가 이벤트가 아니라 상태 기준으로 동작한다는 점이다. 컨트롤러는 “무슨 일이 일어났는가"를 처리하는 것이 아니라 “지금 상태가 선언과 일치하는가"를 판정한다. 그래서 컨트롤러가 죽어 있는 동안 벌어진 변화도 재기동 후 첫 관찰에서 전부 반영되고, 같은 판정을 몇 번 반복해도 결과가 달라지지 않는다.
컨트롤러가 만든 하위 리소스에는 CR을 가리키는 ownerReference가 기록된다. 덕분에 CR을 삭제하면 가비지 컬렉터가 하위 리소스를 연쇄 정리하고, kubectl get에서 어느 리소스가 어느 CR의 산출물인지 추적된다. 커스텀 컨트롤러 자체는 특별한 실행 환경이 아니라 클러스터 안에서 Deployment로 배포되는 평범한 Pod이며, apiserver를 watch할 권한을 RBAC으로 부여받는다는 점만 다르다.
운영 지식을 코드로 옮긴다는 것
Operator라는 이름은 사람 운영자(human operator)에서 왔다. 데이터베이스 같은 상태 저장 시스템을 운영하는 사람의 머릿속에는 매니페스트로 표현되지 않는 지식이 있다. primary가 죽었을 때 어느 replica를 승격할지는 복제 지연을 비교해야 정해지고, 백업은 스케줄과 보존 기간과 실패 시 재시도 판단이 따라붙고, 마이너 버전 업그레이드는 replica부터 순서대로 진행하되 primary는 역할을 넘긴 뒤 마지막에 교체해야 한다. 전부 조건 분기와 상태 판단이 얽힌 절차라서, runbook 문서와 사람의 새벽 대응으로 유지되는 것이 전통적인 모습이었다.
Operator 패턴은 이 판단들을 reconciliation loop 안의 코드로 옮긴다. StatefulSet이 제공하는 것은 안정적인 이름과 저장소, 순서 있는 기동까지다(Part IV. 워크로드). “지금 어느 인스턴스가 primary인가”, “이 replica는 승격해도 안전한가"는 애플리케이션마다 다른 도메인 지식이라 범용 컨트롤러에 넣을 수 없고, 바로 그 부분이 Operator가 채우는 자리다.
Cluster 매니페스트 한 장이 만드는 것
PostgreSQL을 담당하는 Operator인 CloudNativePG를 예로 들면 이 패턴의 밀도가 잘 보인다. 사용자가 작성하는 것은 이런 매니페스트 한 장이다.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-main
spec:
instances: 3
storage:
size: 20Gioperator는 이 선언에서 primary 하나와 replica 둘로 구성된 PostgreSQL Pod 3개를 만들고, streaming replication을 구성하고, 인스턴스별 PVC와 접속 자격 증명 Secret을 준비하고, 쓰기용(-rw), 읽기용(-ro) Service를 분리해 생성한다. 이후의 운영도 루프가 담당한다. primary Pod가 죽으면 replica 중 하나를 승격해 failover를 수행하고, instances를 5로 바꾸면 replica 두 개를 추가하며, 이미지 버전을 올리면 replica부터 차례로 재기동한 뒤 primary는 역할을 넘기고 마지막에 교체한다. 위에서 말한 사람의 runbook이 그대로 코드가 된 형태다.
CloudNativePG가 StatefulSet을 쓰지 않고 Pod를 직접 관리한다는 점도 이 맥락에서 읽힌다. 어느 Pod를 언제 지우고 만들지가 곧 failover와 업그레이드 판단이라서, 그 결정권을 범용 컨트롤러에 위임하지 않는 설계다. 이 operator의 아키텍처와 백업, 복구, 모니터링은 별도의 CloudNativePG 노트에 정리했다. 같은 문제를 Kubernetes 밖 가상 머신 환경에서 푸는 도구가 Patroni인데, DCS의 leader lock을 주기로 갱신하고 감시하는 루프는 Operator의 reconciliation과 같은 모양이다. 두 세계의 비교는 Patroni 노트가 담당한다.
직접 만들 때의 도구
Operator를 직접 작성한다면 바닥부터 시작할 필요는 없다. controller-runtime은 watch 캐시, 작업 큐, 재시도, leader election 같은 컨트롤러 공통 골격을 제공하는 Go 라이브러리이고, 개발자가 구현하는 것은 사실상 Reconcile 함수 하나로 좁혀진다.
func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)kubebuilder는 controller-runtime 위의 스캐폴딩 도구로, Go 구조체에 붙인 마커 주석에서 CRD YAML을 생성해 타입 정의와 스키마가 어긋나지 않게 한다. Operator SDK는 kubebuilder를 기반으로 하면서 Helm 차트나 Ansible 롤을 그대로 operator로 감싸는 경로도 제공한다. 다만 직접 만들기 전에 살펴볼 것이 있다. 데이터베이스, 메시지 큐, 인증서 관리처럼 흔한 도메인에는 이미 성숙한 operator가 있어서, 실무의 첫 선택은 만들기보다 고르기인 경우가 많다.
정리
이 노트의 첫 배포에서 마지막 Part까지, Kubernetes의 동작은 결국 하나의 원리로 수렴한다. 원하는 상태를 API 객체로 선언하면 컨트롤러가 현실을 그 선언에 맞춘다. Operator 패턴은 그 원리를 사용자가 정의한 도메인까지 확장한 것이고, CRD가 어휘를, 커스텀 컨트롤러가 판단을 맡는다. 상태 저장 시스템을 이 패턴으로 운영하는 구체적인 사례는 CloudNativePG 노트에서 이어진다.