Skip to content

Kubernetes Operators Reference: CRDs, Controllers, RBAC, Status Conditions & kubebuilder

Kubernetes Operators extend the Kubernetes API to manage stateful, complex applications. An Operator is a controller that watches a custom resource and reconciles actual state to desired state. The pattern: define a CRD, write a controller with a reconcile loop, deploy with RBAC. The most important principle is idempotent reconciliation — your reconcile function must handle being called multiple times with the same state, because Kubernetes will re-trigger it on any change.

1. CRDs — Custom Resource Definitions

Define CRDs with validation schema, deploy, and create custom resources
# CRD definition:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  names:
    kind: Database
    plural: databases
    singular: database
    shortNames: [db]
  scope: Namespaced   # or Cluster
  versions:
  - name: v1alpha1
    served: true
    storage: true    # only one version can be storage=true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            required: [engine, storage]
            properties:
              engine:    { type: string, enum: [postgres, mysql] }
              version:   { type: string }
              storage:   { type: string, pattern: "^[0-9]+(Gi|Mi)$" }
              replicas:  { type: integer, minimum: 1, maximum: 5 }
          status:
            type: object
            properties:
              phase:       { type: string }
              endpoint:    { type: string }
              conditions:  { type: array, items: { type: object } }
    subresources:
      status: {}     # enables /status subresource endpoint
    additionalPrinterColumns:
    - name: Engine
      type: string
      jsonPath: .spec.engine
    - name: Phase
      type: string
      jsonPath: .status.phase
    - name: Age
      type: date
      jsonPath: .metadata.creationTimestamp

---
# Custom Resource instance:
apiVersion: example.com/v1alpha1
kind: Database
metadata:
  name: my-postgres
spec:
  engine: postgres
  version: "16"
  storage: 10Gi
  replicas: 2

2. Controller Reconcile Loop (Go / controller-runtime)

Reconciler interface, status conditions, finalizers, and error handling
// kubebuilder / controller-runtime reconciler (Go):
package controllers

import (
    "context"
    examplev1 "github.com/example/operator/api/v1alpha1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

type DatabaseReconciler struct {
    client.Client
    Log logr.Logger
}

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := r.Log.WithValues("database", req.NamespacedName)

    // 1. Fetch the custom resource:
    db := &examplev1.Database{}
    if err := r.Get(ctx, req.NamespacedName, db); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil   // deleted — nothing to do
        }
        return ctrl.Result{}, err
    }

    // 2. Handle deletion via finalizer:
    if !db.DeletionTimestamp.IsZero() {
        if containsFinalizer(db, "databases.example.com/finalizer") {
            if err := r.cleanupExternalResources(ctx, db); err != nil {
                return ctrl.Result{}, err
            }
            removeFinalizer(db, "databases.example.com/finalizer")
            return ctrl.Result{}, r.Update(ctx, db)
        }
        return ctrl.Result{}, nil
    }

    // 3. Add finalizer if missing:
    if !containsFinalizer(db, "databases.example.com/finalizer") {
        addFinalizer(db, "databases.example.com/finalizer")
        return ctrl.Result{}, r.Update(ctx, db)
    }

    // 4. Reconcile — create/update owned resources:
    // (StatefulSet, Service, Secret...)
    if err := r.reconcileStatefulSet(ctx, db); err != nil {
        // Update status on failure:
        db.Status.Phase = "Failed"
        r.Status().Update(ctx, db)
        return ctrl.Result{}, err
    }

    // 5. Update status (always use r.Status().Update(), not r.Update()):
    db.Status.Phase = "Running"
    db.Status.Endpoint = db.Name + "." + db.Namespace + ".svc.cluster.local"
    if err := r.Status().Update(ctx, db); err != nil {
        return ctrl.Result{}, err
    }

    // Requeue after 30s for drift detection:
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

3. RBAC, ServiceAccount & Deployment

Operator RBAC markers, ClusterRole for owned resources, and controller Deployment
# ClusterRole for the operator:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: database-operator-role
rules:
# Manage your CRD:
- apiGroups: [example.com]
  resources: [databases, databases/status, databases/finalizers]
  verbs: [get, list, watch, create, update, patch, delete]
# Create owned resources:
- apiGroups: [""]
  resources: [pods, services, secrets, configmaps, persistentvolumeclaims]
  verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [apps]
  resources: [statefulsets, deployments]
  verbs: [get, list, watch, create, update, patch, delete]
