DEV Community

Cover image for Building a Kubernetes Operator from Scratch with operator-sdk
Sandesh Ojha
Sandesh Ojha

Posted on

Building a Kubernetes Operator from Scratch with operator-sdk

GitHub Repo: SandeshOjha06/k8-operator — Clone this to follow along with the code.


The Problem

Standard Kubernetes primitives keep containers running, but they are fundamentally ignorant of your application's business logic. If you deploy a complex database or a custom backend, raw Deployments and StatefulSets are not enough. They cannot execute automated schema migrations, sequence complex initialization steps, or prevent a developer from manually scaling a pod via kubectl scale and accidentally breaking application state.

That is the exact problem Operators solve. An Operator is a custom Go controller running inside the cluster that extends the Kubernetes API by encoding human operational knowledge directly into software. Instead of writing a runbook for your team to follow when things go wrong, you build an Operator that constantly observes the cluster, detects configuration drift, and forces compliance without any human intervention.


What We're Building

We are going to build a fully functional control plane for a custom resource called a WebApp.

Instead of manually wiring up and managing standard Kubernetes objects, users submit a single declarative YAML file defining just two spec fields: their desired replicas and the container image. Our Operator will:

  • Catch that custom API request and dynamically translate it
  • Automatically provision both a core Deployment and a NodePort Service
  • Continuously watch for configuration drift — if an unauthorized user modifies the running Deployment, the Operator instantly detects the deviation and reverts it
  • Report live application health back via a native Kubernetes Status subresource, exposing the current Phase and AvailableReplicas

Scaffolding with operator-sdk

We don't write an Operator from an empty directory. We use the operator-sdk CLI to generate the project scaffold so we can focus entirely on application logic.

Initialize the project:

operator-sdk init --domain sandesh.dev --repo github.com/sandesh-ojha/webapp-operator
Enter fullscreen mode Exit fullscreen mode

This scaffolds the standard Kubebuilder project layout, including the main.go entrypoint and a comprehensive Makefile that handles compiling, testing, and deploying.

Generate the CRD and controller:

operator-sdk create api --group apps --version v1 --kind WebApp --resource --controller
Enter fullscreen mode Exit fullscreen mode
  • --resource generates the Go structs that define your custom API schema
  • --controller scaffolds the Reconciler file where the active management logic will live

This generates two critical files:

  • api/v1/webapp_types.go — defines the Go structs representing the schema of our custom WebApp resource
  • internal/controller/webapp_controller.go — houses the Reconciler, the control loop where our logic executes

The SDK also generates // +kubebuilder: comment markers above our controller functions. These are not standard comments — they are compiler directives. When we run make manifests, the SDK reads these markers to automatically generate the strict RBAC YAML that grants our Operator permission to read, create, and update standard Kubernetes Deployments and Services.


Defining the CRD

Open api/v1/webapp_types.go. This is where we define the schema for our custom resource.

Kubernetes strictly separates what the user wants from what actually exists. We mirror this by defining two distinct Go structs: WebAppSpec and WebAppStatus.

type WebAppSpec struct {
    Replicas int32  `json:"replicas"`
    Image    string `json:"image"`
}

type WebAppPhase string

const (
    PhaseScaling WebAppPhase = "Scaling"
    PhaseRunning WebAppPhase = "Running"
)

type WebAppStatus struct {
    Phase             WebAppPhase `json:"phase,omitempty"`
    AvailableReplicas int32       `json:"availableReplicas"`
}
Enter fullscreen mode Exit fullscreen mode

WebAppSpec holds the data the user submits in their YAML. WebAppStatus holds the live state our Operator reports back: the AvailableReplicas currently running and a Phase (Scaling or Running) indicating overall health.

Further up the file, just above the main WebApp struct, we add the kubebuilder markers:

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas"
// +kubebuilder:printcolumn:name="Available",type="integer",JSONPath=".status.availableReplicas"
// +kubebuilder:resource:scope=Namespaced,shortName=wa
type WebApp struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   WebAppSpec   `json:"spec,omitempty"`
    Status WebAppStatus `json:"status,omitempty"`
}
Enter fullscreen mode Exit fullscreen mode

Two of these markers are vital for a production-grade Operator:

+kubebuilder:subresource:status — tells Kubernetes to expose a dedicated /status endpoint for this resource. This is critical: it allows our Operator to update the status safely without accidentally modifying the user's desired spec or triggering an infinite reconcile loop.

+kubebuilder:printcolumn — maps our internal JSON fields to human-readable columns in the terminal. When a user types kubectl get webapps, they immediately see the Phase, desired Replicas, and Available replicas — not just the resource name.

And the WebAppList type required by the SDK:

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

func init() {
    SchemeBuilder.Register(&WebApp{}, &WebAppList{})
}
Enter fullscreen mode Exit fullscreen mode

The Reconciler

If the CRD is the API, the Reconciler is the brain. Open internal/controller/webapp_controller.go.

This is where we implement the infinite control loop that constantly drives the cluster toward our desired state. We'll break the Reconcile function down exactly as it executes sequentially: Observe → Create → Correct → Report.

