목차
18 장

CRD와 Operator 패턴

K8s API를 사용자 도메인의 객체로 확장하는 두 축을 다룹니다. CustomResourceDefinition으로 새 객체 종류를 정의하고, controller-runtime 기반의 Operator가 1장에서 본 reconcile loop를 그 객체 위에 걸어 K8s의 선언적 모델을 도메인까지 확장합니다. ownerReference · finalizer · status subresource의 세 표준 패턴과 Kubebuilder · Operator SDK의 빌드 도구까지 한 사이클로 정리합니다.

17장 Admission Controller에서 Gatekeeper의 ConstraintTemplate, Kyverno의 ClusterPolicy 같은 객체가 모두 K8s 본체의 표준 자원이 아니라 두 도구가 CRD로 정의한 새 객체 종류라는 점을 한 번 짚어 두었습니다. 이번 챕터의 주제는 그 CRD 자체입니다. 지금까지 다룬 객체는 모두 K8s가 빌트인으로 갖고 있는 표준 자원이었습니다 — Pod, Deployment, Service, ConfigMap, Secret, NetworkPolicy 등. 그러나 K8s API의 진짜 매력은 그 위에 우리 도메인의 객체를 추가할 수 있다는 점입니다. PostgreSQL 클러스터, Redis 마스터-replica 토폴로지, Kafka 브로커 그룹 같은 도메인 개념이 K8s 안에서 1급 객체가 되어, kubectl get postgrescluster로 조회되고 매니페스트로 선언되며 컨트롤러에 의해 자동으로 운영됩니다. 이 확장의 두 축이 CustomResourceDefinition (새 객체 종류 정의)과 Operator (그 객체를 운영하는 컨트롤러)입니다.

이번 챕터의 끝에서는 1장 쿠버네티스란의 reconcile loop 모델이 K8s 본체의 표준 자원을 넘어 사용자 도메인 객체까지 확장된 모양이 손에 들어옵니다. 4장 Deployment 컨트롤러와 18장 Operator가 같은 패턴의 다른 인스턴스라는 점이 그 출발점입니다.

K8s API 확장의 두 길 #

K8s는 자기 API를 확장하는 길을 두 개 제공합니다.

특징
CRD (CustomResourceDefinition)매니페스트로 새 객체 종류를 등록. K8s API 서버가 그 객체를 etcd에 저장하고 표준 객체처럼 다룬다
Aggregation Layer별도 API 서버를 띄워 K8s API 서버가 그쪽으로 호출을 위임. 더 유연하지만 운영 비용이 큼

운영 클러스터에서 압도적으로 자주 쓰이는 길은 CRD입니다. Aggregation Layer는 metrics-server처럼 K8s 코어와 매우 깊게 묶이는 경우 외에는 거의 쓰이지 않습니다. 이번 챕터의 주제는 CRD입니다.

CRD — 새 객체 종류 정의 #

CRD는 그 자체로 K8s의 한 객체 종류입니다. CRD 한 장을 적용하면 그 순간부터 클러스터에 새 객체 종류가 등록되고, kubectl로 그 객체를 만들고 조회할 수 있게 됩니다.

가장 단순한 CRD 예시 #

widget-crd.yaml — 새 객체 종류 'Widget' 정의
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: widgets.myteam.example.com
spec:
  group: myteam.example.com
  scope: Namespaced
  names:
    plural: widgets
    singular: widget
    kind: Widget
    shortNames: ["wg"]
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:
                  type: string
                  enum: ["small", "medium", "large"]
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 10
              required: ["size"]
            status:
              type: object
              properties:
                phase:
                  type: string
      subresources:
        status: {}

이 CRD를 적용하고 나면 그 클러스터에서 다음 매니페스트가 valid 해집니다.

my-widget.yaml — 새 Widget 객체 한 개
apiVersion: myteam.example.com/v1
kind: Widget
metadata:
  name: my-first-widget
  namespace: default
spec:
  size: medium
  replicas: 3
표준 kubectl로 조회 가능
kubectl get widgets
kubectl get wg my-first-widget -o yaml

CRD 정의의 핵심 요소 #

