Back to Blog

Kubernetes Operators in 2026: Custom CRDs, Reconcile Loops, and the Operator SDK

Build production Kubernetes Operators with the Operator SDK: custom resource definitions, reconcile loops, status conditions, finalizers, and operator lifecycle management.

Viprasol Tech Team
August 11, 2026
14 min read

Kubernetes Operators in 2026: Custom CRDs, Reconcile Loops, and the Operator SDK

Operators extend Kubernetes with domain-specific operational knowledge. Instead of running kubectl exec to failover a database or manually scaling a stateful service, an Operator watches custom resources and continuously reconciles the actual cluster state with your desired state. It's the same reconciliation loop that powers Kubernetes itself — you're just adding a controller for your application domain.

The canonical use cases: database operators (Postgres, MySQL, MongoDB), message queue operators (Kafka, RabbitMQ), and application lifecycle operators (canary deployments, certificate rotation, secret sync). This post covers building an Operator from scratch using the Operator SDK and controller-runtime.


When to Build an Operator

ScenarioOperator?Alternative
Stateful app with complex day-2 operationsHelm + manual runbooks
Database provisioning + backup + failoverCloud-managed DB (RDS, CloudSQL)
Simple stateless app deploymentHelm chart or plain manifests
One-off batch jobsCronJob
App config that changes frequentlyConfigMap + Deployment env
Custom autoscaling logicHPA + custom metrics adapter

Building an Operator is significant investment. Only do it when the operational complexity of your stateful application exceeds what Helm and runbooks can reasonably manage.


Operator SDK Setup

# Install Operator SDK
brew install operator-sdk  # macOS
# or: curl -LO https://github.com/operator-framework/operator-sdk/releases/latest/...

# Initialize a new operator project
operator-sdk init \
  --domain viprasol.com \
  --repo github.com/viprasol/myapp-operator \
  --plugins go/v4

# Create a new API (custom resource)
operator-sdk create api \
  --group myapp \
  --version v1alpha1 \
  --kind MyApp \
  --resource \
  --controller

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Custom Resource Definition (CRD)

// api/v1alpha1/myapp_types.go
package v1alpha1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// MyAppSpec defines the desired state
type MyAppSpec struct {
	// Number of replicas for the web tier
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=100
	Replicas int32 `json:"replicas"`

	// Container image to deploy
	// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9._\-/:]+$`
	Image string `json:"image"`

	// Database connection configuration
	Database DatabaseSpec `json:"database"`

	// Resource requirements
	// +optional
	Resources *ResourcesSpec `json:"resources,omitempty"`
}

type DatabaseSpec struct {
	// Secret name containing DATABASE_URL
	SecretName string `json:"secretName"`
	// Run migrations on each deployment
	// +optional
	RunMigrations bool `json:"runMigrations,omitempty"`
}

type ResourcesSpec struct {
	// CPU request and limit (e.g., "500m", "2")
	CPU string `json:"cpu"`
	// Memory request and limit (e.g., "256Mi", "1Gi")
	Memory string `json:"memory"`
}

// MyAppStatus defines the observed state
type MyAppStatus struct {
	// Standard condition array
	// +optional
	Conditions []metav1.Condition `json:"conditions,omitempty"`

	// Number of ready replicas
	ReadyReplicas int32 `json:"readyReplicas,omitempty"`

	// Current deployed image
	DeployedImage string `json:"deployedImage,omitempty"`

	// Timestamp of last successful migration
	// +optional
	LastMigrationTime *metav1.Time `json:"lastMigrationTime,omitempty"`
}

// Condition types
const (
	ConditionAvailable   = "Available"
	ConditionProgressing = "Progressing"
	ConditionDegraded    = "Degraded"
)

// +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"
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.image"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
type MyApp struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   MyAppSpec   `json:"spec,omitempty"`
	Status MyAppStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type MyAppList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []MyApp `json:"items"`
}

func init() {
	SchemeBuilder.Register(&MyApp{}, &MyAppList{})
}

Reconcile Loop

// internal/controller/myapp_controller.go
package controller

import (
	"context"
	"fmt"
	"time"

	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/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log"

	myappv1 "github.com/viprasol/myapp-operator/api/v1alpha1"
)

type MyAppReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=myapp.viprasol.com,resources=myapps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=myapp.viprasol.com,resources=myapps/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete

func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	// 1. Fetch the MyApp resource
	myapp := &myappv1.MyApp{}
	if err := r.Get(ctx, req.NamespacedName, myapp); err != nil {
		if errors.IsNotFound(err) {
			// Object deleted — nothing to do
			return ctrl.Result{}, nil
		}
		return ctrl.Result{}, fmt.Errorf("failed to get MyApp: %w", err)
	}

	// 2. Handle deletion with finalizer
	if !myapp.DeletionTimestamp.IsZero() {
		return r.handleDeletion(ctx, myapp)
	}

	// 3. Add finalizer if not present
	if err := r.ensureFinalizer(ctx, myapp); err != nil {
		return ctrl.Result{}, err
	}

	// 4. Run database migrations if requested
	if myapp.Spec.Database.RunMigrations {
		done, err := r.runMigrations(ctx, myapp)
		if err != nil {
			r.setCondition(myapp, myappv1.ConditionDegraded, metav1.ConditionTrue,
				"MigrationFailed", err.Error())
			_ = r.Status().Update(ctx, myapp)
			return ctrl.Result{RequeueAfter: 30 * time.Second}, err
		}
		if !done {
			// Migration job still running — requeue
			return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
		}
	}

	// 5. Reconcile Deployment
	if err := r.reconcileDeployment(ctx, myapp); err != nil {
		return ctrl.Result{}, err
	}

	// 6. Reconcile Service
	if err := r.reconcileService(ctx, myapp); err != nil {
		return ctrl.Result{}, err
	}

	// 7. Update status
	if err := r.updateStatus(ctx, myapp); err != nil {
		return ctrl.Result{}, err
	}

	logger.Info("Reconciliation complete", "name", myapp.Name)
	return ctrl.Result{}, nil
}

