Platform and scale

Extending Kubernetes yourself

A CRD and controller with controller-runtime — which is also the shortest honest route to your first upstream contribution.

SchedulingGitOps 12 min read

Writing a controller changes how you read everything else. Once you have implemented a reconcile loop, the behaviour of every built-in controller becomes predictable, including the parts that look like bugs.

Start with the question of whether you should

An operator is justified when there is operational knowledge to encode — how to safely upgrade this database, how to fail over, how to resize without data loss. If all you need is "apply this YAML with some values filled in", that is a Helm chart or a Kustomize overlay, and a controller is a daemon you now have to keep alive.

A CRD is a schema plus a status

// api/v1alpha1/types.go
type CacheSpec struct {
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=9
    Replicas int32 `json:"replicas"`

    // +kubebuilder:validation:Enum=redis;valkey
    Engine string `json:"engine"`
}

type CacheStatus struct {
    ReadyReplicas int32              `json:"readyReplicas"`
    Conditions    []metav1.Condition `json:"conditions,omitempty"`
}

// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`

Spend the time on validation markers. Every constraint you express in the schema is a class of bad input the API server rejects before your code runs, which means it is a branch you never have to write or test. And always use the status subresource, so updating status cannot conflict with a user editing spec.

The reconcile contract

func (r *CacheReconciler) Reconcile(ctx context.Context,
    req ctrl.Request) (ctrl.Result, error) {

    var cache v1alpha1.Cache
    if err := r.Get(ctx, req.NamespacedName, &cache); err != nil {
        // gone: nothing to do. Never requeue a NotFound.
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    desired := statefulSetFor(&cache)
    if err := ctrl.SetControllerReference(&cache, desired, r.Scheme); err != nil {
        return ctrl.Result{}, err
    }

    // create-or-update, because this will run many times
    if err := r.Patch(ctx, desired, client.Apply,
        client.ForceOwnership, client.FieldOwner("cache-operator")); err != nil {
        return ctrl.Result{}, err
    }

    cache.Status.ReadyReplicas = readyOf(ctx, r, &cache)
    return ctrl.Result{}, r.Status().Update(ctx, &cache)
}
  • Idempotent, always. Reconcile will be called repeatedly for the same object, with no relationship to how many times it changed.
  • Level-triggered, not edge-triggered. You get "something about this object may have changed", never a diff. Read the world; do not infer it.
  • Return an error to retry. controller-runtime backs off exponentially. Do not sleep inside a reconcile.
  • Set owner references. That is what makes deletion clean up after itself.
Use server-side apply with a field owner rather than get-modify-update. It makes your controller declare only the fields it owns, so it stops fighting with other controllers, an HPA, or a human editing a different part of the same object. This is the single biggest improvement available to most hand-written controllers.

Finalizers, and how to not wedge a namespace

A finalizer with a bug makes an object undeletable forever, and a namespace containing it will hang in Terminating permanently. If your cleanup cannot succeed — the external resource is already gone, credentials have rotated — you must still remove the finalizer. Treat "cleanup failed permanently" as a case to log and release, not to retry forever.

Why this is the contribution route

Having written a controller, you can read any Kubernetes controller, and reading them is how you find real work to do. The path that actually leads somewhere:

  1. Build something small with Kubebuilder. A week, and you will understand informers, work queues and caches properly.
  2. Pick one SIG whose area you now know — whichever matches the controller you just wrote.
  3. Start with tests and documentation. Unglamorous, genuinely needed, and the fastest way to learn a review process without a hard review.
  4. Then fix a real bug, having read the code around it rather than only the issue.

The useful thing about this order is that each step produces something, whether or not the next one happens. A small operator with users is evidence on its own; so is a merged documentation fix.

What to actually do with this

  • Scaffold a CRD with Kubebuilder and reconcile it into a ConfigMap. One afternoon.
  • Convert one get-modify-update to server-side apply and watch the conflicts stop.
  • Read the ReplicaSet controller in kubernetes/kubernetes. It is shorter than you expect, and it is the pattern everything else copies.
This article covers one checkpoint on the roadmap. Open Extending Kubernetes yourself on the roadmap → โ€” it lists what this depends on and everything else written about it.

Something wrong or out of date? Open an issue โ€” corrections are welcome and get credited.