49

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

TermMeaning
ResourceAn endpoint that stores API objects of a kind (e.g. pods)
CRDCustomResourceDefinition — registers a new kind/endpoint
CRCustom Resource — an instance of a kind defined by a CRD
Group / Version / KindThe API coordinates, e.g. stable.example.com/v1, CronTab
ScopeNamespaced or Cluster reach of the resource
OpenAPI v3 schemaValidation rules for the CR's spec
ControllerCode 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 Namespaced objects live in a namespace (kubectl get ct -A); Cluster objects are global (no namespace).
  • apiextensions.k8s.io/v1 requires a structural schema (every level typed, no bare x-kubernetes-preserve-unknown-fields at the root) — this enables pruning of unknown fields and reliable defaulting.
  • Use default: in the schema to set field defaults, and x-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 version storage: 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 additionalPrinterColumns and a shortName
  • [ ] Explained why a CRD without a controller does nothing
  • [ ] Described served vs storage versions and conversion