본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 5장: 커스텀 컨트롤러와 오퍼레이터 개발
2026년 5월 22일·인프라·

5장: 커스텀 컨트롤러와 오퍼레이터 개발

Kubernetes 컨트롤러 패턴의 원리, 조정 루프, controller-runtime과 Kubebuilder를 활용한 오퍼레이터 개발, CRD 설계 모범 사례를 다룹니다.

14분1,184자7개 섹션
kubernetesci-cdinfrastructureautomationobservability
공유
kubernetes-gitops5 / 10
12345678910
이전4장: 멀티클러스터 관리 전략다음6장: Karpenter - 차세대 노드 오토스케일링

지금까지 ArgoCD, Flux, 멀티클러스터 관리까지 Kubernetes 운영의 외부 도구를 중심으로 살펴보았습니다. 그런데 이런 도구들이 공통적으로 사용하는 핵심 패턴이 있습니다. 바로 컨트롤러 패턴(Controller Pattern)입니다. ArgoCD의 Application Controller, Flux의 Source Controller, Karpenter의 Provisioner 등 모두 이 패턴으로 동작합니다. 이번 장에서는 이 패턴의 원리를 이해하고, 직접 오퍼레이터를 개발하는 방법을 다루겠습니다.

Kubernetes 컨트롤러 패턴

원하는 상태와 실제 상태

Kubernetes의 모든 것은 선언적 상태 관리(Declarative State Management)를 기반으로 합니다. 사용자가 "원하는 상태(Desired State)"를 선언하면, 컨트롤러가 "실제 상태(Current State)"를 원하는 상태로 수렴시킵니다.

조정 루프 (Reconciliation Loop)

컨트롤러의 핵심은 조정 루프(Reconciliation Loop)입니다. 이 루프는 다음 과정을 무한 반복합니다.

  1. 관찰(Observe): API 서버에서 리소스의 현재 상태를 읽습니다
  2. 분석(Analyze): 원하는 상태(spec)와 현재 상태(status)를 비교합니다
  3. 조치(Act): 차이가 있으면 현재 상태를 원하는 상태로 수렴시키는 작업을 수행합니다
  4. 보고(Report): 결과를 status 필드에 기록합니다
reconcile-pseudocode.go
go
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. 관찰: 리소스 가져오기
    var myApp v1alpha1.MyApp
    if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
 
    // 2. 분석: 원하는 상태와 현재 상태 비교
    currentReplicas := getCurrentReplicas(ctx, myApp)
    desiredReplicas := myApp.Spec.Replicas
 
    // 3. 조치: 차이가 있으면 수정
    if currentReplicas != desiredReplicas {
        if err := scaleDeployment(ctx, myApp, desiredReplicas); err != nil {
            return ctrl.Result{}, err
        }
    }
 
    // 4. 보고: 상태 업데이트
    myApp.Status.ReadyReplicas = desiredReplicas
    myApp.Status.Phase = "Running"
    if err := r.Status().Update(ctx, &myApp); err != nil {
        return ctrl.Result{}, err
    }
 
    return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
Info

조정 루프는 멱등성(Idempotency)을 반드시 보장해야 합니다. 같은 입력에 대해 여러 번 실행해도 결과가 동일해야 합니다. 네트워크 장애, 컨트롤러 재시작 등으로 인해 동일한 이벤트가 여러 번 처리될 수 있기 때문입니다.

CRD 설계

커스텀 리소스 정의(Custom Resource Definition, CRD)는 Kubernetes API를 확장하는 메커니즘입니다. CRD를 통해 자체 리소스 타입을 정의하고, 해당 리소스를 관리하는 컨트롤러를 작성합니다.

CRD 구조

