Operators and Custom Resources
The idea in one sentence
Section titled “The idea in one sentence”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/v1kind: CustomResourceDefinitionmetadata: name: databases.example.comspec: 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 manifestapiVersion: example.com/v1kind: Databasemetadata: name: orders-dbspec: engine: postgres storageGB: 20kubectl apply -f orders-db.yamlkubectl get databaseskubectl get db orders-db -o yamlA 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