DEV Community

Akshay Gore
Akshay Gore

Posted on

Demystifying CRDs

What is CRD ?

A CRD (Custom Resource Definition) is a Kubernetes feature that lets you teach your cluster about new types of resources. Instead of just using standard Kubernetes objects like Pods or Deployments, a CRD lets you define your own custom objects (like a Database or VirtualService) so you can manage them natively.In plain terms, a CRD acts as a blueprint or schema.

It tells the Kubernetes system: "Here is a new kind of object I want to create, and here are the rules/fields it accepts."

Real-World Examples

  • cert-manager: Uses a CRD to allow you to easily request and manage SSL/TLS Certificates inside your cluster
  • ArgoCD: Uses a CRD to define Applications, letting Kubernetes handle the continuous deployment of your code
  • Istio: Uses CRDs to define network routing rules like VirtualService
  • Prometheus Operator: Prometheus, ServiceMonitor, PodMonitor

What is CR ?

A CR (Custom Resource) is the actual instance or object created from a CRD blueprint.

If the CRD (Custom Resource Definition) is the code that defines a new cookie cutter, the CR (Custom Resource) is the actual cookie you make with it.

A Real-World Example

Prometheus Operator : You cannot create a monitoring rule until the cluster knows what it is.

  • The CRD: The operator installs a blueprint called ServiceMonitor
  • The CR: You write a YAML file using kind: ServiceMonitor filled with your specific app details.
  • When you run kubectl apply -f my-file.yaml you have just created a CR.

Don't talk, just show


STEP 1: Deploying CRD

CRD => crd.yaml

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: greetings.example.com
spec:
  group: example.com
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                name:
                  type: string
  scope: Namespaced
  names:
    plural: greetings
    singular: greeting
    kind: Greeting
    shortNames:
      - gr
Enter fullscreen mode Exit fullscreen mode

Line by line:

  • group: example.com — namespaces your API so it doesn't clash with built-in Kubernetes types. Full API path becomes example.com/v1.
  • schema.openAPIV3Schema — this is the validation rule. It says a Greeting's spec can have a name field, must be a string. If you try to apply a CR with spec.name: 5 (a number), the API server rejects it before it even reaches your operator.
  • names.plural/singular/kind — how kubectl refers to it (kubectl get greetings, kubectl get gr).
  • scope: Namespaced — these objects live inside a namespace, like Pods (as opposed to cluster-scoped, like Nodes).

Apply it

kubectl apply -f crd.yaml
kubectl get crd greetings.example.com
Enter fullscreen mode Exit fullscreen mode

STEP 2: Creating CR

CR => my-greeting.yaml

apiVersion: example.com/v1
kind: Greeting
metadata:
  name: hello-user
spec:
  name: user
Enter fullscreen mode Exit fullscreen mode

Don't apply this yet — apply it after the operator is running, so you can watch it react live.

What is Operator ?

An Operator is the brains or the "worker drone" behind a CRD.

If the CRD is the instruction manual and the CR is the order form, the Operator is the human worker who reads the form and actually builds the product.

In plain terms, an Operator is a custom script or program running inside your cluster that automates complex human tasks. It replaces the need for an engineer to manually log in, run commands, and fix broken applications.

The Loop: How It Thinks

An Operator functions using a continuous Reconciliation Loop. It constantly repeats three basic steps, thousands of times a day:

  1. Observe: Check the current state of the cluster (What is actually running?).
  2. Analyze: Check the desired state from your CR (What did the user ask for?).
  3. Act: Fix any differences (If something is broken or missing, fix it).

A Real-World Scenario

Imagine you deploy a CR asking for a kind: Database with 3 replicas.

  1. Day 1 (Creation): The Operator reads your CR. It realizes 0 databases exist. It automatically creates 3 database pods and provisions cloud storage.
  2. Day 3 (Accident): A hardware failure kills one of your database pods.
  3. The Operator's Action: The Operator notices the count dropped from 3 to 2. Without you waking up at 2:00 AM, it instantly spins up a new pod, attaches the existing storage, and resynchronises the data.

Operator vs. Controller

You will often hear the terms "Controller" and "Operator" used interchangeably, but there is a slight difference:

  • Controller: A general Kubernetes background process (e.g., keeping standard Pods running).
  • Operator: A specific type of controller that packages human operational knowledge for a specific tool (like knowing exactly how to back up a PostgreSQL database or upgrade Prometheus without losing data).

STEP 3: Building an operator

rbac.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: greeting-operator
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: greeting-operator-role
rules:
  - apiGroups: ["example.com"]
    resources: ["greetings"]
    verbs: ["get", "list", "watch", "patch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["create", "delete", "get", "list", "watch"]
  - apiGroups: ["apiextensions.k8s.io"]
    resources: ["customresourcedefinitions"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: greeting-operator-binding
subjects:
  - kind: ServiceAccount
    name: greeting-operator
    namespace: default
roleRef:
  kind: ClusterRole
  name: greeting-operator-role
  apiGroup: rbac.authorization.k8s.io
Enter fullscreen mode Exit fullscreen mode
  • ServiceAccount — an identity for the Pod to act as (instead of using your personal kubeconfig identity).
  • ClusterRole — a set of permissions. Notice it mirrors exactly what your Python code does: watch greetings, create/delete configmaps, plus events (kopf logs Kubernetes Events for visibility) and reading the CRD itself (kopf checks this at startup).
  • ClusterRoleBinding — glues the ServiceAccount to the ClusterRole. Without this, the ServiceAccount exists but has zero permissions — RBAC is deny-by-default.

operator.py

import kopf
import kubernetes.client as k8s

@kopf.on.create('example.com', 'v1', 'greetings')
def create_greeting(spec, name, namespace, logger, **kwargs):
    person_name = spec.get('name', 'World')
    message = f"Hello, {person_name}! This ConfigMap was created by the operator."

    logger.info(f"Reconciling Greeting '{name}': creating ConfigMap")

    api = k8s.CoreV1Api()
    configmap = k8s.V1ConfigMap(
        metadata=k8s.V1ObjectMeta(name=f"{name}-greeting-cm"),
        data={"message": message}
    )
    api.create_namespaced_config_map(namespace=namespace, body=configmap)

    return {"message": "ConfigMap created"}

@kopf.on.delete('example.com', 'v1', 'greetings')
def delete_greeting(name, namespace, logger, **kwargs):
    logger.info(f"Greeting '{name}' deleted — cleaning up ConfigMap")
    api = k8s.CoreV1Api()
    api.delete_namespaced_config_map(name=f"{name}-greeting-cm", namespace=namespace)
Enter fullscreen mode Exit fullscreen mode

What each part means, plain English:

  • import kopf — this library does all the heavy lifting: opening a watch connection to the API server, filtering events for your CRD, retrying on failure. Without it, you'd hand-write hundreds of lines of client-go-equivalent boilerplate.
  • @kopf.on.create('example.com', 'v1', 'greetings') — this decorator says: "call this function whenever a new Greeting object appears." This is the reconcile loop's trigger — kopf watches, and when it sees a create event matching this group/version/plural, it calls your function.
  • spec, name, namespace — kopf automatically hands you the CR's spec, its name, and its namespace. No manual parsing of raw JSON.
  • Inside the function: read spec.name (what the user wants) → build a message → create a ConfigMap (the "act" step of reconcile).
  • @kopf.on.delete(...) — the cleanup counterpart. When someone deletes the Greeting, this fires and deletes the ConfigMap it created — this is exactly the pattern cert-manager uses to clean up secrets, or your earlier Website example would use to clean up Deployments.
  • return {"message": "ConfigMap created"} — kopf automatically writes this into the CR's .status field. This is how kubectl get greetings -o yaml shows live operator state, not just your original spec.

Dockerfile

FROM python:3.11-slim
RUN pip install kopf kubernetes
COPY operator.py /operator.py
CMD ["kopf", "run", "/operator.py", "--namespace=default"]
Enter fullscreen mode Exit fullscreen mode

Build and load image in minikube

docker build -t greeting-operator:1.0.0 .
minikube image load greeting-operator:1.0.0
Enter fullscreen mode Exit fullscreen mode

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: greeting-operator
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: greeting-operator
  template:
    metadata:
      labels:
        app: greeting-operator
    spec:
      serviceAccountName: greeting-operator
      containers:
        - name: operator
          image: greeting-operator:1.0.0
          imagePullPolicy: Never
Enter fullscreen mode Exit fullscreen mode
  • serviceAccountName: greeting-operator — this is what makes the Pod use the permissions from step 1, instead of the default (near-zero-permission) ServiceAccount.
  • imagePullPolicy: Never — tells Kubernetes "don't try to pull this from a registry, it's already local." Without this, it'll try to pull from Docker Hub and fail, since we never pushed it anywhere.
Apply everything if not applied except cr
kubectl apply -f crd.yaml
kubectl apply -f rbac.yaml
kubectl apply -f deployment.yaml
Enter fullscreen mode Exit fullscreen mode

Verification

kubectl get pods -l app=greeting-operator
kubectl logs -f deployment/greeting-operator
Enter fullscreen mode Exit fullscreen mode

STEP : Trigger
In another terminal:

kubectl apply -f my-greeting.yaml
kubectl logs -f deployment/greeting-operator    # watch it react
kubectl get cm/hello-user-greeting-cm -o yaml
Enter fullscreen mode Exit fullscreen mode

Sample Output

kubectl logs -f deployment/greeting-operator
[2026-07-01 15:26:53,077] kopf._core.engines.a [INFO    ] Initial authentication has been initiated.
[2026-07-01 15:26:53,078] kopf.activities.auth [INFO    ] Activity 'login_via_client' succeeded.
[2026-07-01 15:26:53,078] kopf._core.engines.a [INFO    ] Initial authentication has finished.









[2026-07-01 15:27:56,418] kopf.objects         [INFO    ] [default/hello-user] Reconciling Greeting 'hello-user': creating ConfigMap
[2026-07-01 15:27:56,437] kopf.objects         [INFO    ] [default/hello-user] Handler 'create_greeting' succeeded.
[2026-07-01 15:27:56,437] kopf.objects         [INFO    ] [default/hello-user] Creation is processed: 1 succeeded; 0 failed.
[2026-07-01 15:27:56,441] kopf.objects         [WARNING ] [default/hello-user] Merge-patching finished with inconsistencies: (('remove', ('status',), {'create_greeting': {'message': 'ConfigMap created'}}, None),)
Enter fullscreen mode Exit fullscreen mode
kubectl get cm/hello-user-greeting-cm -o yaml
apiVersion: v1
data:
  message: Hello, user! This ConfigMap was created by the operator.
kind: ConfigMap
metadata:
  creationTimestamp: "2026-07-01T15:27:56Z"
  name: hello-user-greeting-cm
  namespace: default
  resourceVersion: "78755"
  uid: 184008f8-39d8-4484-baf1-d1256c66bb93
Enter fullscreen mode Exit fullscreen mode

Top comments (0)