Skip to content

필사 모드: CRD 없는 컨트롤러 — 기존 리소스를 자동화하는 패턴

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — Operator라는 단어에 겁먹지 않기

쿠버네티스에서 무언가를 자동화하고 싶다는 생각이 들면 많은 분들이 곧장 "Operator를 만들어야겠다"로 직행합니다. 그리고 kubebuilder로 프로젝트를 생성하고, CRD를 설계하고, API 타입을 작성하다가 "이렇게까지 해야 하나?" 하는 회의에 빠집니다. 사실 많은 사내 자동화는 CRD가 전혀 필요 없습니다.

핵심을 먼저 말하면 이렇습니다. **컨트롤러는 CRD와 무관합니다.** 컨트롤러는 단지 "어떤 리소스의 상태를 관찰하고, 바람직한 상태로 수렴시키는 루프"입니다. 그 대상 리소스가 꼭 커스텀 리소스일 필요는 없습니다. ConfigMap, Secret, Namespace, Node 같은 쿠버네티스 빌트인 리소스를 대상으로 삼아도 똑같이 컨트롤러를 만들 수 있습니다.

이 글은 "CRD 없이 빌트인 리소스만으로 자동화하는 컨트롤러"라는, 의외로 자주 필요하지만 잘 다뤄지지 않는 패턴을 깊이 있게 살펴봅니다.

컨트롤러란 무엇인가 — reconcile 루프의 본질

쿠버네티스의 모든 컨트롤러는 동일한 멘탈 모델을 따릅니다.

[관찰] 현재 상태를 읽는다 (API 서버 watch)

[비교] 바람직한 상태와 현재 상태의 차이를 계산한다

[조정] 차이를 없애는 행위를 한다 (생성/수정/삭제)

└──> 이벤트가 다시 발생하면 처음으로 (멱등 루프)

여기서 "바람직한 상태"가 어디서 오느냐가 핵심입니다. Operator는 그 바람직한 상태를 사용자가 선언한 커스텀 리소스(CR)에서 읽습니다. 그러나 바람직한 상태가 반드시 CR일 필요는 없습니다.

- "특정 라벨이 붙은 ConfigMap은 모든 네임스페이스에 복제되어야 한다" — 이 규칙 자체가 바람직한 상태입니다. CR이 필요 없습니다.

- "새 네임스페이스가 생기면 기본 NetworkPolicy와 ResourceQuota가 있어야 한다" — 규칙으로 표현됩니다.

- "GPU 노드에는 특정 라벨과 taint가 있어야 한다" — 마찬가지입니다.

이처럼 바람직한 상태가 코드에 박힌 규칙(convention)으로 표현될 수 있다면 CRD는 불필요합니다.

빌트인 리소스를 watch하는 컨트롤러의 전형적 사례

사례 1 — 라벨 기반 ConfigMap/Secret 동기화

가장 흔한 요구입니다. "이 ConfigMap을 모든 팀 네임스페이스에 뿌려줘." 예를 들어 공통 CA 인증서, 공통 레지스트리 자격증명, 공통 설정값 등이 그렇습니다. 컨트롤러는 원본에 특정 라벨이 붙은 ConfigMap을 watch하고, 그 내용을 대상 네임스페이스들에 복제·동기화합니다. 원본이 바뀌면 모든 복제본을 갱신하고, 누가 복제본을 손으로 바꾸면 원본 기준으로 되돌립니다.

사례 2 — 네임스페이스 부트스트랩

새 네임스페이스가 만들어질 때마다 기본 리소스 세트를 자동 주입합니다. 기본 ResourceQuota, LimitRange, 기본 거부 NetworkPolicy, 기본 ServiceAccount RBAC 등입니다. 컨트롤러가 Namespace 생성 이벤트를 watch하고, 누락된 기본 리소스를 만들어줍니다.

사례 3 — 노드 라벨링/taint 관리