위 매니페스트에서 운영 측면에서 중요한 부분 몇 가지를 짚습니다.

  • scope: Namespaced vs Cluster — 이 객체가 네임스페이스 안에 사는지, 클러스터 전역에 사는지 정합니다. 한 번 정하면 바꾸기 어려우므로 도메인의 결을 보고 결정합니다. ConfigMap · Pod는 Namespaced, Node · StorageClass는 Cluster.
  • schema.openAPIV3Schema — 객체의 모양을 OpenAPI 스키마로 정의합니다. K8s API 서버가 admission 단계에서 이 스키마로 매니페스트를 검증합니다. enum, minimum, required 같은 제약을 여기서 표현합니다.
  • subresources.status: {}status 필드를 별도 subresource로 분리합니다. 컨트롤러가 spec을 건드리지 않고 status만 갱신할 수 있게 해 주는 K8s 표준 패턴입니다. 이 한 줄이 매우 중요합니다 — 뒤의 §“status subresource"에서 다시 짚습니다.
  • versions — CRD는 여러 버전을 동시에 지원할 수 있고, 한 버전이 storage: true로 실제 etcd 저장 형식을 결정합니다. 버전 변경 시 storage 마이그레이션이 필요합니다.

CRD 만으로는 부족하다 — Operator의 역할 #

CRD를 등록한 것만으로는 객체에 의미가 없습니다. Widget 객체를 만들어도 그 자체로는 etcd에 저장된 매니페스트일 뿐, 어떤 동작도 일어나지 않습니다. 그 객체를 보고 실제로 무엇인가 만들고 운영하는 컨트롤러가 필요합니다.

이 컨트롤러를 K8s 커뮤니티에서는 Operator라고 부릅니다. Operator는 본질적으로 다음 두 부분의 묶음입니다.

  • CRD — 새 도메인 객체의 모양 정의
  • Custom Controller — 그 객체를 보고 reconcile 루프를 도는 코드

이 모델이 강력한 이유는 K8s의 핵심 패러다임 — 선언적 desired state + 컨트롤러의 reconcile loop — 을 사용자 도메인까지 확장하기 때문입니다. 우리는 “PostgreSQL 마스터 1개 + replica 3개의 클러스터를 원한다” 고 매니페스트로 선언만 하면, Operator가 8장 StatefulSet, Service, PVC, ConfigMap, Secret, backup CronJob까지 자동으로 만들고 관리합니다.

Reconcile loop — 컨트롤러의 본질 #

K8s 컨트롤러의 핵심 패턴은 무한 루프입니다. 1장에서 그림으로 본 reconcile loop의 정확한 형태입니다.

Reconcile loop의 의사코드
loop forever:
  observed_state = K8s API에서 실제 상태 조회
  desired_state = 매니페스트에서 원하는 상태 읽기
  if observed_state != desired_state:
    K8s API 호출로 차이를 좁힘
  sleep until 다음 트리거

이 단순한 루프가 빌트인 컨트롤러 (4장 Deployment / ReplicaSet, StatefulSet, Job 등)와 사용자 정의 Operator의 공통 모델입니다. Operator를 짠다는 것은 **“내 도메인 객체에 대해 이 reconcile 함수를 어떻게 구현할 것인가”**를 정하는 일입니다. 4장에서 Deployment 컨트롤러의 reconcile이 ReplicaSet과 Pod의 차이를 메우는 모습을 봤다면, 본 챕터의 Operator는 같은 패턴을 Widget 같은 사용자 정의 객체에 적용합니다.

controller-runtime — Operator의 표준 골격 #

처음부터 K8s API client를 직접 호출해 reconcile 루프를 짜는 것은 가능하지만 보일러플레이트가 매우 큽니다. controller-runtime은 Kubernetes 프로젝트 자체에서 관리하는 Go 라이브러리로, Operator의 골격을 표준화한 도구입니다. Kubebuilder와 Operator SDK가 이 controller-runtime을 감싼 상위 도구입니다.

Reconciler의 모양 #

controller-runtime 기반의 Operator는 거의 다음 모양을 갖습니다.

WidgetReconciler — 단순화한 골격
package controller

import (
    "context"

    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

    myteamv1 "myteam.example.com/api/v1"
)

type WidgetReconciler struct {
    client.Client
}

