all_lessons/kubernetes/13lesson 14 / 14

Part VI — Extension and synthesis

Extending Kubernetes, and the Whole Machine (Capstone)

Lesson 12 locked the cluster down: authentication, RBAC, admission, namespaces, quotas, and NetworkPolicy turned a wide-open multi-tenant box into one where every API call and every Pod-to-Pod connection is constrained. But all of that machinery still only governs the built-in nouns — Deployment, Service, ConfigMap, StatefulSet. Those nouns model a generic app. They have nothing to say about your domain: a database that needs ordered failover and nightly backups, a model rollout that must canary then promote. So today, to manage those, you are back to a human running runbooks by hand — which is precisely the manual ops we abolished in lesson 00. This final lesson closes that gap by teaching Kubernetes a new noun (Part A), and then steps back to trace the whole machine end to end and show that the thirteen primitives before it form a single derived chain (Part B).

The previous step left this broken
After lesson 12 the cluster is secure, but its vocabulary is fixed. You can declare "5 replicas of a stateless web app" and the system reconciles it — but you cannot declare "a 3-node Postgres cluster, primary elected, replicas streaming, backed up hourly" and have anything converge it. The domain knowledge ("when the primary dies, promote a replica, then re-point the Service") lives only in an operator's head and a wiki page. That is the lesson-00 failure exactly: intent stored nowhere a machine can act on, a human closing the gap by hand at 3am. The built-in nouns end where your domain begins.
Linear position
Forced by: the built-in API objects only model generic apps; managing domain objects (a database with failover, a model rollout) requires a human running runbooks — the manual ops lesson 00 abolished, now resurrected for everything custom.
New capability: a Custom Resource Definition (CRD) teaches the API server a new noun; an operator is a custom controller that watches that noun and reconciles it with the exact loop from lessons 00 and 04 (observe → diff → act). Kubernetes becomes a platform for building platforms — user-extensible all the way down.
Forces next: nothing. This is the last lesson. The chain closes here, and the remaining work is to see the whole machine at once and walk back to lesson 00 with new eyes.
The plan
Five moves. (1) Derive the CRD: why a new noun is the only fix, and what teaching the API server costs. (2) Derive the operator: it is just a controller (the §00/§04 loop) over a custom noun, with a concrete Database{size} behavior. (3) Drive the operator-reconcile widget — the same observe→diff→act loop as the lesson-00 widget, now creating and deleting child StatefulSet Pods to match a custom spec. (4) Synthesis I: trace one kubectl apply end to end through every component the track built. (5) Synthesis II: trace one external request, draw the full architecture map, and put the entire linear chain in one recap table — then bridge to the GenAI sibling track.

1 · The forced fix: teach the API server a new noun

Return to the engine. Kubernetes works because intent is stored as a declarative object and a controller drives reality toward it. The whole reason a Deployment self-heals is that "I want 5 replicas of v2" is a first-class, queryable, watchable object the API server persists. Your domain intent — "I want a 3-node database, primary elected, replicas streaming" — has no such object. It cannot be stored, so it cannot be reconciled, so a human is the controller. To fix this the way Kubernetes fixes everything, you do not write a one-off script: you make your intent a declarative object the API server understands.

That is exactly what a Custom Resource Definition (CRD) is. A CRD is itself an object you kubectl apply; submitting it teaches the API server a brand-new kind — say Database — with a schema you define (an OpenAPI schema validating the fields). From that moment the API server treats your noun like any native one: kubectl get databases works, RBAC rules can grant verbs on it, admission webhooks can guard it, it gets stored in etcd, and clients can watch it. A single instance of the new kind is a custom resource (CR):

apiVersion: acme.io/v1          # your API group + version
kind: Database                  # the new noun the CRD registered
metadata: { name: orders-db }
spec:                           # desired state — what YOU want
  size: 3                       # number of database members
  version: "15"                 # engine version
status:                         # actual state — what the operator OBSERVES
  readyReplicas: 3
  primary: orders-db-0