특정 조건(예: 인스턴스 타입, 존, GPU 유무)에 따라 노드에 일관된 라벨이나 taint를 보장합니다. 클라우드가 붙여주는 라벨을 사내 표준 라벨로 정규화하는 작업이 대표적입니다.

이 세 가지의 공통점은 모두 **빌트인 리소스만 다루며, 사용자가 선언할 새로운 리소스 타입이 없다**는 것입니다. 바람직한 상태는 전부 컨트롤러 코드 안의 규칙입니다.

controller-runtime로 CRD 없이 구현하기

controller-runtime은 kubebuilder의 기반 라이브러리인데, CRD가 없어도 그대로 쓸 수 있습니다. 핵심은 watch 대상을 빌트인 타입(corev1.ConfigMap 등)으로 지정하는 것뿐입니다.

다음은 "특정 라벨이 붙은 원본 ConfigMap을 대상 네임스페이스에 동기화"하는 컨트롤러의 골격입니다.

package main

"context"

corev1 "k8s.io/api/core/v1"

"k8s.io/apimachinery/pkg/api/errors"

"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"

)

const syncLabel = "example.com/sync"

// ConfigMapSyncReconciler는 라벨이 붙은 ConfigMap을 대상 네임스페이스에 복제한다.

type ConfigMapSyncReconciler struct {

client.Client

TargetNamespaces []string

}

func (r *ConfigMapSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

l := log.FromContext(ctx)

var src corev1.ConfigMap

if err := r.Get(ctx, req.NamespacedName, &src); err != nil {

// 원본이 삭제된 경우는 무시 (필요하면 복제본 정리 로직 추가)

return ctrl.Result{}, client.IgnoreNotFound(err)

}

// 동기화 라벨이 없으면 대상이 아니다.

if src.Labels[syncLabel] != "true" {

return ctrl.Result{}, nil

}

for _, ns := range r.TargetNamespaces {

if ns == src.Namespace {

continue

}

desired := corev1.ConfigMap{}

desired.Name = src.Name

desired.Namespace = ns

desired.Data = src.Data

var existing corev1.ConfigMap

key := types.NamespacedName{Namespace: ns, Name: src.Name}

err := r.Get(ctx, key, &existing)

switch {

case errors.IsNotFound(err):

if err := r.Create(ctx, &desired); err != nil {

return ctrl.Result{}, err

}

l.Info("복제본 생성", "namespace", ns)

case err != nil:

return ctrl.Result{}, err

default:

existing.Data = src.Data

if err := r.Update(ctx, &existing); err != nil {

return ctrl.Result{}, err

}

l.Info("복제본 갱신", "namespace", ns)

}

}

return ctrl.Result{}, nil

}

func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {

return ctrl.NewControllerManagedBy(mgr).

For(&corev1.ConfigMap{}). // 빌트인 타입을 watch — CRD 불필요

Complete(r)

}

여기서 주목할 점은 단 한 줄, 빌트인 ConfigMap 타입을 `For(...)`에 넘긴 부분입니다. kubebuilder로 만들었다면 보통 그 자리에 자작 CR 타입이 들어가지만, 여기서는 빌트인 타입을 넣었을 뿐입니다. CRD도, API 타입 정의도, deepcopy 생성도 필요 없습니다.

main 함수에서 매니저를 띄우는 부분도 표준 그대로입니다.

func main() {

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})

if err != nil {

panic(err)

}

reconciler := &ConfigMapSyncReconciler{

Client: mgr.GetClient(),

TargetNamespaces: []string{"team-a", "team-b", "team-c"},

}

if err := reconciler.SetupWithManager(mgr); err != nil {

panic(err)

}

if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {

panic(err)

}

}

predicate로 불필요한 reconcile 줄이기

