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)--nameis indesiredbut notobserved("UPDATE", name, spec)--nameis in both, but the specs differ (drift)("FINALIZE", name, None)--nameis inobservedbut notdesired, AND still has pending finalizers("DELETE", name, None)--nameis inobservedbut notdesired, 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.
finalizersonly ever affects resources that are candidates for deletion (inobserved, not indesired) -- 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 adeletionTimestampinstead 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.