Kubernetes Operators Explained - Building Custom Controllers That Automate Day-2 Operations
Ask any platform engineer how they provision a database cluster, rotate its credentials, or take a nightly backup. The honest answer is usually a long shell script, a wall of Helm values, and a CronJob nobody trusts. It works until reality does something unexpected, and then it fails silently at 3 a.m.
A Kubernetes Operator is the native answer: instead of shipping a procedure, you ship a controller that watches a custom resource, compares what you asked for with what actually exists in the cluster, and continuously drives reality back toward the request.

This article covers the operator pattern, the reconciliation model, the tooling, and how to build one that survives production.
Table of Contents
- The Problem: Day-2 Operations Don't Scale with YAML
- What an Operator Actually Is
- The Operator Pattern
- CRDs: Extending the Kubernetes API
- The Reconciliation Loop
- Operators vs. CronJobs, Hooks and GitOps
- The Operator Ecosystem
- Operator SDK, Kubebuilder and controller-runtime
- Operator Lifecycle Manager and OperatorHub
- Anatomy of a Go Operator
- Defining the API Types
- Writing the Reconciler
- Finalizers and Cleanup
- Real-World Example: A Database Backup Operator
- The CRD
- The Custom Resource
- The Reconcile Function
- What Happens at Runtime
- Testing Operators
- Unit Testing with envtest
- Scorecard and Integration Tests
- Production Hardening
- Least-Privilege RBAC
- High Availability and Leader Election
- Metrics and Observability
- Pitfalls and Best Practices
- Key Takeaways
- Frequently Asked Questions
- Related Articles
The Problem: Day-2 Operations Don't Scale with YAML
Kubernetes has an excellent story for Day-1: how do I get this workload running? Manifests, Helm charts, and GitOps controllers handle that well, because "running" is a declarative, one-time transition.
Day-2 is everything after: scaling the cluster, rotating credentials, failing over, upgrading a member, taking a backup, cleaning up old snapshots. It is the long tail, and it is where platform teams lose the most time.
Three approaches usually get attempted, and each breaks predictably:
- Imperative scripts in CI. They are stateful, order-dependent, and fail halfway through leaving mixed state behind.
- CronJobs that do one thing. They work, but they have no memory: a backup job cannot tell whether last night's backup succeeded.
- Helm hooks. Hooks are one-shot events with
helm upgradesemantics, not a continuous control loop.
What is missing in all three is a component that observes, decides, acts, and re-observes - a controller.
What an Operator Actually Is
The Operator Pattern
The Kubernetes documentation calls this the operator pattern: extend the API with a CustomResourceDefinition (CRD) and write a controller that reconciles those custom resources against the running cluster.
An operator is therefore three things packaged together:
| Component | Role |
|---|---|
| CRD | The API. A new noun that users can kubectl apply. |
| Controller | The brain. A reconciliation loop that enforces desired state. |
| Bundle / chart | The packaging. How the operator itself is installed and versioned. |
Operational knowledge moves out of wiki pages and runbooks and into code, reviewed, tested, and versioned like everything else.
CRDs: Extending the Kubernetes API
A CRD registers a new kind with the API server. Here is an abbreviated version (make manifests generates the full schema from your Go types):
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databasebackups.backup.example.com
spec:
group: backup.example.com
scope: Namespaced
names:
plural: databasebackups
kind: DatabaseBackup
shortNames: [dbbackup]
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [clusterRef, schedule]
properties:
clusterRef: {type: string}
schedule: {type: string}
retention: {type: integer, default: 7}
A few things to notice:
- Structural schemas are mandatory. A modern CRD must declare an OpenAPI v3 schema.
specis the user's intent;statusis the operator's report. Users ownspec, the operator ownsstatus.- Versions are independent.
v1alpha1can be served whilev1beta1is the storage version, with a conversion webhook translating between them.
Since Kubernetes 1.25, CRDs also support CEL validation rules via x-kubernetes-validations, which express cross-field constraints ("retention must not exceed schedule count") in the API server rather than in a webhook.
The Reconciliation Loop
This is the heart of the pattern: watch events, enqueue a request, and reconcile until the world matches the spec.