myapp-crd.yaml
yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.app.example.com
spec:
  group: app.example.com
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [image, replicas]
              properties:
                image:
                  type: string
                  pattern: '^[a-z0-9.-]+/[a-z0-9.-]+:[a-z0-9.-]+$'
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 100
                  default: 1
                port:
                  type: integer
                  minimum: 1
                  maximum: 65535
                  default: 8080
                env:
                  type: object
                  additionalProperties:
                    type: string
            status:
              type: object
              properties:
                phase:
                  type: string
                  enum: [Pending, Running, Failed, Terminating]
                readyReplicas:
                  type: integer
                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: Phase
          type: string
          jsonPath: .status.phase
        - name: Replicas
          type: integer
          jsonPath: .spec.replicas
        - name: Ready
          type: integer
          jsonPath: .status.readyReplicas
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
  scope: Namespaced
  names:
    plural: myapps
    singular: myapp
    kind: MyApp
    shortNames: [ma]

CRD 설계 모범 사례

Spec과 Status 분리: Spec은 사용자가 정의하는 원하는 상태, Status는 컨트롤러가 보고하는 실제 상태입니다. 절대 혼합하지 마십시오.

Conditions 패턴 활용: 상태를 단일 필드가 아닌 조건(Conditions) 배열로 표현하면 여러 차원의 상태를 동시에 추적할 수 있습니다.

conditions.go
go
// 권장: Conditions 패턴
type MyAppStatus struct {
    Phase           string             `json:"phase,omitempty"`
    ReadyReplicas   int32              `json:"readyReplicas,omitempty"`
    Conditions      []metav1.Condition `json:"conditions,omitempty"`
    ObservedGeneration int64           `json:"observedGeneration,omitempty"`
}

ObservedGeneration 포함: metadata.generation과 status.observedGeneration을 비교하여 컨트롤러가 최신 spec을 처리했는지 확인할 수 있습니다.

Kubebuilder로 오퍼레이터 개발

Kubebuilder는 Kubernetes 오퍼레이터를 개발하기 위한 공식 프레임워크입니다. controller-runtime 라이브러리를 기반으로 프로젝트 스캐폴딩, 코드 생성, 테스트 인프라를 제공합니다.

프로젝트 초기화

kubebuilder-init.sh
bash
# Kubebuilder 설치
curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)
chmod +x kubebuilder && mv kubebuilder /usr/local/bin/
 
# 프로젝트 초기화
mkdir myapp-operator && cd myapp-operator
kubebuilder init --domain example.com --repo github.com/org/myapp-operator
 
# API와 컨트롤러 생성
kubebuilder create api --group app --version v1alpha1 --kind MyApp \
  --resource --controller

타입 정의

api/v1alpha1/myapp_types.go
go
package v1alpha1
 
import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
 
// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
    // Image is the container image to deploy
    // +kubebuilder:validation:Required
    // +kubebuilder:validation:Pattern=`^[a-z0-9.-]+/[a-z0-9.-]+:[a-z0-9.-]+$`
    Image string `json:"image"`
 
    // Replicas is the desired number of pods
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=100
    // +kubebuilder:default=1
    Replicas int32 `json:"replicas"`
 
    // Port is the container port to expose
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=65535
    // +kubebuilder:default=8080
    Port int32 `json:"port,omitempty"`
}
 
// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
    Phase              string             `json:"phase,omitempty"`
    ReadyReplicas      int32              `json:"readyReplicas,omitempty"`
    Conditions         []metav1.Condition `json:"conditions,omitempty"`
    ObservedGeneration int64              `json:"observedGeneration,omitempty"`
}
 
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas"
// +kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyReplicas"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
 
// MyApp is the Schema for the myapps API
type MyApp struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
 
    Spec   MyAppSpec   `json:"spec,omitempty"`
    Status MyAppStatus `json:"status,omitempty"`
}

컨트롤러 구현

internal/controller/myapp_controller.go
go
package controller
 
import (
    "context"
    "fmt"
 
    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/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/log"
 
    appv1alpha1 "github.com/org/myapp-operator/api/v1alpha1"
)
 
type MyAppReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}
 
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
 
    // 1. MyApp 리소스 가져오기
    var myApp appv1alpha1.MyApp
    if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }
 
    // 2. Finalizer 처리
    finalizerName := "app.example.com/finalizer"
    if myApp.ObjectMeta.DeletionTimestamp.IsZero() {
        if !controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            controllerutil.AddFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        if controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            // 정리 로직 수행
            logger.Info("Cleaning up resources", "myapp", req.NamespacedName)
            controllerutil.RemoveFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }
 
    // 3. Deployment 조정
    deployment := r.buildDeployment(&myApp)
    if err := controllerutil.SetControllerReference(&myApp, deployment, r.Scheme); err != nil {
        return ctrl.Result{}, err
    }
 
    result, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, func() error {
        deployment.Spec.Replicas = &myApp.Spec.Replicas
        deployment.Spec.Template.Spec.Containers[0].Image = myApp.Spec.Image
        return nil
    })
    if err != nil {
        return ctrl.Result{}, err
    }
    logger.Info("Deployment reconciled", "result", result)
 
    // 4. Status 업데이트
    myApp.Status.Phase = "Running"
    myApp.Status.ObservedGeneration = myApp.Generation
    if err := r.Status().Update(ctx, &myApp); err != nil {
        return ctrl.Result{}, err
    }
 
    return ctrl.Result{}, nil
}
 
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&appv1alpha1.MyApp{}).
        Owns(&appsv1.Deployment{}).  // Deployment 변경도 감시
        Owns(&corev1.Service{}).     // Service 변경도 감시
        Complete(r)
}
Warning

컨트롤러에서 외부 리소스(Deployment, Service 등)를 생성할 때는 반드시 SetControllerReference를 호출하여 소유자 참조(Owner Reference)를 설정하십시오. 이를 통해 부모 리소스가 삭제될 때 자식 리소스가 자동으로 가비지 컬렉션됩니다.

오퍼레이터 모범 사례

레벨 트리거 vs 엣지 트리거

Kubernetes 컨트롤러는 레벨 트리거(Level-Triggered) 방식을 사용해야 합니다. "무엇이 변경되었는가"가 아닌 "현재 상태가 무엇인가"를 기준으로 동작해야 합니다.

level-triggered.go
go
// 좋은 예: 레벨 트리거 - 현재 상태 기반
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    desired := getDesiredState(req)
    current := getCurrentState(ctx, req)
    if !reflect.DeepEqual(desired, current) {
        applyChanges(ctx, desired)
    }
    return ctrl.Result{}, nil
}
 
// 나쁜 예: 엣지 트리거 - 이벤트 기반
// "replicas가 3에서 5로 변경됨" 같은 이벤트에 의존하면 안 됨

에러 처리와 재시도

error-handling.go
go
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 일시적 에러: Result와 error를 모두 반환하여 재시도
    if isTransientError(err) {
        return ctrl.Result{RequeueAfter: 30 * time.Second}, err
    }
 
    // 영구적 에러: 상태에 기록하고 재시도하지 않음
    if isPermanentError(err) {
        setCondition(&myApp, "Ready", metav1.ConditionFalse, "ConfigError", err.Error())
        r.Status().Update(ctx, &myApp)
        return ctrl.Result{}, nil  // error를 반환하지 않아 재시도 안 함
    }
 
    return ctrl.Result{}, nil
}

오퍼레이터 생명주기 관리 (OLM)

오퍼레이터 생명주기 관리자(Operator Lifecycle Manager, OLM)는 Kubernetes 클러스터에서 오퍼레이터의 설치, 업데이트, 종속성 관리를 자동화합니다.

clusterserviceversion.yaml
yaml
apiVersion: operators.coreos.com/v1alpha1
kind: ClusterServiceVersion
metadata:
  name: myapp-operator.v0.1.0
spec:
  displayName: MyApp Operator
  description: Manages MyApp custom resources
  version: 0.1.0
  maturity: alpha
  installModes:
    - type: OwnNamespace
      supported: true
    - type: SingleNamespace
      supported: true
    - type: MultiNamespace
      supported: false
    - type: AllNamespaces
      supported: true
  install:
    strategy: deployment
    spec:
      deployments:
        - name: myapp-operator
          spec:
            replicas: 1
            selector:
              matchLabels:
                app: myapp-operator
            template:
              spec:
                containers:
                  - name: manager
                    image: ghcr.io/org/myapp-operator:v0.1.0
  customresourcedefinitions:
    owned:
      - name: myapps.app.example.com
        version: v1alpha1
        kind: MyApp
        displayName: MyApp
        description: A custom application resource