func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    // 1. desired state — Widget 객체 읽기
    var widget myteamv1.Widget
    if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
        return reconcile.Result{}, client.IgnoreNotFound(err)
    }

    // 2. observed state — Widget이 만들어야 할 Deployment 읽기
    var deploy appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{
        Namespace: widget.Namespace,
        Name:      widget.Name,
    }, &deploy)

    if err != nil && client.IgnoreNotFound(err) == nil {
        // 없으면 만들기
        newDeploy := buildDeployment(&widget)
        if err := r.Create(ctx, newDeploy); err != nil {
            return reconcile.Result{}, err
        }
    } else if err == nil {
        // 있으면 desired state와 비교 후 갱신
        if needsUpdate(&deploy, &widget) {
            updateDeployment(&deploy, &widget)
            if err := r.Update(ctx, &deploy); err != nil {
                return reconcile.Result{}, err
            }
        }
    }

    // 3. status 갱신
    widget.Status.Phase = "Ready"
    if err := r.Status().Update(ctx, &widget); err != nil {
        return reconcile.Result{}, err
    }

    return reconcile.Result{}, nil
}

이 함수가 Widget 객체 한 개당 한 번씩 (또는 변경이 있을 때마다) 호출됩니다. controller-runtime이 watch / queue / retry / leader election 같은 기반 인프라를 모두 처리해 주므로, Operator 개발자는 위 reconcile 로직에만 집중하면 됩니다.

운영의 세 가지 표준 패턴 #

Operator를 짤 때 거의 항상 만나는 세 가지 패턴이 있습니다. K8s API의 표준 메커니즘이므로 잘 알아 두면 한참 굴러갑니다.

1. ownerReference — 자식 객체의 자동 정리 #

Widget이 Deployment를 만들었다면, Widget이 삭제될 때 그 Deployment도 같이 삭제되어야 합니다. 일일이 코드로 처리하지 않고 K8s의 garbage collector가 자동으로 처리하게 만드는 메커니즘이 ownerReference입니다. 4장에서 Deployment가 ReplicaSet을, ReplicaSet이 Pod를 ownerReference로 묶었던 모델이 그대로 사용자 도메인에 적용됩니다.

자식 객체 생성 시 ownerReference 부착
deploy := &appsv1.Deployment{
    ObjectMeta: metav1.ObjectMeta{
        Name:      widget.Name,
        Namespace: widget.Namespace,
        OwnerReferences: []metav1.OwnerReference{
            *metav1.NewControllerRef(&widget, myteamv1.GroupVersion.WithKind("Widget")),
        },
    },
    Spec: ...
}

이 ownerReference가 부착된 Deployment는 부모 Widget이 삭제되는 순간 K8s의 garbage collector에 의해 자동으로 삭제됩니다. 명시적인 삭제 로직을 Operator 코드에 넣을 필요가 없습니다.

2. finalizer — 외부 자원 정리의 hook #

객체가 삭제될 때 K8s 안의 자식 객체는 ownerReference로 자동 정리되지만, K8s 밖의 자원은 그렇지 않습니다. 클라우드 LB, S3 버킷, RDS 인스턴스 같은 외부 자원을 만든 Operator 라면, 객체 삭제 시 그 외부 자원도 같이 정리해야 합니다. 이 hook을 거는 메커니즘이 finalizer입니다.

finalizer가 등록된 객체를 삭제하려고 하면 K8s API 서버는 객체를 곧장 지우지 않고 metadata.deletionTimestamp 필드만 채웁니다. 그 객체는 “삭제 진행 중” 상태가 되고, finalizer 목록이 비워질 때까지 etcd에 남아 있습니다.

finalizer를 활용한 외부 자원 정리
const widgetFinalizer = "widget.myteam.example.com/finalizer"

func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    var widget myteamv1.Widget
    if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
        return reconcile.Result{}, client.IgnoreNotFound(err)
    }

    // 삭제 진행 중인가
    if !widget.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
            // 외부 자원 정리
            if err := cleanupExternalResources(&widget); err != nil {
                return reconcile.Result{}, err
            }
            // 정리가 끝났으면 finalizer 제거 → K8s가 객체를 진짜로 삭제
            controllerutil.RemoveFinalizer(&widget, widgetFinalizer)
            if err := r.Update(ctx, &widget); err != nil {
                return reconcile.Result{}, err
            }
        }
        return reconcile.Result{}, nil
    }

    // 삭제 중이 아니면 finalizer 보장
    if !controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
        controllerutil.AddFinalizer(&widget, widgetFinalizer)
        if err := r.Update(ctx, &widget); err != nil {
            return reconcile.Result{}, err
        }
    }

    // 일반 reconcile 진행
    ...
}