위 컨트롤러는 모든 ConfigMap 변경에 대해 Reconcile이 호출됩니다. 라벨이 없는 ConfigMap까지 전부 들어오면 낭비입니다. predicate로 watch 단계에서 미리 필터링하면 효율이 크게 좋아집니다.

func (r *ConfigMapSyncReconciler) SetupWithManager(mgr ctrl.Manager) error {

hasSyncLabel := predicate.NewPredicateFuncs(func(obj client.Object) bool {

return obj.GetLabels()[syncLabel] == "true"

})

return ctrl.NewControllerManagedBy(mgr).

For(&corev1.ConfigMap{}, builder.WithPredicates(hasSyncLabel)).

Complete(r)

}

이렇게 하면 동기화 라벨이 붙은 ConfigMap만 reconcile 큐에 들어오므로, 클러스터에 ConfigMap이 수천 개여도 부담이 작습니다.

Operator vs 단순 컨트롤러 — 무엇이 다른가

용어 정리가 필요합니다. 실무에서 자주 혼용되지만 명확히 구분하면 다음과 같습니다.

| 구분 | 단순 컨트롤러 | Operator |

| --- | --- | --- |

| CRD 유무 | 없음(빌트인 리소스 watch) | 있음(고유 CR 정의) |

| 바람직한 상태의 출처 | 코드 내 규칙(convention) | 사용자가 선언한 CR |

| 사용자 인터페이스 | 라벨/어노테이션/네임스페이스 | kubectl로 CR 작성 |

| 적합한 일 | 사내 운영 규칙 강제 | 애플리케이션 운영 자동화 |

| 복잡도 | 낮음 | 높음 |

핵심 분기점은 "사용자가 의도를 선언할 새로운 어휘가 필요한가?"입니다. PostgreSQL 클러스터처럼 사용자가 "인스턴스 3개, 백업 매일 2시"라는 풍부한 의도를 표현해야 한다면 CR이 필요하고, 그것은 Operator입니다. 반면 "이 라벨이 붙은 리소스는 이렇게 처리한다"는 고정 규칙이라면 라벨만으로 충분하고, 그것은 단순 컨트롤러입니다.

실무 조언: **의심스러우면 CRD 없이 시작하세요.** 라벨/어노테이션 기반으로 먼저 만들고, 정말로 사용자가 표현해야 할 의도가 라벨로 감당 안 될 만큼 풍부해질 때 비로소 CRD를 도입하는 편이 후회가 적습니다.

kubebuilder 없이 client-go로 만들기 (개요)

더 가벼운 길도 있습니다. controller-runtime조차 쓰지 않고 client-go의 informer로 직접 컨트롤러를 짜는 방식입니다. 표준 쿠버네티스 컨트롤러(예: kube-controller-manager 내부)가 쓰는 패턴입니다.

"k8s.io/client-go/informers"

"k8s.io/client-go/kubernetes"

"k8s.io/client-go/tools/cache"

"k8s.io/client-go/util/workqueue"

)

// 핵심 구성 요소

// 1. SharedInformerFactory — 빌트인 리소스 watch + 로컬 캐시

// 2. workqueue — 처리할 키를 모으는 큐 (rate limit, 재시도)

// 3. EventHandler — Add/Update/Delete 시 큐에 키 추가

// 4. worker 루프 — 큐에서 키를 꺼내 reconcile 로직 실행

func setupInformer(clientset *kubernetes.Clientset, queue workqueue.RateLimitingInterface) {

factory := informers.NewSharedInformerFactory(clientset, 0)

cmInformer := factory.Core().V1().ConfigMaps().Informer()

cmInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{

AddFunc: func(obj interface{}) {

key, _ := cache.MetaNamespaceKeyFunc(obj)

queue.Add(key)

},

UpdateFunc: func(old, new interface{}) {

key, _ := cache.MetaNamespaceKeyFunc(new)

queue.Add(key)

},

})

}

