all_lessons/kubernetes/03lesson 4 / 14

Part II — The control plane and the loop

The Control Plane: One Source of Truth

Lesson 02 gave you the Pod (the smallest schedulable unit) and the Node, where a kubelet runs the reconcile loop for the Pods assigned to it and reports the actual state of those Pods back up. But back up to what? Two facts are now floating without a home: the desired state a user declares ("run 3 replicas of this Pod") and the actual state each kubelet observes ("Pod-7 is dead on node-2"). If each actor keeps its own copy, they drift, disagree, and fight. This lesson derives the single durable, consistent place all of that state lives — and the one gatekeeper that is allowed to write to it.

The previous step left this broken
A kubelet only knows about the Pods on its own node, and it learns which Pods it owns from… somewhere. The desired count, the Pod-to-Node assignment, and every kubelet's reported actual state must all converge on one record that everyone trusts. If the assignment lives in the scheduler's memory and the actual state lives in each kubelet's memory and the desired count lives in a user's terminal, a node reboot or a split-second race makes two actors believe two different truths — and the reconcile loop converges toward whichever lie it happened to read.
Linear position
Forced by: desired state and reported actual state need a single, durable, consistent home; separate copies disagree and fight.
New capability: a consistent key-value store (etcd) holds all cluster state, fronted by one writer — the API server — which authenticates, authorizes, admits, validates, and serves a REST + watch API. Everyone talks only to the API server; nobody touches etcd directly.
Forces next: the API server only stores state — it records a dead Pod as faithfully as a live one. Something must watch the gap between desired and actual and act on it → lesson 04.
The plan
Five moves. (1) Show why split state self-destructs, and name the one fix: a single source of truth. (2) Pick the store — why a consistent, Raft-replicated key-value store (etcd) with watch, not a cache or a SQL box. (3) Put one writer in front of it — the API server — and walk what it does to every request (authn → authz → admission → validation → persist). (4) The resource model: every object is spec (desired) + status (actual), edited safely under optimistic concurrency via resourceVersion; drive the conflict-and-retry widget. (5) The hub-and-spoke, watch-driven architecture, and why it is level-triggered all the way down.

1 · Why split state self-destructs

Picture three actors, each with its own notebook. A user wants 3 replicas, so she writes "3" in her notebook. The scheduler decides Pod-A→node-1, Pod-B→node-2, Pod-C→node-1, and records that in its notebook. Each kubelet writes down which Pods it is actually running. Now node-2 reboots. Its kubelet's notebook is wiped; it comes back believing it runs nothing. The scheduler still thinks Pod-B lives there. The user still thinks 3 are up. Three notebooks, three truths, and no procedure to say which one is authoritative.

The failure is not a bug in any one actor — it is structural. Whenever the same fact is stored in N places, you have N copies that can diverge, and reconciling N copies is its own distributed-systems problem. The only escape is to collapse N to 1: one record that is, by definition, the truth. Desired state and actual state both live there, every actor reads and writes through it, and no actor caches a private authoritative copy. Building that single source of truth correctly is what the entire control plane is for.

Desired state (spec)
What the user declared: "3 replicas of image X." Set by humans/controllers, almost never by the system itself.
Actual state (status)
What is really true right now: "2 Pods Running, 1 Failed on a rebooted node." Written by the components that observe reality (kubelet, controllers).
The gap
desired − actual. The whole job of Kubernetes is to keep driving this gap to zero. You cannot drive a gap you cannot measure against one trusted record.

2 · Choosing the store: consistent, replicated, watchable

"One record" cannot mean one file on one machine — that machine dies and the cluster's brain dies with it. The store must survive node loss, so it is replicated across several machines (typically 3 or 5). But replication reopens the very problem we set out to kill: replicas can disagree. So we need not just replication but strong consistency — every reader sees the same committed value, and there is never a moment where two replicas would answer the same question differently.

That is exactly what etcd provides. etcd is a distributed key-value store: keys look like filesystem paths (/registry/pods/default/web-0) and values are the serialized object. It achieves consistency with the Raft consensus algorithm — the replicas elect a leader, every write is proposed to the leader, and the write is only acknowledged once a majority (a quorum) has durably logged it. With 3 replicas the quorum is 2, so the cluster tolerates 1 failure; with 5 replicas the quorum is 3 and it tolerates 2. (This is why etcd clusters are sized in odd numbers — an even count buys no extra fault tolerance but doubles the chance of a split vote.)

