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. cert-manager.io/v1, Certificate
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. cert-manager is a perfect example: it adds a Certificate kind so teams can request TLS certificates declaratively. You do not want to fork or recompile the API server to add such a kind.

Solution: A CustomResourceDefinition teaches the API server a brand-new kind at runtime. Once cert-manager applies its CRD, kubectl get certificate, RBAC, watches, validation and etcd storage all work for Certificate objects 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 (cert-manager's certificates.cert-manager.io), the front desk is the api-server, each filled-out copy is a Custom Resource (a Certificate), and the ledger it lands in is etcd.

Graph legend — each Kubernetes node maps a port concept to cert-manager's Certificate kind:

Graph nodeMaps toWhat it does
CRD certificates.cert-manager.iothe CustomResourceDefinitionRegisters the Certificate kind, endpoint, and schema
api-serverkube-apiserverServes and validates Certificate objects like native kinds
Certificate (Custom Resource)a kind: Certificate instanceA team's declarative TLS request
etcdcluster datastorePersists each Certificate object

Where this fits in the cluster

The same cluster entities appear in every day's notes; the diagram below shows where this day's topic fits.

Graph legend — each node is a cluster component touched when cert-manager's CRD is installed:

Graph nodeMaps toWhat it does
api-server adds certificates.cert-manager.io endpointkube-apiserverServes the new /certificates REST endpoint
etcd stores each Certificatecluster datastorePersists Certificate objects alongside native ones
cert-manager controller podthe cert-manager DeploymentWatches Certificate CRs and issues real TLS Secrets
watch Certificatea watch API callStreams Certificate changes to the controller

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. cert-manager's CRD gives you the Certificate kind; cert-manager's controller is what actually issues certs.

   CRD        -> registers the kind + endpoint + schema (storage only)
   CR         -> a stored Certificate (data in etcd, served by the api-server)
   Controller -> cert-manager watches Certificates and issues real TLS Secrets

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. Below is an abbreviated view of cert-manager's real Certificate CRD (the shipped one has many more fields):

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: certificates.cert-manager.io       # must be <plural>.<group>
spec:
  group: cert-manager.io
  scope: Namespaced                         # or Cluster
  names:
    plural: certificates
    singular: certificate
    kind: Certificate
    shortNames: ["cert"]
  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:
                secretName:
                  type: string
                dnsNames:
                  type: array
                  items:
                    type: string
                duration:
                  type: string
                renewBefore:
                  type: string
                issuerRef:
                  type: object
                  properties:
                    name: { type: string }
                    kind: { type: string }
                    group: { type: string }
                  required: ["name"]
              required: ["secretName", "issuerRef"]
      additionalPrinterColumns:
        - name: Ready
          type: string
          jsonPath: .status.conditions[?(@.type=="Ready")].status
        - name: Secret
          type: string
          jsonPath: .spec.secretName
# cert-manager ships and applies this CRD as part of its install
kubectl apply -f \
  https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml
kubectl get crd certificates.cert-manager.io
kubectl api-resources | grep certificates   # 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: cert-manager.io/v1
kind: Certificate
metadata:
  name: shop-tls
  namespace: store
spec:
  secretName: shop-tls               # cert-manager writes the TLS keypair here
  dnsNames:
    - shop.example.com
  duration: 2160h                    # 90 days
  renewBefore: 360h                  # renew 15 days before expiry
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
    group: cert-manager.io
kubectl apply -f shop-tls.yaml
kubectl get certificates                   # uses additionalPrinterColumns
kubectl get cert shop-tls -o yaml          # shortName works too
kubectl describe certificate shop-tls

If a field violates the schema (e.g. secretName missing), 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 cert -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:
              duration:
                type: string
                default: "2160h"
            x-kubernetes-validations:
              - rule: "size(self.dnsNames) > 0"
                message: "at least one dnsName is required"

Versions and conversion

A CRD can serve multiple versions (v1alpha2, v1beta1, v1). Exactly one has storage: true. cert-manager historically migrated its Certificate through several versions; to migrate fields between versions you set a conversion strategy (None for identical schemas, or Webhook to call a converter — which cert-manager uses).

kubectl get certificates.v1.cert-manager.io    # ask for a specific version

Graph legend — each node is a real part of CRD version conversion:

Graph nodeMaps toWhat it does
client asks Certificate v1 / v1beta1a kubectl get for a versionRequests the object in a specific served version
cert-manager conversion webhookspec.conversion.strategy: WebhookRewrites the object between versions on the fly
stored as v1 in etcdthe storage: true versionThe single canonical form persisted in etcd

End-to-end: from CRD to reconciled state

This is the full flow from registering the kind to cert-manager acting on it.

Graph legend — each node maps to a step from CRD registration to issued cert:

Graph nodeMaps toWhat it does
kubectl apply CRDthe CustomResourceDefinitionRegisters the Certificate kind and schema
api-server registers /certificates endpointkube-apiserverServes the new resource and its OpenAPI schema
kubectl apply Certificate shop-tlsa Certificate CRThe declarative TLS request
validates against OpenAPI v3 schemaapi-server validationRejects CRs that violate the schema
Certificate stored in etcdpersistenceSaves the object like any native kind
cert-manager controller reconcilescert-manager controllerIssues the cert and writes the shop-tls Secret
status.conditions Ready=True.status write-backReports issuance success on the Certificate

End-to-end example: install cert-manager's Certificate kind and use it

A complete, runnable walkthrough: install cert-manager (which registers the Certificate CRD), prove validation works, see the printer columns, and confirm RBAC and watches behave like native objects.

Step 1 — install cert-manager, which applies the Certificate CRD.

kubectl apply -f \
  https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml
# ... customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io created

kubectl get crd certificates.cert-manager.io
# NAME                            CREATED AT
# certificates.cert-manager.io    2025-06-22T10:00:00Z

# wait until the api-server reports the kind Established
kubectl wait --for=condition=Established crd/certificates.cert-manager.io --timeout=60s
# customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io condition met

Step 2 — confirm the new endpoint is live.

kubectl api-resources --api-group=cert-manager.io | grep certificates
# NAME           SHORTNAMES   APIVERSION             NAMESPACED   KIND
# certificates   cert         cert-manager.io/v1     true         Certificate

Step 3 — create a valid Certificate CR.

# shop-tls.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: shop-tls
  namespace: store
spec:
  secretName: shop-tls
  dnsNames:
    - shop.example.com
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
    group: cert-manager.io
kubectl apply -f shop-tls.yaml
# certificate.cert-manager.io/shop-tls created

kubectl get certificates -n store
# NAME       READY   SECRET     AGE
# shop-tls   True    shop-tls   30s

kubectl get cert shop-tls -n store -o jsonpath='{.spec.secretName}'
# shop-tls

Step 4 — prove schema validation rejects bad input (no controller needed).

kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: broken-cert
  namespace: store
spec:
  dnsNames:
    - shop.example.com
  # secretName + issuerRef omitted
EOF
# The Certificate "broken-cert" is invalid:
#   spec.secretName: Required value
#   spec.issuerRef: Required value

Step 5 — verify native behaviour: watches and RBAC work automatically.

# watches stream events like any built-in kind
kubectl get certificates -n store -w &
kubectl patch cert shop-tls -n store --type merge -p '{"spec":{"renewBefore":"720h"}}'
# certificate.cert-manager.io/shop-tls patched

# RBAC scopes the custom kind exactly like pods/services
kubectl create role cert-reader \
  --verb=get,list,watch --resource=certificates.cert-manager.io -n store
kubectl auth can-i list certificates --as=system:serviceaccount:store:default -n store
# no   (until the role is bound)

Graph legend — each node is a real step in admitting a Certificate CR:

Graph nodeMaps toWhat it does
kubectl apply Certificate CRclient requestSubmits a Certificate object
api-server routes to certificates endpointkube-apiserver routingSends it to the CRD-backed resource
Schema check against OpenAPI v3CRD validationEnforces required secretName/issuerRef
422 reject with field errorvalidation failureRefuses invalid CRs before storage
Pruning and defaulting appliedstructural schemaStrips unknown fields, fills defaults
Admission and RBAC checksadmission + authzApplies policy and permissions to the kind
written to etcdpersistenceStores the Certificate object
Served to the cert-manager controllerwatch deliveryLets cert-manager reconcile it into a Secret

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 (cert-manager) 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

  • [ ] Installed cert-manager and saw certificates in kubectl api-resources
  • [ ] Created a Certificate 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