Skip to content
Published on

Operator 베스트 프랙티스와 안티패턴

Authors

들어가며 — Operator는 만들기보다 길들이기가 어렵다

Operator를 처음 만들 때는 reconcile 함수가 동작하는 것만으로 감동합니다. 그러나 프로덕션에서 6개월쯤 굴려 보면 깨닫습니다. 만드는 것은 시작이고, 길들이는 것이 본론이라는 것을. 무한 루프로 API 서버를 두들기고, RBAC가 너무 넓어 보안 감사에서 지적받고, 업그레이드 한 번에 전체 워크로드가 흔들리는 경험을 하고 나면 "잘 만든 Operator"의 기준이 비로소 손에 잡힙니다.

이 글은 그 기준을 정리합니다. 앞선 두 글에서 무엇을 만들 수 있는지(카탈로그)와 어떻게 만드는지(데이터베이스 Operator)를 봤다면, 이번 글은 어떻게 잘 만드는지입니다. 좋은 Operator의 특징과 피해야 할 안티패턴을 코드와 함께 짚고, 보안·테스트·운영·성숙도까지 한 바퀴 돌겠습니다.

좋은 Operator의 네 가지 특징

1. 작은 API

좋은 Operator는 사용자에게 최소한의 손잡이만 줍니다. spec이 비대해지면 사용자는 무엇을 채워야 할지 모르고, Operator는 모든 조합을 검증해야 하며, 하위 호환을 깨지 않고 진화하기가 어려워집니다.

# 나쁜 예 — 내부 구현이 새어 나온 비대한 API
spec:
  statefulSetName: my-db-sts
  podAntiAffinityWeight: 100
  replicationProtocol: streaming
  walSegmentSize: 16MB
  checkpointTimeout: 300s
  # ... 수십 개의 저수준 옵션

# 좋은 예 — 의도만 선언
spec:
  replicas: 3
  version: "16.3"
  storage: { size: 50Gi }
  highAvailability: true

원칙은 "무엇을(what) 원하는지만 받고, **어떻게(how)**는 Operator가 결정한다"입니다. 사용자가 walSegmentSize를 알아야 한다면 그 추상화는 실패한 것입니다. 고급 사용자를 위한 탈출구가 필요하면 별도의 advanced 필드나 어노테이션으로 분리하되, 기본 경로는 단순하게 유지합니다.

2. 멱등한 reconcile

reconcile은 같은 입력에 대해 몇 번을 호출해도 같은 결과를 내야 합니다. 이것이 컨트롤러 패턴의 근간입니다. reconcile은 언제든, 어떤 이유로든(재시작, 이벤트 중복, 주기적 리싱크) 다시 호출될 수 있기 때문입니다.

// 나쁜 예 — 호출마다 새 리소스를 만든다 (비멱등)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	job := newBackupJob()             // 매번 새 이름의 Job 생성
	r.Create(ctx, job)                // reconcile 100번 = Job 100개
	return ctrl.Result{}, nil
}

// 좋은 예 — 원하는 상태를 선언하고 없을 때만 만든다
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	desired := desiredBackupJob(req)
	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired, mutateFn)
	return ctrl.Result{}, err
}

멱등성을 점검하는 간단한 테스트: "reconcile을 두 번 연속 호출했을 때 두 번째 호출이 아무것도 바꾸지 않는가?" 그렇다면 멱등합니다. 아니라면 어딘가에서 매번 변경을 만들고 있는 것이고, 이는 무한 reconcile 루프의 씨앗입니다.

3. 관측 가능성

운영자가 Operator 내부를 들여다볼 수 없으면 디버깅은 불가능합니다. 좋은 Operator는 세 가지 채널로 자기를 드러냅니다.

관측의 세 채널
 1. status.conditions  — 현재 상태와 이유를 선언적으로
 2. Kubernetes events   — 사건의 타임라인(kubectl describe)
 3. Prometheus metrics  — reconcile 횟수/지연/에러, 도메인 지표

특히 status.conditions의 Reason과 Message는 사람이 읽을 수 있어야 합니다. "Ready=False, Reason=ImagePullBackOff, Message=cannot pull registry.example.com/db:16.3"처럼 구체적이어야 운영자가 다음 행동을 결정할 수 있습니다.

4. 안전한 업그레이드

Operator 자체의 업그레이드와 Operator가 관리하는 리소스의 업그레이드, 둘 다 안전해야 합니다. CRD 스키마는 하위 호환을 유지하고, conversion webhook으로 버전 간 변환을 제공합니다. 관리 대상 워크로드는 한 번에 다 바꾸지 않고 점진적으로(카나리/롤링) 교체합니다.

