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