Advanced Topics

Custom Resource Definitions (CRDs)

● Advanced ⏱ 15 min read

Every resource you interact with in Kubernetes — Pods, Deployments, Services, ConfigMaps — is defined by a schema registered in the API server. CustomResourceDefinitions let you register your own resource types using the same mechanism. Once a CRD is installed, you create, list, update, and delete instances with kubectl, RBAC controls access to them, and controllers can watch them via the standard informer pattern.

What Is a CRD

A CRD is a Kubernetes API object (kind: CustomResourceDefinition) that registers a new resource type with the API server. After installation, the API server accepts and stores instances of that type — called Custom Resources (CRs). The CRD defines the schema; a controller (operator) watches the CRs and acts on them.

CRD extends the Kubernetes API surface
BUILT-IN RESOURCES
core/v1: Pod, Service, ConfigMap
apps/v1: Deployment, StatefulSet
batch/v1: Job, CronJob
networking.k8s.io/v1: Ingress
YOUR CRDs (examples)
myapp.io/v1: Database
cert-manager.io/v1: Certificate
argoproj.io/v1alpha1: Application
monitoring.coreos.com/v1: PrometheusRule
kubectl get databases.myapp.io works exactly like kubectl get pods — same API machinery, same RBAC, same audit log.
CRDs register new types in the Kubernetes API server. Custom resources use the same storage, RBAC, and watch mechanisms as built-in resources.

Defining a CRD

crd.yaml — register a Database resource type
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.myapp.io          # must be plural.group
spec:
  group: myapp.io
  scope: Namespaced                 # or Cluster
  names:
    plural: databases
    singular: database
    kind: Database
    shortNames: ["db"]              # kubectl get db works
  versions:
  - name: v1
    served: true                    # this version is active
    storage: true                   # this version is persisted in etcd
    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
          status:
            type: object
            properties:
              phase:
                type: string
              connectionString:
                type: string
custom resource instance — create a Database
apiVersion: myapp.io/v1
kind: Database
metadata:
  name: production-db
  namespace: production
spec:
  engine: postgres
  storage: 20Gi
  replicas: 2

Structural Schema Validation

The openAPIV3Schema is validated at admission time — the API server rejects CRs that don't match. A structural schema requires every field to have a type, and the schema must cover the entire object (no additionalProperties: true at the top level). Structural schemas enable pruning (remove unknown fields) and defaulting.

schema features — defaults, patterns, enum
properties:
  spec:
    type: object
    properties:
      replicas:
        type: integer
        minimum: 1
        maximum: 10
        default: 1          # injected if omitted — requires x-kubernetes-preserve-unknown-fields: false

      engine:
        type: string
        enum: ["postgres", "mysql"]   # validation: only these values accepted

      version:
        type: string
        pattern: '^\d+\.\d+$'        # regex validation: "14.5" passes, "latest" fails

      backupSchedule:
        type: string
        format: "cronexpr"            # informational — not enforced by default
        nullable: true                # allows null / omission

Status Subresource

Enabling the status subresource separates spec (desired state) from status (observed state). This is important: with the subresource enabled, only a controller can update statuskubectl apply cannot overwrite it. It also enables kubectl get database production-db -o jsonpath='{.status.phase}'.

versions:
- name: v1
  served: true
  storage: true
  subresources:
    status: {}          # enable the /status subresource

# Controller updates status via:
# kubectl patch database production-db --subresource=status \
#   --type=merge -p '{"status":{"phase":"Running"}}'

Printer Columns

Control what kubectl get database prints by defining additional printer columns in the CRD. Without this, kubectl get only shows NAME, AGE.

versions:
- name: v1
  additionalPrinterColumns:
  - name: Engine
    type: string
    jsonPath: .spec.engine
  - name: Storage
    type: string
    jsonPath: .spec.storage
  - name: Replicas
    type: integer
    jsonPath: .spec.replicas
  - name: Phase
    type: string
    jsonPath: .status.phase
  - name: Age
    type: date
    jsonPath: .metadata.creationTimestamp

# kubectl get database:
# NAME             ENGINE     STORAGE  REPLICAS  PHASE    AGE
# production-db    postgres   20Gi     2         Running  3d

API Versioning

CRDs support multiple API versions simultaneously. Serve both v1alpha1 and v1; mark only one as storage: true. When upgrading from alpha to stable, use a conversion webhook to translate between versions on the fly — existing CRs stored as v1alpha1 are served as v1 without a migration job.

versions:
- name: v1alpha1
  served: true
  storage: false          # v1alpha1 no longer stored, but still served for old clients
- name: v1
  served: true
  storage: true           # new storage format

conversion:
  strategy: Webhook
  webhook:
    clientConfig:
      service:
        name: database-webhook
        namespace: myapp-system
        path: /convert
    conversionReviewVersions: ["v1", "v1beta1"]

When to Use CRDs

CRDs are not free — they add API surface, require schema maintenance, and need controllers to be useful. Use them when:

Don't use CRDs for simple config data — a ConfigMap or a Helm values file is simpler. Don't write a CRD if an existing one (cert-manager Certificate, ESO ExternalSecret) already covers your use case.

kubectl Commands

# List all installed CRDs
kubectl get crd

# Describe a CRD (shows schema, versions, printer columns)
kubectl describe crd databases.myapp.io

# List custom resources of a type
kubectl get databases -A
kubectl get databases -n production

# Check CRD schema validation
kubectl explain database.spec
kubectl explain database.spec.engine

# Delete a CRD (also deletes all instances — be careful)
kubectl delete crd databases.myapp.io