안전한 업그레이드의 조건
 - CRD: 새 필드는 optional + default, 기존 필드 의미 불변
 - 여러 API 버전 공존 (v1alpha1, v1beta1, v1) + conversion
 - 관리 워크로드: 헬스 게이트를 통과해야 다음 단계
 - 롤백 경로 존재 (백업, 이전 버전 이미지 보존)

안티패턴 7선

안티패턴 1: reconcile에 부수효과 남발

reconcile은 "현재 상태를 원하는 상태로 수렴"시키는 함수여야 합니다. 그런데 reconcile 안에서 이메일을 보내고, 외부 API를 호출하고, 슬랙 알림을 쏘는 코드를 종종 봅니다. reconcile은 수십 번 재호출될 수 있으므로 이런 부수효과는 중복 발생합니다.

// 나쁜 예 — reconcile마다 알림 폭탄
func (r *Reconciler) Reconcile(...) (ctrl.Result, error) {
	r.slackNotify("deployment started!")  // 재호출마다 슬랙 도배
	// ...
}

외부 부수효과는 "상태가 실제로 전이됐을 때"만 한 번 발생해야 합니다. status에 "이미 알렸음" 같은 플래그를 두고, 전이 시점에만 트리거하는 식으로 멱등하게 만듭니다.

안티패턴 2: status를 spec처럼 쓰기

status는 Operator가 관찰한 결과를 쓰는 곳이고, spec은 사용자가 원하는 바를 쓰는 곳입니다. 사용자가 status에 값을 적게 만들거나, Operator가 입력을 status에서 읽으면 둘의 역할이 뒤섞여 디버깅 지옥이 됩니다.

올바른 역할 분리
  spec   : 사용자가 쓴다 → Operator가 읽는다 (desired)
  status : Operator가 쓴다 → 사용자가 읽는다 (observed)

깨진 패턴 (피할 것)
  - 사용자가 status.replicas를 수정하게 함
  - Operator가 status.targetVersion을 입력으로 신뢰

status 서브리소스를 활성화하면 spec과 status 업데이트가 분리돼 충돌과 권한 혼선을 줄일 수 있습니다.

안티패턴 3: 무한 requeue

에러가 나거나 조건이 안 맞을 때 무조건 즉시 requeue하면, 컨트롤러가 API 서버를 초당 수천 번 두들기는 폭주가 발생합니다. 이는 클러스터 전체를 위협합니다.

// 나쁜 예 — 에러 시 즉시 무한 재시도
if err != nil {
	return ctrl.Result{Requeue: true}, nil  // CPU/API 폭주
}

// 좋은 예 — 에러를 반환하면 controller-runtime이 지수 백오프
if err != nil {
	return ctrl.Result{}, err  // 자동 백오프 재시도
}

// 의도적 지연도 명시적으로
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil

원칙: 에러는 그냥 반환해 controller-runtime의 지수 백오프에 맡기고, 정상이지만 "잠시 후 다시 보고 싶을 때"만 RequeueAfter를 명시합니다. 무조건적인 Requeue: true는 거의 항상 실수입니다.

안티패턴 4: 광범위 RBAC

Operator가 cluster-admin이나 와일드카드 권한(*)을 요구하는 것은 가장 흔한 보안 안티패턴입니다. Operator가 침해되면 클러스터 전체가 함께 침해됩니다.

// 나쁜 예 — 모든 것에 대한 모든 권한
// +kubebuilder:rbac:groups="*",resources="*",verbs="*"

// 좋은 예 — 필요한 리소스에 필요한 동사만
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete

Kubebuilder의 RBAC 마커는 코드에서 정확히 필요한 권한만 선언하고 매니페스트를 생성하게 해 줍니다. 최소 권한 원칙을 코드 가까이에서 강제하는 좋은 도구입니다. 정기적으로 "이 동사가 정말 필요한가"를 감사하시기 바랍니다.

안티패턴 5: 클러스터 스코프 남용

모든 네임스페이스를 감시하는 클러스터 스코프 Operator는 강력하지만 위험합니다. 한 테넌트의 CR이 버그를 유발하면 전체 클러스터가 영향을 받고, RBAC도 자연히 넓어집니다. 가능하면 네임스페이스 스코프로 시작하고, 정말 필요할 때만 클러스터 스코프로 확장하십시오.

