Kubernetes 컨트롤러 패턴의 원리, 조정 루프, controller-runtime과 Kubebuilder를 활용한 오퍼레이터 개발, CRD 설계 모범 사례를 다룹니다.
지금까지 ArgoCD, Flux, 멀티클러스터 관리까지 Kubernetes 운영의 외부 도구를 중심으로 살펴보았습니다. 그런데 이런 도구들이 공통적으로 사용하는 핵심 패턴이 있습니다. 바로 컨트롤러 패턴(Controller Pattern)입니다. ArgoCD의 Application Controller, Flux의 Source Controller, Karpenter의 Provisioner 등 모두 이 패턴으로 동작합니다. 이번 장에서는 이 패턴의 원리를 이해하고, 직접 오퍼레이터를 개발하는 방법을 다루겠습니다.
Kubernetes의 모든 것은 선언적 상태 관리(Declarative State Management)를 기반으로 합니다. 사용자가 "원하는 상태(Desired State)"를 선언하면, 컨트롤러가 "실제 상태(Current State)"를 원하는 상태로 수렴시킵니다.
컨트롤러의 핵심은 조정 루프(Reconciliation Loop)입니다. 이 루프는 다음 과정을 무한 반복합니다.
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
}조정 루프는 멱등성(Idempotency)을 반드시 보장해야 합니다. 같은 입력에 대해 여러 번 실행해도 결과가 동일해야 합니다. 네트워크 장애, 컨트롤러 재시작 등으로 인해 동일한 이벤트가 여러 번 처리될 수 있기 때문입니다.
커스텀 리소스 정의(Custom Resource Definition, CRD)는 Kubernetes API를 확장하는 메커니즘입니다. CRD를 통해 자체 리소스 타입을 정의하고, 해당 리소스를 관리하는 컨트롤러를 작성합니다.
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]Spec과 Status 분리: Spec은 사용자가 정의하는 원하는 상태, Status는 컨트롤러가 보고하는 실제 상태입니다. 절대 혼합하지 마십시오.
Conditions 패턴 활용: 상태를 단일 필드가 아닌 조건(Conditions) 배열로 표현하면 여러 차원의 상태를 동시에 추적할 수 있습니다.
// 권장: 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는 Kubernetes 오퍼레이터를 개발하기 위한 공식 프레임워크입니다. controller-runtime 라이브러리를 기반으로 프로젝트 스캐폴딩, 코드 생성, 테스트 인프라를 제공합니다.
# 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 --controllerpackage 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"`
}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)
}컨트롤러에서 외부 리소스(Deployment, Service 등)를 생성할 때는 반드시 SetControllerReference를 호출하여 소유자 참조(Owner Reference)를 설정하십시오. 이를 통해 부모 리소스가 삭제될 때 자식 리소스가 자동으로 가비지 컬렉션됩니다.
Kubernetes 컨트롤러는 레벨 트리거(Level-Triggered) 방식을 사용해야 합니다. "무엇이 변경되었는가"가 아닌 "현재 상태가 무엇인가"를 기준으로 동작해야 합니다.
// 좋은 예: 레벨 트리거 - 현재 상태 기반
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로 변경됨" 같은 이벤트에 의존하면 안 됨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
}오퍼레이터 생명주기 관리자(Operator Lifecycle Manager, OLM)는 Kubernetes 클러스터에서 오퍼레이터의 설치, 업데이트, 종속성 관리를 자동화합니다.
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오퍼레이터를 GitOps로 배포할 때는 OLM 대신 Helm 차트로 패키징하는 것도 실용적인 방법입니다. ArgoCD나 Flux로 Helm 차트를 직접 관리하면 오퍼레이터 업데이트도 GitOps 워크플로우에 통합할 수 있습니다.
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, 스팟 인스턴스 관리, 비용 최적화 전략을 다룹니다.
이 글이 도움이 되셨나요?
Karpenter의 아키텍처, Cluster Autoscaler와의 비교, NodePool/EC2NodeClass CRD, 통합과 중단 관리, 스팟 인스턴스 전략, GPU 노드 프로비저닝을 다룹니다.
허브-스포크, 메시 등 멀티클러스터 패턴과 ArgoCD ApplicationSet, Flux Kustomize 오버레이를 활용한 클러스터 플릿 관리 전략을 다룹니다.
Kubernetes 리소스 requests/limits 모범 사례, VPA/HPA 전략, Goldilocks, Kubecost/OpenCost, 스팟 인스턴스, 네임스페이스 쿼터, FinOps 실천법을 다룹니다.