🏗️ Kubebuilder — Scaffold in Minutes

Kubebuilder is the official Kubernetes SIG project for building operators in Go. It scaffolds a complete operator project: CRD types, controller skeleton, RBAC markers, Makefile targets, and Dockerfile — so you write business logic, not boilerplate.

Bootstrap a new operator project

# Prerequisites: Go 1.21+, kubectl, Docker
go install sigs.k8s.io/kubebuilder/cmd/kubebuilder@latest

# 1. Initialise the project (creates go.mod, main.go, config/)
mkdir database-operator && cd database-operator
kubebuilder init \
  --domain myorg.example.com \
  --repo   github.com/myorg/database-operator

# 2. Create the API type + controller scaffold
kubebuilder create api \
  --group  myorg \
  --version v1 \
  --kind    Database

# Creates:
# api/v1/database_types.go      ← your CRD struct
# internal/controller/database_controller.go  ← your Reconcile logic
# config/crd/                   ← generated CRD YAML
# config/rbac/                  ← generated RBAC YAML

Generated project structure

database-operator/
├── api/v1/
│   ├── database_types.go       ← CRD spec/status structs
│   └── groupversion_info.go    ← API group registration
├── internal/controller/
│   ├── database_controller.go  ← Reconcile function lives here
│   └── suite_test.go           ← envtest integration test setup
├── config/
│   ├── crd/                    ← generated CRD manifests
│   ├── rbac/                   ← ClusterRole from // +kubebuilder markers
│   ├── manager/                ← controller Deployment manifests
│   └── default/                ← kustomize overlays
├── cmd/main.go                 ← manager entrypoint
└── Makefile                    ← make generate, make manifests, make deploy

Defining the CRD type (api/v1/database_types.go)

// +groupName=myorg.example.com
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Engine",type=string,JSONPath=`.spec.engine`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Database struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   DatabaseSpec   `json:"spec,omitempty"`
    Status DatabaseStatus `json:"status,omitempty"`
}

type DatabaseSpec struct {
    // +kubebuilder:validation:Enum=postgres;mysql;redis
    // +kubebuilder:validation:Required
    Engine string `json:"engine"`

    // +kubebuilder:validation:Pattern=`^[0-9]+Gi$`
    Storage string `json:"storage"`

    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=5
    // +kubebuilder:default=1
    Replicas int32 `json:"replicas,omitempty"`
}

type DatabaseStatus struct {
    // +kubebuilder:validation:Enum=Pending;Provisioning;Ready;Failed
    Phase string `json:"phase,omitempty"`

    ReadyReplicas     int32                `json:"readyReplicas,omitempty"`
    ObservedGeneration int64               `json:"observedGeneration,omitempty"`
    Conditions        []metav1.Condition   `json:"conditions,omitempty"`
}

// Run: make generate manifests
// → updates zz_generated.deepcopy.go and config/crd/ YAML
💡 Markers generate everything The // +kubebuilder: comments (markers) are parsed by controller-gen to produce CRD YAML, RBAC ClusterRoles, and DeepCopy methods. Run make generate manifests after every type change — never edit the generated files by hand.

🔗 Owner References — Automatic Garbage Collection

When your operator creates child resources (StatefulSet, Service, ConfigMap), it should set an owner reference pointing back to the parent CR. Kubernetes garbage collection then automatically deletes the child when the parent is deleted — no cleanup code needed.

// Set the Database CR as the owner of the StatefulSet
func (r *DatabaseReconciler) buildStatefulSet(db *myorgv1.Database) *appsv1.StatefulSet {
    sts := &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      db.Name,
            Namespace: db.Namespace,
            Labels:    map[string]string{"app": db.Name},
        },
        // ... spec
    }

    // SetControllerReference adds ownerReferences with controller=true, blockOwnerDeletion=true
    if err := ctrl.SetControllerReference(db, sts, r.Scheme); err != nil {
        return nil
    }
    return sts
}

// In the object metadata this produces:
// ownerReferences:
// - apiVersion: myorg.example.com/v1
//   kind: Database
//   name: prod-postgres
//   uid: abc-123
//   controller: true
//   blockOwnerDeletion: true
🔵 Cross-namespace owner references are not allowed Kubernetes enforces that owner references must be within the same namespace (for namespaced resources). A cluster-scoped resource (e.g. ClusterRole) cannot be owned by a namespaced resource. Use finalizers for cross-namespace cleanup.

🔒 Finalizers — Custom Cleanup Logic

Finalizers let you run arbitrary cleanup before a resource is actually deleted from etcd. When a resource with finalizers is deleted, Kubernetes sets deletionTimestamp but does NOT remove it until all finalizers are removed.

kubectl delete sets deletionTimestamp resource stays in etcd Reconcile triggered sees deletionTimestamp set runs cleanup logic Remove finalizer controllerutil.RemoveFinalizer → etcd deletion proceeds
const dbFinalizer = "myorg.example.com/cleanup"

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    db := &myorgv1.Database{}
    r.Get(ctx, req.NamespacedName, db)

    // Add finalizer on creation
    if !controllerutil.ContainsFinalizer(db, dbFinalizer) {
        controllerutil.AddFinalizer(db, dbFinalizer)
        return ctrl.Result{}, r.Update(ctx, db)
    }

    // Handle deletion
    if !db.DeletionTimestamp.IsZero() {
        // Run cleanup: delete external cloud resources, revoke secrets, etc.
        if err := r.cleanupExternalResources(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
        // Remove finalizer → triggers actual etcd deletion
        controllerutil.RemoveFinalizer(db, dbFinalizer)
        return ctrl.Result{}, r.Update(ctx, db)
    }

    // Normal reconcile path...
    return r.reconcileDatabase(ctx, db)
}
🔴 Stuck finalizers A buggy cleanup function that always errors will leave resources stuck in terminating state forever. Always add a timeout or skip condition. To force-remove a stuck finalizer: kubectl patch <resource> -p '{"metadata":{"finalizers":[]}}' --type=merge. Use this only in emergencies — it bypasses your cleanup logic.

