Custom Resource Definitions (CRD & CR)
Video: Day 49 — Extending the Kubernetes API with CRDs • Theme: teach the API server new object kinds without recompiling it.
Key terms
| Term | Meaning |
|---|---|
| Resource | An endpoint that stores API objects of a kind (e.g. pods) |
| CRD | CustomResourceDefinition — registers a new kind/endpoint |
| CR | Custom Resource — an instance of a kind defined by a CRD |
| Group / Version / Kind | The API coordinates, e.g. stable.example.com/v1, CronTab |
| Scope | Namespaced or Cluster reach of the resource |
| OpenAPI v3 schema | Validation rules for the CR's spec |
| Controller | Code that watches a CR and drives the real world to match it |
Problem & solution
Kubernetes ships with built-in kinds (Pod, Service, Deployment) but real
platforms need their own objects: Database, Certificate, KafkaTopic. You
do not want to fork or recompile the API server to add them.
Solution: A CustomResourceDefinition teaches the API server a brand-new
kind at runtime. Once applied, kubectl get, RBAC, watches, validation and etcd
storage all work for your Custom Resources exactly like native objects.
The analogy
Imagine the port only accepts a fixed set of cargo forms, and a tenant needs a brand-new kind the front desk has never seen. Rather than rebuild the whole front desk, the port authority registers the new form type in its rulebook, so the desk now knows how to accept it, validate it, and file it in the ledger like any standard form. In Kubernetes, registering that new form type is a CustomResourceDefinition, the front desk is the api-server, each filled-out copy is a Custom Resource, and the ledger it lands in is etcd.
Where this fits in the cluster
The same cluster entities appear in every day's notes; the <== marks what this day touches.
A CRD only adds the API; a controller adds behaviour
This is the most common misconception. A CRD by itself just gives you a typed, validated place to store data in etcd. Nothing happens to the world until a controller watches those objects and acts.
CRD -> registers the kind + endpoint + schema (storage only)
CR -> a stored instance (data in etcd, served by the api-server)
Controller -> watches CRs and makes reality match (the actual behaviour)
Defining a CRD
The CRD declares the API group, one or more versions with an OpenAPI v3 schema,
the scope, and the names used in URLs and kubectl.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: crontabs.stable.example.com # must be <plural>.<group>
spec:
group: stable.example.com
scope: Namespaced # or Cluster
names:
plural: crontabs
singular: crontab
kind: CronTab
shortNames: ["ct"]
versions:
- name: v1
served: true # this version is reachable
storage: true # exactly ONE version stores to etcd
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
cronSpec:
type: string
pattern: '^(\d+|\*)(/\d+)?(\s+(\d+|\*)(/\d+)?){4}$'
image:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
required: ["cronSpec", "image"]
additionalPrinterColumns:
- name: Image
type: string
jsonPath: .spec.image
- name: Replicas
type: integer
jsonPath: .spec.replicas
kubectl apply -f crontab-crd.yaml
kubectl get crd crontabs.stable.example.com
kubectl api-resources | grep crontab # the new resource now appears
Creating a Custom Resource
Once the CRD is established, your new kind is just another object. The api-server validates each CR against the OpenAPI schema before persisting it.
apiVersion: stable.example.com/v1
kind: CronTab
metadata:
name: my-cron
spec:
cronSpec: "* * * * */5"
image: my-cron-image:1.0
replicas: 3
kubectl apply -f my-cron.yaml
kubectl get crontabs # uses additionalPrinterColumns
kubectl get ct my-cron -o yaml # shortName works too
kubectl describe crontab my-cron
If a field violates the schema (e.g. replicas: 99), the apply is rejected
by the api-server with a clear validation error — no controller needed.
Scope, validation, and structural schemas
- Scope
Namespacedobjects live in a namespace (kubectl get ct -A);Clusterobjects are global (no namespace). apiextensions.k8s.io/v1requires a structural schema (every level typed, no barex-kubernetes-preserve-unknown-fieldsat the root) — this enables pruning of unknown fields and reliable defaulting.- Use
default:in the schema to set field defaults, andx-kubernetes-validations(CEL) for cross-field rules:
replicas:
type: integer
default: 1
x-kubernetes-validations:
- rule: "self.replicas <= 10"
message: "replicas must not exceed 10"
Versions and conversion
A CRD can serve multiple versions (v1alpha1, v1beta1, v1). Exactly one has
storage: true. To migrate fields between versions you set a conversion
strategy (None for identical schemas, or Webhook to call a converter).
kubectl get crontabs.v1.stable.example.com # ask for a specific version
End-to-end: from CRD to reconciled state
This is the full flow from registering the kind to a controller acting on it.
End-to-end example: ship a validated CronTab kind from scratch
A complete, runnable walkthrough: register the kind, prove validation works, add a printer column, and confirm RBAC and watches behave like native objects.
Step 1 — apply the CRD with schema validation and a printer column.
# crontab-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: crontabs.stable.example.com
spec:
group: stable.example.com
scope: Namespaced
names:
plural: crontabs
singular: crontab
kind: CronTab
shortNames: ["ct"]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
cronSpec:
type: string
image:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
default: 1
required: ["cronSpec", "image"]
additionalPrinterColumns:
- name: Spec
type: string
jsonPath: .spec.cronSpec
- name: Image
type: string
jsonPath: .spec.image
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
kubectl apply -f crontab-crd.yaml
# customresourcedefinition.apiextensions.k8s.io/crontabs.stable.example.com created
kubectl get crd crontabs.stable.example.com
# NAME CREATED AT
# crontabs.stable.example.com 2025-06-22T10:00:00Z
# wait until the api-server reports the kind Established
kubectl wait --for=condition=Established crd/crontabs.stable.example.com --timeout=30s
# customresourcedefinition.apiextensions.k8s.io/crontabs.stable.example.com condition met
Step 2 — confirm the new endpoint is live.
kubectl api-resources --api-group=stable.example.com
# NAME SHORTNAMES APIVERSION NAMESPACED KIND
# crontabs ct stable.example.com/v1 true CronTab
Step 3 — create a valid CR; defaulting fills in replicas.
# my-cron.yaml
apiVersion: stable.example.com/v1
kind: CronTab
metadata:
name: backup-cron
namespace: default
spec:
cronSpec: "0 */6 * * *"
image: backup-runner:2.1
# replicas omitted -> defaults to 1
kubectl apply -f my-cron.yaml
# crontab.stable.example.com/backup-cron created
kubectl get crontabs
# NAME SPEC IMAGE REPLICAS AGE
# backup-cron 0 */6 * * * backup-runner:2.1 1 5s
kubectl get ct backup-cron -o jsonpath='{.spec.replicas}'
# 1
Step 4 — prove schema validation rejects bad input (no controller needed).
kubectl apply -f - <<'EOF'
apiVersion: stable.example.com/v1
kind: CronTab
metadata:
name: broken-cron
spec:
cronSpec: "* * * * *"
image: x:1
replicas: 99
EOF
# The CronTab "broken-cron" is invalid: spec.replicas: Invalid value: 99:
# spec.replicas in body should be less than or equal to 10
kubectl apply -f - <<'EOF'
apiVersion: stable.example.com/v1
kind: CronTab
metadata:
name: missing-image
spec:
cronSpec: "* * * * *"
EOF
# The CronTab "missing-image" is invalid: spec.image: Required value
Step 5 — verify native behaviour: watches and RBAC work automatically.
# watches stream events like any built-in kind
kubectl get crontabs -w &
kubectl patch ct backup-cron --type merge -p '{"spec":{"replicas":3}}'
# crontab.stable.example.com/backup-cron patched
# RBAC scopes the custom kind exactly like pods/services
kubectl create role ct-reader \
--verb=get,list,watch --resource=crontabs.stable.example.com
kubectl auth can-i list crontabs --as=system:serviceaccount:default:default
# no (until the role is bound)
Key takeaways
- A CRD extends the Kubernetes API with a new kind at runtime — no recompile.
- A CR is an instance; the api-server stores and validates it like a native object.
- CRD = data + schema only; behaviour requires a controller that reconciles CRs.
- Pick a
scope(Namespaced/Cluster), provide a structural OpenAPI v3 schema, and mark exactly one versionstorage: true. kubectl get/describe, RBAC, and watches all work for custom kinds automatically.
Checklist
- [ ] Applied a CRD and saw the new kind in
kubectl api-resources - [ ] Created a CR and confirmed schema validation rejects bad input
- [ ] Used
additionalPrinterColumnsand ashortName - [ ] Explained why a CRD without a controller does nothing
- [ ] Described served vs storage versions and conversion