이 방식은 의존성이 더 가볍고 동작 원리가 투명하다는 장점이 있지만, informer/workqueue/leader election을 전부 손으로 배선해야 합니다. 대부분의 경우 controller-runtime이 이 보일러플레이트를 잘 추상화해 주므로, 특별한 이유가 없다면 controller-runtime을 권합니다. client-go 직접 사용은 "라이브러리 의존을 최소화해야 하거나 동작을 완전히 통제해야 하는" 특수 상황에 적합합니다.

admission webhook만으로 하는 정책

자동화의 또 다른 형태는 컨트롤러가 아니라 admission webhook입니다. 컨트롤러가 "이미 만들어진 리소스를 나중에 조정"한다면, admission webhook은 "리소스가 저장되기 전에 검증하거나 변형"합니다.

- **Validating webhook**: 규칙에 어긋나는 리소스를 거부합니다. 예: "모든 Pod에 team 라벨이 없으면 거부."

- **Mutating webhook**: 리소스를 저장 전에 자동 수정합니다. 예: "사이드카 컨테이너 자동 주입", "기본 라벨 자동 추가."

요즘은 Go로 webhook을 직접 짜는 대신, 정책 엔진을 쓰는 편이 흔합니다. Kyverno(쿠버네티스 네이티브, YAML 정책)나 OPA Gatekeeper(Rego 언어)가 대표적입니다. 다음은 Kyverno 정책의 예로, 라벨을 강제합니다.

apiVersion: kyverno.io/v1

kind: ClusterPolicy

metadata:

name: require-team-label

spec:

validationFailureAction: Enforce

rules:

- name: check-team-label

match:

any:

- resources:

kinds:

- Pod

validate:

message: "모든 Pod에는 team 라벨이 필요합니다."

pattern:

metadata:

labels:

team: "?*"

webhook(정책)과 컨트롤러는 보완 관계입니다. webhook은 "잘못된 것이 들어오지 못하게 입구에서 막고", 컨트롤러는 "이미 있는 것을 지속적으로 올바른 상태로 유지"합니다. 둘을 함께 쓰면 강력합니다.

사내 자동화 아이디어 모음

CRD 없는 컨트롤러로 풀기 좋은 실제 과제들입니다.

- 신규 네임스페이스에 표준 RBAC, ResourceQuota, NetworkPolicy 자동 주입

- 공통 ImagePullSecret을 모든 네임스페이스에 동기화

- 만료 임박 인증서를 가진 Secret을 watch해 갱신 트리거

- 특정 라벨이 붙은 Deployment에 표준 어노테이션(모니터링 스크랩 설정 등) 자동 부착

- 고아가 된(소유자 없는) 리소스를 주기적으로 정리

- 노드 컨디션에 따라 자동으로 cordon/uncordon 보조

이들의 공통점은 "사내 컨벤션을 사람이 매번 챙기는 대신, 컨트롤러가 보장하게 만든다"는 것입니다.

함정 — 권한과 무한 루프

CRD가 없어 단순해 보여도, 빌트인 리소스를 다루는 컨트롤러에는 고유한 함정이 있습니다.

함정 1 — 과도한 RBAC

빌트인 리소스를 다루다 보면 ConfigMap, Secret, Namespace 등에 대한 광범위한 권한이 필요해집니다. Secret 전체에 대한 읽기 권한은 곧 클러스터의 모든 자격증명을 읽을 수 있다는 뜻이므로 신중해야 합니다. 최소 권한 원칙을 지키고, 가능하면 대상 네임스페이스로 RBAC를 좁히세요. controller-runtime을 쓴다면 RBAC 마커로 권한을 명시적으로 생성하는 것이 좋습니다.

함정 2 — 무한 reconcile 루프

가장 흔하고 위험한 함정입니다. 컨트롤러가 리소스를 수정하면, 그 수정이 다시 watch 이벤트를 일으켜 또 reconcile이 돌고, 또 수정하고... 무한 루프가 됩니다. 특히 자기 자신이 관리하는 리소스를 Update할 때 자주 발생합니다.