⚙️ Wiring the Controller (main.go)

The cmd/main.go entrypoint bootstraps the controller-runtime manager — the component that runs all controllers, handles leader election, serves webhook endpoints, and exposes health/metrics endpoints:

func main() {
    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        Metrics: server.Options{BindAddress: ":8080"},
        HealthProbeBindAddress: ":8081",
        LeaderElection:         true,
        LeaderElectionID:       "database-operator-leader",
    })

    // Register the reconciler with the manager
    if err = (&controller.DatabaseReconciler{
        Client: mgr.GetClient(),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        log.Fatal(err)
    }

    // Health probes (used by readiness/liveness probes in the Deployment)
    mgr.AddHealthzCheck("healthz", healthz.Ping)
    mgr.AddReadyzCheck("readyz", healthz.Ping)

    mgr.Start(ctrl.SetupSignalHandler())
}

SetupWithManager — declaring watches

The SetupWithManager method declares which resources trigger reconciliation and how:

func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&myorgv1.Database{}).             // primary resource to watch
        Owns(&appsv1.StatefulSet{}).          // reconcile when owned StatefulSet changes
        Owns(&corev1.Service{}).              // reconcile when owned Service changes
        Watches(                              // watch a Secret and map to owning Database
            &corev1.Secret{},
            handler.EnqueueRequestForOwner(
                mgr.GetScheme(), mgr.GetRESTMapper(),
                &myorgv1.Database{},
            ),
        ).
        WithOptions(controller.Options{
            MaxConcurrentReconciles: 3,       // parallel reconcile goroutines
        }).
        Complete(r)
}

RBAC markers — generating ClusterRole

Place // +kubebuilder:rbac markers directly above the Reconcile function. make manifests generates the ClusterRole YAML:

// +kubebuilder:rbac:groups=myorg.example.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=myorg.example.com,resources=databases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=myorg.example.com,resources=databases/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services;secrets;configmaps,verbs=get;list;watch;create;update;patch;delete
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // ... reconcile logic
}

Testing with envtest

Kubebuilder scaffolds envtest integration tests that spin up a real API server binary (no cluster needed) — much more reliable than mocking:

// suite_test.go — bootstrapped by kubebuilder
var _ = BeforeSuite(func() {
    testEnv = &envtest.Environment{
        CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
    }
    cfg, _ = testEnv.Start()
    k8sClient, _ = client.New(cfg, client.Options{Scheme: scheme})
})

// database_controller_test.go — test reconciliation
It("should create a StatefulSet for a Database", func() {
    db := &myorgv1.Database{
        ObjectMeta: metav1.ObjectMeta{Name: "test-db", Namespace: "default"},
        Spec: myorgv1.DatabaseSpec{Engine: "postgres", Storage: "10Gi"},
    }
    Expect(k8sClient.Create(ctx, db)).To(Succeed())

    sts := &appsv1.StatefulSet{}
    Eventually(func() error {
        return k8sClient.Get(ctx, types.NamespacedName{
            Name: "test-db", Namespace: "default",
        }, sts)
    }, timeout, interval).Should(Succeed())
    Expect(sts.Spec.Replicas).To(Equal(ptr(int32(1))))
})

Build and deploy

# Generate CRD + RBAC manifests from markers
make generate manifests

# Build and push the operator image
make docker-build docker-push IMG=ghcr.io/myorg/database-operator:v0.1.0

# Deploy to cluster (installs CRD + controller Deployment)
make deploy IMG=ghcr.io/myorg/database-operator:v0.1.0

# Or generate plain YAML and apply manually
make build-installer IMG=ghcr.io/myorg/database-operator:v0.1.0
kubectl apply -f dist/install.yaml

🧠 Knowledge Check

Q1. What does make generate manifests do in a Kubebuilder project?

A) Builds the operator binary and pushes it to a registry
B) Runs the full test suite including envtest integration tests
C) Regenerates DeepCopy methods and CRD/RBAC YAML from // +kubebuilder: markers in your Go types
D) Deploys the operator to the cluster and waits for it to be Ready

Q2. Your operator creates a StatefulSet with ctrl.SetControllerReference(db, sts, r.Scheme). A user deletes the Database CR. What happens to the StatefulSet?

A) The StatefulSet remains — owner references only affect Pod scheduling
B) The StatefulSet is automatically garbage collected by Kubernetes
C) The operator must manually delete the StatefulSet in its Reconcile function
D) The StatefulSet is orphaned and must be manually deleted

Q3. A Database CR has a finalizer set. A user runs kubectl delete database prod-postgres. What is the state of the object immediately after?

A) The object is immediately removed from etcd
B) The delete is rejected — you must remove finalizers before deleting
C) deletionTimestamp is set; object remains in etcd until the controller removes the finalizer
D) The object is deleted and the finalizer's cleanup function is called asynchronously

Q4. Why does Owns(&appsv1.StatefulSet{}) in SetupWithManager trigger reconciliation of the parent Database?

A) StatefulSets and Databases share the same API group so watches are automatic
B) It copies the StatefulSet spec into the Database status
C) It watches owned StatefulSets and maps changes back to the parent Database, enqueuing it for reconciliation
D) It prevents other controllers from modifying the StatefulSet