Notice the shape is the universal Kubernetes shape from lesson 03: a spec (desired, written by the user) and a status (actual, written by the controller). The CRD gives you the noun and a place to store both halves of the loop. But a stored noun is inert — exactly like a declaration with no loop behind it (lesson 00, §3). The API server will happily persist a Database{size: 3} and do absolutely nothing about it. Something must watch this noun and close the gap. That something is the operator.

2 · The operator is just a controller over your noun

An operator is not a new mechanism. It is the controller from lessons 00 and 04 — the same observe → diff → act loop — pointed at a custom resource instead of a built-in one. It encodes the operational knowledge a human expert would otherwise apply by hand: how to bring a database up in order, elect a primary, fail over, take a backup. The pattern is so regular that frameworks (controller-runtime, scaffolded by kubebuilder or the Operator SDK) reduce writing one to filling in a single function. Its signature is the loop made literal:

// controller-runtime: called every time the watched object changes,
// AND periodically (re-sync) — because it is level-triggered (§00).
func (r *DatabaseReconciler) Reconcile(ctx, req) (Result, error) {
    var db Database
    r.Get(ctx, req.NamespacedName, &db)        // OBSERVE: read desired (spec)
    pods := r.listChildPods(db)                // OBSERVE: read actual (the world)
    diff := db.Spec.Size - len(pods)           // DIFF:    desired vs actual
    if diff > 0 { r.Create(... diff new Pods)  // ACT:     create the shortfall
    } else if diff < 0 { r.Delete(... -diff)   // ACT:     delete the surplus
    }
    return Result{RequeueAfter: 30 * time.Second}, nil   // REPEAT: forever
}

That is the entire idea. The reconciler returns, and controller-runtime calls it again on the next event or on the requeue timer. It is level-triggered (it re-reads desired and actual every call, so it never relies on catching a "Pod died" event) and idempotent (a call where diff == 0 does nothing) — the two properties from lesson 00 that make the loop self-heal, now yours to reuse. A real Database operator does more than count Pods, but every action is still a diff-and-act pass:

Scale (size N)
Observe spec.size vs the live members. size: 3 → 5 creates db-3, db-4 (each its own StatefulSet Pod with its own PVC, lesson 09); 5 → 2 removes the highest ordinals, newest first.
Failover
Observe that status.primary is unreachable; act by promoting a healthy replica, updating status.primary, and re-pointing the headless Service's endpoints (lesson 06) at the new leader.
Backup
Observe the last-backup timestamp vs the hourly schedule in spec; act by creating a Job (lesson 09) that snapshots a replica's PVC to object storage, then recording the time in status.
Version upgrade
Observe spec.version differs from running members; act by replacing them one ordinal at a time, replica-first, primary-last — the domain-specific safe order a human used to run from a runbook.

This is why the phrase "Kubernetes is a platform for building platforms" is precise, not marketing. The control-plane pattern — declarative noun in etcd, a watching loop that reconciles it — is generic and user-extensible. The built-in controllers (Deployment, ReplicaSet, StatefulSet, Job) are not privileged; they are simply the operators the Kubernetes authors shipped. You write your own with the same API, the same loop, the same guarantees. The manual runbook from the source-note becomes a Reconcile function, and the 3am page becomes a self-healing object.

3 · Drive the operator loop yourself

The widget below is the lesson-00 reconciliation loop, grown up. There it counted abstract Pods; here it is a real operator watching a custom Database resource. You edit one field — spec.size — and the operator reconciles by creating or deleting child StatefulSet Pods (db-0, db-1, …) to match. Watch that it is the same observe → diff → act pass: each tick it reads spec.size (desired) and counts live members (actual), computes the diff, and creates or deletes exactly enough to close it — newest ordinals removed first, just like a StatefulSet (lesson 09). Kill a member to simulate a node death and watch the operator recreate it on the next tick, exactly as the ReplicaSet did in lesson 04. The point of the widget is recognition: you have seen this loop before, in the very first lesson. Custom nouns did not add a new engine; they pointed the old engine at a new object.

