Kubernetes Operators - Introduction

At a high level, Kubernetes has one core job: continuously move the actual state of the system toward the desired state. It does this through a reconciliation-loop. Each controller watches the resources it owns and repeatedly reconciles what’s running in the cluster with what the user declared. In a typical Kubernetes cluster, many controllers run in parallel—each with its own control loop—managing different parts of the system.

All controllers typically use the watch-and-reconcile pattern. Writing a controller from scratch involves lot of repetitive code. Kubebuilder helps with basic code generation (scaffolding) needed for building an operator. Please note an operator can be written in any of the popular languages.

A Kubernetes Operator is a custom controller that extends Kubernetes to automate the management of complex, stateful applications—handling tasks like deployment, scaling, backups, and upgrades that would normally require manual intervention. It encodes operational knowledge into software, essentially acting as an automated site reliability engineer for your application.

Kubebuilder [1] implemented in Go, is one of the standard ways to build a custom controller (often called an operator). Let’s walk through a simple hello-world example.

Prerequisite

# Go (if you don't have it)
brew install go

# Kubebuilder - the framework
brew install kubebuilder

# Kind - runs Kubernetes locally in Docker
brew install kind

# Make sure Docker Desktop is running!

Create Local Kubernetes Cluster

kind create cluster --name dev

# Verify it works
kubectl cluster-info

Create the Project

# Create and enter project folder
mkdir ~/greeting-operator && cd ~/greeting-operator

# Initialize the project
# --domain: your API group suffix (like "apps.example.com")
# --repo: Go module path
kubebuilder init --domain example.com --repo greeting-operator

# Create an API (type 'y' for both prompts)
# --group: first part of API group ("apps")
# --version: API version ("v1")
# --kind: your resource name ("Greeting")
kubebuilder create api --group apps --version v1 --kind Greeting

What just happened?

Define Your Custom Resource

Update the api types file (greeting_types.go) with following:

package v1

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

// =============================================================================
// SPEC: What the user provides in their YAML
// =============================================================================

type GreetingSpec struct {
	// Name to greet (required)
	// +kubebuilder:validation:Required
	Name string `json:"name"`

	// Greeting word (optional, defaults to "Hello")
	// +kubebuilder:default="Hello"
	Greeting string `json:"greeting,omitempty"`
}

// =============================================================================
// STATUS: What the controller reports back
// =============================================================================

type GreetingStatus struct {
	// The composed message
	Message string `json:"message,omitempty"`

	// Whether reconciliation succeeded
	Ready bool `json:"ready"`
}

// =============================================================================
// THE MAIN TYPE
// =============================================================================

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.message"
// +kubebuilder:printcolumn:name="Ready",type="boolean",JSONPath=".status.ready"

type Greeting struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec              GreetingSpec   `json:"spec,omitempty"`
	Status            GreetingStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

type GreetingList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []Greeting `json:"items"`
}

func init() {
	SchemeBuilder.Register(&Greeting{}, &GreetingList{})
}

Write the Controller Logic

Open internal/controller/greeting_controller.go and update Reconcile() with:

// Reconcile - called whenever a Greeting changes (or periodically)
//
// The goal: make reality match the desired state
// This function should be IDEMPOTENT - running it twice = same result
func (r *GreetingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	// -------------------------------------------------------------------------
	// STEP 1: Fetch the Greeting resource
	// -------------------------------------------------------------------------
	var greeting appsv1.Greeting
	if err := r.Get(ctx, req.NamespacedName, &greeting); err != nil {
		// Not found = deleted, ignore
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	logger.Info("Reconciling", "name", greeting.Spec.Name)

	// -------------------------------------------------------------------------
	// STEP 2: Do our "business logic" (just build a message)
	// -------------------------------------------------------------------------
	message := fmt.Sprintf("%s, %s!", greeting.Spec.Greeting, greeting.Spec.Name)

	// -------------------------------------------------------------------------
	// STEP 3: Update the status
	// -------------------------------------------------------------------------
	greeting.Status.Message = message
	greeting.Status.Ready = true

	// Status().Update() only updates the status subresource
	if err := r.Status().Update(ctx, &greeting); err != nil {
		logger.Error(err, "Failed to update status")
		return ctrl.Result{}, err
	}

	logger.Info("Reconciled successfully", "message", message)

	// Return empty result = success, don't requeue
	return ctrl.Result{}, nil
}

Install the CRD (customresource definition)

# Generate deep copy methods and CRD manifests
make generate
make manifests

# Install CRD into your cluster
make install

# Verify CRD is installed
kubectl get crd
# Should show: greetings.apps.example.com

Run and Test

  1. Terminal 1: Run the controller locally : make run
  2. Terminal 2: Create a Greeting resource
   # Create a test resource
cat <<EOF | kubectl apply -f -
apiVersion: apps.example.com/v1
kind: Greeting
metadata:
name: my-greeting
spec:
name: "World"
greeting: "Hello"
EOF

Watch it work!

# See the custom columns we defined
kubectl get greetings

# Output:
# NAME          MESSAGE        READY
# my-greeting   Hello, World!  true

# See full details
kubectl get greeting my-greeting -o yaml

What is happening under the hood ?

┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│    User      │         │  Kubernetes  │         │  Controller  │
│              │         │   API Server │         │   (your code)│
└──────┬───────┘         └──────┬───────┘         └──────┬───────┘
│                        │                        │
│ kubectl apply          │                        │
│ greeting.yaml          │                        │
│───────────────────────>│                        │
│                        │                        │
│                        │  "Hey, Greeting        │
│                        │   changed!"            │
│                        │───────────────────────>│
│                        │                        │
│                        │                        │ Reconcile()
│                        │                        │ - Get Greeting
│                        │<───────────────────────│
│                        │                        │
│                        │                        │ - Build message
│                        │                        │
│                        │  Update Status         │
│                        │<───────────────────────│
│                        │                        │
│ kubectl get greetings  │                        │
│───────────────────────>│                        │
│                        │                        │
│  MESSAGE: Hello, World!│                        │
│<───────────────────────│                        │