# Watch events for debugging:
- apiGroups: [""]
  resources: [events]
  verbs: [create, patch]

---
# kubebuilder RBAC markers (auto-generates ClusterRole):
// +kubebuilder:rbac:groups=example.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=example.com,resources=databases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete

---
# Operator Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: database-operator
  namespace: operators
spec:
  replicas: 1    # operators are usually single-replica with leader election
  selector:
    matchLabels:
      app: database-operator
  template:
    spec:
      serviceAccountName: database-operator
      containers:
      - name: operator
        image: example.com/database-operator:v1.0.0
        resources:
          limits:   { cpu: 500m, memory: 128Mi }
          requests: { cpu: 100m, memory: 64Mi }

4. Status Conditions & Owner References

Standard condition types, setting owner references for garbage collection, and printer columns
// Status Conditions — Kubernetes standard pattern:
// Type: Ready, Available, Progressing, Degraded
// Status: True, False, Unknown
// Reason: camelCase reason code
// Message: human-readable description

func setCondition(db *examplev1.Database, condType, status, reason, message string) {
    now := metav1.Now()
    condition := metav1.Condition{
        Type:               condType,
        Status:             metav1.ConditionStatus(status),
        ObservedGeneration: db.Generation,
        LastTransitionTime: now,
        Reason:             reason,
        Message:            message,
    }
    // Replace existing condition of same type:
    for i, c := range db.Status.Conditions {
        if c.Type == condType {
            db.Status.Conditions[i] = condition
            return
        }
    }
    db.Status.Conditions = append(db.Status.Conditions, condition)
}

// Usage:
setCondition(db, "Ready", "False", "Provisioning", "Creating StatefulSet")
setCondition(db, "Ready", "True", "DatabaseReady", "Database is ready")

// Owner references — owned resources deleted when parent is deleted:
statefulSet.SetOwnerReferences([]metav1.OwnerReference{
    {
        APIVersion:         db.APIVersion,
        Kind:               db.Kind,
        Name:               db.Name,
        UID:                db.UID,
        Controller:         pointer.Bool(true),
        BlockOwnerDeletion: pointer.Bool(true),
    },
})
// When Database is deleted → StatefulSet is GC'd automatically

// Check if resource already exists (owned by this CR):
found := &appsv1.StatefulSet{}
err := r.Get(ctx, types.NamespacedName{Name: db.Name, Namespace: db.Namespace}, found)
if errors.IsNotFound(err) {
    // Create it
} else if err == nil {
    // Update if spec differs
}

5. Operator SDK, kubebuilder & Helm-based Operators

Scaffolding with kubebuilder, Operator SDK alternatives, and Helm chart operators
# kubebuilder — quickstart:
# Initialize project:
kubebuilder init --domain example.com --repo github.com/example/operator

# Create API (generates CRD + controller scaffold):
kubebuilder create api --group example --version v1alpha1 --kind Database

# Generate CRD YAML from types:
make manifests       # runs controller-gen

# Generate DeepCopy methods (required for Kubernetes types):
make generate

# Build + push:
make docker-build docker-push IMG=example.com/database-operator:v1.0.0

# Deploy to cluster:
make deploy IMG=example.com/database-operator:v1.0.0

# ---
# Operator SDK (Go or Ansible/Helm):
operator-sdk init --domain example.com --plugins go/v4
operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller

# Helm-based operator (wraps Helm chart in operator pattern):
operator-sdk init --plugins helm/v1 --domain example.com
operator-sdk create api --helm-chart postgresql --helm-chart-repo https://charts.bitnami.com/bitnami

# Testing with envtest (no real cluster needed):
# controller-runtime/pkg/envtest spins up a real API server + etcd:
var testEnv *envtest.Environment
BeforeSuite(func() {
    testEnv = &envtest.Environment{
        CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")},
    }
    cfg, _ = testEnv.Start()
    k8sClient, _ = client.New(cfg, client.Options{Scheme: scheme})
})

Track Kubernetes releases at ReleaseRun. Related: Kubernetes RBAC Reference | Kyverno Reference | Crossplane Reference | Kubernetes EOL Tracker

🔍 Free tool: K8s YAML Security Linter — check your operator CRD and workload manifests for K8s security misconfigurations.

Founded

2023 in London, UK

Contact

hello@releaserun.com