스코프 선택 가이드
 - 단일 팀/네임스페이스 대상 → 네임스페이스 스코프 권장
 - 멀티테넌트 플랫폼 → 클러스터 스코프 + 강한 격리 필요
 - watch 대상을 특정 네임스페이스로 제한하면 메모리/부하도 절감

안티패턴 6: webhook 강결합

밸리데이팅/뮤테이팅 webhook은 강력하지만, webhook이 죽으면 관련 리소스의 생성·수정이 전부 막힙니다. Operator 파드 하나의 장애가 클러스터의 API 동작을 마비시키는 단일 장애점이 될 수 있습니다.

webhook 안전 설계
 - failurePolicy를 신중히: Fail은 안전하지만 가용성 위험,
   Ignore는 가용성 우선이지만 검증 우회 위험
 - webhook 대상 범위를 namespaceSelector/objectSelector로 좁힘
 - webhook 자신의 네임스페이스는 제외 (자기 부트스트랩 데드락 방지)
 - webhook을 여러 레플리카로 HA 구성

핵심은 webhook이 없어도 핵심 기능이 동작하도록 설계하는 것입니다. webhook은 편의(검증/기본값)이지 필수 의존이 되어선 안 됩니다.

안티패턴 7: 캐시되지 않은 직접 읽기 남발

controller-runtime의 클라이언트는 기본적으로 informer 캐시에서 읽습니다. 그런데 매 reconcile마다 캐시를 우회해 API 서버에 직접 질의(예: 라벨 없는 List)하면 API 서버 부하가 폭증합니다.

// 나쁜 예 — 매번 전체 파드를 API 서버에서 직접 List
pods := &corev1.PodList{}
r.APIReader.List(ctx, pods)  // 캐시 우회 + 셀렉터 없음

// 좋은 예 — 캐시에서, 셀렉터로 좁혀서
pods := &corev1.PodList{}
r.List(ctx, pods,
	client.InNamespace(req.Namespace),
	client.MatchingLabels{"app": req.Name})

멀티테넌시와 보안

플랫폼 팀이 만드는 Operator는 여러 팀이 공유합니다. 한 테넌트가 다른 테넌트의 리소스에 영향을 주지 못하게 격리해야 합니다.

위협방어
테넌트 간 CR 간섭네임스페이스 격리, RBAC로 CR 접근 제한
자원 고갈(noisy neighbor)ResourceQuota, LimitRange, CR에 자원 상한
권한 상승Operator SA의 최소 권한, 사용자 권한 위임 시 검증
시크릿 노출시크릿은 status에 절대 쓰지 않기, 로그 마스킹
악의적 입력webhook/스키마 검증으로 위험한 spec 거부

특히 시크릿을 status나 이벤트, 로그에 노출하지 않는 것은 자주 놓치는 실수입니다. 디버깅 편의로 연결 문자열을 status에 적었다가 RBAC가 느슨한 사용자에게 비밀번호가 새는 사고가 납니다.

사용자 권한을 가장하지 말 것

플랫폼 Operator가 흔히 빠지는 함정 하나는, 자신의 강한 서비스 어카운트로 사용자가 요청한 작업을 그대로 대신 수행하는 것입니다. 이렇게 되면 권한이 약한 사용자가 Operator를 통해 자신은 할 수 없는 일을 우회 수행하는 권한 상승 통로가 열립니다. 안전한 패턴은 SubjectAccessReview로 "이 사용자가 정말 이 작업을 할 권한이 있는지"를 먼저 확인하는 것입니다.

// 사용자가 요청한 작업에 대한 권한을 위임 검증
sar := &authzv1.SubjectAccessReview{
	Spec: authzv1.SubjectAccessReviewSpec{
		User: requestingUser,
		ResourceAttributes: &authzv1.ResourceAttributes{
			Namespace: ns,
			Verb:      "create",
			Resource:  "databases",
			Group:     "db.example.com",
		},
	},
}
if err := r.Create(ctx, sar); err != nil {
	return err
}
if !sar.Status.Allowed {
	// 사용자에게 권한 없음 → Operator도 대신 수행 거부
	return fmt.Errorf("user %s is not allowed to create databases", requestingUser)
}

이 패턴은 특히 셀프서비스 플랫폼에서 중요합니다. Operator가 "권한의 대리인"이 아니라 "권한의 검문소"가 되어야 합니다.

리소스 효율 — 캐시와 동시성

