🧩 What CRDs Are and Why They Matter

A Custom Resource Definition teaches Kubernetes about a new resource type. Once registered, your custom resource gets the full Kubernetes treatment: stored in etcd, accessible via the API server, queryable with kubectl, watchable by controllers, RBAC-protected, and namespaced or cluster-scoped.

📦 Custom Resource (CR)

An instance of your custom type — e.g. a Database or CronTab. Stored in etcd just like a Pod or Deployment.

📋 CRD

The schema definition that tells the API server what fields are valid. One CRD per type; many CRs per CRD.

🤖 Operator

A controller that watches CRs and reconciles them toward desired state. CRDs are the data model; operators are the behaviour.

🔌 Extension point

CRDs are how every major ecosystem project (Istio, cert-manager, ArgoCD, Prometheus Operator) integrates with Kubernetes natively.

A minimal CRD

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.myorg.example.com   # plural.group
spec:
  group: myorg.example.com
  scope: Namespaced    # or Cluster
  names:
    plural:   databases
    singular: database
    kind:     Database
    shortNames: [db]    # kubectl get db
  versions:
  - name: v1alpha1
    served:  true
    storage: true
    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

A Custom Resource instance

apiVersion: myorg.example.com/v1alpha1
kind: Database
metadata:
  name: prod-postgres
  namespace: production
spec:
  engine:   postgres
  storage:  100Gi
  replicas: 3

# Works with standard kubectl commands once CRD is installed:
kubectl get databases -n production
kubectl describe database prod-postgres -n production
kubectl delete database prod-postgres -n production

Schema validation — OpenAPI v3

The openAPIV3Schema field enforces structure at admission time. The API server rejects invalid CRs before they reach etcd. Key validation keywords:

KeywordTypeExample
typeAllstring, integer, boolean, object, array
requiredobjectrequired: [engine, storage]
enumstring/integerenum: [postgres, mysql]
patternstringpattern: '^[0-9]+Gi$'
minimum / maximuminteger/numberminimum: 1, maximum: 100
defaultAllSets field value if omitted by user
x-kubernetes-preserve-unknown-fieldsobjecttrue — allows extra fields (escape hatch)
x-kubernetes-int-or-stringmixedAccepts both "500m" and 500

📊 Status Subresource

The status subresource separates user-managed spec from controller-managed status. Without it, a controller updating status could accidentally overwrite the user's spec in a race condition. With the subresource enabled:

  • kubectl apply / PUT /apis/.../databases/prod-postgres only updates spec — status is ignored.
  • Controllers use PUT /apis/.../databases/prod-postgres/status to update only status.
  • RBAC can grant different permissions for databases vs databases/status.
spec:
  versions:
  - name: v1
    served:  true
    storage: true
    subresources:
      status: {}    # enables the status subresource
      scale:        # enables kubectl scale and HPA integration
        specReplicasPath:   .spec.replicas
        statusReplicasPath: .status.readyReplicas
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              engine:   { type: string }
              replicas: { type: integer }
          status:
            type: object
            properties:
              phase:
                type: string
                enum: [Pending, Provisioning, Ready, Failed]
              readyReplicas:
                type: integer
              conditions:
                type: array
                items:
                  type: object
                  properties:
                    type:               { type: string }
                    status:             { type: string }
                    lastTransitionTime: { type: string }

Updating status from a controller (Go)

// Always use UpdateStatus, not Update, for status changes
db.Status.Phase = "Ready"
db.Status.ReadyReplicas = 3
db.Status.Conditions = []metav1.Condition{
    {
        Type:               "Available",
        Status:             metav1.ConditionTrue,
        LastTransitionTime: metav1.Now(),
        Reason:             "AllReplicasReady",
        Message:            "3/3 replicas are ready",
    },
}
if err := r.Status().Update(ctx, db); err != nil {
    return ctrl.Result{}, err
}

📋 Additional Printer Columns

By default kubectl get databases only shows NAME and AGE. additionalPrinterColumns adds custom columns using JSONPath expressions — making your CRs as informative as built-in resources:

additionalPrinterColumns:
- name:     Engine
  type:     string
  jsonPath: .spec.engine
- name:     Replicas
  type:     integer
  jsonPath: .spec.replicas
- name:     Phase
  type:     string
  jsonPath: .status.phase
- name:     Ready
  type:     string
  jsonPath: .status.readyReplicas
- name:     Age
  type:     date
  jsonPath: .metadata.creationTimestamp

# Result:
# NAME            ENGINE     REPLICAS   PHASE   READY   AGE
# prod-postgres   postgres   3          Ready   3       2d

🔐 RBAC for Custom Resources

