들어가며
앞 글에서 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를 처음부...