Tip

오퍼레이터를 GitOps로 배포할 때는 OLM 대신 Helm 차트로 패키징하는 것도 실용적인 방법입니다. ArgoCD나 Flux로 Helm 차트를 직접 관리하면 오퍼레이터 업데이트도 GitOps 워크플로우에 통합할 수 있습니다.

테스트 전략

envtest를 활용한 통합 테스트

myapp_controller_test.go
go
var _ = Describe("MyApp Controller", func() {
    Context("When creating a MyApp", func() {
        It("Should create a Deployment", func() {
            ctx := context.Background()
 
            myApp := &appv1alpha1.MyApp{
                ObjectMeta: metav1.ObjectMeta{
                    Name:      "test-app",
                    Namespace: "default",
                },
                Spec: appv1alpha1.MyAppSpec{
                    Image:    "nginx:latest",
                    Replicas: 3,
                    Port:     8080,
                },
            }
            Expect(k8sClient.Create(ctx, myApp)).Should(Succeed())
 
            // Deployment가 생성될 때까지 대기
            deployment := &appsv1.Deployment{}
            Eventually(func() error {
                return k8sClient.Get(ctx, client.ObjectKeyFromObject(myApp), deployment)
            }, timeout, interval).Should(Succeed())
 
            Expect(*deployment.Spec.Replicas).Should(Equal(int32(3)))
        })
    })
})

정리

Kubernetes 컨트롤러 패턴은 Kubernetes 생태계 전체를 관통하는 핵심 개념입니다. ArgoCD, Flux, Karpenter, Crossplane 등 이 시리즈에서 다루는 모든 도구가 이 패턴 위에 구축되어 있습니다. 컨트롤러 패턴을 이해하면 이러한 도구들의 동작 원리를 더 깊이 이해할 수 있고, 필요시 조직에 특화된 오퍼레이터를 직접 개발할 수도 있습니다.

다음 장에서는 Kubernetes 노드 관리의 패러다임을 바꾸고 있는 Karpenter를 살펴보겠습니다. Cluster Autoscaler와의 차이점, NodePool과 EC2NodeClass CRD, 스팟 인스턴스 관리, 비용 최적화 전략을 다룹니다.

이 글이 도움이 되셨나요?

관련 글

인프라

6장: Karpenter - 차세대 노드 오토스케일링

Karpenter의 아키텍처, Cluster Autoscaler와의 비교, NodePool/EC2NodeClass CRD, 통합과 중단 관리, 스팟 인스턴스 전략, GPU 노드 프로비저닝을 다룹니다.

2026년 5월 25일·11분
인프라

4장: 멀티클러스터 관리 전략

허브-스포크, 메시 등 멀티클러스터 패턴과 ArgoCD ApplicationSet, Flux Kustomize 오버레이를 활용한 클러스터 플릿 관리 전략을 다룹니다.

2026년 5월 19일·13분
인프라

7장: 비용 최적화와 리소스 관리

Kubernetes 리소스 requests/limits 모범 사례, VPA/HPA 전략, Goldilocks, Kubecost/OpenCost, 스팟 인스턴스, 네임스페이스 쿼터, FinOps 실천법을 다룹니다.

2026년 5월 27일·14분
이전 글4장: 멀티클러스터 관리 전략
다음 글6장: Karpenter - 차세대 노드 오토스케일링

댓글

목차

약 14분 남음
  • Kubernetes 컨트롤러 패턴
    • 원하는 상태와 실제 상태
    • 조정 루프 (Reconciliation Loop)
  • CRD 설계
    • CRD 구조
    • CRD 설계 모범 사례
  • Kubebuilder로 오퍼레이터 개발
    • 프로젝트 초기화
    • 타입 정의
    • 컨트롤러 구현
  • 오퍼레이터 모범 사례
    • 레벨 트리거 vs 엣지 트리거
    • 에러 처리와 재시도
  • 오퍼레이터 생명주기 관리 (OLM)
  • 테스트 전략
    • envtest를 활용한 통합 테스트
  • 정리