RBAC Markers

Before the function itself, we declare the permissions our controller needs. These markers generate the RBAC manifests automatically when you run make manifests:

// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.sandesh.dev,resources=webapps/finalizers,verbs=update
// +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
Enter fullscreen mode Exit fullscreen mode

Step 1 — The Function Signature and the Fetch Block

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

    app := &appsv1.WebApp{}
    err := r.Get(ctx, req.NamespacedName, app)
    if err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, err
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

Notice what req ctrl.Request receives — not the actual WebApp object or its state, only a name and a namespace. Kubernetes is highly asynchronous. By the time the Reconciler runs, the object might have changed again. Therefore, the very first step is to Get the absolute latest state of our WebApp resource directly from the API. If it returns a NotFound error, the user deleted the resource and we exit safely.

Step 2 — Deployment Reconciliation: Creation & Owner References

Next, we check if the underlying Nginx pods exist by attempting to fetch a standard Kubernetes Deployment with the same name as our WebApp:

    foundDep := &k8sappsv1.Deployment{}
    err = r.Get(ctx, types.NamespacedName{Name: app.Name, Namespace: app.Namespace}, foundDep)

    if err != nil && apierrors.IsNotFound(err) {
        dep, err := r.deploymentForWebApp(app)
        if err != nil {
            return ctrl.Result{}, err
        }

        if err := r.Create(ctx, dep); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{Requeue: true}, nil
    }
Enter fullscreen mode Exit fullscreen mode

If the Deployment is missing, we build it. Inside our deploymentForWebApp helper, there is one critical line:

func (r *WebAppReconciler) deploymentForWebApp(app *appsv1.WebApp) (*k8sappsv1.Deployment, error) {
    ls := map[string]string{"app": app.Name}
    replicas := app.Spec.Replicas

    dep := &k8sappsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      app.Name,
            Namespace: app.Namespace,
        },
        Spec: k8sappsv1.DeploymentSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: ls,
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{Labels: ls},
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{{
                        Name:  "webapp",
                        Image: app.Spec.Image,
                        Ports: []corev1.ContainerPort{{
                            ContainerPort: 80,
                            Protocol:      corev1.ProtocolTCP,
                        }},
                    }},
                },
            },
        },
    }

    // This is the critical line — sets the Owner Reference
    if err := ctrl.SetControllerReference(app, dep, r.Scheme); err != nil {
        return nil, err
    }
    return dep, nil
}
Enter fullscreen mode Exit fullscreen mode

ctrl.SetControllerReference(app, dep, r.Scheme) sets an Owner Reference. Kubernetes needs to know that our WebApp is the parent and this Deployment is the child. When a user runs kubectl delete webapp sandy-app, the Kubernetes garbage collector reads this reference and automatically deletes the child Deployments and Services. Without this, you create zombie resources that run forever.

Step 3 — Deployment Reconciliation: Drift Detection

If the Deployment does exist, we don't just move on. We execute drift detection logic. This is what separates a fire-and-forget script from a true Operator.

    desiredReplicas := app.Spec.Replicas
    desiredImage    := app.Spec.Image
    actualImage     := foundDep.Spec.Template.Spec.Containers[0].Image

    if *foundDep.Spec.Replicas != desiredReplicas || actualImage != desiredImage {
        logger.Info("Drift detected! Updating Deployment",
            "DesiredReplicas", desiredReplicas,
            "ActualReplicas", *foundDep.Spec.Replicas,
            "DesiredImage", desiredImage,
            "ActualImage", actualImage,
        )

        foundDep.Spec.Replicas = &desiredReplicas
        foundDep.Spec.Template.Spec.Containers[0].Image = desiredImage

        if err := r.Update(ctx, foundDep); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{Requeue: true}, nil
    }
Enter fullscreen mode Exit fullscreen mode

We compare the Desired state (from our WebApp) against the Actual state (from the running Deployment). If an unauthorized user uses kubectl scale to crank replicas up to 10, or swaps the image to a vulnerable version, this if block catches it, overwrites the corrupted values in memory, and pushes the correction back to the cluster via r.Update().

Step 4 — Service Reconciliation

Once the compute layer is aligned, we repeat the exact same pattern for networking. We check if the Service exists, and if not, we create it. Because we need this exposed locally on Minikube, we provision a NodePort:

    foundSvc := &corev1.Service{}
    err = r.Get(ctx, types.NamespacedName{Name: app.Name + "-service", Namespace: app.Namespace}, foundSvc)

    if err != nil && apierrors.IsNotFound(err) {
        svc, err := r.serviceForWebApp(app)
        if err != nil {
            return ctrl.Result{}, err
        }
        if err := r.Create(ctx, svc); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{Requeue: true}, nil
    }
Enter fullscreen mode Exit fullscreen mode

And the helper:

