K8s Operator 패턴이란?
Kubernetes Operator는 CRD(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를 보장하는 것이 프로덕션 운영의 핵심입니다.