Section 01
What a controller is actually for
You tell Kubernetes what you want: three copies of this application, a certificate for that domain, a database of this size. That is the desired state. Separately, there is whatever is actually running right now — the actual state. The two drift apart constantly, because machines fail, people edit things, and networks break.
A controller is a program with one job: notice the gap, and close it. Then keep doing that, forever. Everything else in this page exists to make that job cheap, safe and reliable.
Section 02
The one idea: react to the state, not to the news
Here is the decision everything else follows from. When something changes, Kubernetes sends your controller a notification. The tempting design is to act on that notification — "replicas went from 3 to 5, so start 2 more". Kubernetes does the opposite. The notification is treated as nothing more than a nudge meaning "go and look again". Your controller then reads the current state from scratch and works out what to do from that.
The two styles have names borrowed from electronics. Acting on the change itself is edge-triggered. Acting on the current value is level-triggered. Kubernetes is level-triggered, and this is written down as an architectural rule rather than left to taste.
Because missed and duplicate messages are harmless, the machinery in the rest of this page is allowed to take shortcuts that would otherwise be dangerous: it can throw away the contents of a notification, merge a hundred notifications into one, handle them out of order, and restart from nothing after a crash. Each of those is a real optimisation that only works because of this one decision.
Section 03
The whole machine, at a glance
Four parts, in a line. Read the picture left to right, then come back for the detail sections.
Section 04
Informers — the part that watches
An informer is the component that keeps your controller in touch with the cluster. Asking the cluster a question every time you need to know something would be far too expensive, so the informer does something smarter: it asks for everything once, then subscribes to a live feed of changes, and maintains its own copy locally.
Inside, it is four smaller pieces in a line.
Informers are shared. If five controllers in the same process all watch Pods, they share one informer, one subscription and one copy in memory — not five. This is why adding another controller to an existing operator is usually much cheaper than it sounds.
Section 05
Listers — reading without asking the cluster
A Lister is simply the read interface to that local copy. When your code asks for an object or a list of objects, the answer comes out of memory. No network request is made, nobody waits, and the cluster never learns the read happened.
If you use controller-runtime you may never type the word Lister — the client the framework hands you does this for you — but it is the same mechanism underneath.
The local copy is always slightly behind the cluster — changes have to travel back over the subscription before it knows. So immediately after your controller writes something, reading it back may still show the old value. This is normal and not an error. Because the controller is level-triggered, it simply gets nudged again shortly afterwards and sees the new value then.
Section 06
The workqueue — a to-do list of names
When the informer announces a change, your event handler does something that surprises most people the first time they see it: it writes down only the object's name and namespace, puts that on a queue, and throws the object itself away.
That sounds wasteful. It is the single most important design decision in the whole machine, because a queue of names behaves in ways a queue of objects cannot.
Alongside merging duplicates, the queue makes sure no two workers ever handle the same name at the same time, and it can hold a failed item back for a while before offering it again, waiting longer after each successive failure. All three behaviours are covered in chapters 04 and 05.
Section 07
Workers — the part that does the work
A worker is a loop. It takes one name off the queue, looks up that object's current state in the local copy, works out what needs to change, and does it. Then it reports how that went and takes the next name. Several workers run side by side, so different objects are handled in parallel — but any single object is only ever handled by one worker at a time.
The function you write as an operator author — Reconcile — is the middle step of this loop. Everything around it is provided.
Section 08
What each part actually buys you
The clearest way to understand a part is to ask what would go wrong without it.
| Part | In one sentence | Without it |
|---|---|---|
| Informer | Watches the cluster and keeps a live local copy of what you care about. | You would poll the cluster on a timer — slow to notice changes, and expensive for everyone. |
| Lister | Reads that local copy, so lookups cost nothing and never leave the process. | Every read becomes a network request; a few hundred controllers would overwhelm the cluster. |
| Workqueue | A to-do list of names that merges duplicates and hands each name to one worker at a time. | Repeated changes to one object would pile up as separate work, and two workers could fight over it. |
| Workers | The loop that takes a name, reads current state, and closes the gap — then retries sensibly on failure. | You would hand-roll concurrency, retries and backoff in every controller you write. |
Every one of these is only safe because the controller is level-triggered. Keeping a slightly-stale local copy, merging five notifications into one, handling objects in an unpredictable order, and starting again from nothing after a restart would all be unacceptable risks in a system that acted on individual changes. In a system that always re-reads the current state, they are free.
Section 09
Glossary
| Term | What it means here |
|---|---|
| desired state | What you asked for, stored in the cluster. |
| actual state | What is really running right now. |
| reconcile | One pass of comparing the two and closing the gap. |
| level-triggered | Acting on the current value, not on the change. What Kubernetes does. |
| edge-triggered | Acting on the change itself. What Kubernetes deliberately avoids. |
| informer | The component that watches the cluster and maintains a local copy. |
| reflector | The part of an informer that actually holds the connection to the cluster. |
| indexer / cache | The in-memory copy of the objects you watch. |
| lister | The read-only way to query that copy. |
| event handler | Your callback, run when something changes. Usually it just records a name. |
| workqueue | The to-do list of names waiting to be reconciled. |
| worker | A loop that pulls names off the queue and reconciles them. |
| resync | A periodic re-announcement of everything already in the local copy. Not a refresh from the cluster — see chapter 03. |
| backoff | Waiting longer before each successive retry of something that keeps failing. |
Section 10
Where to go next
This set
Each chapter takes one part apart and measures it, so the claims on this page can be checked rather than believed.
Good places to start outside this set
All links checked on 2026-09-11 unless noted.
Closing
What this page simplifies
An orientation page earns its clarity by leaving things out. These are the ones worth knowing about before you rely on the picture above:
- The informer’s internal queue is more subtle than “a list of changes”, and its behaviour changed meaningfully in recent Kubernetes versions — including one case where a deletion could be announced to nobody. Chapter 02.
- “The cache is slightly behind” hides a real failure mode. After a restart it is empty rather than stale, and a controller that acts on an empty cache can conclude things were deleted. Chapter 03.
- Merging duplicates is not always active. When a controller is keeping up, every notification gets its own reconcile and nothing merges at all. Chapter 01.
- “Retry after a delay” has a ceiling, and reaching it means a broken object is retried only every 16 minutes 40 seconds. Chapter 05.
- This page describes one controller. Real operators run several, sharing informers and caches, sometimes watching each other’s objects. The shape stays the same; the bookkeeping does not.
Check yourself
If you can answer these without looking, this page has done its job.
Why does the event handler throw away the changed object and keep only its name?
A good answer mentions
- Names are identical for repeated changes, so the queue can merge them into one piece of work
- The object can always be looked up again from the local copy, cheaply
- It is safe because the controller re-reads current state anyway
check against §03 and §06
Your controller misses a notification entirely. How much trouble are you in?
A good answer mentions
- Very little — it reacts to current state, not to individual notifications
- The next nudge for that object causes a full re-read, which repairs everything
- An edge-triggered design would be permanently wrong instead
check against §02
You create something, immediately read it back, and it is not there. What happened?
A good answer mentions
- Writes go to the cluster; reads come from the local copy
- The change has to travel back over the subscription before the copy knows
- Nothing failed — and the controller will be nudged again shortly
check against §05