Skip to content

필사 모드: Kubebuilder로 첫 Operator 만들기 — 프로젝트 생성부터 배포까지

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

들어가며

앞 글에서 Operator 패턴의 원리를 살펴봤습니다. 이번에는 직접 손을 더럽혀 볼 차례입니다. Kubebuilder를 사용해 간단하지만 실제로 동작하는 Operator를 처음부터 만들어 보겠습니다. 만들 대상은 `Guestbook`이라는 CRD로, 사용자가 원하는 replica 수와 이미지를 선언하면 그에 맞는 Deployment와 Service를 자동으로 만들고 조정하는 Operator입니다.

이 글의 목표는 "마법 없이" 전체 흐름을 이해하는 것입니다. 스캐폴딩이 무엇을 만드는지, reconcile 함수에 무엇을 채워야 하는지, RBAC과 CRD는 어떻게 생성되는지, 그리고 로컬과 클러스터에서 각각 어떻게 돌리는지를 끝까지 따라가 봅니다. controller-runtime v0.24.x 기준입니다.

사전 준비

작업을 시작하기 전에 다음 도구가 필요합니다.

- **Go 1.26 이상**: Kubebuilder가 생성하는 모듈은 최신 Go 툴체인을 가정합니다.

- **Kubebuilder CLI**: 프로젝트 스캐폴딩과 코드 생성을 담당합니다.

- **kubectl**: 클러스터와 통신하는 표준 CLI입니다.

- **로컬 또는 원격 클러스터**: kind나 minikube로 만든 로컬 클러스터면 충분합니다.

- **Docker 또는 동등한 컨테이너 빌더**: 컨트롤러 이미지를 빌드해 배포할 때 필요합니다.

설치가 끝났다면 버전을 확인합니다.

go version

kubebuilder version

kubectl version --client

프로젝트 생성: init과 create api

kubebuilder init

먼저 빈 디렉터리에서 프로젝트를 초기화합니다. `--domain`은 API 그룹의 접미사가 되고, `--repo`는 Go 모듈 경로입니다.

mkdir guestbook-operator && cd guestbook-operator

kubebuilder init --domain example.com --repo example.com/guestbook-operator

이 명령은 `main.go`, `Makefile`, `Dockerfile`, `config/` 디렉터리(매니페스트), 그리고 의존성이 채워진 `go.mod`를 생성합니다. 이 시점에서 프로젝트는 아무 API도 없는 빈 매니저(manager)일 뿐입니다.

kubebuilder create api

이제 API와 컨트롤러를 추가합니다.

kubebuilder create api --group webapp --version v1 --kind Guestbook

프롬프트에서 Resource(타입 정의)와 Controller(reconcile 로직) 둘 다 생성할지 묻습니다. 둘 다 yes를 선택합니다. 그러면 다음이 만들어집니다.

- `api/v1/guestbook_types.go` — Spec/Status 등 API 타입 정의

- `internal/controller/guestbook_controller.go` — reconcile 로직이 들어갈 컨트롤러

- `config/crd/`, `config/rbac/`, `config/samples/` — 관련 매니페스트

프로젝트 구조 살펴보기

스캐폴딩이 만든 디렉터리 구조를 한눈에 이해하면 이후 작업이 훨씬 수월합니다. 핵심 디렉터리만 정리하면 다음과 같습니다.

guestbook-operator/

api/v1/ # API 타입 정의 (Spec/Status, 마커)

internal/controller/ # reconcile 로직

config/

crd/ # 생성된 CRD 매니페스트

rbac/ # 생성된 RBAC (role.yaml 등)

manager/ # 컨트롤러 매니저 Deployment

samples/ # 예시 CR

cmd/main.go # 매니저 진입점 (스킴 등록, 매니저 기동)

Makefile # build/run/deploy 등 표준 타깃

Dockerfile # 컨트롤러 이미지 빌드