Two more properties earn etcd the job. First, every change bumps a single, cluster-wide, monotonically increasing revision number — a global logical clock you can compare and order. Second, etcd supports watch: a client can ask "tell me about every change to keys under this prefix, starting from revision R," and etcd streams events as they commit. Watch is the feature that lets the rest of the system stop polling — we lean on it hard in §5.

OptionWhy it fails as the cluster's truth
In-memory cache (e.g. Redis, default)Not durable by default and not consistent under partition; a restart or split-brain loses or forks the truth — the exact failure of §1.
A single SQL databaseStrongly consistent, but a single instance is a single point of failure; HA SQL is heavier than needed and has no native, low-overhead "watch a prefix" stream.
Each actor's local memoryN copies, N truths — the §1 disaster. No actor is authoritative.
etcd (Raft, quorum, watch)Replicated and strongly consistent, durable, ordered by a global revision, and streams changes via watch. Purpose-built for exactly this.

3 · One writer in front: the API server

etcd is now the single store — but should every component connect to it directly? No, and the reason is the same instinct that built etcd in the first place. etcd is dumb on purpose: it stores bytes and orders them. It does not know that a Pod's name must be a valid DNS label, that this user is allowed to delete that Secret, or that a field is read-only. If 30 different programs all wrote to etcd directly, those rules would be re-implemented (and broken) 30 times, the etcd credentials would be everywhere, and a single buggy client could write a malformed object that crashes every reader.

So we put exactly one program in front of etcd: the kube-apiserver. It is the only component permitted to talk to etcd. Everything else — kubelets, controllers, the scheduler, and your kubectl — talks only to the API server, over a REST API (and a watch API). Every write runs the same gauntlet before a single byte reaches etcd:

1Authentication → who is this? Verify the caller's identity (client cert, bearer token, or a ServiceAccount token). An unauthenticated request stops here.
2Authorization → are they allowed to do this verb on this resource? RBAC decides (covered in lesson 12). Denied requests stop here.
3Admission → mutate and/or validate against cluster policy: inject defaults, attach a ServiceAccount, enforce "no privileged Pods." Webhooks plug in here.
4Validation → is the object schema-valid and internally consistent? Reject malformed fields, bad names, conflicting settings.
5Persist → only now is the object serialized and written to etcd, under the optimistic-concurrency check of §4. etcd assigns it a new revision.

This is the hub-and-spoke design: the API server is the hub, etcd is the vault directly behind it, and every other component is a spoke. The payoff is enormous — the validation, security, and defaulting rules live in exactly one place; etcd's credentials never leave the hub; and the rest of the cluster is decoupled from the storage format entirely (the API server can re-encode or even migrate the backing store without any spoke noticing). The API server is otherwise stateless: it holds no authoritative data of its own, so you can run several replicas behind a load balancer for availability, all fronting the same etcd.

Name the control-plane components
You have now met the core of the control plane: etcd (the store) and the kube-apiserver (the only writer and the cluster's front door). Two more live here but are deliberately forward refs: the kube-scheduler, which assigns unbound Pods to nodes (lesson 10), and the kube-controller-manager, which runs the controllers that close the desired/actual gap (lesson 04). All four run on control-plane nodes; the kubelets from lesson 02 run on every worker node and connect inward to the API server.

4 · The resource model: spec, status, and resourceVersion

Now the data model that makes the gap measurable. Every object the API server stores has the same shape: a spec (the desired state, written by the user) and a status (the actual state, written by the controller/kubelet that owns reality for that object). A Deployment's spec says "3 replicas"; its status says "2 available." A Node's spec is nearly empty; its status carries the kubelet's report of allocatable CPU and memory. The single most important convention in Kubernetes is this spec/status split — it is desired-vs-actual from §1, baked into every object, so the gap is always a field you can read.

That raises a concurrency problem. Many actors edit the same object: a user scales a Deployment while its controller updates the same object's status, all within the same instant. With naive last-write-wins, the controller's GET-then-PUT could silently overwrite the user's change with stale data. Kubernetes solves this with optimistic concurrency, not locks. Every object carries a resourceVersion — an opaque token (backed by etcd's revision) that changes on every write. The contract is:

read → GET the object; you receive its current resourceVersion, say rv=42.
modify → change the fields you care about, keeping rv=42 attached.
write → PUT it back. The API server persists only if the stored object is still at rv=42. If someone else already wrote (so the store is now rv=43), your write is rejected with HTTP 409 Conflict.
retry → on conflict you re-GET (now rv=43), re-apply your change onto the fresh object, and PUT again. No one was blocked; the loser simply tries once more.

This is optimistic because it assumes conflicts are rare and pays nothing in the common case — no lock to acquire, no lock to release, no deadlock, no actor stuck waiting on a crashed lock-holder. It only costs a re-read on the rare collision. The widget below makes the race concrete: two writers both read rv=42, both try to write, exactly one wins, and the loser must re-read and retry against the new version.

Optimistic concurrency — two writers, one resourceVersion
Both writers GET the object at the same resourceVersion, then race to PUT. The first PUT to land wins and bumps the version; the second arrives stale and gets a 409 Conflict. Press Both GET (they read the same rv), then choose who PUTs first. Use Retry loser to watch the conflict resolve by re-reading. This is the whole mechanism — no locks anywhere.
store: replicas = 3  |  resourceVersion = 42
Writer A — user: "scale to 5"
read: —
status: idle
Writer B — controller: "status update"
read: —
status: idle
Stored value
3
resourceVersion
42
Conflicts (409)
0

Note what the spec/status split buys here: writer A edits spec.replicas and writer B edits a status field, so even when they collide the loser's retry re-applies its change to a different field — they are not fighting over the same data, just over the same object's version token. (The API server even exposes spec and status as separate subresource endpoints, but the resourceVersion discipline is identical.)

5 · Hub-and-spoke and watch, not poll

With one store and one writer, the architecture is a wheel. Every component is a spoke that connects inward to the API server; etcd sits behind the hub and is invisible to the spokes.

                    desired state in, actual state out
                                 |
            kubectl ----\        v        /---- scheduler
                         \   +---------+  /     (assigns Pods
                          \  |  kube-  | /       to nodes -> L10)
                           \ | apiserver| /
   controllers -------------+----+-----+-------------+ kubelets
   (close desired/actual    |    | (only writer)     | (run Pods,
    gap -> L04)             |    v                   |  report status)
                           authn -> authz -> admission -> validate
                                       |
                                       v
                              +-----------------+
                              |      etcd        |   Raft quorum,
                              |  /registry/...   |   global revision,
                              | (single source   |   WATCH stream
                              |   of truth)       |
                              +-----------------+

   rule: every spoke talks ONLY to the apiserver. nobody touches etcd directly.