finalizer가 있는 객체는 외부 자원 정리가 끝날 때까지 진짜로 삭제되지 않으므로, 운영자가 객체를 강제로 지우려 해도 클라우드 자원이 누락되지 않습니다.

3. status subresource — spec과 status의 분리 #

CRD에 subresources.status: {}를 적은 한 줄의 의미가 여기서 완성됩니다. 이 subresource가 있는 CRD는 다음 두 가지 동작이 분리됩니다.

  • 사용자 (또는 GitOps 도구)는 spec만 수정. status 수정은 무시됨.
  • 컨트롤러는 r.Status().Update()status만 수정. spec은 건드리지 않음.

이 분리가 중요한 이유는 GitOps와의 충돌을 막기 때문입니다. 20장 GitOps에서 다룰 ArgoCD가 git의 매니페스트를 클러스터에 동기화할 때, status 필드는 컨트롤러가 채우는 값이라 git에는 없습니다. status subresource가 분리되어 있으면 ArgoCD는 status를 보지 않고 spec만 비교하므로, 컨트롤러가 status를 갱신해도 ArgoCD가 “drift"로 인식하지 않습니다.

Operator 빌드 도구 — Kubebuilder vs Operator SDK #

controller-runtime을 직접 쓰는 것보다 더 상위 추상화를 제공하는 도구가 둘 있습니다.

도구특징
KubebuilderKubernetes 프로젝트 공식 도구. CRD scaffolding, Makefile, kustomize 통합. controller-runtime을 가장 직접적으로 감쌈
Operator SDKRed Hat의 도구. Kubebuilder를 기반으로 함 + Helm / Ansible 기반 Operator 옵션 추가

골격을 만들고 reconcile 함수만 채우는 흐름은 두 도구가 거의 같습니다. Go로 직접 짜는 본격 Operator는 Kubebuilder가 더 표준에 가깝고, Helm 차트로 시작해 Operator로 옮겨 가는 마이그레이션 시나리오는 Operator SDK가 매끄럽습니다.

Kubebuilder로 새 Operator 프로젝트 시작
kubebuilder init --domain example.com --repo github.com/myteam/widget-operator
kubebuilder create api --group myteam --version v1 --kind Widget

이 두 명령으로 CRD 정의, controller 골격, 매니페스트, Dockerfile, Makefile이 모두 생성됩니다. 그 후 controller.go의 Reconcile 함수를 채우는 게 실제 작업의 본체입니다.

운영에서 자주 만나는 Operator #

본 챕터의 모델을 알면 운영 클러스터에 이미 깔려 있는 다양한 Operator의 의도와 동작이 한 줄로 읽힙니다. 자주 만나는 사례를 짧게 짚어 둡니다.

  • cert-manager10장 Ingress에서 짚었던 인증서 자동 발급 도구. Certificate, Issuer, ClusterIssuer라는 CRD를 정의하고, Let’s Encrypt와의 ACME 챌린지 흐름을 reconcile로 자동화합니다.
  • AWS Load Balancer Controller10장의 ALB Ingress Controller도 Operator입니다. Ingress 객체와 AWS ALB의 일관성을 reconcile로 유지합니다.
  • External Secrets Operator29장 시크릿 운영에서 다룰 도구. ExternalSecret CRD가 AWS Secrets Manager · Vault · GCP SM의 값을 K8s Secret으로 동기화합니다.
  • CloudNativePG · Zalando Postgres Operator8장 StatefulSet 위에 얹혀 PostgreSQL 클러스터의 셋업 · 백업 · 페일오버를 자동화합니다. DB 같은 상태성 워크로드의 운영 자동화에서 Operator의 가치가 가장 큰 영역입니다.
  • Karpenter13장 오토스케일링의 EKS 노드 자동화 도구. NodePool, NodeClass CRD 위에 reconcile을 돌려 노드를 동적으로 띄우고 정리합니다.

운영 클러스터의 매니페스트 디렉터리에서 apiVersion: postgres-operator.crunchydata.com/v1beta1 같은 낯선 API 그룹을 만나면, 그것은 거의 항상 어느 Operator가 정의한 CRD입니다. 본 챕터의 모델로 한 줄로 읽힙니다 — “이 CRD는 어느 Operator가 제공하고, 그 Operator의 reconcile이 어떤 자원들을 만들고 관리하는가”.

언제 Operator를 짜야 하는가 #