대규모 클러스터에서 Operator는 자칫 메모리 먹는 하마가 됩니다. 모든 파드를 캐시하면 수만 개 객체가 메모리에 올라옵니다.

효율화 기법
 - 캐시 범위 제한: 특정 네임스페이스/라벨만 watch
 - predicate로 이벤트 필터: 관심 없는 변경은 reconcile 안 함
 - MaxConcurrentReconciles로 동시성 조절 (너무 높으면 API 폭주,
   너무 낮으면 처리 지연)
 - field selector/label selector로 List 범위 축소

predicate는 특히 강력합니다. 예를 들어 generation이 바뀐 변경(spec 변경)만 reconcile하고, status만 바뀐 이벤트는 무시하면 불필요한 reconcile을 대폭 줄입니다.

// spec 변경(generation 증가)만 reconcile
builder.WithPredicates(predicate.GenerationChangedPredicate{})

테스트 문화

Operator는 분산 시스템과 상호작용하므로 테스트가 까다롭지만, 그래서 더 중요합니다.

테스트 피라미드
 1. 단위 테스트: reconcile 로직, 헬퍼 함수 (fake client 활용)
 2. envtest: 진짜 API 서버 + etcd로 reconcile 통합 검증
    (controller-runtime이 제공, kube-apiserver 바이너리 사용)
 3. e2e: kind 클러스터에 실제 배포해 시나리오 검증
 4. 카오스/장애 주입: 파드 강제 삭제, 네트워크 분단 시 동작

특히 envtest는 Operator 테스트의 핵심입니다. mock이 아니라 진짜 API 서버를 띄워 CR 생성→reconcile→리소스 생성의 전 과정을 검증합니다. "멱등성 테스트"(reconcile 두 번 호출 후 변화 없음 확인)와 "삭제 테스트"(finalizer 정리 확인)를 반드시 포함하십시오.

SRE 관점 운영 — 알림과 런북

Operator도 하나의 서비스입니다. 운영 가능하려면 SLI/SLO, 알림, 런북이 있어야 합니다.

Operator의 운영 지표 예시
 - reconcile 에러율 (controller_runtime_reconcile_errors_total)
 - reconcile 지연 (워크큐 대기 시간)
 - 워크큐 깊이 (밀린 작업이 쌓이는가)
 - 도메인 SLI (예: 페일오버 성공률, 백업 신선도)

알림 규칙 예시
 - reconcile 에러율이 5분간 높음 → 경고
 - 워크큐가 계속 증가 → 컨트롤러 멈춤 의심
 - 백업이 24시간 이상 없음 → 호출

알림에는 반드시 런북 링크를 붙이십시오. 새벽 3시에 호출된 당직자가 "이 알림이 뜨면 무엇을 확인하고 무엇을 하라"를 즉시 알 수 있어야 합니다. Operator가 자동화하지 못한 예외 상황이 바로 사람이 개입할 지점이고, 런북이 그 다리입니다.

디버깅 — Operator가 일을 안 할 때 보는 순서

운영 중 가장 흔한 호출은 "CR을 만들었는데 아무 일도 안 일어난다"입니다. 진단 순서를 체화해 두면 대부분 빨리 원인을 찾습니다.

 1. CR이 실제로 존재하고 spec이 맞는가
    kubectl get database orders-db -o yaml
 2. status.conditions가 무엇을 말하는가
    → Reason/Message에 단서가 있는 경우가 대부분
 3. Operator 파드가 살아 있고 reconcile하고 있는가
    kubectl logs -n operator-system deploy/db-operator
 4. RBAC가 막고 있지 않은가 (Forbidden 로그)
    kubectl auth can-i create statefulsets --as=system:serviceaccount:operator-system:db-operator
 5. 워크큐가 밀려 있는가 (메트릭 확인)
    workqueue_depth, reconcile_errors_total
 6. 이벤트 타임라인
    kubectl describe database orders-db

가장 흔한 원인은 셋입니다. 첫째, RBAC 부족으로 Operator가 리소스를 못 만들어 조용히 실패. 둘째, predicate가 너무 빡빡해 이벤트가 reconcile을 트리거하지 못함. 셋째, finalizer가 걸렸는데 정리 로직이 에러를 반환해 삭제가 영원히 안 끝남. 이 세 가지를 먼저 의심하면 절반은 풀립니다.

finalizer 교착 — 삭제가 안 끝날 때

finalizer 정리 로직이 영구적으로 실패하면(예: 이미 사라진 외부 리소스를 지우려다 에러), CR이 Terminating에서 멈춥니다. 정리 로직은 **"이미 없으면 성공으로 간주"**하도록 멱등하게 작성해야 합니다.

