Custom Resource Definitions with controller-runtime

June 3, 2025

Custom Resource Definitions with controller-runtime
Custom Resource Definitions with controller-runtime

Kubernetes ships with Pods, Deployments, and Services. CRDs let you teach it your own types.

Estimated Reading Time : 10m

What a CRD is

A Custom Resource Definition extends the Kubernetes API with a new resource type. Once registered, you can kubectl get, kubectl create, and kubectl delete your custom resources just like built-in ones.

A CRD has two parts:

  1. The definition — a YAML manifest that tells Kubernetes about the new type (group, version, kind, schema)
  2. The Go types — structs that represent the resource in your controller code

Defining the Go types

Say you’re building a controller that manages database backups. Define the types:

// api/v1alpha1/backup_types.go
package v1alpha1

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

// BackupSpec defines the desired state
type BackupSpec struct {
    // Database is the name of the database to back up
    Database string `json:"database"`

    // Schedule is a cron expression for when to run backups
    Schedule string `json:"schedule"`

    // RetentionDays is how long to keep backups
    RetentionDays int `json:"retentionDays,omitempty"`
}

// BackupStatus defines the observed state
type BackupStatus struct {
    // LastBackupTime is when the last backup completed
    LastBackupTime *metav1.Time `json:"lastBackupTime,omitempty"`

    // Phase is the current state of the backup
    Phase string `json:"phase,omitempty"`

    // Message provides human-readable status information
    Message string `json:"message,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status

// Backup is the Schema for the backups API
type Backup struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BackupSpec   `json:"spec,omitempty"`
    Status BackupStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// BackupList contains a list of Backup
type BackupList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []Backup `json:"items"`
}

The +kubebuilder comments are marker annotations that controller-gen uses to generate the CRD YAML and DeepCopy methods.

Spec vs Status

Every custom resource follows the same convention as built-in resources:

Spec — the desired state. The user writes this. “I want a backup of the orders database every day at 3am, retained for 7 days.”

Status — the observed state. The controller writes this. “The last backup ran at 2023-03-20T03:00:00Z and succeeded.”

This separation is enforced when you use the status subresource (+kubebuilder:subresource:status). Updates to spec and status go through different API endpoints, preventing the controller from accidentally modifying the spec when updating status.

Registering the types

Create a scheme registration file so controller-runtime knows about your types:

// api/v1alpha1/groupversion_info.go
package v1alpha1

import (
    "k8s.io/apimachinery/pkg/runtime/schema"
    "sigs.k8s.io/controller-runtime/pkg/scheme"
)

var (
    GroupVersion = schema.GroupVersion{Group: "backup.example.com", Version: "v1alpha1"}

    SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}

    AddToScheme = SchemeBuilder.AddToScheme
)

func init() {
    SchemeBuilder.Register(&Backup{}, &BackupList{})
}

Then in your main:

import v1alpha1 "github.com/example/backup-controller/api/v1alpha1"

func main() {
    scheme := runtime.NewScheme()
    _ = v1alpha1.AddToScheme(scheme)

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme: scheme,
    })
    // ...
}

Generating the CRD manifest

Install controller-gen:

go install sigs.k8s.io/controller-tools/cmd/controller-gen@latest

Generate the CRD YAML and DeepCopy methods:

controller-gen crd paths="./api/..." output:crd:dir=config/crd
controller-gen object paths="./api/..."

This produces a CRD manifest at config/crd/backup.example.com_backups.yaml and zz_generated.deepcopy.go files for your types.

Apply it to your cluster:

kubectl apply -f config/crd/

Writing the reconciler

Now you can write a controller that watches your custom type:

type BackupReconciler struct {
    client.Client
}

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

    var backup v1alpha1.Backup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    logger.Info("Reconciling backup",
        "database", backup.Spec.Database,
        "schedule", backup.Spec.Schedule,
    )

    // Reconcile logic here...

    return ctrl.Result{}, nil
}

Wire it up with the manager:

ctrl.NewControllerManagedBy(mgr).
    For(&v1alpha1.Backup{}).
    Complete(&BackupReconciler{Client: mgr.GetClient()})

Using your custom resource

Create an instance:

apiVersion: backup.example.com/v1alpha1
kind: Backup
metadata:
  name: orders-daily
spec:
  database: orders
  schedule: "0 3 * * *"
  retentionDays: 7
kubectl apply -f backup.yaml
kubectl get backups
kubectl describe backup orders-daily

Versioning

The v1alpha1 version signals that this API is experimental and may change. The Kubernetes API versioning convention:

  • v1alpha1 — early, unstable, may change or be removed
  • v1beta1 — feature complete, may have minor changes
  • v1 — stable, backwards compatible

Start with v1alpha1. Promote to v1beta1 when the API stabilizes. Graduate to v1 when you’re confident in the contract.

Project layout

A typical controller project:

├── api/
│   └── v1alpha1/
│       ├── backup_types.go
│       ├── groupversion_info.go
│       └── zz_generated.deepcopy.go
├── config/
│   └── crd/
│       └── backup.example.com_backups.yaml
├── controllers/
│   └── backup_controller.go
├── main.go
├── go.mod
└── go.sum

This is the layout that kubebuilder generates and that most operators follow.