CRD와 Operator는 강력한 도구이지만 모든 도메인 객체에 필요하지는 않습니다. 다음 조건이 모일 때 Operator의 가치가 큽니다.

  • 상태가 있는 워크로드의 운영 자동화 — DB 클러스터의 셋업, 백업, 페일오버, 업그레이드처럼 사람이 정기적으로 돌리던 절차
  • 여러 K8s 객체의 묶음을 한 도메인 객체로 추상화 — Deployment + Service + Ingress + PDB + HPA + ServiceMonitor 등을 묶어 한 객체로
  • 외부 자원과 K8s 객체의 일관성 유지 — 클라우드 LB, DNS 레코드 같은 것을 K8s 객체와 묶어 자동 동기화
  • 도메인 지식의 코드화 — “PostgreSQL primary가 죽으면 가장 동기화 lag이 작은 replica를 promote” 같은 운영 노하우

반대로, Helm 차트 한 장으로 끝나는 단순 묶음이라면 Operator를 짤 필요는 없습니다. Operator는 코드 유지보수 비용이 큰 도구이므로, 정말 자동화 가치가 큰 곳에만 도입하는 편이 좋습니다.

연습문제 #

  1. 본인의 클러스터에 설치된 CRD를 확인합니다 (kubectl get crd). 표준 K8s 객체 (Pod, Service 등) 외에 어떤 CRD 들이 있는지 표로 정리하고, 각 CRD가 어느 Operator가 정의한 것인지 (cert-manager, ALB Controller, Karpenter, ArgoCD 등) 매핑합니다. 그 Operator의 reconcile이 어떤 자원들을 만들고 관리하는지 §“운영에서 자주 만나는 Operator"의 모델로 한 단락씩 정리합니다.
  2. 본문의 widget-crd.yaml을 그대로 적용한 뒤, Widget 객체 한 개를 만들어 봅니다. CRD는 등록되어 있지만 그것을 보는 컨트롤러가 없으므로 객체만 etcd에 저장되고 아무 동작도 일어나지 않습니다. 이 “객체는 있는데 컨트롤러가 없다"는 상태가 §“CRD 만으로는 부족하다"의 모델과 어떻게 연결되는지를 자신의 표현으로 한 단락으로 정리합니다.
  3. status subresource가 있는 CRD와 없는 CRD의 차이를 시뮬레이션으로 적어 봅니다. ArgoCD가 git 매니페스트를 클러스터에 동기화할 때, status 필드가 컨트롤러에 의해 갱신되는 동안 git에는 그 값이 없는 상태입니다. status subresource가 분리되어 있을 때와 그렇지 않을 때 ArgoCD의 drift 검출이 어떻게 다르게 동작할지를 20장 GitOps와 연결해 한 단락으로 정리합니다.

한 줄 요약: CRD가 K8s API를 새 객체 종류로 확장하고, controller-runtime 기반의 Operator가 그 객체 위에 1장의 reconcile loop를 걸어 K8s의 선언적 모델을 사용자 도메인까지 끌어들인다. 운영의 세 표준 패턴은 ownerReference (자식 자동 정리) · finalizer (외부 자원 정리 hook) · status subresource (GitOps 충돌 방지) 다. cert-manager · External Secrets · CloudNativePG · Karpenter 같은 운영 클러스터의 핵심 도구가 모두 이 패턴 위에 서 있다.

다음 챕터 #

본 챕터까지 K8s API 자체의 깊이 — CNI · RBAC · Admission · CRD — 를 따라왔습니다. 이만큼이 K8s 객체 모델의 확장 메커니즘입니다. 다음 챕터는 시점을 한 단 옮깁니다 — 이 모든 컴포넌트가 굴러가는 클러스터를 어떻게 관측할 것인가입니다.

19장 옵저버빌리티에서는 클러스터와 워크로드의 메트릭 · 로그 · 트레이스 세 차원을 다룹니다. Prometheus + Grafana의 메트릭 스택, Loki의 로그 집계, OpenTelemetry의 분산 트레이스, 그리고 그 위에 얹히는 kube-state-metrics · node_exporter 같은 표준 익스포터까지 한 사이클로 정리합니다. 11장 resources.requests / limits · 12장 Health check · 13장 오토스케일링의 신호들이 모두 이 옵저버빌리티 스택을 거쳐 운영자에게 도달한다는 점이 본 챕터들의 마지막 매듭이 됩니다.

X