func (r *WebAppReconciler) serviceForWebApp(app *appsv1.WebApp) (*corev1.Service, error) {
    ls := map[string]string{"app": app.Name}

    svc := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      app.Name + "-service",
            Namespace: app.Namespace,
        },
        Spec: corev1.ServiceSpec{
            Selector: ls,
            Type:     corev1.ServiceTypeNodePort,
            Ports: []corev1.ServicePort{{
                Protocol:   corev1.ProtocolTCP,
                Port:       80,
                TargetPort: intstr.FromInt(80),
            }},
        },
    }

    if err := ctrl.SetControllerReference(app, svc, r.Scheme); err != nil {
        return nil, err
    }
    return svc, nil
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Status Updates

Finally, when we are certain the resources exist and are correctly configured, we report the application's health back to the user:

    available := foundDep.Status.AvailableReplicas
    app.Status.AvailableReplicas = available

    if available == app.Spec.Replicas {
        app.Status.Phase = appsv1.PhaseRunning
    } else {
        app.Status.Phase = appsv1.PhaseScaling
    }

    if err := r.Status().Update(ctx, app); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}
Enter fullscreen mode Exit fullscreen mode

We don't count pods ourselves — we read the AvailableReplicas reported by the native Kubernetes Deployment controller, copy that into our WebApp status, determine the Phase, and push the update.

⚠️ Critical: Notice we use r.Status().Update() instead of r.Update(). Kubernetes intentionally separates the Spec endpoint from the Status endpoint. If you update Status using the main r.Update() method, Kubernetes sees a modification to the primary object and instantly triggers another Reconcile loop — an infinite crash loop. The dedicated Status endpoint prevents this. This is why the +kubebuilder:subresource:status marker exists.


Testing on Minikube

With the logic complete, let's deploy the Operator and watch it enforce desired state. Open two terminal windows.

Terminal 1: Install the CRD and start the Operator

make install
make run
Enter fullscreen mode Exit fullscreen mode

You'll see controller-runtime logs start up and wait for events.

Terminal 2: Deploy the Custom Resource

Create my-app.yaml:

apiVersion: apps.sandesh.dev/v1
kind: WebApp
metadata:
  name: sandy-app
  namespace: default
spec:
  replicas: 3
  image: nginx:1.25-alpine
Enter fullscreen mode Exit fullscreen mode

Apply it:

$ kubectl apply -f my-app.yaml
webapp.apps.sandesh.dev/sandy-app created
Enter fullscreen mode Exit fullscreen mode

Check the custom resource. Notice how our printcolumn markers format the output with live state:

$ kubectl get webapps
NAME        PHASE     REPLICAS   AVAILABLE
sandy-app   Running   3          3
Enter fullscreen mode Exit fullscreen mode

Verify the Operator provisioned the underlying resources:

$ kubectl get pods
NAME                         READY   STATUS    RESTARTS   AGE
sandy-app-7c8585559-2mxgk    1/1     Running   0          45s
sandy-app-7c8585559-8pabc    1/1     Running   0          45s
sandy-app-7c8585559-xy9jz    1/1     Running   0          45s
Enter fullscreen mode Exit fullscreen mode

Demo: Drift Detection

Simulate an unauthorized user bypassing the custom resource and manually scaling the Deployment:

$ kubectl scale deployment sandy-app --replicas=10
deployment.apps/sandy-app scaled
Enter fullscreen mode Exit fullscreen mode

Immediately look at Terminal 1. The Operator catches it instantly:

INFO  Drift detected! Updating Deployment  {"DesiredReplicas": 3, "ActualReplicas": 10}
Enter fullscreen mode Exit fullscreen mode

Check the pods — the Operator has already crushed the unauthorized change:

$ kubectl get pods
NAME                         READY   STATUS        RESTARTS   AGE
sandy-app-7c8585559-2mxgk    1/1     Running       0          2m
sandy-app-7c8585559-8pabc    1/1     Running       0          2m
sandy-app-7c8585559-xy9jz    1/1     Running       0          2m
sandy-app-7c8585559-zbt99    0/1     Terminating   0          12s
sandy-app-7c8585559-plk22    0/1     Terminating   0          12s
Enter fullscreen mode Exit fullscreen mode

Kubernetes is terminating the excess pods. Within seconds, only the original 3 remain.

While this recovery is happening, our Status update block reports the degraded state in real time:

$ kubectl get webapps
NAME        PHASE     REPLICAS   AVAILABLE
sandy-app   Scaling   3          10
Enter fullscreen mode Exit fullscreen mode

Once the cluster fully converges, the phase automatically switches back to Running.


What I Learned / What's Next

Building this Operator demystified a lot of the "magic" of Kubernetes. The biggest hurdle wasn't Go syntax — it was wrapping my head around the asynchronous, declarative nature of the Reconcile loop. You don't write scripts that execute step-by-step; you write a state machine that constantly compares what is versus what should be and corrects the difference.

Mastering this control loop pattern was exactly what I needed for my next step. For the upcoming Fall LFX Mentorship term, my target is containerd. Since containerd relies entirely on this same highly concurrent, API-driven Go architecture to manage low-level Linux primitives, building this Operator gave me the exact foundation to start contributing to the core daemon and the runtime shim.


If you have questions or want to dig into the code, the full repo is at SandeshOjha06/k8-operator. Drop a comment below — happy to discuss the controller-runtime internals.

Top comments (0)