Operator reconcile loop — edit a custom Database{size}, watch child Pods converge
Drag spec.size (the only knob — it is the declaration). The operator ticks on its own: green = running member, yellow = being created this tick, red = being deleted this tick. It is the same observe → diff → act loop as the lesson-00 widget — it just creates and deletes ordinal StatefulSet Pods (db-0…) to match the spec. Hit kill a member to simulate node death; the operator notices the gap next tick and recreates it (level-triggered). When actual already equals spec.size, a tick does nothing (idempotent).
spec.size (desired)
status.readyReplicas (actual)
Reconcile actions taken
State

That recognition is the hinge of the whole track. There was never a pile of unrelated features — there was one loop, applied to nouns the loop turned out to need. The next two sections make that claim literal by tracing the entire machine.

4 · Synthesis I — one kubectl apply, end to end

You type kubectl apply -f deployment.yaml. Follow the request through every component the track built, in order. Each hop is a lesson.

1kubectl → reads your kubeconfig, POSTs the Deployment YAML (as JSON) to the API server over TLS. It is just a REST client; it holds no cluster state (lesson 03).
2API server — authn → who are you? Verifies your client cert / token / ServiceAccount and resolves an identity (lesson 12).
3API server — authz (RBAC) → may this identity create deployments in this namespace? A Role/RoleBinding must grant it, or the request is 403 (lesson 12).
4API server — admission → mutating then validating webhooks and Pod Security Standards run: defaults injected, policy enforced, quota checked (lessons 08, 12).
5API server — validate & write to etcd → the object is schema-validated and persisted in etcd under optimistic concurrency (a resourceVersion). This write is the only thing that touches etcd (lesson 03).
6Deployment controller → was watching for Deployments; observes the new one, diffs (no ReplicaSet exists), acts by creating a ReplicaSet for this template revision (lesson 05).
7ReplicaSet controller → observes "desired 3, 0 Pods match my selector," acts by creating 3 Pod objects carrying the right labels (lesson 04). The Pods are written to etcd, still unbound — no node yet.
8kube-scheduler → watches for unbound Pods; for each, filters nodes that fit (requests, taints) then scores survivors (spread, affinity) and writes a binding: this Pod → that node (lesson 10).
9kubelet (on the chosen node) → watches for Pods bound to it; pulls the image, then via the CRI tells containerd to create and start the containers — the per-node reconcile loop (lesson 02).
10CNI → as the Pod sandbox comes up, the CNI plugin assigns it a cluster-routable Pod IP on the flat network (lesson 06).
11readiness probe → kubelet runs it; once it passes, the Pod is marked Ready and its true status is reported up to the API server (lesson 11).
12Service / EndpointSlice → the EndpointSlice controller, watching for Ready Pods matching the Service's selector, adds this Pod's IP to the Service's EndpointSlice; kube-proxy programs it into every node (lessons 06, 11). The Pod now receives traffic.

Twelve hops, and every one is a watch-driven, level-triggered reconcile against state in etcd. No component called another directly; each only watched the API server and acted on what it saw. That is the hub-and-spoke design from lesson 03 doing its job: the API server is the single source of truth, and a dozen independent loops converge the cluster on it. You declared a result; the machine computed the steps.

5 · Synthesis II — one external request, the map, and the chain

Now the other direction. An external user opens your app in a browser. Follow the HTTP request inward:

1DNS → the browser resolves app.example.com to the public IP of the cluster's load balancer / Ingress (lesson 07).
2Ingress / Gateway → the L7 proxy at the edge terminates TLS, matches host + path against its routing rules, and forwards to the right Service (lesson 07).
3Service ClusterIP → the request is aimed at a stable virtual IP that fronts the live, label-selected set of Pods — it never moves even as Pods churn (lesson 06).
4kube-proxy → iptables/IPVS rules on the node rewrite the ClusterIP to one real Pod IP from the current EndpointSlice, load-balancing across the Ready members (lesson 06).
5a live Pod → the request lands on a container the kubelet is running; it answers, and the stream returns the way it came (lesson 02).

