K8s 고급 #4 CRD와 Operator 패턴 — controller-runtime
K8s 고급 시리즈의 네 번째 글입니다. 지금까지 다룬 객체는 모두 K8s가 빌트인으로 갖고 있는 표준 자원이었습니다 — Pod, Deployment, Service, ConfigMap, Secret, NetworkPolicy 등. 그러나 K8s API의 진짜 매력은 그 위에 우리 도메인의 객체를 추가할 수 있다는 점입니다. PostgreSQL 클러스터, Redis 마스터-replica 토폴로지, Kafka 브로커 그룹 같은 도메인 개념이 K8s 안에서 1급 객체가 되어, kubectl get postgrescluster로 조회되고 매니페스트로 선언되며 컨트롤러에 의해 자동으로 운영됩니다. 이 확장의 두 축이 CustomResourceDefinition(새 객체 종류 정의)과 Operator(그 객체를 운영하는 컨트롤러)입니다.
이번 시리즈는 K8s 고급 6편입니다.
- #1 CNI 깊이 — Calico / Cilium / eBPF
- #2 RBAC / ServiceAccount 깊이 — Aggregated ClusterRole / Impersonation / IRSA / Workload Identity
- #3 Admission Controller — OPA Gatekeeper / Kyverno
- #4 CRD와 Operator 패턴 — controller-runtime ← 이번 글
- #5 옵저버빌리티 — Prometheus / Grafana / Loki / OpenTelemetry
- #6 GitOps — ArgoCD / Flux
K8s API 확장의 두 길 #
K8s는 자기 API를 확장하는 길을 두 개 제공합니다.
| 길 | 특징 |
|---|---|
| CRD (CustomResourceDefinition) | 매니페스트로 새 객체 종류를 등록. K8s API 서버가 그 객체를 etcd에 저장하고 표준 객체처럼 다룬다. |
| Aggregation Layer | 별도 API 서버를 띄워 K8s API 서버가 그쪽으로 호출을 위임. 더 유연하지만 운영 비용이 큼. |
운영 클러스터에서 압도적으로 자주 쓰이는 길은 CRD입니다. Aggregation Layer는 metrics-server처럼 K8s 코어와 매우 깊게 묶이는 경우 외에는 거의 쓰이지 않습니다. 이번 글의 주제는 CRD입니다.
CRD — 새 객체 종류 정의 #
CRD는 그 자체로 K8s의 한 객체 종류입니다. CRD 한 장을 적용하면 그 순간부터 클러스터에 새 객체 종류가 등록되고, kubectl로 그 객체를 만들고 조회할 수 있게 됩니다.
가장 단순한 CRD 예시 #
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.myteam.example.com
spec:
group: myteam.example.com
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
shortNames: ["wg"]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
enum: ["small", "medium", "large"]
replicas:
type: integer
minimum: 1
maximum: 10
required: ["size"]
status:
type: object
properties:
phase:
type: string
subresources:
status: {}이 CRD를 적용하고 나면 그 클러스터에서 다음 매니페스트가 valid해집니다.
apiVersion: myteam.example.com/v1
kind: Widget
metadata:
name: my-first-widget
namespace: default
spec:
size: medium
replicas: 3kubectl get widgets
kubectl get wg my-first-widget -o yamlCRD 정의의 핵심 요소 #
위 매니페스트에서 운영 측면에서 중요한 부분 몇 가지를 짚겠습니다.
scope: NamespacedvsCluster— 이 객체가 네임스페이스 안에 사는지, 클러스터 전역에 사는지. 한 번 정하면 바꾸기 어려우므로 도메인의 결을 보고 결정합니다. ConfigMap,Pod는 Namespaced, Node,StorageClass는 Cluster.schema.openAPIV3Schema— 객체의 모양을 OpenAPI 스키마로 정의. K8s API 서버가 admission 단계에서 이 스키마로 매니페스트를 검증합니다.enum,minimum,required같은 제약을 여기서 표현합니다.subresources.status: {}—status필드를 별도 subresource로 분리. 컨트롤러가spec을 건드리지 않고status만 갱신할 수 있게 해 주는 K8s 표준 패턴입니다. 이 한 줄이 매우 중요합니다.versions— CRD는 여러 버전을 동시에 지원할 수 있고, 한 버전이storage: true로 실제 etcd 저장 형식을 결정합니다. 버전 변경 시 storage 마이그레이션이 필요합니다.
CRD만으로는 부족하다 — Operator의 역할 #
CRD를 등록한 것만으로는 객체에 의미가 없습니다. Widget 객체를 만들어도 그 자체로는 etcd에 저장된 매니페스트일 뿐, 어떤 동작도 일어나지 않습니다. 그 객체를 보고 실제로 무엇인가 만들고 운영하는 컨트롤러가 필요합니다.
이 컨트롤러를 K8s 커뮤니티에서는 Operator라고 부릅니다. Operator는 본질적으로 다음 두 부분의 묶음입니다.
- CRD — 새 도메인 객체의 모양 정의
- Custom Controller — 그 객체를 보고 reconcile 루프를 도는 코드
이 모델이 강력한 이유는 K8s의 핵심 패러다임 — 선언적 desired state + 컨트롤러의 reconcile loop — 을 사용자 도메인까지 확장하기 때문입니다. 우리는 “PostgreSQL 마스터 1개 + replica 3개의 클러스터를 원한다"고 매니페스트로 선언만 하면, Operator가 StatefulSet, Service, PVC, ConfigMap, Secret, backup CronJob까지 자동으로 만들고 관리합니다.
Reconcile loop — 컨트롤러의 본질 #
K8s 컨트롤러의 핵심 패턴은 무한 루프입니다.
loop forever:
observed_state = K8s API에서 실제 상태 조회
desired_state = 매니페스트에서 원하는 상태 읽기
if observed_state != desired_state:
K8s API 호출로 차이를 좁힘
sleep until 다음 트리거이 단순한 루프가 빌트인 컨트롤러(Deployment, StatefulSet, Job 등)와 사용자 정의 Operator의 공통 모델입니다. Operator를 짠다는 것은 “내 도메인 객체에 대해 이 reconcile 함수를 어떻게 구현할 것인가” 를 정하는 일입니다.
controller-runtime — Operator의 표준 골격 #
처음부터 K8s API client를 직접 호출해 reconcile 루프를 짜는 것은 가능하지만 보일러플레이트가 매우 큽니다. controller-runtime은 Kubernetes 프로젝트 자체에서 관리하는 Go 라이브러리로, Operator의 골격을 표준화한 도구입니다. Kubebuilder와 Operator SDK가 이 controller-runtime을 감싼 상위 도구입니다.
Reconciler의 모양 #
controller-runtime 기반의 Operator는 거의 다음 모양을 갖습니다.
package controller
import (
"context"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
myteamv1 "myteam.example.com/api/v1"
)
type WidgetReconciler struct {
client.Client
}
func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
// 1. desired state — Widget 객체 읽기
var widget myteamv1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
// 2. observed state — Widget이 만들어야 할 Deployment 읽기
var deploy appsv1.Deployment
err := r.Get(ctx, client.ObjectKey{
Namespace: widget.Namespace,
Name: widget.Name,
}, &deploy)
if err != nil && client.IgnoreNotFound(err) == nil {
// 없으면 만들기
newDeploy := buildDeployment(&widget)
if err := r.Create(ctx, newDeploy); err != nil {
return reconcile.Result{}, err
}
} else if err == nil {
// 있으면 desired state와 비교 후 갱신
if needsUpdate(&deploy, &widget) {
updateDeployment(&deploy, &widget)
if err := r.Update(ctx, &deploy); err != nil {
return reconcile.Result{}, err
}
}
}
// 3. status 갱신
widget.Status.Phase = "Ready"
if err := r.Status().Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}이 함수가 Widget 객체 한 개당 한 번씩(또는 변경이 있을 때마다) 호출됩니다. controller-runtime이 watch / queue / retry / leader election 같은 기반 인프라를 모두 처리해 주므로, Operator 개발자는 위 reconcile 로직에만 집중하면 됩니다.
운영의 세 가지 표준 패턴 #
Operator를 짤 때 거의 항상 만나는 세 가지 패턴이 있습니다. K8s API의 표준 메커니즘이므로 잘 알아 두면 오래 두고 쓸 수 있습니다.
1. ownerReference — 자식 객체의 자동 정리 #
Widget이 Deployment를 만들었다면, Widget이 삭제될 때 그 Deployment도 같이 삭제되어야 합니다. 일일이 코드로 처리하지 않고 K8s의 garbage collector가 자동으로 처리하게 만드는 메커니즘이 ownerReference입니다.
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: widget.Name,
Namespace: widget.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(&widget, myteamv1.GroupVersion.WithKind("Widget")),
},
},
Spec: ...
}이 ownerReference가 부착된 Deployment는 부모 Widget이 삭제되는 순간 K8s의 garbage collector에 의해 자동으로 삭제됩니다. 명시적인 삭제 로직을 Operator 코드에 넣을 필요가 없습니다.
2. finalizer — 외부 자원 정리의 hook #
객체가 삭제될 때 K8s 안의 자식 객체는 ownerReference로 자동 정리되지만, K8s 밖의 자원은 그렇지 않습니다. 클라우드 LB, S3 버킷, RDS 인스턴스 같은 외부 자원을 만든 Operator라면, 객체 삭제 시 그 외부 자원도 같이 정리해야 합니다. 이 hook을 거는 메커니즘이 finalizer입니다.
finalizer가 등록된 객체를 삭제하려고 하면 K8s API 서버는 객체를 곧장 지우지 않고 metadata.deletionTimestamp 필드만 채웁니다. 그 객체는 “삭제 진행 중” 상태가 되고, finalizer 목록이 비워질 때까지 etcd에 남아 있습니다.
const widgetFinalizer = "widget.myteam.example.com/finalizer"
func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
var widget myteamv1.Widget
if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
// 삭제 진행 중인가
if !widget.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
// 외부 자원 정리
if err := cleanupExternalResources(&widget); err != nil {
return reconcile.Result{}, err
}
// 정리가 끝났으면 finalizer 제거 → K8s가 객체를 진짜로 삭제
controllerutil.RemoveFinalizer(&widget, widgetFinalizer)
if err := r.Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
}
return reconcile.Result{}, nil
}
// 삭제 중이 아니면 finalizer 보장
if !controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
controllerutil.AddFinalizer(&widget, widgetFinalizer)
if err := r.Update(ctx, &widget); err != nil {
return reconcile.Result{}, err
}
}
// 일반 reconcile 진행
...
}finalizer가 있는 객체는 외부 자원 정리가 끝날 때까지 진짜로 삭제되지 않으므로, 운영자가 객체를 강제로 지우려 해도 클라우드 자원이 누락되지 않습니다.
3. status subresource — spec과 status의 분리 #
CRD에 subresources.status: {}를 적은 한 줄의 의미가 여기서 완성됩니다. 이 subresource가 있는 CRD는 다음 두 가지 동작이 분리됩니다.
- 사용자(또는 GitOps 도구)는
spec만 수정.status수정은 무시됨. - 컨트롤러는
r.Status().Update()로status만 수정.spec은 건드리지 않음.
이 분리가 중요한 이유는 GitOps와의 충돌을 막기 때문입니다. ArgoCD가 git의 매니페스트를 클러스터에 동기화할 때, status 필드는 컨트롤러가 채우는 값이라 git에는 없습니다. status subresource가 분리되어 있으면 ArgoCD는 status를 보지 않고 spec만 비교하므로, 컨트롤러가 status를 갱신해도 ArgoCD가 “drift"로 인식하지 않습니다.
Operator 빌드 도구 — Kubebuilder vs Operator SDK #
controller-runtime을 직접 쓰는 것보다 더 상위 추상화를 제공하는 도구가 둘 있습니다.
| 도구 | 특징 |
|---|---|
| Kubebuilder | Kubernetes 프로젝트 공식 도구. CRD scaffolding, Makefile, kustomize 통합. controller-runtime을 가장 직접적으로 감쌈. |
| Operator SDK | Red Hat의 도구. Kubebuilder를 기반으로 함 + Helm / Ansible 기반 Operator 옵션 추가. |
골격을 만들고 reconcile 함수만 채우는 흐름은 두 도구가 거의 같습니다. Go로 직접 짜는 본격 Operator는 Kubebuilder가 더 표준에 가깝고, Helm 차트로 시작해 Operator로 옮겨 가는 마이그레이션 시나리오는 Operator SDK가 매끄럽습니다.
kubebuilder init --domain example.com --repo github.com/myteam/widget-operator
kubebuilder create api --group myteam --version v1 --kind Widget이 두 명령으로 CRD 정의, controller 골격, 매니페스트, Dockerfile, Makefile이 모두 생성됩니다. 그 후 controller.go의 Reconcile 함수를 채우는 게 실제 작업의 본체입니다.
언제 Operator를 짜야 하는가 #
CRD와 Operator는 강력한 도구이지만 모든 도메인 객체에 필요하지는 않습니다. 다음 조건이 모일 때 Operator의 가치가 큽니다.
- 상태가 있는 워크로드의 운영 자동화 — DB 클러스터의 셋업, 백업, 페일오버, 업그레이드처럼 사람이 정기적으로 돌리던 절차
- 여러 K8s 객체의 묶음을 한 도메인 객체로 추상화 — Deployment + Service + Ingress + PDB + HPA + ServiceMonitor 등을 묶어 한 객체로
- 외부 자원과 K8s 객체의 일관성 유지 — 클라우드 LB, DNS 레코드 같은 것을 K8s 객체와 묶어 자동 동기화
- 도메인 지식의 코드화 — “PostgreSQL primary가 죽으면 가장 동기화 lag이 작은 replica를 promote” 같은 운영 노하우
반대로, Helm 차트 한 장으로 끝나는 단순 묶음이라면 Operator를 짤 필요는 없습니다. Operator는 코드 유지보수 비용이 큰 도구이므로, 정말 자동화 가치가 큰 곳에만 도입하는 편이 좋습니다.
마무리 #
K8s API의 확장 메커니즘인 CRD와 그 위에 얹히는 Operator 패턴을 정리했습니다. CRD 한 장으로 새 객체 종류를 등록할 수 있고, controller-runtime 기반의 Operator가 그 객체에 reconcile 루프를 걸어 K8s의 선언적 모델을 사용자 도메인까지 확장한다는 흐름을 따라갔습니다. ownerReference로 자식 객체를 자동 정리하고, finalizer로 외부 자원 정리 hook을 걸고, status subresource로 GitOps와의 충돌을 막는 세 가지 표준 패턴까지 짚었습니다. 다음 글에서는 이 모든 컴포넌트가 굴러가는 클러스터를 어떻게 관측할 것인가 — Prometheus / Grafana / Loki / OpenTelemetry로 구성하는 옵저버빌리티 스택을 다루겠습니다.