- **api/**: 우리가 직접 손대는 곳입니다. 타입과 마커를 여기서 정의합니다.

- **internal/controller/**: 역시 우리가 reconcile를 채우는 곳입니다.

- **config/**: 대부분 자동 생성되므로 손으로 고치는 일이 적습니다. 마커를 바꾸고 make manifests를 돌리는 방식으로 관리합니다.

- **cmd/main.go**: 매니저를 만들고, 스킴에 타입을 등록하고, 컨트롤러를 매니저에 연결하는 부트스트랩 코드입니다. 새 컨트롤러를 추가하면 보통 자동으로 여기에 등록 코드가 들어갑니다.

이 구조의 핵심 철학은 "내가 쓰는 코드(api, controller)"와 "생성되는 산출물(config)"을 명확히 분리하는 것입니다. 생성물은 직접 고치지 말고, 항상 소스(타입과 마커)를 고친 뒤 재생성하는 흐름을 지키면 일관성이 유지됩니다.

API 타입 정의: Spec과 Status

`api/v1/guestbook_types.go`를 열어 우리가 원하는 필드를 정의합니다. Spec은 사용자가 선언하는 "원하는 상태"이고, Status는 컨트롤러가 채우는 "관찰된 상태"입니다.

package v1

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

)

// GuestbookSpec은 사용자가 원하는 상태를 정의합니다.

type GuestbookSpec struct {

// 원하는 replica 수입니다.

// +kubebuilder:validation:Minimum=1

// +kubebuilder:validation:Maximum=10

Replicas int32 `json:"replicas"`

// 사용할 컨테이너 이미지입니다.

// +kubebuilder:validation:MinLength=1

Image string `json:"image"`

// 서비스가 노출할 포트입니다.

// +kubebuilder:default=80

Port int32 `json:"port,omitempty"`

}

// GuestbookStatus는 관찰된 상태를 담습니다.

type GuestbookStatus struct {

// 준비된 replica 수입니다.

ReadyReplicas int32 `json:"readyReplicas"`

// 상태 조건 목록입니다.

Conditions []metav1.Condition `json:"conditions,omitempty"`

}

// +kubebuilder:object:root=true

// +kubebuilder:subresource:status

// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`

// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`

// Guestbook은 우리의 커스텀 리소스입니다.

type Guestbook struct {

metav1.TypeMeta `json:",inline"`

metav1.ObjectMeta `json:"metadata,omitempty"`

Spec GuestbookSpec `json:"spec,omitempty"`

Status GuestbookStatus `json:"status,omitempty"`

}

// +kubebuilder:object:root=true

// GuestbookList는 Guestbook의 목록입니다.

type GuestbookList struct {

metav1.TypeMeta `json:",inline"`

metav1.ListMeta `json:"metadata,omitempty"`

Items []Guestbook `json:"items"`

}

func init() {

SchemeBuilder.Register(&Guestbook{}, &GuestbookList{})

}

여기서 `// +kubebuilder:` 로 시작하는 줄을 **마커(marker)** 라고 부릅니다. 마커는 코드 생성 도구가 읽는 지시문입니다. 예를 들어 `validation:Minimum`은 CRD의 OpenAPI 스키마에 검증 규칙을 추가하고, `subresource:status`는 status 서브리소스를 활성화하며, `printcolumn`은 `kubectl get`에 추가 컬럼을 보여 줍니다.

reconcile 구현 전체 코드

이제 핵심인 reconcile 함수를 채웁니다. `internal/controller/guestbook_controller.go`를 다음과 같이 작성합니다. 이 컨트롤러는 Guestbook을 읽어 원하는 Deployment와 Service를 멱등하게 만들고, 마지막에 status를 갱신합니다.

package controller

"context"

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

"k8s.io/apimachinery/pkg/runtime"

ctrl "sigs.k8s.io/controller-runtime"

"sigs.k8s.io/controller-runtime/pkg/client"

"sigs.k8s.io/controller-runtime/pkg/log"

webappv1 "example.com/guestbook-operator/api/v1"

)

type GuestbookReconciler struct {

client.Client

Scheme *runtime.Scheme

}

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

logger := log.FromContext(ctx)

// 1. 대상 Guestbook을 읽는다.

var gb webappv1.Guestbook

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

if errors.IsNotFound(err) {

// 이미 삭제됨. owner reference 덕에 하위 리소스는 GC가 정리한다.

return ctrl.Result{}, nil

}

return ctrl.Result{}, err

}

// 2. 원하는 Deployment를 만들고 멱등하게 조정한다.

desiredDeploy := r.buildDeployment(&gb)

if err := ctrl.SetControllerReference(&gb, desiredDeploy, r.Scheme); err != nil {

return ctrl.Result{}, err

}

var existingDeploy appsv1.Deployment

err := r.Get(ctx, client.ObjectKeyFromObject(desiredDeploy), &existingDeploy)

if errors.IsNotFound(err) {

logger.Info("Deployment 생성", "name", desiredDeploy.Name)

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

return ctrl.Result{}, err

}

} else if err != nil {

return ctrl.Result{}, err

} else {

// 이미 존재하면 원하는 모습으로 맞춘다.

existingDeploy.Spec = desiredDeploy.Spec

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

return ctrl.Result{}, err

}

}

// 3. 원하는 Service를 조정한다.

desiredSvc := r.buildService(&gb)

if err := ctrl.SetControllerReference(&gb, desiredSvc, r.Scheme); err != nil {

return ctrl.Result{}, err

}

var existingSvc corev1.Service

err = r.Get(ctx, client.ObjectKeyFromObject(desiredSvc), &existingSvc)

if errors.IsNotFound(err) {

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

return ctrl.Result{}, err

}

} else if err != nil {

return ctrl.Result{}, err

}

// 4. status를 갱신한다.

gb.Status.ReadyReplicas = existingDeploy.Status.ReadyReplicas

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

return ctrl.Result{}, err

}

return ctrl.Result{}, nil

}

func (r *GuestbookReconciler) buildDeployment(gb *webappv1.Guestbook) *appsv1.Deployment {

labels := map[string]string{"app": gb.Name}

replicas := gb.Spec.Replicas

return &appsv1.Deployment{

ObjectMeta: metav1.ObjectMeta{

Name: gb.Name,

Namespace: gb.Namespace,

},

Spec: appsv1.DeploymentSpec{

Replicas: &replicas,

Selector: &metav1.LabelSelector{MatchLabels: labels},

Template: corev1.PodTemplateSpec{

ObjectMeta: metav1.ObjectMeta{Labels: labels},

Spec: corev1.PodSpec{

Containers: []corev1.Container{{

Name: "app",

Image: gb.Spec.Image,

Ports: []corev1.ContainerPort{{ContainerPort: gb.Spec.Port}},

}},

},

},

},

}

}

func (r *GuestbookReconciler) buildService(gb *webappv1.Guestbook) *corev1.Service {

labels := map[string]string{"app": gb.Name}

return &corev1.Service{

ObjectMeta: metav1.ObjectMeta{

Name: gb.Name,

Namespace: gb.Namespace,

},

Spec: corev1.ServiceSpec{

Selector: labels,

Ports: []corev1.ServicePort{{

Port: gb.Spec.Port,

TargetPort: intstr.FromInt32(gb.Spec.Port),

}},

},

}

}

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

return ctrl.NewControllerManagedBy(mgr).

For(&webappv1.Guestbook{}).

Owns(&appsv1.Deployment{}).

Owns(&corev1.Service{}).

Complete(r)

}

`SetupWithManager`의 `Owns`가 중요합니다. 이렇게 등록하면 컨트롤러가 만든 Deployment나 Service가 외부에서 변경될 때도 reconcile이 트리거됩니다. 즉 누군가 손으로 replica를 바꿔도 컨트롤러가 곧바로 원하는 값으로 되돌립니다. 이것이 자가 치유의 원리입니다.

RBAC 마커

컨트롤러가 Deployment와 Service를 만들려면 그에 맞는 권한이 필요합니다. Kubebuilder에서는 RBAC를 **마커**로 선언합니다. 컨트롤러 파일 상단에 다음 주석을 추가합니다.

// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete

// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patch

// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete

// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete

이 마커들은 코드 생성 시 `config/rbac/role.yaml`의 ClusterRole로 변환됩니다. 손으로 RBAC YAML을 쓰지 않고 코드 옆에 권한 의도를 선언하는 방식이라, 권한과 코드가 같이 유지됩니다. 필요 이상의 권한을 주지 않도록 verbs를 최소화하는 것이 좋습니다.

CRD 생성: make manifests

API 타입과 마커를 다 작성했으면 매니페스트를 생성합니다.

make manifests

make generate

- `make manifests`는 마커를 읽어 `config/crd/`의 CRD와 `config/rbac/`의 RBAC를 생성합니다.

- `make generate`는 `DeepCopy` 같은 보일러플레이트 Go 코드를 생성합니다.

생성된 CRD를 클러스터에 설치합니다.

make install

kubectl get crd guestbooks.webapp.example.com

로컬 실행 vs 클러스터 배포

로컬 실행 (개발 루프에 적합)

개발 중에는 컨트롤러를 클러스터 밖에서, 즉 내 노트북에서 직접 실행하는 것이 가장 빠릅니다. kubeconfig를 통해 클러스터에 연결됩니다.

make run

코드를 고치고 다시 `make run` 하면 즉시 반영되므로 반복 속도가 빠릅니다. 이제 샘플 CR을 적용해 봅니다.

kubectl apply -f config/samples/webapp_v1_guestbook.yaml

kubectl get guestbook

kubectl get deployment,service

샘플 CR의 replica를 5로 바꿔 다시 적용하면 Deployment의 replica가 5로 따라오는 것을 볼 수 있습니다. 손으로 Deployment의 replica를 3으로 바꾸면 컨트롤러가 곧바로 원래 값으로 되돌립니다.

클러스터 배포 (운영에 적합)

실제 운영에서는 컨트롤러도 클러스터 안의 파드로 돌립니다. 이미지를 빌드해 푸시한 뒤 배포합니다.

make docker-build docker-push IMG=registry.example.com/guestbook-operator:v0.1.0

make deploy IMG=registry.example.com/guestbook-operator:v0.1.0

`make deploy`는 RBAC, CRD, 그리고 컨트롤러 Deployment를 한꺼번에 적용합니다. 배포 후에는 컨트롤러 매니저 파드의 로그로 동작을 확인합니다.

kubectl -n guestbook-operator-system get pods

kubectl -n guestbook-operator-system logs deploy/guestbook-operator-controller-manager

테스트: envtest 개념

Operator를 진지하게 만들려면 테스트가 필요합니다. Kubebuilder는 **envtest**라는 도구를 제공합니다. envtest는 실제 클러스터 없이 etcd와 kube-apiserver 바이너리만 띄워서, 컨트롤러가 진짜 API Server와 상호작용하는 것처럼 테스트할 수 있게 합니다.

make test

envtest의 장점은 fake client보다 현실적이라는 점입니다. CRD 검증, status 서브리소스, owner reference 같은 API Server 차원의 동작을 실제로 검증할 수 있습니다. 테스트에서는 보통 CR을 만든 뒤, reconcile이 원하는 Deployment를 만들었는지 폴링으로 확인하는 패턴을 씁니다.

흔한 실수

실제로 처음 Operator를 만들 때 자주 마주치는 함정을 정리합니다.

| 실수 | 증상 | 해결 |

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

| RBAC 마커 누락 | "forbidden" 에러로 리소스 생성 실패 | 필요한 그룹/리소스/verb 마커 추가 후 make manifests |

| owner reference 미설정 | CR 삭제해도 하위 리소스가 남음 | SetControllerReference 호출 |

| 비멱등 reconcile | "already exists" 에러, 무한 루프 | get 후 없으면 create, 있으면 update 패턴 |

| status를 Update로 갱신 | status 충돌 또는 무시됨 | Status().Update 사용(서브리소스) |

| Owns 누락 | 하위 리소스 변경에 반응 안 함 | SetupWithManager에 Owns 추가 |

| make generate 잊음 | DeepCopy 관련 컴파일 에러 | 타입 변경 후 make generate 실행 |

특히 멱등성과 owner reference는 앞 글에서 강조한 두 가지 기본기입니다. 이 두 가지만 제대로 지켜도 대부분의 초보적 버그를 피할 수 있습니다.

2026년의 맥락

2026년 기준 Kubebuilder는 Kubernetes 1.36과 Go 1.26을 지원하며, 내부적으로 controller-runtime v0.24.x와 controller-tools v0.21.x를 사용합니다. 과거 스캐폴딩에 포함되던 kube-rbac-proxy 사이드카는 제거되었고, 대신 controller-runtime의 인증·인가 미들웨어(WithAuthenticationAndAuthorization)로 메트릭 엔드포인트를 보호합니다. 새로 만든 프로젝트라면 이 방식이 기본이므로 추가 컨테이너 없이도 안전한 메트릭 노출이 가능합니다.

마치며

Kubebuilder로 Operator를 만드는 흐름은 결국 다음 다섯 단계로 요약됩니다. init으로 프로젝트를 만들고, create api로 타입과 컨트롤러를 추가하고, Spec/Status와 마커로 API를 정의하고, reconcile에 멱등한 조정 로직을 채우고, make manifests/install/run으로 돌려 보는 것입니다.

처음에는 스캐폴딩이 만들어 내는 파일이 많아 막막해 보이지만, 핵심은 결국 reconcile 함수 하나입니다. "원하는 상태를 만들고, 실제 상태와 비교해 멱등하게 맞춘다"는 한 문장을 코드로 옮기는 것이 Operator 개발의 본질입니다. 다음 글에서는 이 reconcile 루프를 성능과 에러 처리 관점에서 더 깊이 파고듭니다.

참고 자료

- [Kubebuilder Book — Quick Start](https://book.kubebuilder.io/quick-start.html)

- [Kubebuilder Book — Tutorial](https://book.kubebuilder.io/cronjob-tutorial/cronjob-tutorial.html)

- [Kubebuilder 마커 레퍼런스](https://book.kubebuilder.io/reference/markers.html)

- [controller-runtime (pkg.go.dev)](https://pkg.go.dev/sigs.k8s.io/controller-runtime)

- [Operator SDK 문서](https://sdk.operatorframework.io/)

- [kubernetes-sigs/kubebuilder (GitHub)](https://github.com/kubernetes-sigs/kubebuilder)

- [kubernetes-sigs/controller-runtime (GitHub)](https://github.com/kubernetes-sigs/controller-runtime)

- [envtest 사용 가이드](https://book.kubebuilder.io/reference/envtest.html)

- [Operator 패턴 (쿠버네티스 공식 문서)](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)

현재 단락 (1/256)

앞 글에서 Operator 패턴의 원리를 살펴봤습니다. 이번에는 직접 손을 더럽혀 볼 차례입니다. Kubebuilder를 사용해 간단하지만 실제로 동작하는 Operator를 처음부...

작성 글자: 0원문 글자: 10,750작성 단락: 0/256