chaowen.← Back to archive

Kubernetes control loops / API design

Kubernetes controllers: who owns what from watch to reconcile

An informer is not a controller, a watch event is not a job, and an Operator is not a magical deployment format. Kubernetes works because each layer has a narrow contract: the API stores intent, list/watch distributes change, informers maintain a local view, queues schedule keys, and reconcilers make reality converge. Labels, annotations, spec, and status exist to keep those contracts from collapsing into one convenient but ungovernable map.

Kubernetes deep dive · Controllers, watches, and API contracts

2026-08-18

The whole machine in one picture

human / automation │ writes desired state ▼ kube-apiserver ── validate, authorize, admit, persist ──▶ etcd │ │ LIST snapshot + resourceVersion, then WATCH changes ▼ Reflector ──▶ DeltaFIFO ──▶ local Store / Indexer │ │ add / update / delete notification ▼ event handler / predicate │ enqueue namespace/name ▼ rate-limiting workqueue │ ▼ reconcile(key) / \ create/patch child objects call external API \ / update status │ └── creates another watch event

The arrows form a loop, not a pipeline that handles each event exactly once. A controller can crash after performing a side effect but before updating status. A watch can disconnect. Several updates can collapse into one queued key. A resync can enqueue an object that did not change. Correctness therefore cannot depend on receiving a perfect event history.

Central rule: events are hints that some key may need attention. Current API state is the input to reconciliation. Make the reconcile function level-driven and idempotent: running it twice should converge toward the same result.

Responsibility map

ComponentIt should ownIt should not own
API type / CRD schemaStable vocabulary, validation, defaultable fields, version conversion, and the boundary between desired and observed state.Runtime orchestration or hidden business rules that exist only in controller code.
API serverAuthentication, authorization, admission, schema validation, optimistic concurrency, persistence, and list/watch delivery.Application-specific convergence. It stores and serves objects; it does not operate PostgreSQL for an Operator.
Admission webhookRequest-time validation or deterministic mutation that must happen before an object is accepted.Long-running work, external provisioning, retries, or eventual convergence. Admission blocks the write path.
Reflector / list-watch clientBuild a snapshot, continue from resourceVersion, reconnect, and relist when history is gone.Business decisions and side effects.
Informer cache / indexerShare a local, read-optimized, eventually consistent view; offer fast lookups and secondary indexes.Authoritative reads immediately after writes, durable storage, or mutation of cached objects.
Event handler / predicateDecide whether a notification could matter and enqueue a stable key.Slow API calls or reconciliation. Blocking here stalls event distribution.
WorkqueueDeduplicate keys, absorb bursts, provide backoff, and bound worker concurrency.Store object truth or preserve every historical event.
Reconciler / controllerRead current intent and observed state, compute a diff, perform bounded idempotent actions, and report status.Assume the triggering event is complete, unique, or still current.
OperatorPackage domain-specific operational knowledge—upgrade order, backup, failover, repair—using one or more controllers and APIs.Replace Kubernetes primitives merely to earn the Operator label.

Subscription means LIST, then WATCH

A fresh controller cannot safely start with “tell me what changes next.” It first needs the objects that already exist. Kubernetes returns a collection plus a collection resourceVersion; the client then watches from that version. The resulting stream contains ADDED, MODIFIED, DELETED, and sometimes BOOKMARK or ERROR events.

LIST /apis/example.io/v1/widgets └─ items=[A,B,C], metadata.resourceVersion=1200 WATCH /apis/example.io/v1/widgets?watch=1&resourceVersion=1200 ├─ MODIFIED B @1204 ├─ DELETED C @1210 └─ ADDED D @1211

resourceVersion is an opaque concurrency and continuation token, not a timestamp or portable sequence number. Watch history is finite. If the requested version has been compacted, the server can return 410 Gone; the client must rebuild its snapshot with another list and resume. A standard Reflector exists so every controller author does not reimplement this state machine badly.

Watch is also not Kafka. It does not promise durable replay forever, consumer offsets, or exactly-once work. Its purpose is efficient cache synchronization. The durable thing is the current object in the API, so recovery returns to current state rather than reconstructing business truth from old notifications.

Where filtering belongs

“Filter the subscription” hides three different decisions. They have different cost and correctness properties.

LayerMechanismWhat it savesTradeoff
API serverlabelSelector or supported fieldSelector on LIST and WATCHNetwork traffic, decode cost, and client-cache memoryThe controller is blind to objects outside the selector. Changing selection logic may require another informer and fresh list.
Informer factoryNamespace scope plus TweakListOptions / cache selectorsSame server-side savings while sharing one filtered cacheEvery consumer of that shared informer inherits the filter.
Event handlerPredicates or FilteringResourceEventHandlerQueue and reconcile workThe full object still crossed the network and occupies cache. An update can move an object into or out of the predicate, so old and new objects both matter.
ReconcilerRecheck ownership, generation, deletion, and domain conditionsIncorrect side effectsSaves little transport cost, but this check is still required because queued hints become stale.

