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.