The spokes do not poll. Polling — "ask the API server every 2 seconds whether anything changed" — wastes CPU and network when nothing changes and still reacts up to a full interval late when something does. Instead each spoke opens a watch: a long-lived connection that says "stream me every change to Pods I care about, starting from the resourceVersion I last saw." The API server (backed by etcd's watch) pushes an event the instant an object changes. A new Pod is created → the scheduler's watch fires immediately → it binds the Pod to a node → that write fires the target kubelet's watch → the kubelet starts the containers. Reactions are event-driven and near-instant, not interval-bound.

PollWatch (what Kubernetes uses)
Latency to reactUp to one poll interval (e.g. 0–2 s)Milliseconds — pushed on commit
Cost when idleConstant requests foreverOne idle open connection, ~free
Cost when busyMay miss bursts between pollsOne event per actual change
Resync after a gapJust poll againRe-list + re-watch from last resourceVersion

One subtlety the resourceVersion makes possible: a watcher that disconnects (network blip) does not have to re-fetch the world. It reconnects and asks to watch "from resourceVersion R" — the last revision it processed — and the API server replays everything since. If R is too old to be retained, the watcher does a fresh list (to get a consistent snapshot and its current resourceVersion) and re-watches from there. List-then-watch is the standard pattern every controller uses; you will see it reappear in lesson 04 as the heart of the controller loop. And because watch delivers the current object (not a "field X incremented" delta), the whole system stays level-triggered: a spoke that wakes from any starting point simply reads the current truth and reconciles toward it — exactly the engine from lesson 00, now wired through a single hub.

Failure modes & checklist

Failure modes

  • Touching etcd directly. A script or operator that reads/writes etcd behind the API server bypasses authn/authz/admission/validation and can write objects no validator ever saw. Signal: objects that violate cluster policy appear with no audit-log entry on the API server.
  • Losing etcd quorum. Running 2 etcd members (or letting a 3-member cluster drop to 1 healthy) means no majority, so writes block cluster-wide — the control plane goes read-only. Signal: kubectl apply hangs or times out while existing Pods keep running.
  • Retry-storm on conflicts. A controller that retries 409s with no backoff against a hot object can hammer the API server. Signal: high 409 rate and API-server latency climbing on one resource.
  • Assuming a write means action. Treating "the API server accepted my Pod" as "the Pod is running." The API server only stored it. Signal: a Pod sits in Pending forever because no component picked it up — the gap is recorded but nothing is closing it.
  • Stale resourceVersion in scripts. Caching an object and PUTting it minutes later; the world moved on and every write 409s. Signal: automation that "randomly" fails to update under load.

Checklist

  • State that etcd is the single source of truth and the API server is its only writer; everyone else is a spoke.
  • Run etcd in an odd-sized cluster (3 or 5) so a quorum survives 1–2 failures; back it up — losing etcd is losing the cluster.
  • Read state as spec (desired) vs status (actual); the gap between them is what everything reconciles.
  • On a 409 Conflict, re-GET, re-apply, re-PUT — with backoff. Never hold a lock; never assume your cached object is current.
  • Use watch (list-then-watch), not poll, for any component reacting to cluster state.
  • Remember a successful write is a recorded intent, not an executed action — something else must close the gap.

Checkpoint exercise

Try it
In the widget, press Both GET so A and B both read rv=42. Have A PUT first (it wins, store goes to rv=43), then watch B's PUT fail with 409. Now press Retry loser and confirm B re-reads rv=43, re-applies its change, and succeeds — with the conflict counter at 1 and no data lost. Then answer in one sentence each: (a) why running etcd as 2 members instead of 3 makes the control plane less available, not more; and (b) after A's PUT lands, which single mechanism makes the kubelet on the target node react in milliseconds instead of after a polling delay.

Where this points next

You now have a single, durable, consistent truth (etcd) and one disciplined gatekeeper in front of it (the API server), with every actor reading and writing through watches over one hub. But notice the hole this leaves: the API server only stores state. It will record "desired: 3 replicas" and, when a node dies, faithfully record "actual: 1 Pod left, 2 gone" — and then do absolutely nothing about it. A successful write is a recorded intent, not an executed action. The gap between desired and actual is now perfectly visible in one place, and yet no component is watching that gap and acting to close it. That missing actor — a program that watches a spec, compares it to reality, and continuously drives the difference to zero — is the controller, and figuring out which Pods are "its" Pods forces the label/selector model. That is lesson 04, Controllers & Labels.

Takeaway
Desired state and reported actual state cannot live in separate notebooks — N copies means N truths that diverge and fight, so you collapse them to one. That single source of truth is etcd: a Raft-replicated, strongly consistent key-value store, quorum-written (odd-sized: 3 tolerates 1 failure, 5 tolerates 2), ordered by a global revision, and watchable. To keep its rules in one place, exactly one component — the kube-apiserver — writes to etcd; everyone (kubectl, kubelet, scheduler, controllers) talks only to it, and every write runs authn → authz → admission → validation → persist. The data model bakes the gap into every object as spec (desired) vs status (actual), and concurrent edits are safe via optimistic concurrency: each object carries a resourceVersion, a stale PUT gets a 409, and the loser simply re-reads and retries — no locks. The result is a hub-and-spoke, watch-driven, level-triggered architecture. But the API server only stores the gap; it never closes it — which is what forces controllers next.

Interview prompts