Skip to content

필사 모드: Building Your First Operator with Kubebuilder — From Project Creation to Deployment

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

Introduction

In the previous article we looked at the principles of the Operator pattern. Now it is time to get our hands dirty. Using Kubebuilder, we will build a simple but genuinely working Operator from scratch. The target is a CRD called `Guestbook`: when a user declares the desired replica count and image, the Operator automatically creates and reconciles a matching Deployment and Service.

The goal here is to understand the whole flow "without magic." We will follow all the way through what scaffolding produces, what to fill into the reconcile function, how RBAC and the CRD are generated, and how to run it both locally and in a cluster. This is based on controller-runtime v0.24.x.

Prerequisites

Before starting, you need the following tools.

- **Go 1.26 or later**: The modules Kubebuilder generates assume a recent Go toolchain.

- **Kubebuilder CLI**: Handles project scaffolding and code generation.

- **kubectl**: The standard CLI for talking to the cluster.

- **A local or remote cluster**: A local cluster made with kind or minikube is enough.

- **Docker or an equivalent container builder**: Needed to build and deploy the controller image.

Once installed, check the versions.

go version

kubebuilder version

kubectl version --client

Project Creation: init and create api

kubebuilder init

First, initialize the project in an empty directory. `--domain` becomes the suffix of the API group, and `--repo` is the Go module path.

mkdir guestbook-operator && cd guestbook-operator

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

This command generates `main.go`, a `Makefile`, a `Dockerfile`, the `config/` directory (manifests), and a `go.mod` with dependencies filled in. At this point the project is just an empty manager with no APIs.

kubebuilder create api

Now add an API and a controller.

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

The prompt asks whether to generate both Resource (the type definition) and Controller (the reconcile logic). Choose yes for both. That produces:

- `api/v1/guestbook_types.go` — API type definitions like Spec/Status

- `internal/controller/guestbook_controller.go` — the controller where reconcile logic goes

- `config/crd/`, `config/rbac/`, `config/samples/` — related manifests

A Look at the Project Structure

Understanding the directory structure scaffolding created at a glance makes subsequent work much easier. Summarizing only the key directories:

guestbook-operator/

api/v1/ # API type definitions (Spec/Status, markers)

internal/controller/ # reconcile logic

config/

crd/ # generated CRD manifests

rbac/ # generated RBAC (role.yaml, etc.)

manager/ # the controller manager Deployment

samples/ # example CRs

cmd/main.go # manager entry point (scheme registration, manager startup)

Makefile # standard targets like build/run/deploy

Dockerfile # builds the controller image