func (r *Reconciler) reconcileDelete(ctx context.Context, db *dbv1alpha1.Database) (ctrl.Result, error) {
	// 외부 리소스 정리 — 이미 없어도 에러로 보지 않는다
	if err := r.cleanupExternalBackupBucket(ctx, db); err != nil {
		if !isNotFound(err) {
			return ctrl.Result{}, err // 진짜 에러만 재시도
		}
	}
	// 정리 끝 → finalizer 제거 → 쿠버네티스가 실제 삭제
	controllerutil.RemoveFinalizer(db, dbFinalizer)
	return ctrl.Result{}, r.Update(ctx, db)
}

응급 상황에서 finalizer를 강제로 떼어내(kubectl patch로 finalizers 비우기) 삭제를 진행시킬 수도 있지만, 이는 외부 리소스가 고아로 남을 수 있는 최후의 수단입니다.

Operator 성숙도 로드맵

CNCF Capability Level을 로드맵으로 활용할 수 있습니다.

Level 1: 기본 설치
  - CR로 설치/설정. helm으로 배포 가능.

Level 2: 원활한 업그레이드
  - 관리 대상의 버전 업그레이드를 안전하게 수행.

Level 3: 풀 라이프사이클
  - 백업/복구, 스케일, 장애 복구를 자동화.

Level 4: 깊은 인사이트
  - 메트릭/알림/로그로 관측 가능. 성능 분석 제공.

Level 5: 오토 파일럿
  - 오토스케일, 자동 튜닝, 이상 감지, 자가 치유.

모든 Operator가 Level 5를 목표할 필요는 없습니다. 대부분의 사내 Operator는 Level 2~3이면 충분합니다. 중요한 것은 자기 Operator가 지금 어느 레벨이고, 다음 레벨로 가는 데 무엇이 필요한지를 아는 것입니다.

종합 체크리스트

[API 설계]
[ ] spec이 작고 의도(what) 중심인가
[ ] 새 필드가 optional + default로 하위 호환을 지키는가
[ ] 여러 API 버전 + conversion 전략이 있는가

[reconcile]
[ ] 멱등한가 (두 번 호출 시 두 번째가 무변화)
[ ] 외부 부수효과가 전이 시점에만 한 번 발생하는가
[ ] 에러는 반환해 백오프에 맡기는가 (무한 requeue 없음)
[ ] finalizer로 정리 로직을 보장하는가

[관측]
[ ] status.conditions가 사람이 읽을 수 있는가
[ ] 중요한 사건을 event로 남기는가
[ ] 도메인 메트릭을 노출하는가

[보안]
[ ] RBAC가 최소 권한인가 (와일드카드 없음)
[ ] 시크릿을 status/event/log에 노출하지 않는가
[ ] 가능한 한 네임스페이스 스코프인가

[효율]
[ ] 캐시/watch 범위를 제한했는가
[ ] predicate로 불필요한 reconcile을 거르는가
[ ] MaxConcurrentReconciles를 적절히 설정했는가

[테스트/운영]
[ ] envtest로 통합 검증하는가
[ ] 멱등성/삭제 테스트가 있는가
[ ] 알림 규칙과 런북이 있는가

마치며

좋은 Operator와 나쁜 Operator를 가르는 것은 화려한 기능이 아니라 기본기입니다. 멱등한 reconcile, 작은 API, 최소 RBAC, 관측 가능성 — 이 평범해 보이는 원칙들이 새벽 3시의 장애와 보안 감사의 지적과 끝없는 유지보수 부채를 가릅니다.

세 편에 걸친 Operator 여정을 정리하면 이렇습니다. 첫 글에서 Operator로 무엇을 만들 수 있는지 카탈로그를 봤고, 둘째 글에서 데이터베이스 Operator로 어떻게 만드는지 손을 움직였으며, 이 글에서 어떻게 잘 만드는지 기준을 세웠습니다. Operator는 운영 지식을 코드로 옮기는 강력한 도구이지만, 그 코드 역시 누군가 운영해야 할 또 하나의 시스템임을 잊지 마시기 바랍니다. 잘 만든 Operator는 운영 부담을 줄이고, 잘못 만든 Operator는 새로운 부담을 만듭니다. 이 글의 체크리스트가 그 갈림길에서 좋은 쪽을 고르는 데 도움이 되기를 바랍니다.

참고 자료