func (r *MyAppReconciler) reconcileDeployment(ctx context.Context, myapp *myappv1.MyApp) error {
	desired := r.buildDeployment(myapp)

	// Try to get existing deployment
	existing := &appsv1.Deployment{}
	err := r.Get(ctx, types.NamespacedName{
		Name:      myapp.Name,
		Namespace: myapp.Namespace,
	}, existing)

	if errors.IsNotFound(err) {
		// Create
		if err := ctrl.SetControllerReference(myapp, desired, r.Scheme); err != nil {
			return err
		}
		return r.Create(ctx, desired)
	}
	if err != nil {
		return fmt.Errorf("failed to get Deployment: %w", err)
	}

	// Update if spec has changed
	existing.Spec = desired.Spec
	return r.Update(ctx, existing)
}

func (r *MyAppReconciler) buildDeployment(myapp *myappv1.MyApp) *appsv1.Deployment {
	labels := map[string]string{
		"app":                         myapp.Name,
		"app.kubernetes.io/managed-by": "myapp-operator",
	}

	replicas := myapp.Spec.Replicas

	return &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      myapp.Name,
			Namespace: myapp.Namespace,
			Labels:    labels,
		},
		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: myapp.Spec.Image,
							EnvFrom: []corev1.EnvFromSource{
								{
									SecretRef: &corev1.SecretEnvSource{
										LocalObjectReference: corev1.LocalObjectReference{
											Name: myapp.Spec.Database.SecretName,
										},
									},
								},
							},
							ReadinessProbe: &corev1.Probe{
								ProbeHandler: corev1.ProbeHandler{
									HTTPGet: &corev1.HTTPGetAction{
										Path: "/healthz",
										Port: 3000,
									},
								},
								InitialDelaySeconds: 5,
								PeriodSeconds:       10,
							},
						},
					},
				},
			},
		},
	}
}

func (r *MyAppReconciler) setCondition(
	myapp *myappv1.MyApp,
	condType string,
	status metav1.ConditionStatus,
	reason, message string,
) {
	cond := metav1.Condition{
		Type:               condType,
		Status:             status,
		Reason:             reason,
		Message:            message,
		LastTransitionTime: metav1.Now(),
	}

	for i, c := range myapp.Status.Conditions {
		if c.Type == condType {
			if c.Status != status {
				myapp.Status.Conditions[i] = cond
			}
			return
		}
	}
	myapp.Status.Conditions = append(myapp.Status.Conditions, cond)
}

func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&myappv1.MyApp{}).
		Owns(&appsv1.Deployment{}).  // Watch owned Deployments
		Complete(r)
}

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

Applying the CRD

# config/samples/myapp_v1alpha1_myapp.yaml
apiVersion: myapp.viprasol.com/v1alpha1
kind: MyApp
metadata:
  name: api-server
  namespace: production
spec:
  replicas: 3
  image: "ghcr.io/myorg/api-server:v1.2.3"
  database:
    secretName: api-server-db-secret
    runMigrations: true
  resources:
    cpu: "500m"
    memory: "512Mi"
# Generate CRD manifests from Go types
make generate manifests

# Install CRDs in cluster
make install

# Run operator locally (against remote cluster)
make run

# Build and push operator image
make docker-build docker-push IMG=ghcr.io/myorg/myapp-operator:latest

# Deploy operator to cluster
make deploy IMG=ghcr.io/myorg/myapp-operator:latest

# Apply the custom resource
kubectl apply -f config/samples/myapp_v1alpha1_myapp.yaml

# Watch status
kubectl get myapps -w
# NAME         REPLICAS   READY   IMAGE                           AGE
# api-server   3          3       ghcr.io/myorg/api-server:v1.2.3  2m

Operator Maturity Levels

LevelCapabilitiesExample
1 — Basic InstallAutomated installation via OLMHelm-based operator
2 — Seamless UpgradesPatch and minor version upgradesImage tag updates
3 — Full LifecycleBackups, failure recoveryPostgres operator
4 — Deep InsightsMetrics, alerts, log managementFull observability
5 — Auto PilotHorizontal/vertical scaling, anomaly detectionSelf-healing system

Working With Viprasol

We design and build Kubernetes Operators for stateful applications — from CRD schema design through reconcile loop implementation and OLM packaging.

What we deliver:

  • Operator architecture design (when to build vs. use existing operator)
  • CRD design with validation markers and status conditions
  • Reconcile loop implementation with proper error handling and requeue strategies
  • Finalizer patterns for safe resource cleanup
  • Operator lifecycle manager (OLM) packaging for OperatorHub

Discuss your Kubernetes automation needsCloud infrastructure services


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.