방지법:

- 수정이 정말 필요할 때만 Update를 호출합니다. 현재 상태와 바람직한 상태를 비교해 차이가 없으면 아무것도 하지 않습니다(멱등성).

- predicate로 status만 바뀐 이벤트는 무시합니다(generation 변화 기준 필터 등).

- 자신이 만든 변경과 외부 변경을 구분할 어노테이션/해시를 활용합니다.

함정 3 — 다른 컨트롤러와의 충돌

내가 만든 컨트롤러가 라벨을 붙이는데 다른 컨트롤러(또는 GitOps 에이전트)가 그 라벨을 지운다면, 둘이 무한히 싸웁니다. 같은 필드를 누가 소유하는지 명확히 하고, GitOps와 함께 쓴다면 ignoreDifferences로 컨트롤러 관리 필드를 제외하세요.

함정 4 — 캐시 일관성

controller-runtime의 client는 기본적으로 캐시에서 읽습니다. 방금 만든 리소스를 곧바로 Get하면 캐시에 아직 없을 수 있습니다. 이런 경합을 가정하고, NotFound를 정상 흐름의 일부로 처리하며, 필요하면 재큐(requeue)로 다시 시도하게 설계하세요.

운영 — 배포와 관측

CRD 없는 컨트롤러도 운영 관점에서 챙길 것은 동일합니다.

- **단일 인스턴스 보장**: leader election을 켜서 여러 복제본이 동시에 같은 리소스를 건드리지 않게 합니다. controller-runtime이 기본 지원합니다.

- **메트릭**: reconcile 횟수, 에러율, 처리 지연을 노출합니다. controller-runtime은 기본 메트릭을 제공합니다.

- **이벤트 기록**: 중요한 조정 행위는 Event로 남겨 kubectl describe로 추적 가능하게 합니다.

- **헬스 체크**: liveness/readiness 프로브를 붙입니다.

- **인증/인가**: 메트릭 엔드포인트는 보호되어야 합니다. 최신 controller-runtime은 WithAuthenticationAndAuthorization으로 이를 처리하며, 별도 kube-rbac-proxy 사이드카는 더 이상 필요하지 않습니다.

배포 매니페스트 — 컨트롤러를 실제로 클러스터에 올리기

코드를 작성했다면 이제 클러스터에 배포해야 합니다. CRD가 없으므로 필요한 것은 ServiceAccount, RBAC, Deployment 세 가지뿐입니다.

1) 컨트롤러용 ServiceAccount

apiVersion: v1

kind: ServiceAccount

metadata:

name: cm-sync-controller

namespace: platform-system

2) 최소 권한 RBAC — ConfigMap에 대해서만, 그것도 필요한 동사만

apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRole

metadata:

name: cm-sync-controller

rules:

- apiGroups: [""]

resources: ["configmaps"]

verbs: ["get", "list", "watch", "create", "update"]

apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRoleBinding

metadata:

name: cm-sync-controller

roleRef:

apiGroup: rbac.authorization.k8s.io

kind: ClusterRole

name: cm-sync-controller

subjects:

- kind: ServiceAccount

name: cm-sync-controller

namespace: platform-system

3) 컨트롤러 Deployment (단일 인스턴스, leader election 켜는 것을 권장)

apiVersion: apps/v1

kind: Deployment

metadata:

name: cm-sync-controller

namespace: platform-system

spec:

replicas: 1

selector:

matchLabels:

app: cm-sync-controller

template:

metadata:

labels:

app: cm-sync-controller

spec:

serviceAccountName: cm-sync-controller

containers:

- name: controller

image: registry.example.com/cm-sync-controller:v0.1.0

resources:

requests:

cpu: 50m

memory: 64Mi

limits:

memory: 128Mi