The properties of this loop matter more than the code inside it:
- Level-triggered, not edge-triggered. The reconciler acts on the current state, so a restart mid-flight is harmless.
- Idempotent. Running reconcile twice must equal running it once: create-if-missing, patch-if-different, never append to a list blindly.
- Retries with backoff. A failed reconcile returns an error and is requeued with exponential backoff, so a broken dependency does not spin the CPU.
- Requeue for time.
ctrl.Result{RequeueAfter: 30 * time.Second}makes the loop time-driven, which is how operators handle schedules, heartbeats and leases.
Operators vs. CronJobs, Hooks and GitOps
| Capability | Helm / GitOps | CronJob | Operator |
|---|---|---|---|
| Continuous convergence | On sync only | No | Yes |
| Rich status reporting | Poor | None | First-class conditions |
| Handles failures and retries | Limited | One retry per schedule | Exponential backoff, per-object |
These are complements, not competitors. A common architecture is: GitOps owns the manifests, the operator owns the stateful workload. Argo CD or Flux deploys a CR; the operator takes it from there.
The Operator Ecosystem
Operator SDK, Kubebuilder and controller-runtime
The Operator Framework has three main pieces:
- Operator SDK - the CLI and libraries for building operators. For Go projects it scaffolds with Kubebuilder and runs on controller-runtime.
- Operator Lifecycle Manager (OLM) - installs, upgrades and manages operators inside a cluster.
- OperatorHub - a public catalog of published operators.
You can also write Helm-based operators (a controller that renders a Helm chart) and Ansible-based operators (a controller that runs Ansible roles). Both are fine for simpler cases, but a Go operator on controller-runtime is the default for anything stateful.
The scaffolding flow:
operator-sdk init --domain example.com --repo example.com/databasebackup-operator
operator-sdk create api --version v1alpha1 --kind DatabaseBackup
operator-sdk create controller --version v1alpha1 --kind DatabaseBackup
make manifests generate
The Kubebuilder-only equivalents are kubebuilder init --domain example.com and kubebuilder create api --group backup --version v1alpha1 --kind DatabaseBackup. Because Kubebuilder is embedded in the SDK, existing Kubebuilder projects work unchanged.
Operator Lifecycle Manager and OperatorHub
OLM makes operators first-class cluster citizens through a few new resources: a CSV (ClusterServiceVersion) holds packaging metadata, owned APIs, RBAC and the upgrade path; a Subscription says "install this operator, this version, from this channel"; an InstallPlan is the resolved set of resources to install.
With the SDK you build a bundle and push it to a catalog:
operator-sdk generate bundle
operator-sdk bundle build
operator-sdk bundle push ./bundle/latest
OLM also handles upgrade edge cases that are painful to solve by hand, such as keeping two operator versions alive during a migration.
Anatomy of a Go Operator
A scaffolded Go operator keeps four concerns apart: api/ holds the versioned API types, controllers/ holds the reconcilers, config/ holds generated manifests and main.go wires the manager. Keep API packages free of business logic.
Defining the API Types
package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Schedule",type=string,JSONPath=`.spec.schedule`
type DatabaseBackup struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DatabaseBackupSpec `json:"spec,omitempty"`
Status DatabaseBackupStatus `json:"status,omitempty"`
}
type DatabaseBackupSpec struct {
ClusterRef string `json:"clusterRef"` // managed cluster
Schedule string `json:"schedule"` // cron expression
// +kubebuilder:default=7
Retention int32 `json:"retention,omitempty"`
}
type DatabaseBackupStatus struct {
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
LastBackupTime *metav1.Time `json:"lastBackupTime,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
The kubebuilder markers are compiled into CRD YAML, RBAC rules and printer columns by controller-gen. Writing markers is far less error-prone than hand-maintaining CRD YAML.
Writing the Reconciler
func (r *DatabaseBackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
backup := &v1alpha1.DatabaseBackup{}
if err := r.Get(ctx, req.NamespacedName, backup); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !backup.DeletionTimestamp.IsZero() {
return ctrl.Result{}, r.finalize(ctx, backup)
}
// 1. Make sure the CronJob that performs the backup exists and matches spec.
if _, err := r.ensureCronJob(ctx, backup); err != nil {
return ctrl.Result{}, err
}
// 2. Observe the most recent Job and reflect reality in status.
job, err := r.latestJobFor(ctx, backup)
if err != nil {
return ctrl.Result{}, err
}
if job != nil && job.Status.Succeeded > 0 {
backup.Status.LastBackupTime = &metav1.Time{Time: job.Status.CompletionTime.Time}
}
backup.Status.ObservedGeneration = backup.Generation
// 3. Publish status, then requeue to catch the next scheduled run.
if err := r.Status().Update(ctx, backup); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
Four habits to copy from this skeleton:
- Ignore not-found on the primary get. A deleted object is not an error.
- Use
Owns()to watch child resources, so the controller is re-triggered when the CronJob or Job changes. - Set
status.observedGeneration, so consumers know whether status matches the latest spec. - Use conditions, not ad-hoc booleans.
metav1.Conditiongives youReady,ProgressingandDegradedwith reasons thatkubectl describerenders nicely.
func (r *DatabaseBackupReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.DatabaseBackup{}).
Owns(&batchv1.CronJob{}).
Owns(&batchv1.Job{}).
Complete(r)
}
Finalizers and Cleanup
Owner references handle garbage collection for objects inside the cluster. For anything outside it (object storage buckets, external DNS records, cloud credentials), you need a finalizer:
const finalizer = "backup.example.com/cleanup"
func (r *DatabaseBackupReconciler) finalize(ctx context.Context, backup *v1alpha1.DatabaseBackup) error {
if !controllerutil.ContainsFinalizer(backup, finalizer) {
return nil
}
if err := r.deleteExternalArtifacts(ctx, backup); err != nil {
return err
}
controllerutil.RemoveFinalizer(backup, finalizer)
return r.Update(ctx, backup)
}
Caution: A finalizer that never gets removed blocks deletion forever, and the resource stays in
Terminatingstate. Always implement a timeout or a force-removal escape hatch, and test the delete path explicitly.
Real-World Example: A Database Backup Operator
The CRD
The CRD from earlier is the whole API surface: clusterRef, schedule and retention. Now the controller turns it into real work.
The Custom Resource
apiVersion: backup.example.com/v1alpha1
kind: DatabaseBackup
metadata:
name: orders-db-nightly
namespace: payments
spec:
clusterRef: orders-db
schedule: "0 2 * * *"
retention: 7
Note that nothing in the YAML says "run a CronJob". The user expresses intent; the operator decides implementation. That indirection is the point: you can move from a CronJob to an in-process backup agent without touching a user manifest.
The Reconcile Function
The interesting part is ensureCronJob, a textbook idempotent reconcile step:
func (r *DatabaseBackupReconciler) ensureCronJob(ctx context.Context, backup *v1alpha1.DatabaseBackup) (*batchv1.CronJob, error) {
cronJob := &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: backup.Name + "-backup",
Namespace: backup.Namespace,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, cronJob, func() error {
cronJob.Spec.Schedule = backup.Spec.Schedule
cronJob.Spec.ConcurrencyPolicy = batchv1.ForbidConcurrent
cronJob.Spec.SuccessfulJobsHistoryLimit = ptr.To(backup.Spec.Retention)
cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers = []corev1.Container{{
Name: "pg-backup",
Image: "ghcr.io/example/pg-backup:1.4.2",
}}
return controllerutil.SetControllerReference(backup, cronJob, r.Scheme)
})
if err != nil {
return nil, err
}
return cronJob, nil
}
CreateOrUpdate fetches the object, applies the mutator, and issues a create or an update only if something changed - removing the entire class of "apply twice, fight the second apply" bugs.
What Happens at Runtime

The sequence at the end matters: the operator learns about the completed Job because it watches Jobs, not because the Job calls it.
Testing Operators
Unit Testing with envtest
envtest runs a real kube-apiserver and etcd locally - no cluster, no container runtime. It is fast enough for unit-test suites and catches schema, RBAC and status-subresource mistakes that mocks never will.
func TestDatabaseBackupReconciler(t *testing.T) {
testEnv := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
cfg, err := testEnv.Start()
require.NoError(t, err)
defer func() { _ = testEnv.Stop() }()
require.NoError(t, v1alpha1.AddToScheme(scheme.Scheme))
k8sClient, err := client.New(cfg, client.Options{Scheme: scheme.Scheme})
require.NoError(t, err)
// Create a custom resource, run the reconciler, assert convergence.
backup := &v1alpha1.DatabaseBackup{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}}
require.NoError(t, k8sClient.Create(ctx, backup))
}
Binaries are downloaded automatically on macOS and Linux; elsewhere point KUBEBUILDER_ASSETS at a directory with etcd and kube-apiserver.
Scorecard and Integration Tests
The Operator SDK's Scorecard suite runs a black-box checklist against a running operator: does it survive CR deletion, handle an invalid spec, restore from a backup, support upgrades. It is the closest thing to a conformance suite for operators.
operator-sdk test scorecard --scenario basic-check.spec
operator-sdk test e2e --go-test-flags "-v"
Production Hardening
Least-Privilege RBAC
RBAC rules are declared with markers next to the code that needs them, and make manifests turns them into a ClusterRole:
// +kubebuilder:rbac:groups=backup.example.com,resources=databasebackups,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=backup.example.com,resources=databasebackups/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=backup.example.com,resources=databasebackups/finalizers,verbs=update
// +kubebuilder:rbac:groups=batch,resources=cronjobs;jobs,verbs=get;list;watch;create;update;patch;delete
Grant list and watch on exactly what you watch, patch rather than update where you can, and nothing else. An operator with cluster-admin is one compromised dependency away from cluster compromise.
High Availability and Leader Election
Run at least two replicas and let them elect a leader:
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
LeaderElection: true,
LeaderElectionID: "databasebackup-operator.backup.example.com",
LeaderElectionNamespace: "operators",
HealthProbeBindAddress: ":8081",
})
The leader does the reconciling; the standby takes over if the leader dies. Leader election is lease-based, so handover takes roughly the lease duration (commonly 15 seconds).
Metrics and Observability
controller-runtime ships Prometheus metrics on a separate port: reconcile counters, error counters, duration histograms and work queue depth under the controller_runtime_ namespace. The most valuable operator-specific metric is almost always reconcile errors by reason:
var reconcileErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "operator_reconcile_errors_total",
Help: "Total reconcile errors by reason",
}, []string{"reason"})
A good operator exposes three things to on-call: an error metric, how long the last successful reconcile took, and a status condition on the object. Alert on the condition, not on the operator pod - a healthy pod says nothing about whether backups are happening.
Pitfalls and Best Practices
Pitfalls
- Non-idempotent reconciles. Appending to a list on every pass is the classic bug; the loop keeps calling you.
- Storing state in memory. The controller may restart or the leader may change; the API server is the only source of truth.
- Long-running work in the reconcile function. A backup that takes 40 minutes must be a Job, not a
forloop insideReconcile. - Writing
specfrom the controller. Users ownspec; the operator ownsstatus. - Missing
observedGeneration. Without it, consumers cannot tell whether status is current. - Owner references across namespaces. They are not allowed, forcing you into finalizers.
Best practices
- Keep
Reconcilesmall and delegate to testable functions ininternal/. - Use
CreateOrUpdateandSetControllerReferenceinstead of hand-rolled create logic. - Model failure with conditions (
Ready,Progressing,Degraded) plus reasons. - Make every external side effect idempotent or guarded, because you will get retried.
- Version the CRD: serve
v1alpha1while you learn, then promote tov1beta1andv1. - Ship the operator with the same rigor as your product: SBOM, image scanning, pinned digests, a rollback path.
Key Takeaways
- An operator is a controller plus a CRD: an API for intent and a loop that enforces it.
- Reconciliation is level-triggered, idempotent and retried with backoff, which is why operators survive restarts and missed events.
- The CRD defines the contract, the reconciler defines the behaviour, and
statusplus conditions are how the operator reports back. - Use
Owns()for child resources, finalizers for anything outside the cluster, andobservedGenerationso consumers can trust status. - Tools divide cleanly: Operator SDK and Kubebuilder for writing, controller-runtime for the loop, OLM for installing and upgrading.
- Production readiness comes down to least-privilege RBAC, leader election, metrics and a real test suite.
Frequently Asked Questions
Do I need an operator, or is a CronJob enough? A CronJob is fine for a single, stateless, fire-and-forget task. Write an operator when you need continuity (knowing the last outcome), multi-object orchestration, or configuration that non-experts can use safely.
Should I write it in Go? Go with controller-runtime is the default because it is the most mature path, but operators can be written in any language with a Kubernetes client - Helm-based and Ansible-based operators exist for teams that avoid Go.
How is this different from Argo CD or Flux? GitOps controllers reconcile manifests from Git. Operators reconcile the runtime state of a stateful system. GitOps delivers the custom resource; the operator delivers the reality.
Can an operator be written without a webhook? Yes. CRD structural schemas plus CEL rules handle validation inside the API server. Webhooks are needed only for defaulting CEL cannot express, cross-object validation, or mutation.
What breaks operators most often in production? Finalizers that block deletion, non-idempotent updates causing hot loops, overly broad RBAC, and reconcile functions that perform long-running work inline.
Related Articles
- Kubernetes Autoscaling Explained: HPA, VPA, KEDA, Cluster Autoscaler and Karpenter
- Policy as Code: Securing Kubernetes with Open Policy Agent and Kyverno
- Mastering Helm: The Complete Guide to Kubernetes Package Management
- Push vs Pull Deployment Models - Understanding GitOps and Continuous Delivery
Comments
Post a Comment