Put both directions together and the entire cluster is one picture — a control plane of stored truth plus watching loops, and worker nodes that run the actual Pods:

                         CONTROL PLANE (the brain)
   +-------------------------------------------------------------------+
   |   API SERVER  (only writer of etcd; authn -> authz -> admission)  |
   |        ^   ^        ^            ^               ^                 |
   |        |   |        |            |               |        +------+ |
   |   kubectl  |   scheduler   controllers      EndpointSlice |      | |
   |  (clients) |  (bind Pods)  (Deploy/RS/      controller -> | etcd | |
   |            |               StatefulSet/Job/             ^  |(truth| |
   |            |               YOUR OPERATOR)               |  +------+ |
   +------------|-------------------|--------------------------|--------+
                | watch/act         | bind                     | watch
                v                   v                          |
   ============ NODES (the hands) ===========================  |
   |  NODE A                         NODE B                 |  |
   |  kubelet  <-- reconciles Pods   kubelet                |  |
   |  containerd (CRI) runs:         containerd runs:       |  |
   |  [Pod: app  IP .11]  <--CNI-->  [Pod: app  IP .21]     |  |
   |  [Pod: db-0 IP .12]             [Pod: db-1 IP .22]     |  |
   |  kube-proxy (Service VIP rules) kube-proxy             |  |
   ==========================================================  |
            ^                                                  |
            |  external request: DNS -> Ingress -> Service VIP -> kube-proxy -> Pod
       [ user ] ------------------------------------------------+

And here is the payoff of the whole linear spine: every primitive exists because the previous one left a specific failure unanswered. Read this table top to bottom and you have re-derived Kubernetes — each row is forced by the row above.

The failure left openThe primitive forcedLesson
Manual ops: intent lives in a human's head, nothing watches the gapDeclarative state + the reconcile loop (observe → diff → act)00
A "process" is too slippery to reconcile — dependency hell, noisy neighborsThe container: a frozen image under namespaces + cgroups01
A lone container can't co-locate helpers or be scheduled as one thingThe Pod (shared netns/IP) on a Node (kubelet + CRI runtime)02
Desired + actual state need one durable, consistent homeetcd + the API server as sole gateway (hub-and-spoke)03
A dead Pod stays dead; nothing restores count, nothing tracks "its" PodsControllers + the ReplicaSet, matched by label selector04
A ReplicaSet pins one version — no safe rollout, no undoThe Deployment: rolling update over ReplicaSets + rollback05
Rolling Pods get new IPs; clients can't hold a moving targetFlat Pod network (CNI) + the Service (stable ClusterIP)06
A ClusterIP is a bare number; no name, no L7 routing, no TLS edgeCoreDNS names + Ingress / Gateway front door07
Config baked into images; a Pod's disk dies with itConfigMap/Secret + PersistentVolume/PVC + StorageClass/CSI08
Deployment Pods are interchangeable — wrong for databases, finite jobs, per-node agentsStatefulSet, Job/CronJob, DaemonSet09
A new Pod is unbound — which of N nodes runs it?The kube-scheduler: filter → score → bind (requests, taints, spread)10
Running ≠ healthy; a fixed replica count is wrong as load movesProbes (liveness/readiness/startup) + HPA/VPA/Cluster Autoscaler11
Many tenants, but any Pod can read any Secret and call any APIAuthn → RBAC → admission, Namespaces/Quota, NetworkPolicy12
Built-in nouns only model generic apps; domain objects need a humanCRD (a new noun) + Operator (a custom controller — the §00 loop)13

The bridge to the sibling track. With this chain in hand, the entire Generative AI on Kubernetes track stops being a separate subject and becomes these exact primitives under one extra constraint: GPUs and their scarce HBM. A model server is a Pod (lesson 02) whose readiness probe (lesson 11) must wait for tens of gigabytes of weights to load; "schedule it" is the scheduler (lesson 10) filtering on a GPU resource and node taints (GPU nodes as a platform); autoscaling is the HPA (lesson 11) driven by KV-cache and TTFT signals instead of CPU; multi-tenant GPU fairness is RBAC + quota (lesson 12). The sibling track's opening lesson, Why GenAI Is Different on Kubernetes, even names the same failure modes — a CPU HPA that can't see the bottleneck, a readiness probe that lies — but now you can place each one on a row of the table above. Nothing there is new machinery; it is this machine, bound by HBM instead of CPU.

Failure modes & checklist

Failure modes

  • Reaching for an operator too early. Writing a CRD + controller for something a Deployment plus a Job would have handled. Operators are real software you must maintain. Signal: a 2,000-line reconciler that only ever scales a stateless web app.
  • An imperative operator. A reconciler that fires once on the "created" event and assumes its work stuck, instead of re-deriving desired-vs-actual every call. Signal: the database never recovers after a node dies because no event re-fired — the §00 edge-triggered trap, now in your code.
  • A non-idempotent reconcile. A pass that creates a member instead of ensuring N exist, so requeues pile up duplicates. Signal: member count drifts above spec.size with no scale-up requested.
  • Confusing spec and status. The operator writing to spec (the user's intent) or the user editing status. Signal: a fight between human and controller, intent silently overwritten each tick.
  • Treating the GenAI track as a different system. Re-learning "GPU scheduling" or "model autoscaling" from scratch instead of seeing the scheduler and HPA you already know. Signal: you can't say which recap-table row a GenAI feature sits on.

Checklist

  • Before writing an operator, ask if built-ins suffice. StatefulSet + Job + a Service often is the answer; reach for a CRD only when real domain logic (failover, ordered upgrade) must be encoded.
  • Model intent as spec, observed reality as status. Only the user writes spec; only the controller writes status.
  • Make every reconcile level-triggered and idempotent. Re-read both states every call; "ensure N," never "create one."
  • Lean on the platform you extended. Your operator should create normal Pods/StatefulSets/Jobs and let the scheduler, kubelet, and Services do their jobs — don't reinvent them.
  • Trace one apply end to end for any new workload; if you can't name the twelve hops, you don't yet understand where it can fail.

Checkpoint exercise

Try it
Open the widget and set spec.size to 5; let it settle (actual 5, state converged — a tick now does nothing, that is idempotence). Kill two members at once and, before it recovers, predict the diff the operator will compute on its next tick — then watch it recreate exactly to 5 (level-triggered, just like lesson 00's "kill all" test). Now scale spec.size from 5 down to 2 and confirm it deletes the highest ordinals first. Finally, write the §4 twelve-hop trace from memory for a brand-new Deployment, and for any three hops name the failure that would occur if that component were missing — then map each hop to its row in the §5 recap table.

Where this points next

There is no lesson 15 — the chain closes here. The forced-derivation arc that began with a human typing ./server at 3am ends with that same human's runbook compiled into a Reconcile function the cluster runs forever. The honest next step is to re-read lesson 00 with new eyes: every noun it promised the loop would need — container, Pod, control plane, controllers, Services, scheduler — you have now built, and the operator you just wrote is literally the lesson-00 widget pointed at a custom object. From here, go wide instead of deeper: open the Generative AI on Kubernetes track and watch every one of these primitives reappear under GPU/HBM pressure — the scheduler placing model Pods on tainted GPU nodes, the HPA scaling on KV-cache instead of CPU, the readiness probe waiting on weights. You will not be learning a new system; you will be applying this one to the hardest workload it runs.

Takeaway
Kubernetes' built-in nouns model only generic apps, so managing your domain objects by hand resurrects the manual ops of lesson 00 — the fix is to make domain intent a first-class object. A Custom Resource Definition teaches the API server a new noun (a Database{size, version}, a ModelRollout) with a spec (desired) and status (actual); an operator is just a custom controller whose Reconcile function runs the exact observe → diff → act loop from lessons 00 and 04 — level-triggered and idempotent — over that noun, creating and deleting child Pods/StatefulSets/Jobs to converge it and encoding failover, backup, and ordered upgrade that a human used to do from a runbook. That is why "Kubernetes is a platform for building platforms": the control-plane pattern is generic and user-extensible, and the shipped controllers are just the operators its authors wrote. The synthesis seals it: one kubectl apply flows through twelve watch-driven, level-triggered hops (kubectl → authn → RBAC → admission → etcd → Deployment ctrl → ReplicaSet ctrl → scheduler → kubelet/CRI → CNI → readiness → EndpointSlice), one external request flows DNS → Ingress → ClusterIP → kube-proxy → Pod, and the recap table shows every primitive as the forced answer to the previous one's failure. The whole GenAI sibling track is then just this machine under one more constraint — scarce GPU HBM instead of CPU.

Interview prompts