🧩 What CRDs Are and Why They Matter
A Custom Resource Definition teaches Kubernetes about a new resource type. Once registered, your custom resource gets the full Kubernetes treatment: stored in etcd, accessible via the API server, queryable with kubectl, watchable by controllers, RBAC-protected, and namespaced or cluster-scoped.
📦 Custom Resource (CR)
An instance of your custom type — e.g. a Database or CronTab. Stored in etcd just like a Pod or Deployment.
📋 CRD
The schema definition that tells the API server what fields are valid. One CRD per type; many CRs per CRD.
🤖 Operator
A controller that watches CRs and reconciles them toward desired state. CRDs are the data model; operators are the behaviour.
🔌 Extension point
CRDs are how every major ecosystem project (Istio, cert-manager, ArgoCD, Prometheus Operator) integrates with Kubernetes natively.
A minimal CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.myorg.example.com # plural.group
spec:
group: myorg.example.com
scope: Namespaced # or Cluster
names:
plural: databases
singular: database
kind: Database
shortNames: [db] # kubectl get db
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [engine, storage]
properties:
engine:
type: string
enum: [postgres, mysql, redis]
storage:
type: string
pattern: '^[0-9]+Gi$'
replicas:
type: integer
minimum: 1
maximum: 5
default: 1
A Custom Resource instance
apiVersion: myorg.example.com/v1alpha1
kind: Database
metadata:
name: prod-postgres
namespace: production
spec:
engine: postgres
storage: 100Gi
replicas: 3
# Works with standard kubectl commands once CRD is installed:
kubectl get databases -n production
kubectl describe database prod-postgres -n production
kubectl delete database prod-postgres -n production
Schema validation — OpenAPI v3
The openAPIV3Schema field enforces structure at admission time. The API server rejects invalid CRs before they reach etcd. Key validation keywords:
| Keyword | Type | Example |
|---|---|---|
type | All | string, integer, boolean, object, array |
required | object | required: [engine, storage] |
enum | string/integer | enum: [postgres, mysql] |
pattern | string | pattern: '^[0-9]+Gi$' |
minimum / maximum | integer/number | minimum: 1, maximum: 100 |
default | All | Sets field value if omitted by user |
x-kubernetes-preserve-unknown-fields | object | true — allows extra fields (escape hatch) |
x-kubernetes-int-or-string | mixed | Accepts both "500m" and 500 |
📊 Status Subresource
The status subresource separates user-managed spec from controller-managed status. Without it, a controller updating status could accidentally overwrite the user's spec in a race condition. With the subresource enabled:
kubectl apply/PUT /apis/.../databases/prod-postgresonly updatesspec— status is ignored.- Controllers use
PUT /apis/.../databases/prod-postgres/statusto update onlystatus. - RBAC can grant different permissions for
databasesvsdatabases/status.
spec:
versions:
- name: v1
served: true
storage: true
subresources:
status: {} # enables the status subresource
scale: # enables kubectl scale and HPA integration
specReplicasPath: .spec.replicas
statusReplicasPath: .status.readyReplicas
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine: { type: string }
replicas: { type: integer }
status:
type: object
properties:
phase:
type: string
enum: [Pending, Provisioning, Ready, Failed]
readyReplicas:
type: integer
conditions:
type: array
items:
type: object
properties:
type: { type: string }
status: { type: string }
lastTransitionTime: { type: string }
Updating status from a controller (Go)
// Always use UpdateStatus, not Update, for status changes
db.Status.Phase = "Ready"
db.Status.ReadyReplicas = 3
db.Status.Conditions = []metav1.Condition{
{
Type: "Available",
Status: metav1.ConditionTrue,
LastTransitionTime: metav1.Now(),
Reason: "AllReplicasReady",
Message: "3/3 replicas are ready",
},
}
if err := r.Status().Update(ctx, db); err != nil {
return ctrl.Result{}, err
}
📋 Additional Printer Columns
By default kubectl get databases only shows NAME and AGE. additionalPrinterColumns adds custom columns using JSONPath expressions — making your CRs as informative as built-in resources:
additionalPrinterColumns:
- name: Engine
type: string
jsonPath: .spec.engine
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Phase
type: string
jsonPath: .status.phase
- name: Ready
type: string
jsonPath: .status.readyReplicas
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
# Result:
# NAME ENGINE REPLICAS PHASE READY AGE
# prod-postgres postgres 3 Ready 3 2d
🔐 RBAC for Custom Resources
CRDs integrate with Kubernetes RBAC naturally. The resource name in rules is the plural form from the CRD:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: database-operator
rules:
- apiGroups: ["myorg.example.com"]
resources: ["databases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["myorg.example.com"]
resources: ["databases/status"] # separate permission for status subresource
verbs: ["get", "update", "patch"]
- apiGroups: ["myorg.example.com"]
resources: ["databases/finalizers"] # needed to set finalizers
verbs: ["update"]
🔄 CRD Versioning
APIs evolve. CRDs support multiple versions simultaneously — old clients can use v1alpha1 while new clients use v1. Key rules:
- Only one version can have
storage: true— that's what gets written to etcd. - Multiple versions can have
served: true— the API server serves all of them. - When a client requests a non-storage version, the API server either converts automatically (if schemas are compatible) or calls a conversion webhook.
spec:
versions:
- name: v1alpha1
served: true
storage: false # old clients can still use this
schema:
openAPIV3Schema:
type: object
properties:
spec:
properties:
dbEngine: # old field name
type: string
- name: v1
served: true
storage: true # written to etcd
schema:
openAPIV3Schema:
type: object
properties:
spec:
required: [engine]
properties:
engine: # renamed from dbEngine
type: string
enum: [postgres, mysql, redis]
conversion:
strategy: Webhook # or "None" if schemas are identical
webhook:
conversionReviewVersions: ["v1"]
clientConfig:
service:
name: database-webhook-svc
namespace: operators
path: /convert
Conversion webhook — translating between versions
The conversion webhook receives a ConversionReview object containing one or more CRs in the source version and must return them in the requested target version:
// Go conversion webhook handler (simplified)
func convertDatabase(src *v1alpha1.Database) *v1.Database {
return &v1.Database{
ObjectMeta: src.ObjectMeta,
Spec: v1.DatabaseSpec{
Engine: src.Spec.DbEngine, // rename dbEngine → engine
Storage: src.Spec.Storage,
Replicas: src.Spec.Replicas,
},
}
}
// Register the webhook endpoint
http.HandleFunc("/convert", func(w http.ResponseWriter, r *http.Request) {
review := &apiextensionsv1.ConversionReview{}
json.NewDecoder(r.Body).Decode(review)
converted := make([]runtime.RawExtension, 0)
for _, obj := range review.Request.Objects {
src := &v1alpha1.Database{}
json.Unmarshal(obj.Raw, src)
converted = append(converted, runtime.RawExtension{
Object: convertDatabase(src),
})
}
review.Response = &apiextensionsv1.ConversionResponse{
UID: review.Request.UID,
Result: metav1.Status{Status: "Success"},
ConvertedObjects: converted,
}
json.NewEncoder(w).Encode(review)
})
CRD version lifecycle
| Stage | served | storage | Meaning |
|---|---|---|---|
| Active (current) | true | true | Default version — write and read here |
| Supported (old) | true | false | Old clients still work; data auto-converted from storage version |
| Deprecated | true | false | Add deprecated: true + deprecationWarning to surface warnings to users |
| Removed | false | false | API endpoint gone — all clients must upgrade before this |
🧠 Knowledge Check
Q1. A CRD has two versions: v1alpha1 with storage: false, served: true and v1 with storage: true, served: true. What happens when a client reads a v1alpha1 Database?
Q2. Why is the status subresource important for operators?
Q3. A user applies a Database CR with replicas: 10 but the CRD schema has maximum: 5. What happens?
Q4. What is the naming convention for a CRD's metadata.name and why?
Databaseplural.group (e.g. databases.myorg.example.com) — ensures global uniqueness across API groups