CRD Design & kubebuilder Scaffolding
Custom Resource Definitions expose your operator's domain language to cluster users as YAML.
kubebuilder turns Go API types and marker comments into CRD manifests, RBAC rules, and webhook stubs through controller-gen.
Summary
Define types under api/v1/ (and future versions) with struct fields tagged for JSON and validation.
Marker comments like +kubebuilder:validation:Minimum=1 become OpenAPI constraints on the CRD.
Run make manifests to regenerate YAML whenever types change.
Plan versioning early: add v2 as a new package, mark one version as storage, implement conversion if fields rename.
Recipe
Quick-reference recipe card - copy-paste ready.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=".spec.frontendSize"
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=".status.ready"
type Guestbook struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GuestbookSpec `json:"spec,omitempty"`
Status GuestbookStatus `json:"status,omitempty"`
}
type GuestbookSpec struct {
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
FrontendSize int32 `json:"frontendSize"`
}When to reach for this:
- Defining a new CRD kind with kubebuilder
create api - Adding validation before users apply broken specs
- Exposing
kubectl getcolumns for support engineers - Introducing a new API version without breaking existing manifests
Working Example
// api/v1/guestbook_types.go
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GuestbookSpec defines the desired state of Guestbook.
type GuestbookSpec struct {
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
FrontendSize int32 `json:"frontendSize"`
// +kubebuilder:validation:Enum=small;medium;large
Tier string `json:"tier,omitempty"`
}
// GuestbookStatus defines the observed state of Guestbook.
type GuestbookStatus struct {
Ready bool `json:"ready,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:storageversion
// +kubebuilder:resource:shortName=gb
// +kubebuilder:printcolumn:name="Tier",type=string,JSONPath=".spec.tier"
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=".status.ready"
type Guestbook struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GuestbookSpec `json:"spec,omitempty"`
Status GuestbookStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type GuestbookList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Guestbook `json:"items"`
}
func init() {
SchemeBuilder.Register(&Guestbook{}, &GuestbookList{})
}# Makefile target (generated by kubebuilder)
controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/basesWhat this demonstrates:
- Root list and object types registered on SchemeBuilder
- Validation markers become CRD schema constraints
+kubebuilder:storageversionpins etcd storage to this versionmake manifestsfeedsconfig/crd/bases/webapp.example.com_guestbooks.yaml
Deep Dive
How It Works
- controller-gen scans Go files for
+kubebuilder:markers and emits CRD YAML, ClusterRole rules, and webhook configurations. - CRDs land in
config/crd/bases/; kustomize overlays patch names, namespaces, and webhook CA bundles for install. - The API group comes from
PROJECTfile domain +create api --groupflag (webapp.example.com/v1). - Status updates require both the CRD
subresources.statusstanza and RBAC*/statuspermissions generated from+kubebuilder:rbacmarkers on reconcilers.
Versioning Strategy
| Pattern | Purpose |
|---|---|
v1alpha1 | Experimental, breaking changes allowed |
v1beta1 | Field set stabilizing, conversion may appear |
v1 | GA-compatible; prefer backward-compatible additions |
| Hub-spoke conversion | Map v1 and v2 fields through a hub type |
Common Markers
| Marker | Effect |
|---|---|
+kubebuilder:validation:Required | Field required in OpenAPI |
+kubebuilder:default=value | Default injected by CRD |
+kubebuilder:validation:Pattern | Regex on strings |
+kubebuilder:rbac:groups=...,resources=...,verbs=... | RBAC rule in config/rbac |
+kubebuilder:webhook:... | Validating or mutating webhook config |
Go Notes
// Reconciler RBAC markers (on controller file)
// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patchGotchas
- Editing CRD YAML by hand - next
make manifestsoverwrites changes. Fix: change Go types and markers, then regenerate. - Renaming JSON fields without a new API version - breaks stored objects and client caches. Fix: add
v2, implement conversion, keepv1served. - Omitting list type - codegen fails or Scheme registration breaks. Fix: always generate
GuestbookListwith+kubebuilder:object:root=true. - Multiple storage versions - CRD apply fails. Fix: exactly one
+kubebuilder:storageversionacross versions. - Pointer vs value for optional fields -
omitemptyon scalars cannot distinguish zero from unset for validation. Fix: use pointers for optional integers or enums when zero is valid. - Forgetting status RBAC -
Status().Updatereturns 403. Fix: addguestbooks/statusverbs to rbac markers.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| kubebuilder + controller-gen | Go operators with typed APIs | CRD is maintained by a separate team in raw YAML |
| Hand-written CRD YAML | Language-agnostic schema source of truth | You want RBAC and webhook codegen from Go |
| Kubebuilder declarative validation only | Simple field constraints | Complex cross-field rules need CEL or webhooks |
| Admission webhooks for validation | Policies beyond OpenAPI | Every simple min/max rule (prefer markers first) |
FAQs
What generates the CRD YAML?
controller-gen invoked by make manifests reads Go types and kubebuilder markers.
Output is under config/crd/bases/.
How do I add a second API version?
Run kubebuilder create api --group webapp --version v2 --kind Guestbook, implement conversion, mark storage version on the canonical version.
What is the storage version?
The CRD version etcd persists objects as.
Other served versions are converted to and from storage on read/write.
Do markers validate at compile time?
Markers are comments interpreted by controller-gen at codegen time.
Invalid marker syntax fails make manifests, not go build.
How do print columns help?
They add kubectl get columns without custom plugins.
JSONPath must match published CRD schema fields.
When do I need CEL validation?
When rules involve multiple fields (spec.end > spec.start) that OpenAPI per-field markers cannot express.
Add +kubebuilder:validation:XValidation rules in newer kubebuilder releases or patch CRD YAML.
What does PROJECT file store?
Domain, repo path, and layout version kubebuilder uses for scaffolding commands.
Keep it in version control.
Can I scope CRDs cluster-wide?
Use +kubebuilder:resource:scope=Cluster on cluster-scoped kinds.
RBAC and namespace handling in reconcilers change accordingly.
How do short names work?
+kubebuilder:resource:shortName=gb allows kubectl get gb.
Still requires users to know the API group for ambiguous short names.
Should spec and status structs be separate?
Yes.
Kubernetes convention and RBAC separation require spec user-writable and status controller-writable.
Related
- Kubernetes Operators Basics - scaffold and
make install - Admission Webhooks: Validating & Mutating in Go - beyond OpenAPI validation
- Writing a Reconciler: ctrl.Request & Reconcile Loop - act on CR spec
- OLM Bundles, Upgrades & Multi-Tenant RBAC - ship CRDs in bundles
- Why Go Dominates the Kubernetes Operator Ecosystem - codegen advantage
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).