K8s CRD·Operator 패턴 심화

K8s Operator 패턴이란?

Kubernetes OperatorCRD(Custom Resource Definition)커스텀 컨트롤러를 결합하여, 복잡한 애플리케이션의 배포·운영·복구를 자동화하는 패턴입니다. 데이터베이스 클러스터, 메시지 브로커, ML 파이프라인 같은 스테이트풀 워크로드를 쿠버네티스 네이티브 방식으로 관리할 수 있습니다.

이 글에서는 CRD 스키마 설계, 컨트롤러 Reconciliation Loop, Operator SDK로 구현, 상태 관리와 Finalizer, 테스트와 운영 전략까지 실무 패턴을 다룹니다.

CRD 스키마 설계

CRD는 쿠버네티스 API를 확장하여 프로젝트 도메인에 맞는 리소스를 정의합니다.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.app.example.com
spec:
  group: app.example.com
  names:
    kind: Database
    listKind: DatabaseList
    plural: databases
    singular: database
    shortNames:
      - db
  scope: Namespaced
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: ["engine", "version", "storage"]
              properties:
                engine:
                  type: string
                  enum: ["postgresql", "mysql", "mongodb"]
                version:
                  type: string
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 7
                  default: 1
                storage:
                  type: object
                  required: ["size"]
                  properties:
                    size:
                      type: string
                      pattern: "^[0-9]+(Gi|Ti)$"
                    storageClass:
                      type: string
                      default: "standard"
                backup:
                  type: object
                  properties:
                    enabled:
                      type: boolean
                      default: true
                    schedule:
                      type: string
                      default: "0 2 * * *"
                    retention:
                      type: integer
                      default: 7
                resources:
                  type: object
                  properties:
                    cpu:
                      type: string
                      default: "500m"
                    memory:
                      type: string
                      default: "1Gi"
            status:
              type: object
              properties:
                phase:
                  type: string
                  enum: ["Pending", "Creating", "Running", "Failed", "Deleting"]
                readyReplicas:
                  type: integer
                endpoint:
                  type: string
                conditions:
                  type: array
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Engine
          type: string
          jsonPath: .spec.engine
        - name: Version
          type: string
          jsonPath: .spec.version
        - name: Replicas
          type: integer
          jsonPath: .spec.replicas
        - name: Status
          type: string
          jsonPath: .status.phase
        - name: Endpoint
          type: string
          jsonPath: .status.endpoint
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp

이 CRD를 적용하면 kubectl get databases 또는 kubectl get db로 커스텀 리소스를 조회할 수 있습니다.

CR(Custom Resource) 인스턴스

apiVersion: app.example.com/v1alpha1
kind: Database
metadata:
  name: order-db
  namespace: production
spec:
  engine: postgresql
  version: "16"
  replicas: 3
  storage:
    size: 100Gi
    storageClass: fast-ssd
  backup:
    enabled: true
    schedule: "0 */6 * * *"
    retention: 14
  resources:
    cpu: "2"
    memory: 4Gi

Go Operator: Reconciliation Loop

Operator의 핵심은 Reconciliation Loop입니다. 원하는 상태(spec)와 현재 상태(status)를 비교하여 필요한 작업을 수행합니다.

// controller/database_controller.go
package controller

import (
    "context"
    "fmt"
    "time"

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

    appv1alpha1 "github.com/example/db-operator/api/v1alpha1"
)

type DatabaseReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

func (r *DatabaseReconciler) Reconcile(
    ctx context.Context,
    req ctrl.Request,
) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. CR 조회
    db := &appv1alpha1.Database{}
    if err := r.Get(ctx, req.NamespacedName, db); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil // 이미 삭제됨
        }
        return ctrl.Result{}, err
    }

    // 2. Finalizer 처리 (삭제 시 정리 작업)
    if db.DeletionTimestamp != nil {
        return r.handleDeletion(ctx, db)
    }
    if !containsFinalizer(db, "database.app.example.com/cleanup") {
        addFinalizer(db, "database.app.example.com/cleanup")
        if err := r.Update(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
    }

    // 3. StatefulSet 생성/업데이트
    if err := r.reconcileStatefulSet(ctx, db); err != nil {
        r.updateStatus(ctx, db, "Failed", err.Error())
        return ctrl.Result{RequeueAfter: 30 * time.Second}, err
    }

    // 4. Service 생성
    if err := r.reconcileService(ctx, db); err != nil {
        return ctrl.Result{}, err
    }

    // 5. Secret (비밀번호) 생성
    if err := r.reconcileSecret(ctx, db); err != nil {
        return ctrl.Result{}, err
    }

    // 6. 백업 CronJob 생성
    if db.Spec.Backup.Enabled {
        if err := r.reconcileBackupCronJob(ctx, db); err != nil {
            logger.Error(err, "Failed to reconcile backup")
        }
    }

    // 7. 상태 업데이트
    r.updateStatus(ctx, db, "Running", "")

    // 주기적 재조정 (헬스체크 용도)
    return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}

func (r *DatabaseReconciler) reconcileStatefulSet(
    ctx context.Context,
    db *appv1alpha1.Database,
) error {
    sts := &appsv1.StatefulSet{}
    err := r.Get(ctx, types.NamespacedName{
        Name:      db.Name,
        Namespace: db.Namespace,
    }, sts)

    desired := r.buildStatefulSet(db)

    if errors.IsNotFound(err) {
        // 생성
        ctrl.SetControllerReference(db, desired, r.Scheme)
        return r.Create(ctx, desired)
    }
    if err != nil {
        return err
    }

    // 업데이트 (replicas, image 변경 등)
    sts.Spec.Replicas = desired.Spec.Replicas
    sts.Spec.Template = desired.Spec.Template
    return r.Update(ctx, sts)
}

