A CRD teaches the API server a new noun. That is all it does: storage, validation, RBAC integration, kubectl support and watch semantics, all inherited for free. Behaviour needs a controller (section 3.3). Splitting those two in your head (schema versus behaviour) is what makes the whole domain legible.
make upmake apiOrientation
Everything in this section needs nothing installed; the whole point is that a CRD needs no controller to exist. Apply one and you immediately have a typed, validated, RBAC-aware, watchable, kubectl explain-documented API, with zero behaviour behind it. That gap is where platform engineering lives.
"Create a CRD for X with required field Y, an enum, and a printer column; create one valid instance; show that an invalid one is rejected." Every clause is graded by an object or an error message. Typing a CRD from memory under time pressure is a genuine skill; do it by hand at least three times before exam day.
Anatomy, field by field that matters
group + names (plural, singular, kind, shortNames, categories) + scope (Namespaced or Cluster) + versions[]. The metadata name is not free-form: it must be exactly <plural>.<group>, and getting that wrong is the most common first error.
| Field | Why it matters |
|---|---|
| scope | Namespaced gets you RBAC per namespace and quota via count/<plural>.<group>. Cluster-scoped resources cannot be owned by namespaced ones, a real constraint in Crossplane (3.5). |
| names.shortNames | cluster-global and first-come. Two CRDs claiming tb collide, and kubectl warns you. A platform API designer owns that namespace collision. |
| names.categories | puts your kind into kubectl get all-style groupings. Cheap usability win. |
| versions[].served | whether the API answers for this version at all |
| versions[].storage | exactly one version is what etcd holds; the rest are converted on the fly |
| versions[].deprecated | emits a warning to clients using it, the polite way to sunset |
Versioning, at exam depth: deprecating a version means served: true with storage moved on; removing it means served: false. That three-state dance is the entire story unless the schemas actually differ, in which case you need conversion: strategy: None (same shape, different name) or a Webhook converter (real transformation). Know that the choice exists and what each costs.
Schema: where design happens
The schema is OpenAPI v3: types, required, enum, pattern, minimum/maximum, default, format. Two extensions worth knowing by name:
x-kubernetes-validations: CEL rules with custom messages, evaluated by the API server. This is how you express cross-field constraints (self.max >= self.min) and immutability (self == oldSelf) without a webhook. The message you write is the message your user sees, so write it like a human.x-kubernetes-preserve-unknown-fields: opts a subtree out of pruning. By default anything not in the schema is silently pruned on write, which looks like data loss and is actually the schema doing its job.- Honourable mentions:
x-kubernetes-list-type: mapwithlistMapKeys(makes server-side apply merge lists sanely instead of replacing them), andx-kubernetes-int-or-string.
Apply a field the schema does not declare and it disappears with no complaint, no event, no warning by default. Users report "my setting is ignored"; the truth is it was never stored. This is why a permissive `preserve-unknown-fields` subtree is tempting and why a platform API mostly should not have one: you would be trading a clear rejection for an invisible mystery.
Subresources and presentation
subresources.statussplits/statusinto its own endpoint, and that split cuts both ways: writes to the main resource then ignore thestatusstanza, and writes to/statusignore everything else. So the classic "my controller's status writes vanish" is the subresource working: the controller is callingUpdate()where it needsUpdateStatus(), orkubectl patchwithout--subresource=status. Without the subresource, status is just another field and anyone can clobber it, which is why every operator convention in section 3.3 assumes it is on.subresources.scalemakeskubectl scaleand HPAs work against your kind; three JSONPaths and your CRD is autoscalable.additionalPrinterColumnsdecides whatkubectl getshows. A platform API without a useful READY column is user-hostile, per section 3.1.
Once applied, wait for the Established condition; until then, requests for the new kind may 404. After that the kind behaves like any built-in: RBAC rules can name it, quota can count/ it, admission webhooks and policy engines see it, and kubectl explain documents it from the descriptions you wrote. Descriptions are not decoration; they are the docs your users get.
Where CRDs stop
- Aggregated API servers: for when you need custom storage, huge object counts, or non-etcd backends. Named in the docs, almost never the answer.
- Admission webhooks: for validation CEL cannot express (cross-object lookups) or mutation with logic. Every webhook is a new availability dependency for the whole API server;
failurePolicyis where you choose which risk you prefer (section 5.2). - Operators: for behaviour. A CRD with no controller is a typed ConfigMap, which is occasionally exactly what you want (Crossplane's XRs, Argo's Applications and Tekton's Tasks are all "data with a controller elsewhere").
- Generators: Crossplane's XRD and kro's ResourceGraphDefinition both generate CRDs for you. Sections 3.5 and 3.6 are exactly that: the same thing you hand-write here, produced by a higher-level API.
kubectl api-resources | grep <tool> to find the nouns, kubectl explain <kind> --recursive to read the schema, kubectl get crd <name> -o yaml to see printer columns, versions and validation. Three commands and you can operate an operator you have never met, which is precisely the exam scenario.
Exercises
A platform-flavoured example, typed out rather than pasted, because the exam gives you a task description, not a starting file:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: tenantbuckets.platform.lab.local
spec:
group: platform.lab.local
scope: Namespaced
names: { plural: tenantbuckets, singular: tenantbucket, kind: TenantBucket, shortNames: [tb] }
versions:
- name: v1alpha1
served: true
storage: true
subresources: { status: {} }
additionalPrinterColumns:
- { name: Tier, type: string, jsonPath: .spec.tier }
- { name: SizeGB, type: integer, jsonPath: .spec.sizeGB }
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [tier]
properties:
tier: { type: string, enum: [bronze, silver, gold], description: "Service tier; sets replication and backup policy." }
sizeGB: { type: integer, minimum: 1, maximum: 500, default: 10 }
x-kubernetes-validations:
- rule: "self.tier != 'bronze' || self.sizeGB <= 50"
message: "bronze tier is capped at 50GB"
status:
type: object
properties:
phase: { type: string }Apply it, then make the API server prove each design decision:
kubectl wait --for=condition=Established crd/tenantbuckets.platform.lab.local
kubectl apply -f - <<'EOF'
apiVersion: platform.lab.local/v1alpha1
kind: TenantBucket
metadata: { name: good, namespace: default }
spec: { tier: silver, sizeGB: 100 }
EOF
kubectl get tb # printer columns show Tier and SizeGB, default filled in elsewhereWith cicd installed, that last command also prints a warning that tb could match Tekton's triggerbindings. Keep it; it is teaching you that short names are cluster-global and first-come, which is exactly the kind of collision a platform API designer owns.
tier: platinum must fail on the enum, sizeGB: 900 on the maximum, and tier: bronze, sizeGB: 100 on the CEL rule with your message in the error. Three different validators refusing three different ways; know which produced which text.Apply a TenantBucket with an extra field spec.color: red, read it back, and observe the field is gone with no error. Then kubectl explain tenantbucket.spec --recursive and see your descriptions serving as live documentation.
kubectl patch tenantbucket good --subresource=status --type=merge -p '{"status":{"phase":"Ready"}}', then confirm kubectl get tb good -o jsonpath='{.status.phase}' says Ready and that a plain spec-edit did not clear it.
kubectl get crd appenvironments.platform.lab.local -o yaml (Crossplane generated it from the lab's XRD). Compare its schema and printer columns against yours; note what a machine-generated platform API includes that your hand-rolled one lacks (conditions conventions, connection details).
Self-check
A user says their field "does not work". kubectl get -o yaml shows it missing entirely, and no error was printed. What happened?
Pruning: the field is not in the schema, so the API server dropped it on write. Either add it to the schema or, rarely, mark that subtree with x-kubernetes-preserve-unknown-fields. The user experience argument is for adding it properly.
Your controller's status writes vanish. One likely cause?
The status subresource is enabled and the writer is going through the main endpoint, which ignores the status stanza. Use UpdateStatus() (or kubectl patch --subresource=status). The mirror-image bug also exists: with no subresource at all, a spec update from anyone overwrites the status your controller just wrote.
You must forbid changing spec.tier after creation. How, without a webhook?
A CEL validation on the field: rule: "self == oldSelf" with a message like "tier is immutable". x-kubernetes-validations is evaluated by the API server and can compare against the old object, which is exactly the immutability case.
You want to serve v1alpha1 and v1beta1 with different shapes. What do you need?
Both versions served, exactly one marked storage, and a conversion strategy: None only if the shapes are compatible, otherwise a conversion webhook. Everything read from etcd is stored in the storage version and converted on the way out.
What does a CRD give you for free, and what does it definitively not give you?
Free: persistence, validation, defaulting, RBAC integration, watch/informers, kubectl support and explain docs, quota counting, policy-engine visibility. Not free: any behaviour whatsoever. Nothing happens until a controller acts on the object.
Docs to know your way around
- kubernetes.io: "Extend the Kubernetes API with CustomResourceDefinitions" (one long page covering versions, pruning, CEL validation and defaults), and "Versions in CustomResourceDefinitions".
- Offline:
kubectl explain crd.spec.versions --recursivewhen you forget field placement, which everyone does;kubectl get crd <name> -o yamlto learn from an operator's own schema.