CRDs integrate with Kubernetes RBAC naturally. The resource name in rules is the plural form from the CRD:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: database-operator
rules:
- apiGroups: ["myorg.example.com"]
  resources: ["databases"]
  verbs:     ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["myorg.example.com"]
  resources: ["databases/status"]   # separate permission for status subresource
  verbs:     ["get", "update", "patch"]
- apiGroups: ["myorg.example.com"]
  resources: ["databases/finalizers"]   # needed to set finalizers
  verbs:     ["update"]
🔵 CRD vs ConfigMap for structured data A common anti-pattern is storing structured config in ConfigMaps. CRDs are better when: you need schema validation, versioning, RBAC per-resource, watch semantics, or kubectl integration. Use ConfigMaps only for flat key-value config consumed by apps.

🔄 CRD Versioning

APIs evolve. CRDs support multiple versions simultaneously — old clients can use v1alpha1 while new clients use v1. Key rules:

  • Only one version can have storage: true — that's what gets written to etcd.
  • Multiple versions can have served: true — the API server serves all of them.
  • When a client requests a non-storage version, the API server either converts automatically (if schemas are compatible) or calls a conversion webhook.
spec:
  versions:
  - name: v1alpha1
    served:  true
    storage: false   # old clients can still use this
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            properties:
              dbEngine:        # old field name
                type: string

  - name: v1
    served:  true
    storage: true    # written to etcd
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            required: [engine]
            properties:
              engine:          # renamed from dbEngine
                type: string
                enum: [postgres, mysql, redis]

  conversion:
    strategy: Webhook     # or "None" if schemas are identical
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          name:      database-webhook-svc
          namespace: operators
          path:      /convert

Conversion webhook — translating between versions

The conversion webhook receives a ConversionReview object containing one or more CRs in the source version and must return them in the requested target version:

// Go conversion webhook handler (simplified)
func convertDatabase(src *v1alpha1.Database) *v1.Database {
    return &v1.Database{
        ObjectMeta: src.ObjectMeta,
        Spec: v1.DatabaseSpec{
            Engine:   src.Spec.DbEngine,   // rename dbEngine → engine
            Storage:  src.Spec.Storage,
            Replicas: src.Spec.Replicas,
        },
    }
}

// Register the webhook endpoint
http.HandleFunc("/convert", func(w http.ResponseWriter, r *http.Request) {
    review := &apiextensionsv1.ConversionReview{}
    json.NewDecoder(r.Body).Decode(review)

    converted := make([]runtime.RawExtension, 0)
    for _, obj := range review.Request.Objects {
        src := &v1alpha1.Database{}
        json.Unmarshal(obj.Raw, src)
        converted = append(converted, runtime.RawExtension{
            Object: convertDatabase(src),
        })
    }
    review.Response = &apiextensionsv1.ConversionResponse{
        UID:             review.Request.UID,
        Result:          metav1.Status{Status: "Success"},
        ConvertedObjects: converted,
    }
    json.NewEncoder(w).Encode(review)
})
💡 Hub-and-spoke versioning pattern For CRDs with many versions, designate one version as the hub (usually the latest stable). All conversions go through the hub: v1alpha1 → v1 → v2beta1 rather than writing N×(N-1) converters. Kubebuilder scaffolds this pattern automatically.

CRD version lifecycle

StageservedstorageMeaning
Active (current)truetrueDefault version — write and read here
Supported (old)truefalseOld clients still work; data auto-converted from storage version
DeprecatedtruefalseAdd deprecated: true + deprecationWarning to surface warnings to users
RemovedfalsefalseAPI endpoint gone — all clients must upgrade before this

🧠 Knowledge Check

Q1. A CRD has two versions: v1alpha1 with storage: false, served: true and v1 with storage: true, served: true. What happens when a client reads a v1alpha1 Database?

A) An error is returned — only the storage version can be read
B) The v1alpha1 object is read directly from a separate etcd path
C) The API server reads the v1 object from etcd and converts it to v1alpha1 before returning it to the client
D) The v1alpha1 client must update to v1 before reading

Q2. Why is the status subresource important for operators?

A) It allows the status to be stored in a separate etcd cluster for performance
B) It makes the status field required in every Custom Resource
C) It separates spec updates (user) from status updates (controller), preventing races and enabling separate RBAC
D) It automatically syncs status from child resources like Pods

Q3. A user applies a Database CR with replicas: 10 but the CRD schema has maximum: 5. What happens?

A) The CR is stored but the controller ignores the invalid value
B) The API server rejects the request at admission time with a validation error — the CR is never stored
C) The value is silently clamped to 5
D) A warning is emitted but the CR is stored with replicas: 10

Q4. What is the naming convention for a CRD's metadata.name and why?

A) It must match the Kind field exactly: Database
B) Any unique string is acceptable — the name is cosmetic only
C) Must be plural.group (e.g. databases.myorg.example.com) — ensures global uniqueness across API groups
D) Must match the cluster name to avoid conflicts