- **api/**: This is where we work directly. We define types and markers here.

- **internal/controller/**: Also where we fill in reconcile.

- **config/**: Mostly auto-generated, so there is little to edit by hand. Manage it by changing markers and running make manifests.

- **cmd/main.go**: Bootstrap code that creates the manager, registers types with the scheme, and wires controllers into the manager. When you add a new controller, registration code usually goes here automatically.

The core philosophy of this structure is to clearly separate "the code I write (api, controller)" from "generated artifacts (config)." Do not edit generated artifacts directly; keep the flow of always editing the source (types and markers) then regenerating, and consistency is maintained.

Defining API Types: Spec and Status

Open `api/v1/guestbook_types.go` and define the fields we want. Spec is the "desired state" the user declares, and Status is the "observed state" the controller fills in.

package v1

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

)

// GuestbookSpec defines the desired state the user declares.

type GuestbookSpec struct {

// Desired number of replicas.

// +kubebuilder:validation:Minimum=1

// +kubebuilder:validation:Maximum=10

Replicas int32 `json:"replicas"`

// Container image to use.

// +kubebuilder:validation:MinLength=1

Image string `json:"image"`

// Port the service exposes.

// +kubebuilder:default=80

Port int32 `json:"port,omitempty"`

}

// GuestbookStatus holds the observed state.

type GuestbookStatus struct {

// Number of ready replicas.

ReadyReplicas int32 `json:"readyReplicas"`

// List of status conditions.

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 is our custom resource.

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 is a list of Guestbook.

type GuestbookList struct {

metav1.TypeMeta `json:",inline"`

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

Items []Guestbook `json:"items"`

}

func init() {

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

}

The lines starting with `// +kubebuilder:` are called **markers**. Markers are directives read by code generation tools. For example, `validation:Minimum` adds a validation rule to the CRD's OpenAPI schema, `subresource:status` enables the status subresource, and `printcolumn` shows extra columns in `kubectl get`.

The Full Reconcile Code

Now we fill in the core reconcile function. Write `internal/controller/guestbook_controller.go` as follows. This controller reads a Guestbook, idempotently creates the desired Deployment and Service, and finally updates 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. Read the target Guestbook.

var gb webappv1.Guestbook

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

if errors.IsNotFound(err) {

// Already deleted. Thanks to owner references, GC cleans up children.

return ctrl.Result{}, nil

}

return ctrl.Result{}, err

}

// 2. Build the desired Deployment and reconcile it idempotently.

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("Creating 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 {

// If it already exists, bring it to the desired shape.

existingDeploy.Spec = desiredDeploy.Spec

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

return ctrl.Result{}, err

}

}

// 3. Reconcile the desired 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. Update 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)

}

The `Owns` calls in `SetupWithManager` matter. Registering them this way triggers reconcile even when a Deployment or Service the controller created is changed externally. In other words, even if someone changes the replicas by hand, the controller immediately reverts it to the desired value. This is the principle of self-healing.

RBAC Markers

For the controller to create Deployments and Services, it needs the corresponding permissions. In Kubebuilder, RBAC is declared with **markers**. Add the following comments at the top of the controller file.

// +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

These markers are converted into a ClusterRole in `config/rbac/role.yaml` during code generation. Instead of writing RBAC YAML by hand, you declare the permission intent right next to the code, so permissions and code stay together. It is good practice to minimize verbs so you do not grant more permission than necessary.

CRD Generation: make manifests

Once you have written the API types and markers, generate the manifests.

make manifests

make generate

- `make manifests` reads the markers to generate the CRD in `config/crd/` and the RBAC in `config/rbac/`.

- `make generate` generates boilerplate Go code such as `DeepCopy`.

Install the generated CRD into the cluster.

make install

kubectl get crd guestbooks.webapp.example.com

Local Run vs. Cluster Deploy

Local run (suited to the dev loop)

During development, running the controller outside the cluster — that is, directly on your laptop — is the fastest approach. It connects to the cluster via kubeconfig.

make run

Edit the code and run `make run` again, and it is reflected immediately, so iteration is fast. Now apply a sample CR.

kubectl apply -f config/samples/webapp_v1_guestbook.yaml

kubectl get guestbook

kubectl get deployment,service

Change the sample CR's replicas to 5 and re-apply, and you will see the Deployment's replicas follow to 5. Change the Deployment's replicas to 3 by hand, and the controller immediately reverts it to the original value.

Cluster deploy (suited to production)

In real production, you run the controller as a pod inside the cluster too. Build and push the image, then deploy.

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` applies the RBAC, CRD, and controller Deployment all at once. After deployment, check behavior in the controller manager pod's logs.

kubectl -n guestbook-operator-system get pods

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

Testing: The envtest Concept

To build an Operator seriously, you need tests. Kubebuilder provides a tool called **envtest**. envtest spins up only the etcd and kube-apiserver binaries — no real cluster — so you can test as if the controller interacts with a real API Server.

make test

The advantage of envtest is that it is more realistic than a fake client. You can actually verify API Server-level behavior such as CRD validation, the status subresource, and owner references. In tests, the common pattern is to create a CR, then poll to confirm that reconcile created the desired Deployment.

Common Mistakes

Here are pitfalls you frequently hit when building your first Operator.

| Mistake | Symptom | Fix |

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

| Missing RBAC markers | Resource creation fails with "forbidden" | Add the needed group/resource/verb markers, then make manifests |

| Owner reference not set | Children remain even after deleting the CR | Call SetControllerReference |

| Non-idempotent reconcile | "already exists" errors, infinite loop | get then create if missing, update if present |

| Updating status with Update | Status conflicts or is ignored | Use Status().Update (subresource) |

| Missing Owns | Does not react to child changes | Add Owns in SetupWithManager |

| Forgetting make generate | DeepCopy-related compile errors | Run make generate after changing types |

In particular, idempotency and owner references are the two fundamentals emphasized in the previous article. Just keeping these two right avoids most beginner bugs.

The 2026 Context

As of 2026, Kubebuilder supports Kubernetes 1.36 and Go 1.26, and internally uses controller-runtime v0.24.x and controller-tools v0.21.x. The kube-rbac-proxy sidecar formerly included in scaffolding has been removed; instead, controller-runtime's authentication/authorization middleware (WithAuthenticationAndAuthorization) protects the metrics endpoint. For a newly created project this is the default, so you can expose metrics safely without an extra container.

Conclusion

The flow of building an Operator with Kubebuilder ultimately comes down to five steps: create the project with init, add the type and controller with create api, define the API with Spec/Status and markers, fill reconcile with idempotent reconciliation logic, and run it with make manifests/install/run.

At first the many files scaffolding produces look daunting, but the core is really just one reconcile function. Moving the single sentence "build the desired state, compare it with the actual state, and converge idempotently" into code is the essence of Operator development. In the next article, we dig deeper into this reconcile loop from the angles of performance and error handling.

References

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

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

- [Kubebuilder marker reference](https://book.kubebuilder.io/reference/markers.html)

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

- [Operator SDK documentation](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 usage guide](https://book.kubebuilder.io/reference/envtest.html)

- [Operator pattern (Kubernetes official docs)](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)

현재 단락 (1/256)

In the previous article we looked at the principles of the Operator pattern. Now it is time to get o...

작성 글자: 0원문 글자: 14,073작성 단락: 0/256