Use server-side labels when they define a stable population: for example, one controller shard managing control-plane.example.com/shard=west. Use client-side predicates when the condition is richer, changes often, or depends on old-versus-new state. Field selectors are intentionally limited to fields each resource exposes; they are not arbitrary JSONPath queries. Annotations have no native selector API.

Filtering rule: filter early for cost, filter again in reconcile for correctness. Early filtering narrows what can wake the controller; it does not prove that an object is still eligible when the worker runs.

Informer anatomy: transport, cache, notification

“Informer” is commonly used for the whole client-side mechanism, but its pieces deserve separate names:

The controller workqueue is deliberately separate from DeltaFIFO. Informer internals care about synchronizing object snapshots. The workqueue cares about retrying reconciliation. Queue namespace/name, not a pointer to the event object: by the time a worker runs, that object may be obsolete, while the cache contains a newer version.

Wait for cache sync before starting workers. Otherwise an empty cache is indistinguishable from “the cluster contains no matching object,” which can trigger destructive cleanup. Never mutate an object returned by a lister; informer objects are shared. Deep-copy before modification, then write through a typed or dynamic client.

A periodic informer resync does not necessarily poll the API server or invent a missing watch history. It re-delivers current cached objects to handlers so level-driven logic gets another chance. Treat it as a safety nudge, not the primary retry mechanism. Reconcile errors belong in the rate-limiting workqueue with explicit backoff.

Controller versus Operator

A controller is a control loop. An Operator is a product and design pattern: an application-specific controller, usually paired with custom resources, that encodes knowledge a skilled human operator would otherwise apply. The term was introduced by CoreOS in 2016 while explaining how to automate stateful systems such as etcd and Prometheus—upgrades, membership changes, backup, and recovery—not merely create a Deployment.

ThingEnough to be a controller?Enough to be an Operator?
Watches objects and patches labelsYesUsually no; little application operations knowledge is encoded.
CRD plus generated CRUD APINo control loop yetNo. A custom noun is not an operator.
Helm chart that installs resourcesNoNo. Packaging and continuous operation are complementary.
Database controller handling safe upgrades, quorum, backup, restore, and failoverYesYes—the domain runbook became software.

Good Operators reuse built-in primitives. A database Operator may own a StatefulSet, Services, Secrets, Jobs, and PVCs; it should not rebuild scheduling or storage attachment. Its special value is the ordering and policy between those resources and the external application state.

One object, four different kinds of information

apiVersion: database.example.com/v1
kind: Database
metadata:
  name: orders
  labels:
    app.kubernetes.io/name: orders-db
    platform.example.com/tier: critical
  annotations:
    platform.example.com/runbook: https://runbooks.example.com/orders
    platform.example.com/change-ticket: CHG-2048
spec:
  engine: postgres
  version: "18"
  replicas: 3
  backup:
    schedule: "0 3 * * *"
status:
  observedGeneration: 7
  readyReplicas: 3
  currentVersion: "18"
  conditions:
  - type: Ready
    status: "True"
    reason: ClusterHealthy
LocationMeaningTypical writerQuery / validation
metadata.name, namespace, UIDObject identity and lifecycle identityClient chooses name; API server assigns UIDDirect address, strongly defined
metadata.labelsSmall identifying dimensions and set membershipUser, controller, or policy component with a namespaced keyNative equality and set selectors
metadata.annotationsNon-identifying metadata, links, tool hints, checksums, provenanceAny owning integrationNo native selection; string values, loose schema
specDurable desired configuration—the API contract users ask controllers to realizeUser or higher-level declarative controllerOpenAPI schema, admission, defaulting, versioning
statusObserved state and progress toward the current specReconciler via the status subresourceTyped schema; separately authorizable

metadata.generation usually advances when desired state changes. A controller copies the generation it has processed into status.observedGeneration. Without that handshake, Ready=True might describe the previous spec. Conditions should answer operational questions with type, status, reason, message, and transition time; they should not become an append-only event log.

Why not put everything in labels?

Because convenience at write time becomes coupling everywhere else. Labels are Kubernetes’ core grouping primitive. Services, workload controllers, affinity rules, policies, CLIs, UIs, and LIST/WATCH clients use selectors to turn labels into populations. Changing a label can reroute traffic, transfer controller attention, change scheduling, or alter policy membership. That is a control surface, not a junk drawer.

