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:
- The definition: a YAML manifest that tells Kubernetes about the new type (group, version, kind, schema)
- 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 removedv1beta1: feature complete, may have minor changesv1: 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.