Operators & CRDs
Every controller you have used — Deployment, HPA, Argo Rollouts, the Prometheus Operator — is the same idea: a control loop that watches desired state and works to make actual state match. This lesson teaches that pattern directly: Custom Resource Definitions (CRDs) that extend the Kubernetes API with your own objects, and operators — controllers that reconcile those objects into real infrastructure. We model the reconcile loop in plain Python (offline-runnable), walk conceptually through writing a controller, survey how real platforms extend k8s, and answer the money question: when to build an operator vs when not to.
kubectl here need a cluster (minikube/kind local, or EKS) and output is illustrative. Operator SDKs (Kubebuilder, Operator SDK, the client libraries) and CRD schema fields drift — verify against current docs. The core payoff of this lesson, the reconcile loop, is modeled in plain Python that runs offline with no cluster or dependencies, so you can internalize the pattern before touching a real controller.Learning objectives
- Explain the controller pattern: watch → diff desired vs actual → act → repeat (a level-triggered loop).
- Define a CRD to extend the Kubernetes API with a custom object, and a Custom Resource instance.
- Describe what an operator is: a controller + CRDs that encodes operational knowledge.
- Trace and implement reconcile logic (modeled offline in Python) that is idempotent and level-triggered.
- Use existing operators to run stateful software, and know how real platforms extend k8s.
- Decide when to build an operator vs not — the cost/benefit that keeps you honest.
1 · The pattern you've used all along: control loops
You have been trusting this pattern for ten chapters without naming it. Every time you ran kubectl scale deployment ... --replicas=3 and walked away, you were relying on something to keep three Pods alive — to notice when a node died at 3 a.m. and quietly bring a replacement up before you woke. Nobody re-ran your command. That silent, tireless custodian is a control loop, and once you see it clearly here you will recognize it inside every Kubernetes object you have ever touched, and you'll be ready to write one of your own.
The best analogy is the thermostat on your wall. You don't tell it "turn the heater on for 12 minutes" — you tell it the goal: "keep this room at 21°C." From then on it does one thing in an endless loop: read the actual temperature, compare it to the target, and if there's a gap, act (heat or cool); if there's no gap, do nothing. It never assumes it "already handled" the room — it just keeps checking and correcting. A Kubernetes controller is that thermostat pointed at cluster state instead of air temperature. You declare the goal in an object's spec; the controller reads actual reality, diffs it against the goal, and nudges the world toward it, forever.
Now the precise version, which will land because you have the feel for it. Kubernetes is, at heart, a pile of controllers each running the same loop: observe the desired state (from an object's spec), observe the actual state (the world), compute the difference, and take action to close the gap — forever. A Deployment controller sees 'you want 3 replicas, there are 2' and creates one Pod. This is declarative: you state the goal, the controller continuously drives toward it.
The common mistake here is thinking in terms of commands instead of goals. Newcomers picture Kubernetes as a robot that executes a script — "create Pod, wait, create Pod" — and then are baffled when a Pod they deleted reappears, or frustrated that there's no "run this once" button. That mental model is wrong and it will fight you the entire chapter. The right model is a goal that is continuously enforced: you never command an action, you register a desired end-state and something works ceaselessly to maintain it. Hold onto that reframing, because an operator — the thing we build in this lesson — is nothing more than your own control loop enforcing your own goal. Master the loop and the rest of the chapter is just applying it to custom objects.
Read the four boxes strictly left-to-right, following the arrows — it's one lap of a loop that then starts over.
- The first box, "Watch desired" (spec), is where the loop begins: it reads the goal you declared in an object's
spec. - The solid arrows carry you through the middle two boxes — "Observe actual" (the world) reads reality, then "Diff" (what's missing) computes the gap between goal and reality.
- The last box, "Act → repeat" (converge), is the payoff: it closes the gap and loops back to the start, so the state is driven toward the goal forever rather than in a single pass.
In short: watch desired → observe actual → diff → act, then repeat forever — the control loop that keeps actual state converging on the goal.
2 · CRDs: teaching the API server a new noun
Here's the surprising thing about Kubernetes: you can add your own words to its vocabulary. Out of the box the API knows nouns like Pod, Deployment, Service, ConfigMap. But nothing about those is privileged — you can teach the API server an entirely new noun, say LLMService, and from that moment it behaves like a first-class citizen: kubectl get llmservice works, it obeys RBAC, it lives in etcd, it shows up in kubectl explain. The tool that performs this teaching is a CustomResourceDefinition, and it is the foundation everything else in this lesson stands on.
Think of the Kubernetes API as a database with a fixed set of tables. A CRD is you running "CREATE TABLE" — you hand the API server a schema (what fields an LLMService has, which are required, what types they must be) and it creates a new place to store and validate objects of that shape. Just like a fresh database table, defining it doesn't make anything happen: an empty table doesn't process orders, and a CRD doesn't launch Pods. It gives you validated, queryable storage and nothing more. That distinction is the single most important idea in this section, so say it plainly: on its own a CRD is just structured storage with validation; it does nothing until a controller watches it.
The mistake almost everyone makes on their first CRD is expecting behavior. They write a beautiful LLMService schema, apply it, create an instance with replicas: 3, run kubectl get pods — and see nothing. Then they conclude the CRD is broken. It isn't. A CRD with no controller is like defining a form with no clerk to read it: the paperwork is filed and validated, but no one has been hired to act on it. The "clerk" is the operator in the next section. Keeping the noun (the data) mentally separate from the verb (the behavior) will save you hours of confusion. With that framing set, suppose we want a high-level LLMService that captures 'run this model with this many replicas' so app teams don't hand-write Deployments:
LLMService objects — but no workload is ever created until a controller watches the kind.
Read left-to-right: the CRD flows into the API server (solid arrow = things you now get), and the dashed arrow on the right shows the one thing you do not get.
- The left blue box is the CRD you apply — just a schema describing the new
LLMServicekind. - The middle panel is the API server after the CRD lands: the green rows are the real powers you gain —
kubectl get llmserviceworks, objects are validated and stored in etcd, and they obey RBAC like any built-in. - The red box on the dashed arrow is the payoff/aha: still zero Pods. The dashed edge marks the missing verb — a CRD is storage only; behavior arrives in the next section when a controller watches this kind.
In short: A CRD gives you a validated, queryable place to keep your objects (the noun); it never launches a workload until an operator (the verb) reconciles them.
crd.yamlapiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: { name: llmservices.ai.example.com }
spec:
group: ai.example.com
names: { kind: LLMService, plural: llmservices, singular: llmservice, shortNames: [llm] }
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
model: { type: string }
replicas: { type: integer, minimum: 1 }
required: [model, replicas]
llmservice.yamlapiVersion: ai.example.com/v1
kind: LLMService
metadata: { name: chat, namespace: web }
spec:
model: llama-3-8b
replicas: 3
# `kubectl get llmservice -n web` now lists this. Nothing happens yet —
# a controller must watch LLMService and create the real Deployment/Service.
LLMService objects and query them, but the cluster won't create a single Pod from one. The operator (next section) is the controller that watches LLMService objects and reconciles each into the Deployment, Service, HPA, etc. it implies. Split the concepts: CRD is the noun; the operator is the verb.3 · Reconcile logic, modeled offline in Python
This is the section that turns "operators" from a buzzword into something you could write on a napkin. People assume the reconcile function at the core of every operator — the thing Kubebuilder scaffolds, the thing the Prometheus Operator and cert-manager and every cloud database operator are built around — must be some intricate distributed-systems machine. It is not. Strip away the cluster plumbing and the whole idea fits in about a dozen lines of ordinary Python, and you can run it right now with no cluster, no YAML, no dependencies. Get this function into your fingers and you understand the beating heart of the entire ecosystem.
The intuition is a checklist you re-run rather than a script you run once. Imagine a diligent building superintendent whose job is "the lobby should have three working light bulbs." Every round they walk in, count the working bulbs, and compare to the goal: zero bulbs → install three; three bulbs → touch nothing and move on; someone swapped in the wrong wattage → replace the wrong ones. Crucially, they don't keep a diary of "I installed bulbs last Tuesday" and refuse to look again — each round they judge only by what they see right now. That "act on the present gap, not on memory of past events" discipline is called being level-triggered, and the "safe to run the exact same check a thousand times" property is called idempotence. Reconcile is that superintendent's round, expressed in code.
The classic beginner error is writing reconcile as if it fires once per event. The tempting shape is "when a new LLMService is created, create a Deployment" — event-driven, edge-triggered thinking. It looks fine and it is quietly broken: miss the event (your operator was restarting), get a duplicate event, or have someone delete the Deployment out from under you, and an edge-triggered handler either does nothing or does the wrong thing twice. The fix is to never ask "what just happened?" and always ask "what's the difference between what I want and what exists right now?" — then act only on that difference. That is exactly what the code below does, and it's why re-running it is always safe. Here is the heart of every operator, with the cluster replaced by a plain dict so it runs offline. The reconcile function takes the desired Custom Resource and the actual world, and makes actual match — creating what's missing, updating what drifted, and doing nothing when already correct (idempotence). Study this and you understand Kubebuilder's Reconcile():
Start at the left box and follow the arrows right: the single comparison fans into exactly three possible results.
- The left blue box is the one comparison every reconcile makes — the desired object (
want) against what actually exists (have). - The three coloured boxes are the only outcomes: green
createdwhen the object is missing, amberupdatedwhen it drifted, greynoopwhen it already matches. Colour signals how much work each does — green/amber act, grey does nothing. - The dashed curved arrow is the aha: if something deletes the object, the next comparison lands on "missing" again and re-creates it. Nobody re-ran anything — that's self-healing, and it works precisely because the loop acts on the present gap, not on remembered events.
In short: create / update / noop are the whole vocabulary of reconcile; because it only acts on the current gap, re-running is always safe and deletions self-heal.
reconcile.pydef desired_deployment(cr):
"""Translate a high-level LLMService CR into the low-level object it implies."""
return {"name": cr["name"], "replicas": cr["spec"]["replicas"],
"image": f"vllm:{cr['spec']['model']}"}
def reconcile(cr, cluster):
"""Make the cluster match one LLMService. Returns the action taken.
LEVEL-TRIGGERED: acts on the current gap. IDEMPOTENT: safe to re-run."""
want = desired_deployment(cr)
have = cluster.get(want["name"])
if have is None:
cluster[want["name"]] = dict(want) # create what's missing
return "created"
if have != want:
cluster[want["name"]].update(want) # update drifted fields
return "updated"
return "noop" # already correct -> do nothing
# --- drive it like the control loop would ---
cr = {"name": "chat", "spec": {"model": "llama-3-8b", "replicas": 3}}
world = {}
print(reconcile(cr, world)) # created
print(reconcile(cr, world)) # noop (idempotent: nothing changed)
cr["spec"]["replicas"] = 5
print(reconcile(cr, world)) # updated (drift closed)
del world["chat"] # something deleted our Deployment...
print(reconcile(cr, world)) # created (self-heals on next loop)
created
noop
updated
created
4 · Writing a controller: the conceptual walkthrough
You now hold the hardest 20% of an operator — the reconcile logic — so what's the other 80%? It's the wiring that keeps that little function fed with reality and lets it write reality back: waking it up when something changes, telling users what it decided, and cleaning up gracefully when an object is deleted. This section walks that wiring conceptually, and the good news is you will write almost none of it by hand — but you must understand it, because getting these lifecycle pieces wrong is how real operators leak resources and lie to their users.
A helpful analogy: your reconcile function is a skilled chef, but a restaurant is more than a chef. Someone has to seat customers and pass tickets to the kitchen (the watch), the kitchen has to send dishes out and call back "table 5 is ready" (status and events), and at closing time someone has to make sure the gas is off and the fridge locked before the doors are bolted (finalizers). The chef is the interesting, domain-specific part; the rest is the standard machinery every restaurant needs. An operator framework hands you a fully-staffed restaurant and asks only that you be the chef — you fill in Reconcile(), it runs the front-of-house.
The trap to avoid is treating the lifecycle pieces as optional garnish. It's tempting to write the reconcile body, see Pods appear, and declare victory — skipping status, skipping finalizers. That produces an operator that works in the demo and betrays you in production: with no status, users run kubectl describe and see nothing about why their service is stuck; with no finalizer, deleting the Custom Resource silently orphans the cloud bucket or DNS record it created, and you find out on the invoice. So read the table below not as trivia but as the checklist that separates a toy from something you'd let run unattended. A real operator wires that reconcile function to the API server. Conceptually the steps are the same whatever the SDK (Kubebuilder/Operator SDK in Go, or Kopf/client libraries in Python):
reconcile() — is the domain logic you actually fill in.
Follow the arrows left-to-right along the top row, then note the extras below — everything except the green box is provided for you.
- The left blue box (Watch) wakes the loop whenever your kind or its children change, feeding reality in.
- The centre green box (
reconcile()) is the only part you write — the idempotent create/update/noop logic from section 3. It is deliberately highlighted because it is the whole of your job. - The right grey box (Status + events) writes what the operator did back to the object so users see it via
kubectl describe. The amber strip and the side labels — finalizers, periodic resync, requeue-with-backoff — are the lifecycle safety nets the framework owns; skipping them is what turns a demo operator into one that leaks infra in production.
In short: Kubebuilder/Operator SDK/Kopf hand you a fully-staffed restaurant; you supply the chef (reconcile()) and let the framework run watch, status, and cleanup.
| Step | What you do | Why |
|---|---|---|
| 1. Define the CRD | Schema for your kind (spec + status) | Gives the API server the new noun |
| 2. Watch | Subscribe to add/update/delete of your kind (and its children) | So the loop wakes on relevant changes |
| 3. Reconcile | Diff desired vs actual; create/update/delete children idempotently | The control-loop body (section 3) |
| 4. Status + events | Write observed state back to the CR; emit events | Users see what the operator did |
| 5. Finalizers | Clean up external resources before the CR is deleted | No orphaned infra on delete |
operator_loop.py# Pseudocode for the operator loop. Real code uses Kubebuilder (Go) or Kopf (Python).
for event in watch(kind="LLMService"): # 2. wake on add/update/delete
cr = event.object
try:
action = reconcile(cr, live_cluster) # 3. the idempotent body
set_status(cr, ready=True, note=action) # 4. write status back
except TransientError:
requeue(cr, backoff=True) # retry later, don't crash
# The loop also periodically re-reconciles everything (resync) so it's level-triggered,
# not dependent on catching every event.
Reconcile() — the exact function you modeled offline. In Python, Kopf lets you decorate a handler. Focus your effort on correct, idempotent reconcile logic and good status reporting; let the framework own the plumbing. Verify the current scaffolding commands against the tool's docs.5 · How real platforms extend Kubernetes
Here's the payoff that recontextualizes the whole track: you've been an operator user since chapter 8. When you installed the Prometheus Operator for observability, or Argo Rollouts for progressive delivery, or cert-manager to mint TLS certificates, you weren't using "add-ons" — you were installing custom control loops that watch CRDs and reconcile them into real infrastructure, exactly the pattern you just built in a dozen lines of Python. The reason this lesson comes near the end of the Kubernetes track is that you can finally see what those tools were all along.
The mental model to lock in is operational knowledge as software. Think about what it takes to run production Postgres: a seasoned DBA carries a runbook — how to provision, when and how to back up, how to promote a replica when the primary dies, how to upgrade a version without losing data. Historically that knowledge lived in a human's head and a wiki page, and it executed only when someone was awake to run it. A database operator takes that runbook and compiles it into a control loop: the knowledge now runs continuously, at 3 a.m., without anyone paging the DBA. That is the deep idea — an operator is a runbook that reconciles itself, turning "steps a person performs" into "state a loop maintains."
Don't mistake this for a niche, exotic corner of Kubernetes. The common misconception is that operators are something only platform wizards touch; in reality the modern cluster is mostly operators — the pattern is how the ecosystem distributes expertise. Every entry in the table below is a team packaging their hard-won domain knowledge so you get it as a short Custom Resource instead of a manual procedure, and recognizing them as instances of one pattern is what lets you reason about, debug, and choose between them. You have already been using operators throughout this track — that's the point. The pattern is how the ecosystem ships operational knowledge as software:
Read it left-to-right as a before/after: the red panel is the old way, the arrow is the transformation, the green panel is what an operator gives you.
- The left red panel is operational knowledge trapped in a human — a DBA's runbook (provision, back up, fail over, upgrade) that only executes when someone is awake to run it.
- The middle arrow ("compile") is the key move: that runbook is turned into software — a controller with a reconcile loop.
- The right green panel is the payoff: a team submits a short Custom Resource and gets self-healing, backed-up Postgres out, with the loop reconciling continuously and unattended. The red-vs-green colour is the whole point — "awake-only" becomes "24/7".
In short: every operator in the table below is this picture — a team's hard-won runbook compiled into a control loop you consume as a one-line Custom Resource.
| Operator / CRDs | What it manages | Seen in |
|---|---|---|
| Prometheus Operator | Prometheus, ServiceMonitor, PrometheusRule | K8 (observability) |
| Argo Rollouts / Argo CD | Rollout, Application CRDs | K10 / K6 (GitOps) |
| cert-manager | Certificate CRDs → issued + rotated TLS certs | Ingress/mesh TLS |
| External Secrets / Sealed Secrets | ExternalSecret / SealedSecret → real Secrets | K9 (security) |
| Cloud DB / Kafka / Elastic operators | Stateful software with backups, failover, upgrades | Data platforms |
The recurring win: a database operator encodes a DBA's runbook — provision, take backups, fail over, upgrade — as a controller, so a team gets 'production Postgres' from a short Custom Resource instead of a wiki page of manual steps. That is the operator value proposition: operational knowledge as code, reconciled continuously.
6 · When to build an operator — and when not to
Now that you know how to build an operator, the most valuable skill is knowing when not to. There's a predictable moment on every platform team: someone learns the pattern, gets excited, and proposes "let's build an operator for that." It's the Kubernetes version of "when all you have is a hammer, everything looks like a nail" — and it produces expensive, permanently-running, over-permissioned components that solve problems a ten-line Helm chart already solved. This section is the antidote: a clear-eyed cost/benefit test so your enthusiasm for the pattern doesn't cost your team a maintenance burden they'll resent.
Frame it as a genuine cost, not a free capability. An operator isn't a script you run and forget — it's a long-lived service that sits in your cluster with broad RBAC permissions (it has to create and delete Deployments, Secrets, and more), which means it's another thing to version, patch for CVEs, monitor, and reason about when the cluster misbehaves. You are, in effect, hiring a full-time robot employee and taking on the duty to manage it forever. That's completely worth it when the robot does a job no simpler tool can — and pure overhead when it doesn't. So the question is never "could I build an operator for this?" (you almost always could) but "does this job actually need one?"
The reliable tell is whether the problem is a control-loop problem. An operator earns its keep only when there's genuine continuous reconciliation of state plus real operational logic — the backup/failover/rotate/self-heal kind of work that has to keep happening, unattended, in response to a drifting world. If instead you just need to template and install a bundle of manifests, that's Helm (K6). If you need to run a task on a schedule, that's a CronJob. If it's a one-time setup, it's a script. The honest test that cuts through the excitement: do you have domain operational logic that must run continuously and automatically, that plain Kubernetes objects can't express? Operators are powerful and are also a maintained piece of software running in your cluster with broad permissions. Building one is a real, ongoing commitment.
Start at the diamond at the top and follow the branch that matches your problem.
- The diamond is the single question that decides everything: does the job need continuous reconciliation of state plus real operational logic (backup, failover, rotate, self-heal)?
- The green "yes" branch leads to building or adopting an operator — the only case where its permanent cost is justified.
- The grey "no" branch is where most requests actually land: templating and installing manifests is Helm, a scheduled task is a CronJob, a one-time setup is a script. The bottom caption is the trap to avoid — you could build an operator for almost anything, so the real question is whether the problem needs one.
In short: route on "is this a control loop?" — yes → operator; no → the simplest of Helm / CronJob / script.
| Build/adopt an operator when… | …don't build one when… |
|---|---|
| You repeat a multi-step operational runbook often (backup, failover, rotate) | A Deployment/Job/CronJob already expresses it |
| You want a high-level abstraction for many teams (self-service) | One team, one app — a Helm chart is enough |
| State must be continuously reconciled/self-healed | A one-time setup script would do |
| A mature operator exists for your stateful software (adopt it) | You'd be reinventing a well-supported one |
✓ Checkpoint — you can move on when you can…
- Explain the reconcile control loop and why level-triggered + idempotent makes it robust.
- Define a CRD and a Custom Resource, and say why a CRD alone does nothing.
- Trace the offline reconcile: created → noop → updated → self-heal, and why each result happens.
- List the conceptual steps to write a controller (CRD, watch, reconcile, status, finalizers).
- Name three real operators you've used in this track and what they manage.
- Decide when to build/adopt an operator vs use Helm or a CronJob instead.
Someone deletes the Deployment that your operator created from an LLMService. Minutes later it's back, and nobody re-ran anything. Explain the mechanism, and what property of the reconcile code makes this safe.
Show answer
created → noop → updated → created behavior in the offline model.A team wants to 'build an operator' to install their app's 5 manifests with a couple of configurable values, and to run a nightly cleanup job. Is an operator the right tool? What would you suggest?
Show answer
values.yaml covers it with far less to maintain than a custom controller. A nightly cleanup is a CronJob. An operator is justified only when you need continuous reconciliation of custom state and real operational logic that plain objects can't express (backup/failover/rotation, or a self-service abstraction for many teams). Building an operator here would add a permanently-running, broadly-permissioned component to maintain for no benefit — pick the simpler tool.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every k8s controller — and every operator — is the same loop; naming it is the first step.
Your task: In your own words, describe the reconcile loop and why it's called level-triggered and declarative.
Requirements:
- State the four steps of the loop
- Define level-triggered vs edge-triggered
- Explain why that makes controllers robust
- Connect it to a built-in like the Deployment controller
💡 Hint: It acts on the current gap, not on a one-time event.
Show solution
The loop: (1) read desired state (spec), (2) observe actual state (the world), (3) diff them, (4) act to close the gap — then repeat forever.
Level- vs edge-triggered: edge-triggered acts on a one-off event ('a pod died'); level-triggered acts on the current gap ('desired 3, have 2'). Level-triggered is robust because even if the controller missed events (it was down), it still fixes the gap on the next pass.
Declarative: you state the goal; the controller continuously drives toward it. The Deployment controller is exactly this — you say '3 replicas', it keeps 3 alive no matter what kills them.
Context: Extending the API with your own kind is the foundation of every operator.
Your task: Define a CRD for a custom kind and write one instance, and explain what does (and doesn't) happen on apply.
Requirements:
- A CRD with a small validated spec
- A Custom Resource instance of that kind
- State what kubectl can now do
- State why no workload is created yet
💡 Hint: A CRD is validated storage; behavior needs a controller.
Show solution
kind: CustomResourceDefinition
metadata: { name: llmservices.ai.example.com }
spec:
group: ai.example.com
names: { kind: LLMService, plural: llmservices }
scope: Namespaced
versions: [ { name: v1, served: true, storage: true, schema: { openAPIV3Schema:
{ type: object, properties: { spec: { type: object, properties:
{ model: {type: string}, replicas: {type: integer} } } } } } } ]
---
kind: LLMService
apiVersion: ai.example.com/v1
metadata: { name: chat }
spec: { model: llama-3-8b, replicas: 3 }After apply, kubectl get llmservice works and the object is validated + stored in etcd. But no Pod is created — a CRD is only structured storage; behavior requires a controller that watches this kind. Needs a cluster; verify schema fields vs docs.
Context: Idempotent reconcile is the single most important property of operator code.
Your task: Write offline Python that reconciles a desired CR into an actual world and returns created/updated/noop, and demonstrate self-heal.
Requirements:
- Translate the CR into the object it implies
- Create if missing, update if drifted, noop if equal
- Show it's safe to run repeatedly
- Show self-heal after a delete
💡 Hint: Compare want vs have; only act on a real difference.
Show solution
def reconcile(cr, world):
want = {'name': cr['name'], 'replicas': cr['spec']['replicas']}
have = world.get(want['name'])
if have is None:
world[want['name']] = dict(want); return 'created'
if have != want:
world[want['name']].update(want); return 'updated'
return 'noop'
cr = {'name':'chat','spec':{'replicas':3}}; w = {}
reconcile(cr, w) # created
reconcile(cr, w) # noop
cr['spec']['replicas']=5; reconcile(cr, w) # updated
del w['chat']; reconcile(cr, w) # created (self-heal)Because it only acts on the gap, re-running is safe (noop when equal) and a deleted object is recreated on the next pass — that's idempotence + level-triggering. Runs offline; no cluster needed.
Context: A production operator is reconcile plus the lifecycle concerns around it.
Your task: Describe the pieces a real controller adds around the reconcile body and why each matters.
Requirements:
- Watch (and periodic resync)
- Status subresource + events
- Finalizers for cleanup on delete
- Owner references and requeue-with-backoff
💡 Hint: These are the difference between a toy loop and a safe operator.
Show solution
Watch + resync: subscribe to add/update/delete of your kind and its children, and periodically re-reconcile everything so the loop stays level-triggered even if events are missed.
Status + events: write observed state back to the CR's status and emit Kubernetes events so users can kubectl describe and see what the operator did/why it's waiting.
Finalizers: a finalizer blocks deletion until the operator cleans up external resources (e.g. a cloud bucket) — otherwise you orphan infra.
Owner references + backoff: set the CR as the owner of children so they're garbage-collected with it; on transient errors, requeue with backoff rather than crashing. Frameworks (Kubebuilder/Kopf) provide all this scaffolding — you fill in reconcile. Verify SDK specifics vs docs.
Context: A team wants production Postgres on Kubernetes and is debating a hand-rolled StatefulSet vs an operator.
Your task: Make a reasoned recommendation, covering what an operator encodes and the risks of rolling your own.
Requirements:
- List the operational tasks production Postgres needs
- Explain what a mature DB operator encodes
- State the risks of a hand-rolled StatefulSet + scripts
- Give a recommendation with caveats
💡 Hint: Backups, failover, and safe upgrades are the hard, ongoing parts.
Show solution
What production Postgres needs: provisioning, automated backups + restore, failover (promote a replica on primary loss), safe version upgrades, connection routing, and monitoring — continuously, not once.
What a mature operator encodes: exactly those runbooks as a control loop, so a short Postgres CR yields a self-healing, backed-up, failover-capable cluster — years of DBA knowledge as software.
Risks of rolling your own: a StatefulSet + scripts gives you storage and identity but not backup/failover/upgrade logic; you'd be reimplementing (and forever maintaining) the hard parts, and data systems are unforgiving of bugs.
Recommendation: adopt a mature, well-supported operator rather than build — after verifying its maturity, community, and version compatibility with your cluster. Build custom only if no suitable operator exists and the operational logic is genuinely yours.
Context: Representative scenario: your platform team is asked to give product teams 'one-line' LLM services and to run several stateful systems, and there's enthusiasm to 'build operators for everything'.
Your task: Produce a strategy for how to extend Kubernetes: where a custom operator is justified, where to adopt existing ones, and where Helm/CronJobs suffice — with the failure mode each choice avoids.
Requirements:
- A self-service LLMService abstraction — build or not?
- Stateful systems (DB/Kafka/search) — adopt existing operators
- Simple installs and scheduled tasks — Helm / CronJob
- The maintenance/permission cost of every operator you run
- Name the failure mode each choice avoids
💡 Hint: Operators are for continuous reconciliation + real ops logic; everything else is simpler.
Show solution
Self-service LLMService (build — carefully): if many teams need a high-level 'run this model' abstraction that reconciles Deployment+Service+HPA+autoscaling policy continuously, a custom operator is justified — it encodes your serving runbook and self-heals. Scaffold with Kubebuilder/Kopf; invest in idempotent reconcile, status, and finalizers. → avoids every team hand-writing (and drifting) low-level manifests.
Stateful systems (adopt, don't build): use mature operators for Postgres/Kafka/Elastic — they encode backup/failover/upgrade. → avoids reinventing hard, data-loss-prone operational logic.
Simple installs & tasks (Helm / CronJob): templated manifests → Helm (K6); scheduled cleanup/reporting → CronJob. → avoids paying operator complexity for non-control-loop problems.
The cost lens: every operator is a permanently-running, broadly-permissioned component you must maintain, upgrade, and secure (K9). So the strategy is: build few, adopt many, template the rest — reach for a custom control loop only when there's genuine continuous reconciliation and domain ops logic. Verify all CRD/SDK/operator specifics against current docs; this ecosystem drifts fast.