The split is historical and architectural. Kubernetes needed cross-cutting sets that did not force objects into one rigid hierarchy: “production,” “frontend,” “canary,” and “customer A” can overlap. Labels provided that loose, selector-friendly identity. It also needed metadata that tools could attach without making it part of grouping semantics or forcing an external database. Annotations became that non-identifying escape hatch. Typed API fields and the spec/status convention then provide evolvable contracts where metadata maps are too weak.

If everything were a label…Consequence
JSON config, URLs, certificates, timestamps, or long hashesLabel values are constrained to 63 characters and a restricted character set. Encoding works until every tool must reverse a private encoding.
High-churn progress such as replica countsEvery status update also mutates the selection surface and metadata watched by unrelated consumers.
Primary configuration such as backup policyNo nested types, numeric or boolean types, structural validation, field documentation, defaulting, or clean API-version conversion.
Large or high-cardinality valuesMore object bytes, cache churn, watch traffic, and accidental query dimensions. “Queryable” is an operational budget, not a free feature.
Controller-private scratch stateOther components see a public-looking selector contract and may start depending on it, freezing an implementation detail.
Practical test: if operators should run kubectl get ... -l key=value, it is probably a label. If a tool should read it after finding the object, it may be an annotation. If changing it asks the system to behave differently and deserves schema, docs, defaults, and compatibility, it belongs in spec. If the system measured it, it belongs in status.

Annotations are not harmless merely because they are unselectable. Controllers often use them as opt-in directives, and Kubernetes itself has well-known annotation contracts. Use a DNS-qualified key, document ownership, and avoid multiple writers. Promote a widely used, essential annotation into a typed spec field when you control the API. Annotations are useful for extension and compatibility; they are poor permanent homes for a product’s central configuration.

Ownership, children, and deletion

A controller should make ownership explicit. ownerReferences express lifecycle and garbage-collection relationships; labels help find and group children but do not establish ownership. Before adopting or deleting a child, compare owner UID, not only name or labels: names can be reused after deletion.

Finalizers are another contract boundary. Setting a finalizer says, “do not complete deletion until this controller performs required cleanup.” Once deletionTimestamp appears, reconciliation should enter a dedicated finalize path, make cleanup idempotent, and remove only its own finalizer. A finalizer is not a guarantee that an unreachable external service can be cleaned forever; define timeouts, abandonment policy, and observability so an object does not remain terminating invisibly.

For writes, prefer narrow patches and clear field ownership. The spec writer should not overwrite status. The status writer should not rewrite spec. Two controllers should not race over the same condition or annotation key. Server-Side Apply can track field managers, but it cannot repair a confused ownership model.

A reconcile shape that survives reality

func Reconcile(key NamespacedName) error {
    obj := cache.Get(key)
    if obj == nil {
        // Object is gone. Usually nothing to do unless external cleanup
        // is represented elsewhere; finalization should happen before deletion.
        return nil
    }

    if obj.DeletionTimestamp != nil {
        return finalizeIdempotently(obj)
    }

    desired := renderChildren(obj.Spec)
    actual := readCachedChildren(obj.UID)

    // Patch only owned fields; repeated calls converge.
    apply(desired, actual)

    observed := inspectApplicationState(obj)
    patchStatusIfChanged(obj, observed, obj.Generation)
    return nil
}

Real code needs timeouts, context cancellation, conflict retries, per-operation error classification, and rate limits. Still, this skeleton has the important shape: fetch current state by key, branch on deletion, compute desired state from spec, observe owned reality, make bounded changes, and publish status only when it changed.

Avoid doing an entire upgrade in one reconciliation. Encode phase in real observed resources or typed status, perform one safe step, and requeue. Long synchronous workflows hold workers, complicate leader changes, and make crash recovery dependent on call stacks instead of durable state.

Common boundary failures

The historical logic, compressed

  1. Resources and controllers: Kubernetes modeled intent as API objects and used many small control loops rather than one orchestration transaction. Failure recovery becomes “re-read and converge.”
  2. Labels: flat, overlapping, user-defined dimensions let clients select sets without forcing infrastructure-owned hierarchy. They became the API’s grouping and routing primitive.
  3. Annotations: tools still needed arbitrary non-identifying metadata near the object. Keeping it out of selectors preserved freedom in size and characters and reduced accidental semantic coupling.
  4. Spec and status: separating desired input from observed output lets users and controllers write different subresources, supports generation tracking, and prevents observation from masquerading as intent.
  5. LIST/WATCH and informers: many controllers polling full collections would crush the API server. Shared client caches turn one synchronized stream into cheap local reads for many handlers.
  6. Operators: once core primitives handled generic scheduling and lifecycle, CoreOS’ 2016 pattern encoded the missing application runbook for stateful systems in custom resources plus controllers.

The boundaries are not ceremony. Each arose because a single mechanism stopped scaling semantically or operationally. “Put it all in labels” feels elegant only while one author, one controller, and ten objects exist.

Design checklist

Primary sources