// 컨트롤러 등록: 어떤 리소스 변경을 감시할지 선언
func (r *DatabaseReconciler) SetupWithManager(
    mgr ctrl.Manager,
) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&appv1alpha1.Database{}).
        Owns(&appsv1.StatefulSet{}).    // 소유한 StatefulSet 변경도 감지
        Owns(&corev1.Service{}).
        Owns(&corev1.Secret{}).
        Complete(r)
}

Finalizer: 안전한 리소스 정리

func (r *DatabaseReconciler) handleDeletion(
    ctx context.Context,
    db *appv1alpha1.Database,
) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    if containsFinalizer(db, "database.app.example.com/cleanup") {
        r.updateStatus(ctx, db, "Deleting", "")

        // 1. 최종 백업 생성
        if db.Spec.Backup.Enabled {
            logger.Info("Creating final backup before deletion")
            if err := r.createFinalBackup(ctx, db); err != nil {
                logger.Error(err, "Final backup failed, proceeding with deletion")
            }
        }

        // 2. 외부 리소스 정리 (DNS, 인증서 등)
        if err := r.cleanupExternalResources(ctx, db); err != nil {
            return ctrl.Result{RequeueAfter: 10 * time.Second}, err
        }

        // 3. PVC 정리 (선택적)
        if err := r.cleanupPVCs(ctx, db); err != nil {
            logger.Error(err, "PVC cleanup failed")
        }

        // 4. Finalizer 제거 → K8s가 CR 삭제 완료
        removeFinalizer(db, "database.app.example.com/cleanup")
        if err := r.Update(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
    }

    return ctrl.Result{}, nil
}

Finalizer가 없으면 CR 삭제 시 소유 리소스(StatefulSet 등)만 GC로 삭제되고, 외부 리소스(S3 백업, DNS 레코드 등)는 고아 상태로 남습니다. K8s Pod Probe 헬스체크와 마찬가지로 안전한 종료 절차가 핵심입니다.

Operator SDK 프로젝트 구조

# Operator SDK로 프로젝트 생성
operator-sdk init --domain example.com --repo github.com/example/db-operator
operator-sdk create api --group app --version v1alpha1 --kind Database 
  --resource --controller

# 프로젝트 구조
db-operator/
├── api/
│   └── v1alpha1/
│       ├── database_types.go      # CR 타입 정의
│       ├── groupversion_info.go
│       └── zz_generated.deepcopy.go
├── config/
│   ├── crd/                       # CRD YAML 자동 생성
│   ├── manager/                   # Deployment 매니페스트
│   ├── rbac/                      # RBAC 권한
│   └── samples/                   # CR 예시
├── controllers/
│   └── database_controller.go     # Reconciler 구현
├── main.go
├── Dockerfile
└── Makefile

# 빌드 & 배포
make manifests      # CRD 생성
make generate       # DeepCopy 생성
make docker-build IMG=myregistry/db-operator:v0.1.0
make deploy IMG=myregistry/db-operator:v0.1.0

운영: RBAC과 리더 선출

# Operator가 필요한 최소 RBAC 권한
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: db-operator-role
rules:
  # CR 관리
  - apiGroups: ["app.example.com"]
    resources: ["databases", "databases/status", "databases/finalizers"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  # 소유 리소스 관리
  - apiGroups: ["apps"]
    resources: ["statefulsets"]
    verbs: ["get", "list", "watch", "create", "update", "delete"]
  - apiGroups: [""]
    resources: ["services", "secrets", "configmaps", "persistentvolumeclaims"]
    verbs: ["get", "list", "watch", "create", "update", "delete"]
  - apiGroups: ["batch"]
    resources: ["cronjobs", "jobs"]
    verbs: ["get", "list", "watch", "create", "update", "delete"]
  # 이벤트 기록
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]
  # 리더 선출
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "list", "watch", "create", "update", "delete"]
// main.go — 리더 선출로 HA 보장
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme:                 scheme,
    LeaderElection:         true,
    LeaderElectionID:       "db-operator-leader",
    HealthProbeBindAddress: ":8081",
    MetricsBindAddress:     ":8080",
})

// 헬스체크 엔드포인트
mgr.AddHealthzCheck("healthz", healthz.Ping)
mgr.AddReadyzCheck("readyz", healthz.Ping)

설계 원칙

원칙 설명
멱등성 Reconcile은 몇 번 호출되든 같은 결과를 보장해야 함
Level-triggered 이벤트(Edge)가 아닌 현재 상태(Level) 기반으로 판단
Owner Reference 생성한 리소스에 항상 OwnerRef 설정 → GC 자동 정리
Status Subresource spec 변경 없이 status만 독립 업데이트 → 무한 루프 방지
Finalizer 외부 리소스가 있으면 반드시 Finalizer로 정리
에러 시 Requeue 실패하면 RequeueAfter로 재시도, 지수 백오프 적용

마무리

K8s Operator 패턴은 CRD로 도메인 리소스 정의, Reconciliation Loop로 상태 수렴, Finalizer로 안전한 정리를 구현하여 복잡한 워크로드를 쿠버네티스 네이티브로 관리합니다. K8s RBAC 권한 관리와 함께 최소 권한 원칙을 적용하고, 리더 선출로 HA를 보장하는 것이 프로덕션 운영의 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux