Skip to content

Operators and Custom Resources

A CustomResourceDefinition extends the Kubernetes API with a new object Kind, and an Operator is that CRD paired with a controller that continuously reconciles real-world state to match it.

CustomResourceDefinition: teaching the API server a new Kind

Section titled “CustomResourceDefinition: teaching the API server a new Kind”

A CustomResourceDefinition (CRD) registers a brand new object Kind with the Kubernetes API server. Once installed, instances of that Kind behave exactly like a built-in object: kubectl get/kubectl apply work on it, it is stored in etcd like everything else, and it can be watched over the API just like a Pod or a Deployment. The CRD itself defines the schema for that Kind using an OpenAPI v3 schema, so the API server validates instances on write:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.example.com
spec:
group: example.com
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine:
type: string
enum: ['postgres', 'mysql']
storageGB:
type: integer
minimum: 1
scope: Namespaced
names:
plural: databases
singular: database
kind: Database
shortNames: ['db']
# An instance of the new Kind, just like any other manifest
apiVersion: example.com/v1
kind: Database
metadata:
name: orders-db
spec:
engine: postgres
storageGB: 20
Terminal window
kubectl apply -f orders-db.yaml
kubectl get databases
kubectl get db orders-db -o yaml

A CRD alone is just a schema, the controller is what makes it an Operator

Section titled “A CRD alone is just a schema, the controller is what makes it an Operator”

Applying that Database object does nothing on its own — the API server happily stores it in etcd and validates its shape, but nothing provisions an actual database. A CRD only adds a schema and storage for a new Kind; it has no behavior. The Operator pattern is that CRD paired with a controller that watches instances of the custom resource and drives real infrastructure to match what they describe, using the same reconciliation loop (watch, diff, act) that Kubernetes’s own built-in controllers use for Deployments and ReplicaSets — just aimed at application- or operationally-specific knowledge instead of generic Pod scheduling. A database Operator might provision a real database instance when a Database resource is created, take periodic backups because the resource says so, and handle failover automatically when a replica goes unhealthy. Without that controller, a CRD is nothing more than a shape that kubectl happens to accept.

Why this is where a programmatic client earns its place

Section titled “Why this is where a programmatic client earns its place”

Everywhere else in this course, kubectl apply is enough because a human runs it once and the built-in controllers take it from there. A controller for an Operator is different: it has to run continuously, reacting to every add, update, and delete of the custom resource, for as long as the cluster exists. That is exactly the situation a one-shot CLI command cannot serve and a programmatic, watch-based client can. Using @kubernetes/client-node’s Watch class against the custom resource’s API path is a realistic way to build the reconciliation loop at the heart of an Operator:

import * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const watch = new k8s.Watch(kc);
// Path for a namespace-scoped custom resource: /apis/{group}/{version}/namespaces/{namespace}/{plural}
const path = '/apis/example.com/v1/namespaces/default/databases';
async function reconcile(database: any) {
const name = database.metadata?.name;
const engine = database.spec?.engine;
const storageGB = database.spec?.storageGB;
console.log(`reconciling Database ${name}: engine=${engine} storageGB=${storageGB}`);
// Real controller: provision or update the actual database instance here
}
const req = await watch.watch(
path,
{},
(type, apiObj) => {
if (type === 'ADDED' || type === 'MODIFIED') {
reconcile(apiObj);
} else if (type === 'DELETED') {
console.log(`Database ${apiObj.metadata?.name} deleted, tearing down real instance`);
}
},
(err) => {
console.error('watch ended', err);
},
);
// Later, on shutdown:
// req.abort();

Every ADDED or MODIFIED event runs the reconcile step again, which is the entire idea behind the Operator pattern: keep comparing desired state (the custom resource) against real state (the actual infrastructure) and correct the difference, forever, without a human re-running a command.

flowchart LR
  cr["Database custom resource (desired state)"] -->|watch| ctrl["Operator controller"]
  ctrl -->|diff| decide{"Does real state match spec?"}
  decide -->|no| act["Provision, resize, or repair"]
  decide -->|yes| noop["Do nothing this cycle"]
  act --> infra["Real PostgreSQL instance"]
  infra -->|status| ctrl
A custom resource feeding an Operator's watch-diff-act loop, which drives a real database instance
What does a CustomResourceDefinition add to the Kubernetes API
Why does a CRD by itself not make something an Operator
Why does an Operator controller need a programmatic, watch-based API client rather than one-shot kubectl apply
In the Operator reconciliation loop, what happens after the controller diffs desired state against real state