- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 사전 준비
- 프로젝트 생성: init과 create api
- 프로젝트 구조 살펴보기
- API 타입 정의: Spec과 Status
- reconcile 구현 전체 코드
- RBAC 마커
- CRD 생성: make manifests
- 로컬 실행 vs 클러스터 배포
- 테스트: envtest 개념
- 흔한 실수
- 2026년의 맥락
- 마치며
- 참고 자료
들어가며
앞 글에서 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
import (
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
import (
"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 루프를 성능과 에러 처리 관점에서 더 깊이 파고듭니다.