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.
Run for real against a live Widget CRD (proven below), the informer produces a genuine reconcile log — makeInformer from @kubernetes/client-node 2.x, not a hand-rolled Watch loop:
$ npx tsx src/widget-controller.ts[ADDED] gizmo[reconcile] Widget/gizmo size=medium resourceVersion=1579informer started, watching Widgets...patched gizmo.spec.size -> small[MODIFIED] gizmo[reconcile] Widget/gizmo size=small resourceVersion=1661demo complete, exitingimport * as k8s from '@kubernetes/client-node';
const kc = new k8s.KubeConfig();kc.loadFromDefault();
const customApi = kc.makeApiClient(k8s.CustomObjectsApi);
const GROUP = 'example.com';const VERSION = 'v2';const PLURAL = 'widgets';const NAMESPACE = 'default';
function reconcile(widget: any): void { const name = widget.metadata?.name; const size = widget.spec?.size; const resourceVersion = widget.metadata?.resourceVersion; console.log(`[reconcile] Widget/${name} size=${size} resourceVersion=${resourceVersion}`);}
async function main(): Promise<void> { const listFn = () => customApi.listNamespacedCustomObject({ group: GROUP, version: VERSION, namespace: NAMESPACE, plural: PLURAL });
const informer = k8s.makeInformer( kc, `/apis/${GROUP}/${VERSION}/namespaces/${NAMESPACE}/${PLURAL}`, listFn as any, );
informer.on('add', (obj: any) => reconcile(obj)); informer.on('update', (obj: any) => reconcile(obj)); informer.on('delete', (obj: any) => console.log(`[DELETED] ${obj.metadata?.name}`)); informer.on('error', (err: any) => { console.error('[informer error]', err?.body ?? err); setTimeout(() => informer.start(), 1000); });
await informer.start();}
main();makeInformer wraps the raw Watch class with a local cache and automatic relist-on-error: the error handler above restarts the informer rather than letting the process die on a dropped connection, which is exactly the resiliency a real Operator’s controller needs running unattended for the life of the cluster.
Multi-version CRDs: served, storage, and the version kubectl actually picks
Section titled “Multi-version CRDs: served, storage, and the version kubectl actually picks”A production CRD rarely stays on one version forever. spec.versions[] is a list, and each entry independently controls whether it is served (can API requests use this version at all) and whether it is storage (etcd persists objects in this version’s shape — exactly one version in the list must set storage: true):
apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata: name: widgets.example.comspec: group: example.com scope: Namespaced names: plural: widgets singular: widget kind: Widget shortNames: ['wg'] versions: - name: v1 served: true storage: false additionalPrinterColumns: - name: Size type: string jsonPath: .spec.size - name: Ready type: string jsonPath: .status.conditions[?(@.type=="Ready")].status - name: Age type: date jsonPath: .metadata.creationTimestamp schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string enum: ['small', 'medium', 'large'] replicas: type: integer required: ['size'] x-kubernetes-validations: - rule: "self.size != 'large' || has(self.replicas)" message: "replicas is required when size is 'large'" status: type: object properties: conditions: type: array items: type: object properties: type: { type: string } status: { type: string } subresources: status: {} - name: v2 served: true storage: true additionalPrinterColumns: - name: Size type: string jsonPath: .spec.size schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string enum: ['small', 'medium', 'large'] required: ['size'] status: type: object properties: conditions: type: array items: type: object properties: type: { type: string } status: { type: string } subresources: status: {} conversion: strategy: Noneconversion.strategy: None proven: the same object, read as two versions
Section titled “conversion.strategy: None proven: the same object, read as two versions”None is only valid when every served version shares an identical schema (here, both v1 and v2 share the spec.size shape) — the API server “assumes the same schema for all versions and only sets the apiVersion field of custom resources to the proper value,” per the Kubernetes CRD versioning docs. Proven by creating one object as v1 and reading the identical object back as v2 with no webhook running anywhere:
$ kubectl apply -f widget-instance.yaml # apiVersion: example.com/v1widget.example.com/gizmo created
$ kubectl get widget.v2.example.com gizmo -o yamlapiVersion: example.com/v2kind: Widgetmetadata: name: gizmo namespace: default resourceVersion: "1421" uid: 8365eba8-52b9-4a35-b491-48dc446e0ab7spec: size: mediumIf the versions’ schemas genuinely diverge (a renamed field, a restructured spec), None cannot round-trip correctly and conversion.strategy: Webhook is required instead: a real webhook server, referenced from spec.conversion.webhook.clientConfig, receives a ConversionReview request and returns the object transformed into the requested version. This course runs no webhook server, so that path is docs-only here — see the same versioning guide for the ConversionReview request/response shape.
A real CEL rejection from x-kubernetes-validations
Section titled “A real CEL rejection from x-kubernetes-validations”The CEL rule above is enforced by the API server itself, no webhook or admission controller needed — proven by a real rejection:
$ kubectl apply -f widget-bad.yaml # spec: { size: large } (no replicas)The Widget "bad-gizmo" is invalid: spec: Invalid value: replicas is required when size is 'large'Getting there took one real, compile-time CEL error first: has(self.replicas) fails to compile unless replicas is actually declared somewhere in the schema —
$ kubectl apply -f widget-crd-broken.yamlThe CustomResourceDefinition "widgets.example.com" is invalid: spec.versions[0].schema.openAPIV3Schema.properties[spec].x-kubernetes-validations[0].rule:Invalid value: ...: compilation failed: ERROR: <input>:1:28: undefined field 'replicas' | self.size != 'large' || has(self.replicas) | ...........................^has() can only test presence of a field the OpenAPI schema actually knows about — a CEL rule referencing an undeclared field is rejected at CRD-apply time, before any instance is ever created, not silently ignored.
Printer columns: a real, surprising gotcha
Section titled “Printer columns: a real, surprising gotcha”Both versions declare additionalPrinterColumns, but v1 has three (Size, Ready, Age) while v2 — the storage version — has only one (Size). A bare kubectl get widgets does not use the storage version’s columns:
$ kubectl explain widgetGROUP: example.comKIND: WidgetVERSION: v2
$ kubectl api-resources --api-group=example.com -o wideNAME SHORTNAMES APIVERSION NAMESPACED KIND VERBSwidgets wg example.com/v2 true Widget ...
$ kubectl get widgetsNAME SIZEgizmo medium
$ kubectl get widget.v1.example.comNAME SIZE READY AGEgizmo medium True 14skubectl resolved the bare widgets/widget to example.com/v2 — not the storage version — because CRD version priority for names that follow the vN/vNbetaM/vNalphaM convention ranks purely by number (v2 outranks v1) regardless of which one is marked storage: true. Storage version and the version kubectl picks by default are two separate concepts, proven here by the printer columns simply disappearing when the higher-numbered served version happens to declare fewer of them — an easy, silent trap for a reader who assumes “the storage version” is “the one kubectl get shows.”
storedVersions: accumulates on a storage flip, never cleans up on its own
Section titled “storedVersions: accumulates on a storage flip, never cleans up on its own”Flipping which version is storage: true does not retroactively rewrite existing etcd objects — status.storedVersions tracks every version any object is still physically encoded in, and the API server never removes an entry from it automatically:
$ kubectl apply -f widget-crd.yaml # v1 storage:false, v2 storage:true (flipped)customresourcedefinition.apiextensions.k8s.io/widgets.example.com configured
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v1","v2"]
$ kubectl label widget gizmo touch=1 --overwrite # any write re-stores the object in the new storage versionwidget.example.com/gizmo labeled
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v1","v2"]Even after the only existing object has genuinely been rewritten in v2’s storage encoding, storedVersions still lists v1 — the Kubernetes docs are explicit that this is a manual cleanup step: “verify that the old version is no longer listed” only after confirming (by migrating every object, e.g. with a kubectl get <kind> -o json | kubectl replace -f - sweep across every namespace) that nothing is still stored in it, then patch the field yourself:
$ kubectl patch crd widgets.example.com --subresource=status --type=merge -p '{"status":{"storedVersions":["v2"]}}'customresourcedefinition.apiextensions.k8s.io/widgets.example.com patched
$ kubectl get crd widgets.example.com -o jsonpath='{.status.storedVersions}'["v2"]Only after storedVersions is manually confirmed and cleaned up like this is it safe to remove the old version from spec.versions entirely — removing it while it is still listed fails, because objects may still exist encoded in that version’s shape.
status subresource: proven separate from spec
Section titled “status subresource: proven separate from spec”subresources: { status: {} } splits /status into its own endpoint with its own RBAC surface, exactly like a built-in Deployment — updating it requires --subresource=status, and a plain spec-only patch cannot touch it:
$ kubectl patch widget gizmo --subresource=status --type=merge -p '{"status":{"conditions":[{"type":"Ready","status":"True"}]}}'widget.example.com/gizmo patchedOperator SDKs and OLM: building the controller without hand-rolling everything
Section titled “Operator SDKs and OLM: building the controller without hand-rolling everything”The Kubernetes Operator pattern docs list several frameworks that generate the CRD scaffolding, reconcile-loop boilerplate, and testing harness this lesson’s tiny makeInformer example does by hand: kubebuilder (Go, scaffolds a full controller-runtime project), the Operator Framework’s Operator SDK (Go/Ansible/Helm-based operators, home of the Operator Lifecycle Manager (OLM) — the piece that manages installing, upgrading, and dependency-resolving operators themselves across a cluster, not just what a single operator’s controller does), Kopf (a Pythonic framework), kube-rs (Rust), KubeOps (.NET), and Metacontroller (compose an operator from webhooks you implement, without writing a controller binary at all). Which one fits depends on the team’s language and whether OLM’s own install/upgrade/dependency machinery for the operator itself is worth adopting on top of the operator’s own reconcile logic.
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 Mental Model
Section titled “Mental Model”A CRD is a schema registration, nothing more — proven above, conversion.strategy: None genuinely round-trips an object between versions with zero code running, because both versions describe the same shape; x-kubernetes-validations genuinely rejects bad instances with zero code running, because CEL runs inside the API server itself. Everything that looks like “behavior” — provisioning a database, restarting a failed replica — comes entirely from a separate controller watching that schema, which is why the whole Operator pattern reduces to: CRD is the contract, controller is the implementation, and storedVersions/printer-column resolution (proven above to be genuinely surprising) are really just “which shape does the API server hand back right now,” never “which shape is correct.”
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
conversion.strategy: None | Proven above: zero extra infrastructure, instant round-trip between versions | Only valid when every served version’s schema is identical — any real field rename needs Webhook instead |
conversion.strategy: Webhook | Handles genuine schema changes between versions | Requires running and maintaining a real webhook server (docs-only in this lesson, no server deployed) |
x-kubernetes-validations (CEL) | Proven above: real rejection with a custom message, no extra component to deploy or maintain | Compile-time errors reference only fields already in the schema (proven above) — cannot validate against external state |
Higher-numbered version marked served but not storage | Lets clients read/write a newer shape immediately | Proven above: kubectl silently prefers it for default discovery even when it is not the storage version, which can hide printer columns the storage version declares |
Manual storedVersions cleanup | Full control over exactly when an old encoding is declared gone | Proven above: nothing does this automatically — skipping it (or doing it too early) either leaves stale state forever or risks breaking reads of not-yet-migrated objects |
makeInformer (client-node) over a hand-rolled Watch loop | Proven above: automatic relist-on-error, local cache, less boilerplate | Still a from-scratch controller — no leader election, no work queue, no rate limiting, unlike kubebuilder/Operator SDK scaffolding |
Common Misconceptions
Section titled “Common Misconceptions”- “The storage version is the version kubectl uses by default.” Proven above:
kubectl explain widget/kubectl get widgetsresolved tov2purely by numeric version priority, independent of which version hadstorage: true. - “Flipping storage: true to a new version instantly migrates existing objects.” Proven above:
status.storedVersionsstill listed the old version even after every existing object had genuinely been rewritten — the field is not auto-updated, it must be checked and patched by hand. - “has() in a CEL rule can check any field name, declared or not.” Proven above with a real compile error:
has(self.replicas)fails CRD validation outright unlessreplicasis declared in the OpenAPI schema. - “A Webhook conversion server is only needed for cosmetic version renames.”
Nonestrategy already handles a same-shape rename viaapiVersionalone (proven above) — a realWebhookis only required when the actual field shape differs between versions, which is a materially bigger commitment than it sounds. - “A CRD instance failing to apply because the Kind isn’t installed is the same class of error as an admission rejection.” A CRD instance applied against a cluster where the CRD itself was never installed fails with
no matches for kind(a discovery-layer error, before any schema or CEL validation runs) — a completely different failure mode from the realx-kubernetes-validationsrejection proven above, which requires the CRD to already be installed and Established.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated CRDs, conversion configuration, or controller code, check for:
- A multi-version CRD with
conversion.strategy: Nonewhere the versions’ schemas actually differ (a renamed or restructured field) — proven above,Noneonly ever changes theapiVersionlabel; it does not transform data, so a real shape difference silently corrupts reads under it. x-kubernetes-validationsrules referencing a field never declared in the same schema — proven above, this is rejected at CRD-apply time with a real compile error, not silently ignored, so it fails fast but the fix (declare the field) is easy to miss in a quick review.- Assuming
kubectl get <plural>reflects the CRD’s storage version — proven above, it reflects the highest-priority served version by naming convention, which can be a different version entirely. - A storage-version migration with no explicit
storedVersionscheck/cleanup step — proven above, the field never updates itself, so “we bumped storage to v2” without this step leaves stale version references indefinitely. - A controller with no error handler on its watch/informer (or one that lets the process exit on a dropped connection) — proven above,
makeInformer’serrorevent restarting the informer is what keeps a real controller alive for the life of the cluster, not a one-shot script. - A CRD instance manifest applied without first confirming the CRD itself is installed on the target cluster — a
no matches for kinderror means the schema was never registered at all, a different problem from any validation rule inside that schema.
🐛 Spot the bug
# @expect-error real CEL compile error, proven above: undefined field 'replicas'apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata:name: widgets.example.comspec:group: example.comscope: Namespacednames: plural: widgets singular: widget kind: Widgetversions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: size: type: string x-kubernetes-validations: - rule: "self.size != 'large' || has(self.replicas)" message: "replicas is required when size is 'large'"Show the answer
replicas is referenced by has(self.replicas) but never declared anywhere in spec’s properties. Proven above with the real API-server response: The CustomResourceDefinition "widgets.example.com" is invalid: ... compilation failed: ERROR: <input>:1:28: undefined field 'replicas'. CEL’s has() can only test presence of a field the OpenAPI schema actually declares — the fix is adding replicas: { type: integer } alongside size in the same properties block.