여기서 주목할 점은 RBAC가 ConfigMap에만, 그리고 delete 없이 필요한 동사만 부여되었다는 것입니다. Secret을 다루지 않으므로 Secret 권한은 아예 없습니다. 이것이 앞서 강조한 최소 권한의 구체적 모습입니다. 권한을 좁히면 설령 컨트롤러가 침해되어도 피해 범위가 제한됩니다.

테스트 — envtest로 reconcile 검증하기

CRD 없는 컨트롤러도 테스트가 필요합니다. controller-runtime 생태계의 envtest는 실제 클러스터 없이 가짜 API 서버(etcd + kube-apiserver 바이너리)를 띄워 reconcile를 검증하게 해줍니다.

// 테스트의 큰 흐름 (의사 코드 수준)

// 1. envtest로 테스트용 API 서버 기동

// 2. 원본 ConfigMap을 라벨과 함께 생성

// 3. reconciler.Reconcile 호출 (또는 매니저 실행)

// 4. 대상 네임스페이스에 복제본이 생겼는지 단언(assert)

// 5. 원본 Data를 바꾸고 다시 reconcile → 복제본도 갱신되는지 확인

func TestConfigMapSync(t *testing.T) {

// envtest 환경 준비

// (TestEnv 시작, scheme 등록, client 생성)

// given: 라벨이 붙은 원본 ConfigMap

// when: reconcile 실행

// then: 대상 네임스페이스에 동일 Data의 복제본 존재

}

테스트에서 특히 확인해야 할 것은 **멱등성**입니다. 같은 입력으로 reconcile를 두 번 호출했을 때 두 번째 호출이 아무 변경도 만들지 않아야 합니다. 이 성질이 깨지면 운영 환경에서 무한 루프나 불필요한 API 부하로 이어집니다. 최신 setup-envtest는 Kubernetes 1.36 계열 바이너리까지 받을 수 있어, 실제 운영 버전과 가까운 환경에서 검증할 수 있습니다.

마치며

쿠버네티스 자동화에서 "Operator를 만들자"는 종종 과한 출발점입니다. 정말로 사용자가 표현할 새로운 의도가 있을 때에만 CRD가 정당화됩니다. 사내 컨벤션을 강제하거나 빌트인 리소스를 돌보는 일이라면, CRD 없는 단순 컨트롤러(또는 정책 엔진)가 훨씬 가볍고 유지보수하기 쉽습니다.

핵심을 다시 정리하면, 컨트롤러의 본질은 reconcile 루프이지 CRD가 아닙니다. 빌트인 리소스를 watch하는 것만으로도 강력한 자동화를 만들 수 있고, 그 출발점은 라벨 한 줄과 controller-runtime의 For() 한 줄입니다. 단, 권한은 최소로, 루프는 멱등으로 — 이 두 가지만 지키면 됩니다.

참고 자료

- Kubernetes 컨트롤러 개념: https://kubernetes.io/docs/concepts/architecture/controller/

- controller-runtime: https://pkg.go.dev/sigs.k8s.io/controller-runtime

- Kubebuilder Book: https://book.kubebuilder.io/

- client-go 예제(샘플 컨트롤러): https://github.com/kubernetes/sample-controller

- Admission Controllers 참고: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/

- Kyverno 공식 문서: https://kyverno.io/docs/

- OPA Gatekeeper: https://open-policy-agent.github.io/gatekeeper/website/docs/

- RBAC 인가: https://kubernetes.io/docs/reference/access-authn-authz/rbac/

- controller-runtime GitHub: https://github.com/kubernetes-sigs/controller-runtime

현재 단락 (1/273)

쿠버네티스에서 무언가를 자동화하고 싶다는 생각이 들면 많은 분들이 곧장 "Operator를 만들어야겠다"로 직행합니다. 그리고 kubebuilder로 프로젝트를 생성하고, CRD를...

작성 글자: 0원문 글자: 10,649작성 단락: 0/273