1. Control Loops

Reconciliation Control Loop

middistributed-systemsconsistency-models

Kubernetes doesn't have an "apply this change" API underneath the hood -- every controller (Deployments, ReplicaSets, and dozens more) runs the same loop forever: look at what you WANT (desired state), look at what actually EXISTS (observed state), and compute the actions needed to close the gap. This is called reconciliation, and it's what makes Kubernetes self-healing -- if something external changes or deletes a resource, the next reconcile loop just fixes it again.

Implement reconcile(desired, observed, finalizers=None). desired/observed are {name: spec} dicts. finalizers is a separate {name: [finalizer, ...]} dict. Return a list of actions:

  • ("CREATE", name, spec) -- name is in desired but not observed
  • ("UPDATE", name, spec) -- name is in both, but the specs differ (drift)
  • ("FINALIZE", name, None) -- name is in observed but not desired, AND still has pending finalizers
  • ("DELETE", name, None) -- name is in observed but not desired, with no pending finalizers (or none tracked for it)

Return the CREATE/UPDATE actions (for names in desired) sorted by name, THEN the FINALIZE/DELETE actions (for names only in observed) sorted by name.

The missing link: deletion isn't always safe to do immediately

The naive version of this loop (desired vs. observed, CREATE/UPDATE/DELETE, done) is what most people build first -- and it's wrong the moment a resource has cleanup work that MUST happen before it can actually go away. A LoadBalancer Service, for example, has to release its real cloud load balancer first; a PersistentVolumeClaim might need its underlying volume detached. Kubernetes models this with finalizers: a list of names attached to a resource, each representing "something that must run before deletion." A resource with pending finalizers that's no longer desired doesn't disappear -- it enters a "terminating but not gone" state (FINALIZE here) until whatever controller owns that finalizer does its cleanup and removes its entry from the list. Only once the list is empty does DELETE actually happen.

Example

desired  = {"a": {"replicas": 1}, "b": {"replicas": 3}}
observed = {"b": {"replicas": 1}, "c": {"replicas": 1}}
reconcile(desired, observed)
# -> [("CREATE", "a", {"replicas": 1}),
#     ("UPDATE", "b", {"replicas": 3}),
#     ("DELETE", "c", None)]

reconcile({}, {"svc": {"type": "LoadBalancer"}}, {"svc": ["cloud/lb-cleanup"]})
# -> [("FINALIZE", "svc", None)]   -- not deleted yet, cleanup still pending

Constraints

  • A resource present in both with an identical spec produces no action.
  • finalizers only ever affects resources that are candidates for deletion (in observed, not in desired) -- a still-desired resource with finalizers tracked produces no FINALIZE/DELETE action at all.
  • Output order matters for the tests: CREATE/UPDATE (sorted by name), then FINALIZE/DELETE (sorted by name).

Real-world grounding

  • Kubernetes' controller pattern (this exact compare-and-converge loop, applied across dozens of controllers): pkg/controller/
  • Finalizer handling in object metadata (ObjectMeta.Finalizers, and the apiserver logic that turns a delete request into a deletionTimestamp instead of an immediate removal when finalizers are present): staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go

Real controllers add a lot more on top of this: they react to change events instead of polling in a tight loop, use a work queue with retries/backoff so one bad resource doesn't block the rest (see this course's Work Queue exercise), and often need multiple reconcile passes to fully converge -- but "diff desired vs. observed, compute actions, respect finalizers" is the operation at the center of all of it.

hints
reconcile.pysign in to run
Loading...
test result

This is a preview. Sign in to run this exercise against the real tests.

Reconciliation Control Loop — nossletter.tech