1 APP · 3 NODES · 8 MINUTES · 220 WRITES

Kubernetes basics:
what happens after kubectl apply

ONE APP · namespace shop· kind · Kubernetes v1.37.0 ·
writes to cluster state, 20:11:46 → 20:19:48 UTC·····································220
WHO MADE THEM
controllers in kube-controller-manager·····································142
kubelets on the two worker nodes·····································58
the scheduler·····································11
you, through kubectl·····································8
the storage add-on·····································1
Pods replaced after a node stopped·····································2

Kubernetes is a set of programs that each read and write one API, and none of them calls another. This tutorial follows one small app, a web Deployment and a Redis StatefulSet, on a three-node cluster for eight minutes on 7 November 2026, from the first kubectl apply to the recovery from a stopped node. It counts every write to cluster state the app causes. There are 220, and you make 8 of them.

Every number is from that run’s API audit log. The scenes play themselves, so watch or just scroll. are tappable. The exam angle is the KCNA curriculum revised in November 2025: each chapter’s deep dive ends with the questions it answers.

Three nodes, thirteen Pods, and nothing of yours yet

kubectl get pods -A on a new three-node cluster lists 13 Pods, and you created none of them. Those 13 are Kubernetes itself: the programs that store, schedule and run everything you will add. This chapter names each one and what it is for. The counter this tutorial keeps, writes to cluster state for your app, starts at 0.

STEP 01 / 05

TWO KINDS OF MACHINE

kind-control-plane172.23.0.7
taint · NoSchedule
node-role.kubernetes.io/control-plane
  • kube-apiserverthe API
  • etcdstores cluster state
  • kube-schedulerpicks nodes for Pods
  • kube-controller-managerruns control loops
  • kubelet
  • kube-proxy
  • containerd 2.3.4
your Pods never run here
kind-worker172.23.0.6
  • kubelet
  • kube-proxy
  • containerd 2.3.4
your Pods run here
kind-worker2172.23.0.5
  • kubelet
  • kube-proxy
  • containerd 2.3.4
your Pods run here
3 Docker containers built by kind v0.33.0 in 22.8 s · Kubernetes v1.37.0
Fig 0.1Read the left box: only the control plane runs the API server and etcd, and its accent taint keeps Pods off.

A Kubernetes cluster is a , which stores the cluster’s state and acts on it, plus worker nodes, which run your containers. A node is any machine running a kubelet, the agent chapter 04 follows. The unit Kubernetes places on a node is a : one or more containers that share one IP address.

This cluster comes from kind (Kubernetes in Docker) v0.33.0, which built three nodes as three Docker containers in 22.8 seconds: kind-control-plane, kind-worker and kind-worker2, running Kubernetes v1.37.0, released on 26 August 2026. In chapter 07, stopping one of those containers is how a node loses power.

The control plane carries a taint, node-role.kubernetes.io/control-plane:NoSchedule. A taint repels every Pod that doesn’t explicitly tolerate it, so none of your Pods will run there. Chapter 03 shows the scheduler applying that rule.

KIND.YAML: THE WHOLE CLUSTER, ONE CONTROL PLANE AND TWO WORKERS
# kind create cluster --config kind.yaml   (22.8 s on a laptop)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane   # kind-control-plane
- role: worker          # kind-worker
- role: worker          # kind-worker2
STEP 02 / 05

THE CONTROL PLANE RUNS AS FOUR PODS

  1. file · etcd.yamletcd-kind-control-planestores every object in the cluster
  2. file · kube-apiserver.yamlkube-apiserver-kind-control-planethe only door to cluster state
  3. file · kube-controller-manager.yamlkube-controller-manager-kind-control-planeruns the control loops
  4. file · kube-scheduler.yamlkube-scheduler-kind-control-planepicks a node for each new Pod
read by the kubelet on kind-control-plane · no API server needed to start them
Fig 0.2The four files sit on the control-plane node’s disk; each becomes one Pod in kube-system. The accent pair is the API server.

The kubelet on kind-control-plane reads four YAML files from /etc/kubernetes/manifests and runs each one as a . A static Pod needs no API server, and that is the point: at boot there isn’t one yet. The kubelet starts the API server itself, from a file.

The four Pods are the control plane. is the only door to cluster state. is the key-value store that holds that state. picks a node for each new Pod. runs the control loops that turn what you asked for into what exists.

Once the API server is up, it lists each static Pod as a read-only mirror, so kubectl get pods -n kube-system shows all four. A cloud cluster adds a fifth component, cloud-controller-manager, which talks to the provider’s API. kind runs on a laptop, so it has none.

STEP 03 / 05

THREE PROGRAMS ON EVERY NODE

kind-control-plane172.23.0.7
  • kubeletsystemd service
  • kube-proxyPod kube-proxy-rkr7w
  • containerd 2.3.4systemd service
kind-worker172.23.0.6
same three programs · kubelet, kube-proxy-mscwz, containerd
kind-worker2172.23.0.5
same three programs · kubelet, kube-proxy-ldszb, containerd
Fig 0.3The same three rows repeat in every node box. Only kube-proxy has a Pod name; the accent kubelet runs outside Kubernetes.

Every node, the control plane included, runs the same three programs. The starts the Pods bound to its node and reports on them. writes the packet rules behind Services, which chapter 05 reads line by line. containerd 2.3.4 runs the containers, and the kubelet drives it through the , a gRPC API.

kube-proxy runs as a Pod, one per node: kube-proxy-rkr7w, kube-proxy-mscwz and kube-proxy-ldszb. The kubelet and containerd are plain systemd services on the node’s own operating system. They have to be: something must already be running before the first Pod can start.

STEP 04 / 05

ADD-ONS ARE ORDINARY WORKLOADS

STARTED FROM FILES · 4 PODS
etcd-kind-control-planekube-apiserver-kind-control-planekube-controller-manager-kind-control-planekube-scheduler-kind-control-plane
DAEMONSETS, ONE PER NODE · 6 PODS
kindnet-brwjnkindnet-ksqzmkindnet-q5vhvkube-proxy-ldszbkube-proxy-mscwzkube-proxy-rkr7w
DEPLOYMENTS · 3 PODS
coredns-559f6c778d-6r5gncoredns-559f6c778d-shh5jlocal-path-provisioner-75f7fc7dc5-jvc5f
13 Pods running · 0 of them yours · accent: the cluster’s DNS
Fig 0.4The 13 chips are grouped by what started them: files, DaemonSets or Deployments. The accent pair is the cluster’s DNS.

The other Pods are add-ons: they run as ordinary workloads and can be swapped. CoreDNS answers DNS queries for Service names and runs as a Deployment of 2 Pods. kindnet is the plugin that gives every Pod an IP address, with one Pod per node. local-path-provisioner creates volumes on a node’s disk, from its own namespace.

The count: 4 static Pods, 3 kube-proxy, 3 kindnet, 2 CoreDNS and 1 provisioner make 13. Production clusters usually swap kindnet for Calico or Cilium, and the provisioner for their cloud’s storage driver. The rest stays the same.

STEP 05 / 05

FOUR NAMESPACES AND 408 KEYS

  • kube-system12 Podsthe cluster’s own components
  • kube-node-lease3 Leasesone per node, renewed every 10 s
  • local-path-storage1 Podkind’s storage add-on
  • default0 Podswhere objects go with no -n
  • kube-public2 ConfigMapscluster-info, readable without logging in
keys in etcd before your app408
etcd database size1.6 MB of a 2 GiB quota
Fig 0.5Read the accent card: kube-node-lease holds one Lease per node, and chapter 07 depends on them.

A is a name scope for objects and a boundary for permissions and quotas. A new cluster has four: default, kube-system for the cluster’s own 12 Pods, kube-public and kube-node-lease. kind adds a fifth, local-path-storage, for its provisioner.

kube-node-lease holds one Lease object per node. Each kubelet renews its Lease every 10 seconds, and that renewal is the node’s heartbeat. Chapter 07 stops one node and follows what the control plane does once the renewals stop.

Before your app exists, etcd already holds 408 keys under /registry in a 1.6 MB database, against a default quota of 2 GiB. 141 of them are cluster-wide RBAC roles and bindings. Nodes are stored under /registry/minions, the name early Kubernetes releases used for nodes.

WHAT YOU NOW KNOW

  • The control plane runs the API server, etcd, scheduler and controller manager as 4 static Pods on one tainted node; workers run your Pods.
  • Every node runs a kubelet, kube-proxy and containerd; only kube-proxy runs as a Pod.
  • A new cluster runs 13 Pods and stores 408 etcd keys before you deploy anything; the write counter starts at 0.
deep dive: high availability, managed control planes and where Kubernetes came from

Production control planes run on 3 or 5 nodes. etcd accepts a write only when a majority of its members, (n/2)+1, agree, so 3 members tolerate 1 failure and 5 tolerate 2. A fourth member adds no tolerance at all. etcd runs either stacked, on the control-plane nodes as it does here, or external, on machines of its own.

EKS, GKE and AKS run these four components for you and hide the control-plane nodes, so you only ever see workers. cloud-controller-manager is the piece that talks to a provider. It removes Node objects whose virtual machines are gone, programs routes, and creates load balancers for LoadBalancer Services.

Kubernetes ships three minor releases a year, and each is supported for about 14 months: 12 months of patches, then 2 in maintenance mode. v1.38 is due on 16 December 2026.

The first commit landed on 6 June 2014: 250 files and 47,501 lines, by Joe Beda. v1.0 followed on 21 July 2015. Kubernetes descends from Borg, Google’s internal cluster manager. It was the CNCF’s seed project in March 2016 and its first graduate on 6 March 2018. The name is Greek for helmsman, and K8s counts the 8 letters between K and s.

CNCF projects move through three maturity levels: sandbox, incubating and graduated. 41 projects had graduated by November 2026. One Kubernetes cluster is tested up to 5,000 nodes, 150,000 Pods, 300,000 containers and 110 Pods per node.

ON THE EXAM

  • “which component stores all cluster state” → etcd
  • “the only component that talks to etcd” → kube-apiserver
  • “runs on every node” → kubelet, kube-proxy and a container runtime
  • “assigns Pods to nodes” → kube-scheduler
  • “runs controllers” → kube-controller-manager
  • “integrates with a cloud’s APIs” → cloud-controller-manager
  • “etcd members for high availability” → an odd number; 3 members tolerate 1 failure
  • “namespace holding node heartbeats” → kube-node-lease
  • “first CNCF project to graduate” → Kubernetes, on 6 March 2018

kubectl is an HTTP client, and the API server is the only door

POST /apis/apps/v1/namespaces/shop/deployments returned 201 Created in 4 milliseconds at 20:11:46.843 UTC. Every other component in this tutorial reacts to that one write. This chapter follows it from kubectl to etcd and adds your first 2 writes to the counter: the namespace shop and the Deployment web.

STEP 01 / 05

FIVE REQUESTS, ONE WRITE

  1. 1GET/openapi/v3the schema index200 OK · 5 ms
  2. 2GET/openapi/v3/apis/apps/v1the apps/v1 schema, to check field names200 OK · 0 ms
  3. 3GET/apis/apps/v1/namespaces/shop/deployments/webdoes web exist? no, so create it404 Not Found · 1 ms
  4. 4GET/api/v1/namespaces/shopthe namespace exists200 OK · 1 ms
  5. 5POST/apis/apps/v1/namespaces/shop/deploymentsthe only write: Deployment web stored at 20:11:46.843 UTC201 Created · 4 ms
5 HTTPS requests · 4 reads · 1 write
Fig 1.1Only the accent POST changes anything; the four GETs above it read the schema and check what already exists.

kubectl is an HTTPS client. It reads the API server’s URL and your client certificate from a kubeconfig file, ~/.kube/config, then sends plain REST requests. kubectl apply -f web.yaml sent 5 of them.

Two GETs fetched the OpenAPI schema, so kubectl could check the manifest’s field names before sending anything. A GET for deployments/web returned 404 Not Found, which is how apply decides to create rather than patch. A GET for the namespace returned 200. Then one POST: 201 Created in 4 ms.

kubectl create namespace shop had run a moment earlier, one POST of its own. Those two are your first writes, so Σ writes to cluster state is now 2.

KUBECTL APPLY WITH -v=6: EVERY REQUEST, AND THE ONE WRITE
kubectl apply -f web.yaml -v=6
# GET  /openapi/v3                                    200 OK          5 ms
# GET  /openapi/v3/apis/apps/v1                       200 OK          0 ms
# GET  /apis/apps/v1/namespaces/shop/deployments/web  404 Not Found   1 ms
# GET  /api/v1/namespaces/shop                        200 OK          1 ms
# POST /apis/apps/v1/namespaces/shop/deployments
#      ?fieldManager=kubectl-client-side-apply
#      &fieldValidation=Strict                        201 Created     4 ms
# deployment.apps/web created
STEP 02 / 05

WHO ARE YOU, MAY YOU, IS IT ALLOWED

authenticationclient cert CN kubernetes-admin, O kubeadm:cluster-adminselse 401
authorization · RBACClusterRoleBinding grants the group cluster-adminelse 403
admissionmutating plugins may edit, then validating ones may refuseelse 403
validationevery field matches the apps/v1 schemaelse 422
etcdstored at /registry/deployments/shop/web201 Created
POST /apis/apps/v1/namespaces/shop/deployments · a refusal at any gate stores nothing
Fig 1.2Follow the request through the gates left to right; the accent gate, admission, is the only one that can change the object.

Every request passes the same checks, in order. Authentication: here a client certificate whose CN is kubernetes-admin and whose O is kubeadm:cluster-admins. Authorization: finds a ClusterRoleBinding that grants that group the cluster-admin role. Then , schema validation and, only after all of them, storage in etcd.

Admission is the one gate that can change the object. Mutating plugins run first and may edit it; validating plugins run next and may refuse it. The DefaultTolerationSeconds plugin, for one, adds two 300-second tolerations to every Pod created later in this tutorial, and chapter 07 depends on them.

A refusal ends the request with 401 Unauthorized, 403 Forbidden or 422 Unprocessable Entity, and nothing is stored. Kubernetes keeps no user database: a user is whatever name a trusted certificate or token carries.

STEP 03 / 05

33 LINES IN, 81 LINES BACK

Deploymentweb· namespace shop
SPEC AS STORED · PLAIN ROWS ARE YOURS
replicas3
selectorapp=web
strategy.typeRollingUpdatedefault
maxSurge25%default
maxUnavailable25%default
revisionHistoryLimit10default
progressDeadlineSeconds600default
imagenginx:1.30-alpine
imagePullPolicyIfNotPresentdefault
restartPolicyAlwaysdefault
dnsPolicyClusterFirstdefault
terminationGracePeriodSeconds30default
schedulerNamedefault-schedulerdefault
etcd key /registry/deployments/shop/web · 3,069 bytes of protobuf
web.yaml, as you wrote it33 lines
kubectl get deploy web -o yaml81 lines
Fig 1.3The accent rows are defaults you never typed; the API server added them before storing the object.

The API server fills in every field you left out before it stores the object. web.yaml says nothing about how to update Pods, so the stored Deployment gets strategy RollingUpdate with maxSurge 25% and maxUnavailable 25%. The Pod template gains imagePullPolicy IfNotPresent, restartPolicy Always, dnsPolicy ClusterFirst and terminationGracePeriodSeconds 30.

It also adds what only the server can know: a uid, a creationTimestamp, managedFields recording which client set which field, and kubectl’s own last-applied-configuration annotation. kubectl get deploy web -o yaml returns 81 lines for a 33-line file.

The object is stored as protobuf, a compact binary encoding, under the etcd key /registry/deployments/shop/web: 3,069 bytes, starting with the four bytes k8s\0.

THE STORED DEPLOYMENT, READ STRAIGHT FROM ETCD
kubectl -n kube-system exec etcd-kind-control-plane -- etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/deployments/shop/web --print-value-only > web.bin

wc -c < web.bin
# 3069
head -c 4 web.bin | od -c
# 0000000   k   8   s  \0
STEP 04 / 05

EVERY WRITE GETS A NUMBER

etcd revision84190665 revisions in 5.1 s, every writer in the cluster
revision 845 · Deployment web stored
  • writes for shop · 35
  • Events · 22
  • Lease renewals · 8
two updates carrying a stale resourceVersion got 409 Conflict and took no number
Fig 1.4The accent mark is the revision the Deployment was stored at; every write after it, by anyone, takes the next number.

etcd keeps one revision counter for the whole cluster. Every write, by anyone, takes the next number, and each object’s is the revision of its own last write. Deployment web was stored at revision 845.

From revision 841, just before the apply, to 906, when all three Pods were Ready 5.1 seconds later, etcd took 65 writes: 35 for the shop app, 22 Events and 8 Lease renewals.

The number is also a lock. An update carries the resourceVersion its writer read, and if the object has changed since, the API server refuses it with 409 Conflict. The deployment-controller hit 2 conflicts in this run, read the object again and retried. Neither refused update counts as a write.

STEP 05 / 05

NOBODY IS TOLD

  1. kubectlyour laptop
  2. kube-apiserverthe only door
  3. etcdread and written only by the API server
kubectl POSTs Deployment web; the API server authenticates and admits it
watch streams, each opened by the component below it, all ending at kube-apiserver
  1. kube-controller-managerwatches Deployments, ReplicaSets, Pods+5 ms: creates the ReplicaSet
  2. kube-schedulerwatches Pods with no node
  3. kubelet on kind-workerwatches Pods bound to kind-worker
  4. kubelet on kind-worker2watches Pods bound to kind-worker2
Fig 1.5Every path starts or ends at the API server: components open watches to it, and none talks to another or to etcd.

After the write, the API server calls no one. Each component instead keeps a watch open: a long-lived GET that streams every change to the resources it handles. kube-controller-manager watches Deployments, ReplicaSets and Pods; the scheduler watches Pods with no node; each kubelet watches Pods bound to its own node.

ReplicaSet web-67fddbbb7 was stored 5 ms after the Deployment, at 20:11:46.848, by a controller that had only read a watch event. Components never call each other, and only the API server reads or writes etcd. The Kubernetes docs treat access to etcd as equal to root on the whole cluster.

WHAT YOU NOW KNOW

  • kubectl sent 5 HTTP requests to apply web.yaml; one POST, 201 Created in 4 ms, was the only write.
  • Every request is authenticated, authorized by RBAC and passed through admission; only the API server touches etcd.
  • The API server tells nobody: components watch it, and etcd’s revision went 841 → 906 in 5.1 s. Σ writes 2.
deep dive: API groups, imperative vs declarative, and server-side apply

Resources live in API groups with versions: v1 is the core group (Pods, Services, ConfigMaps), then apps/v1, batch/v1 and networking.k8s.io/v1. A kind is the type name in a manifest (Deployment); a resource is its URL segment (deployments). kubectl api-resources lists them all, and says which are namespaced and which are cluster-scoped, like Nodes and PersistentVolumes.

kubectl run and kubectl create are imperative: each command makes one change. kubectl apply is declarative: you keep the manifests, and apply works out the difference. Client-side apply records your last manifest in the last-applied-configuration annotation, and all annotations on one object share a 256 KiB cap. Server-side apply, GA in v1.22, tracks who owns each field in managedFields instead; ask for it with --server-side.

A CustomResourceDefinition adds a new resource type to the API, and an operator is a CRD plus a controller that acts on it. ServiceAccounts are the identities Pods use: the 11 TokenRequests left out of this tutorial’s count were kubelets fetching tokens for the Pods’ default ServiceAccount.

The counts in this tutorial come from the API server’s audit log, switched on with --audit-policy-file. Its policy recorded full request and response bodies for namespace shop and metadata elsewhere, and left reads and watches out.

ON THE EXAM

  • “component that validates and stores API objects” → kube-apiserver
  • “order of checks on a request” → authentication, authorization, admission
  • “RBAC objects” → Role or ClusterRole, bound with a RoleBinding or ClusterRoleBinding
  • “identity for a process in a Pod” → ServiceAccount
  • “add your own resource type” → CustomResourceDefinition
  • “declarative management” → kubectl apply
  • “field that detects concurrent updates” → metadata.resourceVersion

A Deployment never creates a Pod

One binary, kube-controller-manager, contains 49 controllers, 46 of them on by default. Four of them wrote 17 times in this chapter, turning one Deployment record into a ReplicaSet and three Pod records, and none of them was told to. Each one read a watch event and compared two numbers. By the end of the chapter Σ writes to cluster state for shop is 19.

STEP 01 / 05

A LOOP THAT COMPARES TWO NUMBERS

watch eventReplicaSet web-67fddbbb7 added
read specreplicas 3
count Podsmatching the selector · 0
compare3 wanted − 0 exist = 3 missing
actcreate 3 Pods, then wait
next watch event: run the same comparison again
a difference of 0 means do nothing
Fig 2.1Follow the loop: read the spec, count what exists, act on the gap. The accent box is the comparison.

A reads the desired state from an object’s spec, counts what actually exists, and acts on the difference. A room thermostat works the same way: you set 21 °C, it measures the room, and it only switches the heater on when the two differ. Then the controller waits for the next watch event and runs the same comparison again.

kube-controller-manager runs 46 such loops in one process, for Deployments, ReplicaSets, StatefulSets, EndpointSlices, nodes and more. Each loop watches only the resources it handles. With several control-plane nodes every node runs a copy, but only the copy holding a Lease in kube-system is active. In this run it renewed that Lease 241 times, once every 2 seconds.

STEP 02 / 05

DEPLOYMENT, THEN REPLICASET, THEN PODS

Deployment · youwebrv 845 · +0 ms
ownerReference: Deployment web
ReplicaSet · deployment-controllerweb-67fddbbb7rv 846 · +5 ms
ownerReference: ReplicaSet web-67fddbbb7
Pod · replicaset-controllerweb-67fddbbb7-d9sc4rv 849 · +8 ms
Pod · replicaset-controllerweb-67fddbbb7-pwq2wrv 851 · +14 ms
Pod · replicaset-controllerweb-67fddbbb7-6h5g4rv 852 · +14 ms
67fddbbb7 = pod-template-hash, also a label on each Pod
Fig 2.2Follow the owner references upward: each arrow points from a child to the object that created it.

At 20:11:46.848, 5 ms after your Deployment was stored, the deployment-controller created web-67fddbbb7. The suffix is a hash of the Pod template, and every Pod carries it again as the label pod-template-hash. Apply the same template to a fresh Deployment and you get the same name.

The replicaset-controller then saw replicas 3 against 0 matching Pods and created d9sc4, pwq2w and 6h5g4 between .851 and .857, 6 ms in all. Each child records an owner reference, a pointer to the object that created it, so deleting the Deployment also deletes its ReplicaSet and their Pods.

The split is deliberate. A Deployment manages ReplicaSets, one per version of the template, and only a ReplicaSet creates Pods. Chapter 07 depends on it: a rolling update is two ReplicaSets changing size at the same time.

WEB.YAML, WITH RESOURCES AND PROBE CUT
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
spec:
  replicas: 3                # the number every controller compares against
  selector:
    matchLabels:
      app: web               # how the ReplicaSet finds its Pods
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:1.30-alpine
        ports:
        - containerPort: 80
STEP 03 / 05

LABELS ARE THE ONLY GLUE

BEFORE
selectorapp=web, pod-template-hash=d75cc9d86
  • web-d75cc9d86-vt6xjapp=web · counted
  • web-d75cc9d86-jmxkwapp=web · counted
  • web-d75cc9d86-dwv6lapp=web · counted
matching Pods 3 · replicas 3 · create 0
AFTER kubectl label pod … app=debug
selectorapp=web, pod-template-hash=d75cc9d86
  • web-d75cc9d86-vt6xjapp=web · counted
  • web-d75cc9d86-jmxkwapp=web · counted
  • web-d75cc9d86-dwv6lapp=debug · not counted, no owner
  • web-d75cc9d86-zf8xkapp=web · counted · new, +16 ms
matching Pods 2 · replicas 3 · create 1
Fig 2.3Compare the selector with each Pod’s labels: the accent Pod no longer matches, so the ReplicaSet counts two and creates one.

A ReplicaSet keeps no list of its Pods. On every watch event it counts the Pods whose labels match its , here app=web plus pod-template-hash, and compares that count with replicas. Labels are free-form key-value pairs, and you can change them on a running Pod.

After the measured run, the same cluster was used to test this. Relabelling web-d75cc9d86-dwv6l to app=debug made the replicaset-controller remove its owner reference 9 ms later and create a fourth Pod, zf8xk, 16 ms after the change. The relabelled Pod kept running, owned by nothing. Services, in chapter 05, find their Pods by label in the same way.

STEP 04 / 05

ELEVEN OF SEVENTEEN WRITES ARE STATUS

  1. #3create configmap kube-root-ca.crt20:11:46.752 · root-ca-cert-publisher · rv 843
  2. #4create serviceaccount default20:11:46.752 · service-account-controller · rv 844
  3. #5create replicaset web-67fddbbb720:11:46.848 · deployment-controller · rv 846
  4. #6update deployment status webstatus20:11:46.851 · deployment-controller · rv 847
  5. #7create pod web-67fddb…d9sc420:11:46.851 · replicaset-controller · rv 849
  6. #8create pod web-67fddb…pwq2w20:11:46.857 · replicaset-controller · rv 851
  7. #9create pod web-67fddb…6h5g420:11:46.857 · replicaset-controller · rv 852
  8. #10update deployment status webstatus20:11:46.857 · deployment-controller · rv 853
  9. #11update replicaset status web-67fddbbb7status20:11:46.861 · replicaset-controller · rv 856
  10. #12update replicaset status web-67fddbbb7status20:11:46.867 · replicaset-controller · rv 861
  11. #13update deployment status webstatus20:11:46.874 · deployment-controller · rv 863
  12. #14update replicaset status web-67fddbbb7status20:11:50.844 · replicaset-controller · rv 890
  13. #15update deployment status webstatus20:11:50.847 · deployment-controller · rv 891
  14. #16update replicaset status web-67fddbbb7status20:11:50.959 · replicaset-controller · rv 893
  15. #17update deployment status webstatus20:11:50.961 · deployment-controller · rv 894
  16. #18update replicaset status web-67fddbbb7status20:11:51.972 · replicaset-controller · rv 905
  17. #19update deployment status webstatus20:11:51.978 · deployment-controller · rv 906
Σ writes so far19
Fig 2.4The ledger lists this chapter’s 17 writes in time order; accent rows write status, the others create objects.

Every object has a spec, what you asked for, and a status, what exists, which controllers and kubelets write. Of this chapter’s 17 writes, 11 are status updates: 6 to the Deployment and 5 to the ReplicaSet, each recording a change in how many Pods exist or are ready. kubectl get reads these fields; it counts nothing itself.

The other 6 writes create objects: one ReplicaSet and three Pods from the apply, and two from the namespace alone. The root-ca-cert-publisher created ConfigMap kube-root-ca.crt and the service-account-controller created ServiceAccount default, both 4 ms after shop existed. Σ writes to cluster state is now 19.

STEP 05 / 05

FIVE CONTROLLERS, FIVE SHAPES OF WORK

  • Deploymentinterchangeable replicas, rolling updatesweb-67fddbbb7-pwq2wPod name in this runbefore shop: coredns, local-path-provisioner
  • DaemonSetone Pod on every nodekube-proxy-ldszbPod name in this runbefore shop: kube-proxy, kindnet
  • StatefulSetstable names, one volume per Podredis-0Pod name in this runarrives in chapter 06
  • Jobrun until it succeeds, then stopreport-29384710-x7k2pexample name formatnone in this run
  • CronJoba new Job on a schedulecreates Jobs, not Podsexample name formatnone in this run
Fig 2.5Each card is one workload controller; accent marks the two kinds already running in this cluster before shop arrived.

Workload controllers differ in what they promise. A Deployment runs interchangeable replicas and replaces them in rolling updates. A StatefulSet gives each Pod a stable name and its own storage (chapter 06). A DaemonSet runs one Pod on every node. A Job runs Pods until a set number succeed, and a CronJob creates a new Job on a schedule.

This cluster ran two of these kinds before shop existed: DaemonSets for kube-proxy and kindnet, and Deployments for CoreDNS and local-path-provisioner. Join a fourth node and the daemon-set-controller creates a kube-proxy Pod and a kindnet Pod for it, without anyone applying anything.

WHAT YOU NOW KNOW

  • A controller compares spec with what exists and acts on the difference; kube-controller-manager runs 46 of them in one process.
  • Deployment web created ReplicaSet web-67fddbbb7, which created three Pods; labels, not names, tie them together.
  • Controllers wrote 17 times in this chapter, 11 of them status updates. Σ writes 19.
deep dive: garbage collection, leader election and operators

Owner references drive garbage collection. Deleting a Deployment by default is a background cascading delete: the Deployment goes at once and the garbage collector removes its ReplicaSets and Pods after. --cascade=foreground deletes the children first, and --cascade=orphan leaves them running with no owner, like the relabelled Pod in step 3.

Leader election runs on Leases. During this 8-minute run kube-controller-manager renewed its Lease 241 times and kube-scheduler 240 times, 481 of the 626 Lease writes the finale reports. If the leader stops renewing, another copy takes the Lease within about 15 seconds.

ReplicationController is the legacy predecessor of ReplicaSet, with equality-only selectors; nothing new should use it. A Job counts successes with completions, runs several Pods at once with parallelism and gives up after backoffLimit failures, 6 by default. A CronJob takes a cron schedule such as 0 3 * * * and a concurrencyPolicy of Allow, Forbid or Replace for runs that overlap.

An operator is a custom controller plus a CustomResourceDefinition: the Prometheus Operator and cert-manager each watch their own resource types and reconcile them the way the replicaset-controller reconciles ReplicaSets. Controllers are level-triggered: each pass compares whole states, not the event that woke it, so a controller that misses an event still converges on the next one.

ON THE EXAM

  • run one Pod on every node → DaemonSet
  • stable network identity and storage → StatefulSet
  • task that runs to completion → Job
  • object that manages ReplicaSets and rollouts → Deployment
  • how a ReplicaSet finds its Pods → label selector
  • desired vs current state loop → reconciliation, the control loop
  • extend Kubernetes with domain logic → the operator pattern

The scheduler picks a node and writes one field

spec.nodeName is empty on all three new Pods. Filling it in is the scheduler’s entire output: 3 writes in 4 ms, which take Σ writes to cluster state from 19 to 22. This chapter follows how it picks the value, what it reads to pick it, and the one thing it never does, which is talk to a node.

STEP 01 / 05

A POD WITH AN EMPTY FIELD

Podweb-67fddbbb7-pwq2w· namespace shop
SPEC · WHAT YOU ASKED FOR
nodeName"" (empty)
schedulerNamedefault-schedulerdefault
containers[0].imagenginx:1.30-alpine
requests.cpu100m
requests.memory64Mi
STATUS · WHAT EXISTS
phasePending
qosClassBurstable
podIPnone yet
containers running0
created by replicaset-controller at 20:11:46.857 · rv 851
Fig 3.1The accent field is the only one the scheduler will change; the rest of the Pod stays as the ReplicaSet wrote it.

A new Pod is a stored record with spec.nodeName: "" and status.phase: Pending. No container exists anywhere yet. No node has been told about the Pod, because no node has been chosen, and choosing one is the only job of .

The scheduler watches for Pods with no node whose schedulerName is default-scheduler, the value the API server fills in when a manifest names none. It puts them in a queue and takes them one at a time, highest priority first. A Pod that fits nowhere goes back in the queue and stays Pending.

STEP 02 / 05

FILTER, THEN SCORE

FILTER: can this node run the Pod? · SCORE: of those that can, which is best?
  • kind-control-plane
    ✗taintcontrol-plane:NoSchedule, no toleration
    ·resources fitnot reached
    ·scorenot scored
    filtered out (CoreDNS tolerates this taint; web does not)
  • kind-worker
    ✓taintnone
    ✓resources fit100m of 12 CPUs requested so far
    ✓scorebalanced use, image locality, spread
    chosen for pwq2w
  • kind-worker2
    ✓taintnone
    ✓resources fit100m of 12 CPUs requested so far
    ✓scorebalanced use, image locality, spread
    chosen for d9sc4 and 6h5g4
Fig 3.2Read each node card top to bottom; the accent cross is the taint check removing the control plane before scoring.

For each Pod the scheduler first filters out nodes that cannot run it. kind-control-plane fails at once: it carries the node-role.kubernetes.io/control-plane:NoSchedule, and the web Pods have no matching toleration. Other filters check free CPU and memory against requests, node affinity and host ports.

The two workers pass and go on to scoring. Plugins such as balanced resource use, image locality and topology spread give each node a score, and the highest wins. Below 100 nodes the scheduler scores every feasible node; on a 5,000-node cluster it stops searching once it has found feasible nodes for 10% of the cluster.

STEP 03 / 05

REQUESTS, NOT USAGE

kind-workerrequested 200m of allocatable cpu 12 (12,000m)
the first 1,000m, magnified: one cell = 100m requested
  1. kindnet
  2. web pwq2w
  3. free
  4. free
  5. free
  6. free
  7. free
  8. free
  9. free
  10. free
kind-worker2requested 300m of allocatable cpu 12 (12,000m)
the first 1,000m, magnified: one cell = 100m requested
  1. kindnet
  2. web d9sc4
  3. web 6h5g4
  4. free
  5. free
  6. free
  7. free
  8. free
  9. free
  10. free
3 nodes × cpu 12 = 36 schedulable CPUs, on 1 laptop VM with 1212 CPUs hold 120 web Pods by request; each node caps Pods at 110
Fig 3.3The bars fill with requests, not measured use; each accent cell is one web Pod’s 100m of a node’s 12 CPUs.

The scheduler adds up , never live usage. Each web Pod requests cpu: 100m, a tenth of a core, and memory: 64Mi. A node fits a Pod when its allocatable capacity, minus the requests of the Pods already placed there, still covers the new one.

Each kind node reports allocatable cpu 12, memory 12232528Ki (about 11.7 GiB) and pods 110. All three nodes are containers on one laptop VM with 12 CPUs, so the scheduler sees 36 CPUs that are really 12. It has no way to check: it trusts what each kubelet reports.

Limits are enforced on the node, not by the scheduler. Past 128 MiB the kernel kills the container, and the kubelet reports it as OOMKilled. Requests below limits give the Pod the QoS class Burstable, which decides its place in line when a node runs short of memory.

THE WEB CONTAINER’S RESOURCES, FROM WEB.YAML
containers:
- name: nginx
  image: nginx:1.30-alpine
  resources:
    requests:          # the scheduler adds these up per node
      cpu: 100m        # a tenth of a core: 120 fit in "cpu 12"
      memory: 64Mi
    limits:            # the kubelet and the kernel enforce these
      memory: 128Mi    # over 128Mi: OOMKilled, exit code 137
# requests below limits → QoS class Burstable
STEP 04 / 05

A BINDING, NOT A COMMAND

KUBE-SCHEDULER WRITES
Binding web-67fddbbb7-d9sc4target kind-worker2 · 20:11:46.859
Binding web-67fddbbb7-pwq2wtarget kind-worker · 20:11:46.862
Binding web-67fddbbb7-6h5g4target kind-worker2 · 20:11:46.863
POST …/binding
kube-apiserversets spec.nodeName on each Pod
watch
kind-workerkubelet sees pwq2w
kind-worker2kubelet sees d9sc4, 6h5g4
no arrow from the scheduler reaches a node
Fig 3.4Each accent row is one Binding write; every arrow from the scheduler ends at the API server, none at a node.

The scheduler’s output is a Binding object, POSTed to the Pod’s binding subresource. The API server applies it by setting spec.nodeName. Three Bindings landed in 4 ms, from 20:11:46.859 to .863: d9sc4 and 6h5g4 to kind-worker2, and pwq2w to kind-worker.

The scheduler never contacts a node. Each kubelet watches for Pods bound to its own node and learns about its new Pods from that watch, as chapter 04 shows. One Binding per Pod is the scheduler’s whole output, and these three take Σ writes to cluster state from 19 to 22.

POST /API/V1/NAMESPACES/SHOP/PODS/WEB-67FDDBBB7-PWQ2W/BINDING
{
  "apiVersion": "v1",
  "kind": "Binding",
  "metadata": {
    "name": "web-67fddbbb7-pwq2w",
    "namespace": "shop"
  },
  "target": {
    "apiVersion": "v1",
    "kind": "Node",
    "name": "kind-worker"
  }
}
STEP 05 / 05

TWO ON ONE NODE, AND THAT’S ALLOWED

default spread · maxSkew 3 per hostname · ScheduleAnywayallowed for 3 replicas on 2 nodes: 3/0, 2/1
kind-worker172.23.0.6
PODS
  • web-67fddbbb7-pwq2w10.244.1.2
1 replica
kind-worker2172.23.0.5
PODS
  • web-67fddbbb7-d9sc410.244.2.2
  • web-67fddbbb7-6h5g410.244.2.3
2 replicas
this run: 2/1, a skew of 1
Fig 3.5Compare the two node boxes: the accent rule, maxSkew 3, lets any split from 3/0 to 2/1 through.

The result was a 2/1 split: kind-worker2 got two replicas and kind-worker one. The scheduler’s built-in spreading is soft. Its default constraint allows nodes to differ by up to 3 Pods (maxSkew: 3 per hostname) with whenUnsatisfiable: ScheduleAnyway, so a 3/0 split would also have passed.

Forcing spread is opt-in: add topologySpreadConstraints with whenUnsatisfiable: DoNotSchedule, or Pod anti-affinity, to the template. To steer Pods toward nodes, use nodeSelector or node affinity. To keep them off a node, taint it, and give the Pods that may still run there a toleration, a matching exception in the Pod spec.

WHAT YOU NOW KNOW

  • A new Pod waits Pending with an empty spec.nodeName; the scheduler filters out nodes that can’t run it, then scores the rest.
  • It counts requests (100m CPU, 64 MiB per web Pod), not measured load, and trusts what nodes report.
  • Its whole output was 3 Binding writes in 4 ms; it never contacted a node. Σ writes 22.
deep dive: affinity, priority, preemption and why Pods stay Pending

nodeSelector matches node labels exactly. Node affinity does the same with operators, in two forms: requiredDuringSchedulingIgnoredDuringExecution is a filter, and preferredDuringSchedulingIgnoredDuringExecution adds to the score. “IgnoredDuringExecution” means a Pod already running stays put when the labels change. Pod affinity and anti-affinity apply the same idea to the labels of Pods already on a node.

Taints come in three effects. NoSchedule keeps new Pods off, PreferNoSchedule is a soft version, and NoExecute also evicts Pods already running there, which chapter 07 watches happen. A PriorityClass ranks Pods: when a high-priority Pod fits nowhere, the scheduler may preempt, evicting lower-priority Pods to make room.

QoS classes follow from requests and limits. Guaranteed means every container has requests equal to limits for CPU and memory; Burstable means some requests are set; BestEffort means none are. Under node memory pressure the kubelet evicts BestEffort Pods first, then Burstable Pods using more than they requested. LimitRange sets per-container defaults in a namespace, and ResourceQuota caps a namespace’s total.

The scheduler is built from plugins at fixed extension points (queue sort, filter, score, bind and others), so its behaviour is configured rather than rewritten. A second scheduler can run beside the default one, and a Pod picks it with schedulerName.

ERRORS THIS CHAPTER EXPLAINS

  • 0/3 nodes are available: 1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: }, 2 Insufficient cpu → the Pod’s requests exceed what is left on every worker → lower the requests, or add nodes
  • pod has unbound immediate PersistentVolumeClaims → no PersistentVolume or StorageClass matches the claim → fix the claim or the class; chapter 06
  • OOMKilled, exit code 137 → the container passed its memory limit → raise the limit, or fix the leak

ON THE EXAM

  • keep Pods off a node unless they opt in → taint plus toleration
  • attract Pods to labelled nodes → nodeSelector or node affinity
  • requests equal limits for every container → Guaranteed QoS class
  • the two scheduling phases → filtering and scoring
  • what the scheduler writes → a Binding, which sets spec.nodeName

From a field on a record to a process on a node

All three Pods were Ready 5.1 seconds after the Deployment was stored. About 3 of those seconds were image downloads, and the rest was the work of two kubelets: one on each worker node, each starting the Pods bound to it and reporting back. They wrote 15 status updates, taking Σ writes from 22 to 37.

STEP 01 / 05

THE KUBELET WATCHES FOR ITS OWN NAME

kube-apiserver3 Pods in namespace shop, each with spec.nodeName set
fieldSelector spec.nodeName=kind-worker
kind-worker172.23.0.6
  • kubeletsees only the Pods below
PODS
  • web-67fddbbb7-pwq2w
1 Pod bound here
fieldSelector spec.nodeName=kind-worker2
kind-worker2172.23.0.5
  • kubeletsees only the Pods below
PODS
  • web-67fddbbb7-d9sc4
  • web-67fddbbb7-6h5g4
first status patch for d9sc4 15 ms after its Binding
Binding for web-67fddbbb7-d9sc4 at 20:11:46.859 UTC
Fig 4.1The accent filter on each kubelet’s watch is its own node name; Pods bound elsewhere never reach it.

The kubelet on kind-worker2 keeps one watch open on Pods with the field selector spec.nodeName=kind-worker2. When the scheduler’s Binding for web-67fddbbb7-d9sc4 set that field at 20:11:46.859, the Pod appeared on that watch. The kubelet’s first status patch for it was stored 15 ms later.

The API server also limits what a kubelet may read. Its Node authorizer lets the kubelet on kind-worker2 read only the Pods bound to kind-worker2, and the Secrets, ConfigMaps and volumes those Pods name. A compromised node can’t list every Secret in the cluster.

STEP 02 / 05

A SANDBOX BEFORE A CONTAINER

kubeletcontainerdkindnet (CNI)
  1. 1. RunPodSandboxstarts the pause container, registry.k8s.io/pause:3.10 · 268 kB
  2. 2. CNI ADDIP 10.244.2.2 from this node’s 10.244.2.0/24
  3. 3. PullImagenginx:1.30-alpine · 25,987,805 bytes · 3.099 s
  4. 4. CreateContainernginx, in the sandbox
  5. 5. StartContainernginx is now a process
sandbox and IP first, image second: every container in the Pod shares this IP
Fig 4.2Read top to bottom: the accent sandbox and its IP exist before the nginx image is even pulled.

The kubelet doesn’t start containers itself; it calls containerd over CRI. RunPodSandbox comes first: containerd starts the pause container, registry.k8s.io/pause:3.10, 268 kB, whose only job is to hold the Pod’s network namespace open. containerd then calls the CNI plugin, kindnet, which gives the sandbox 10.244.2.2 from this node’s range, 10.244.2.0/24.

Only then come PullImage, CreateContainer and StartContainer for nginx. Every container in a Pod joins the sandbox’s network namespace, so they share one IP and reach each other on localhost. On kind-worker2, crictl pods lists 4 sandboxes and crictl ps 4 containers: two web Pods, kube-proxy and kindnet.

WHAT CONTAINERD IS RUNNING ON KIND-WORKER2
docker exec kind-worker2 crictl pods
# POD ID          STATE   NAME                  NAMESPACE
# c081d0d9ab306   Ready   web-67fddbbb7-6h5g4   shop
# 9d6b6860bb75c   Ready   web-67fddbbb7-d9sc4   shop
# a1bdfec61a97b   Ready   kube-proxy-ldszb      kube-system
# 081949ff4402f   Ready   kindnet-brwjn         kube-system

docker exec kind-worker2 crictl ps
# CONTAINER       STATE     NAME          POD
# 4f6715657c960   Running   nginx         web-67fddbbb7-6h5g4
# 75a4c19e9cbec   Running   nginx         web-67fddbbb7-d9sc4
# 52ceecb91d56c   Running   kindnet-cni   kindnet-brwjn
# 5b038c6b63479   Running   kube-proxy    kube-proxy-ldszb
STEP 03 / 05

26 MEGABYTES, PULLED ONCE PER NODE

+0 s = Deployment stored, 20:11:46.843 UTC · each bar is one Pod’s nginx:1.30-alpine pull
+0 s+1 s+2 s+3 s+4 s
pull 3.239 s
…pwq2wkind-worker
pull 3.099 s
…d9sc4kind-worker2
waiting 3.099 s
own 0.770 s
…6h5g4kind-worker2
image size 25,987,805 bytes · pulled once per node, cached by containerd
Fig 4.3Compare the three pull bars: the accent bar on kind-worker2 is mostly waiting for the other Pod’s download.

nginx:1.30-alpine is 25,987,805 bytes. The kubelet on kind-worker pulled it in 3.239 s, and the one on kind-worker2 in 3.099 s. kind-worker2’s second Pod, web-67fddbbb7-6h5g4, asked for the same image at the same moment and waited for that download: its Pulled event reads 770 ms, or 3.869 s including the wait.

containerd caches images per node, so the next Pod on kind-worker2 starts without a download. For an image with a tag other than :latest, the default imagePullPolicy is IfNotPresent: pull only when the node lacks it. For :latest or no tag at all, it is Always.

The image format is OCI, an open standard for images and runtimes. Kubernetes removed its Docker Engine support, the dockershim, in v1.24 on 2022-05-03. Images built with docker build still run on containerd unchanged, because both follow OCI.

STEP 04 / 05

RUNNING IS NOT READY

offsets from the Deployment being stored, 20:11:46.843 UTC
…pwq2wkind-worker
  1. 1. Initialized+34 ms
  2. 2. sandbox ready+420 ms
  3. 3. Running+3,704 ms
  4. 4. Ready+3,996 ms
  5. 5. cpu 79m+4,996 ms
…d9sc4kind-worker2
  1. 1. Initialized+31 ms
  2. 2. sandbox ready+421 ms
  3. 3. Running+3,572 ms
  4. 4. Ready+4,111 ms
  5. 5. cpu 79m+6,116 ms
…6h5g4kind-worker2
  1. 1. Initialized+42 ms
  2. 2. sandbox ready+427 ms
  3. 3. Running+4,336 ms
  4. 4. Ready+5,115 ms
  5. 5. cpu 79m+7,118 ms
…pwq2w: Running → Ready 292 ms, the readiness probe passing
…d9sc4: Running → Ready 539 ms, the readiness probe passing
…6h5g4: Running → Ready 779 ms, the readiness probe passing
Fig 4.4Each row is one Pod’s five status patches; the gap from Running to the accent Ready column is the probe.

A started container puts its Pod in phase Running, but a Service sends it traffic only after its passes. Here the kubelet runs GET / on port 80 every 5 s. Each Pod got 5 status patches, and the audit log shows what each one set: Initialized, sandbox ready, Running with an IP, Ready, and the CPU the container actually got.

Running to Ready took 292, 539 and 779 ms, because nginx answers at once; a slow application can sit Running and unready for much longer. The fifth patch reports 79m of CPU, not the 100m requested: the kernel stores the request as a coarse cgroup CPU weight, and reading that weight back rounds it down.

A liveness probe is different: when it fails, the kubelet restarts the container. Repeated crashes back off 10 s, 20 s, 40 s and so on, up to 5 minutes, shown as CrashLoopBackOff. A startup probe holds both off for slow starters. The kubelets’ 15 status patches bring Σ writes to 37.

THE PROBE THAT GATES TRAFFIC, AND THE ONE THAT RESTARTS
containers:
- name: nginx
  image: nginx:1.30-alpine
  readinessProbe:        # readiness: gates Service traffic
    httpGet:
      path: /
      port: 80
    periodSeconds: 5
# livenessProbe:         # liveness: restarts the container
#   httpGet:
#     path: /
#     port: 80
#   periodSeconds: 5
STEP 05 / 05

5.1 SECONDS, APPLY TO READY

+0 = Deployment stored, 20:11:46.843 UTC · left of the dashed line in ms, right of it in s
youkubectl apply
POST stored +0
controllerskube-controller-manager
ReplicaSet +5 ms
3 Pods +8 to +14 ms
schedulerkube-scheduler
3 Bindings +16 to +20 ms
kubeletskind-worker, kind-worker2
first status +31 ms
last Pod Ready +5,115 ms
image pullsnginx:1.30-alpine
about 3 s each, downloads
kubectlrollout status
returned +5,179 ms
0+20 ms+2 s+5 s
Fig 4.5Watch the cursor: every control-plane row finishes before the 31 ms mark, and the accent pull bars fill the rest.

Measured from the POST being stored at 20:11:46.843: the ReplicaSet at +5 ms, the three Pods at +8 to +14 ms, the Bindings at +16 to +20 ms, the first kubelet status patch at +31 ms. The control plane’s part was finished by then. The downloads took about 3 s, the last Pod was Ready at +5,115 ms, and kubectl rollout status returned at +5,179 ms.

Each stage also left an Event, a short record such as Scheduled, Pulling, Pulled, Created or Started: 22 in namespace shop for this apply. kubectl get events and kubectl describe pod read them, and the API server keeps them for 1 hour by default. Events aren’t in the Σ count: they report what happened rather than what exists.

WHAT YOU NOW KNOW

  • Each kubelet watches only Pods bound to its node and drives containerd over CRI: sandbox, IP from the CNI plugin, image, container.
  • Running is not Ready: a Pod takes traffic only after its readiness probe, run every 5 s, passes.
  • Apply to three Ready Pods took 5.1 s, 31 ms of it control plane; the kubelets wrote 15 status patches. Σ writes 37.
deep dive: restart policies, sidecars and the errors you will see

restartPolicy is Always by default, and can be OnFailure or Never (Jobs use those two). CrashLoopBackOff starts at 10 s and doubles to a 300 s cap, and resets once a container has run cleanly for 10 minutes; a per-node cap on that delay has been beta since v1.35.

Init containers run to completion, one after another, before the app containers start. A sidecar is an init container with restartPolicy: Always: it starts first and keeps running beside the app, GA since v1.33. On deletion the kubelet sends SIGTERM, waits terminationGracePeriodSeconds (30 s by default), then sends SIGKILL.

kubectl debug adds an ephemeral container to a running Pod, useful when the image has no shell. In-place resize of a running container’s CPU and memory is GA since v1.35, and since v1.35 the kubelet refuses to start on a cgroup v1 host by default.

Two runtimes implement CRI today: containerd and CRI-O. Underneath both, a low-level runtime such as runc implements the OCI runtime spec and makes the actual Linux namespaces and cgroups.

ERRORS THIS CHAPTER EXPLAINS

  • ErrImagePull / ImagePullBackOff → a wrong tag, or a private registry with no credentials → fix the tag, or add imagePullSecrets
  • CrashLoopBackOff → the container’s process keeps exiting → kubectl logs --previous shows the last run’s output
  • CreateContainerConfigError → a ConfigMap or Secret key the Pod references is missing (chapter 06) → create the key, or fix the reference
  • Running but 0/1 READY → the readiness probe is failing → kubectl describe pod lists the probe failures as Events

ON THE EXAM

  • node agent that runs Pods → kubelet
  • interface between the kubelet and the container runtime → CRI
  • smallest deployable unit → Pod
  • what containers in one Pod share → network namespace (IP, ports) and volumes
  • image and runtime standards → OCI
  • probe whose failure restarts the container → liveness
  • probe that gates Service traffic → readiness
  • Kubernetes version that removed dockershim → 1.24

A stable name for addresses that keep changing

10.96.192.87 answers HTTP on port 80, and no network interface in the cluster has that address. It belongs to a Service, the object that gives a changing set of Pods one stable name. Behind it are two address lists, 11 packet rules on every node and a DNS name, and setting all of that up took 3 writes: Σ goes from 37 to 40.

STEP 01 / 06

POD IPS ARE DISPOSABLE

UTC, 2026-11-07 · ≈ skips 20:12:25 → 20:19:40 · each bar is the Pod holding that IP
20:11:4620:12:1920:19:48
web …pwq2w
redis-0
10.244.1.2kind-worker
web …d9sc4
10.244.2.2kind-worker2
web …6h5g4
10.244.2.3kind-worker2
hollow: web …pwq2w stopped but still listed on the stopped node until 20:19:47
Fig 5.1Follow the accent address 10.244.1.2: it belongs to a web Pod at 20:11 and to redis-0 at 20:19.

Every Pod gets its own IP from its node’s slice of the Pod network, 10.244.0.0/16: 10.244.1.2 on kind-worker, 10.244.2.2 and 10.244.2.3 on kind-worker2. The network model is flat: any Pod can reach any other Pod’s IP directly, across nodes, without NAT. Making that true is the CNI plugin’s job.

None of those three Pods lasts eight minutes. The rollout in chapter 07 replaces all of them, and when redis-0 was recreated at 20:19:48 it was given 10.244.1.2, the address web Pod web-67fddbbb7-pwq2w held at 20:11. A client that had saved that Pod IP would now be talking to Redis.

STEP 02 / 06

AN ADDRESS NOTHING ANSWERS ON

Service web · ClusterIP 10.96.192.87:80held by no interface, listened on by no process
DNAT → 10.244.1.2:80DNAT → 10.244.2.2:80DNAT → 10.244.2.3:80
kind-worker172.23.0.6
PODS
  • web-67fddbbb7-pwq2w10.244.1.2
kind-worker2172.23.0.5
PODS
  • web-67fddbbb7-d9sc410.244.2.2
  • web-67fddbbb7-6h5g410.244.2.3
ping 10.96.192.87no replies · no rule matches ICMP
HTTP GET http://10.96.192.87/200 OK · Server: nginx/1.30.6
Fig 5.2The accent ClusterIP sits on no node; the dashed chips show it rewritten to three real Pod addresses.

kubectl apply -f web-svc.yaml created a named web: type ClusterIP, selector app=web, port 80. The API server gave it 10.96.192.87 from the Service range, 10.96.0.0/12, which is separate from the Pod range. It is your only write in this chapter, and it takes Σ writes to 38.

No interface holds that address and no process listens on it. Packets sent to it are rewritten on the sending node, before they leave, to a real Pod IP. On the reference cluster, ping 10.96.192.87 got no replies, because no rule rewrites ICMP, while HTTP to the same address returned 200 OK.

WEB-SVC.YAML, YOUR WRITE #38
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shop
spec:                # type: ClusterIP is the default
  selector:
    app: web         # the same label the ReplicaSet counts
  ports:
  - port: 80
    targetPort: 80
# clusterIP assigned: 10.96.192.87
STEP 03 / 06

THE ADDRESS LIST IS ITS OWN OBJECT

EndpointSliceweb-gbr96· namespace shop
ENDPOINTS · discovery.k8s.io/v1
10.244.1.2ready true · kind-worker · …pwq2w
10.244.2.2ready true · kind-worker2 · …d9sc4
10.244.2.3ready true · kind-worker2 · …6h5g4
writes over the run · 24 writes · endpointslice-controller
Endpointsweb· namespace shop
SUBSETS · v1, DEPRECATED SINCE v1.33
10.244.1.2ready true · kind-worker · …pwq2w
10.244.2.2ready true · kind-worker2 · …d9sc4
10.244.2.3ready true · kind-worker2 · …6h5g4
writes over the run · 19 writes · endpoint-controller
Fig 5.3The accent slice and its purple deprecated copy carry the same three addresses, and every change is written to both.

A Service object holds no addresses. The endpointslice-controller watches Services and Pods and writes an , web-gbr96, listing each Pod behind web with its IP, node and readiness. Only Pods whose readiness probe passes are marked ready, and kube-proxy sends traffic only to those.

The endpoint-controller writes the same list a second time, into the older Endpoints object web. Endpoints has been deprecated since v1.33 and is still written in v1.37, so every address change in this run cost 2 writes: 24 to EndpointSlices and 19 to Endpoints. The two creates brought Σ writes to 40.

STEP 04 / 06

ELEVEN RULES ON EVERY NODE

connections to 10.96.192.87:80 enter at the top · connection 1 of 12
rule 1 · probability 0.333of every connection
→ 10.244.1.20of 12
rule 2 · probability 0.5of what is left
→ 10.244.2.20of 12
rule 3 · no testeverything left
→ 10.244.2.30of 12
1/3 · then 1/2 of 2/3 = 1/3 · then all of the last 1/3: each Pod gets a third
Fig 5.4In the accent chain, a third of connections exit at rule 1, half the rest at rule 2, and the remainder last.

kube-proxy runs on every node, watches Services and EndpointSlices, and turns them into packet rules in the node’s kernel; it writes nothing to the API. In iptables mode, still the default in v1.37 (nftables went GA in v1.33, ipvs was deprecated in v1.35), it wrote 11 rules on kind-worker for shop/web.

One rule matches 10.96.192.87:80, one marks traffic from outside the Pod network for masquerading, and 3 chained rules pick a Pod. Each Pod then gets 2: a mark, and a DNAT that rewrites the destination address to the Pod’s IP. The first pick fires with probability 1/3, the second with 1/2 of what is left, and the third takes the rest.

THE RULES KUBE-PROXY WROTE ON KIND-WORKER, TRIMMED
docker exec kind-worker iptables-save -t nat | grep 'shop/web'
# -A KUBE-SERVICES -d 10.96.192.87/32 -p tcp --dport 80
#      -j KUBE-SVC-E3PXEPMWAWIUT6ZV
# -A KUBE-SVC-E3PXEPMWAWIUT6ZV -m statistic --mode random
#      --probability 0.33333333349 -j KUBE-SEP-PPGG7CLVJ7O24ZED
# -A KUBE-SVC-E3PXEPMWAWIUT6ZV -m statistic --mode random
#      --probability 0.50000000000 -j KUBE-SEP-JR4P2S7ZV6K5VKP3
# -A KUBE-SVC-E3PXEPMWAWIUT6ZV -j KUBE-SEP-FGH6ZQZIMO7RP3J3
# -A KUBE-SEP-PPGG7CLVJ7O24ZED -p tcp
#      -j DNAT --to-destination 10.244.1.2:80
# 11 rules for shop/web on this node
STEP 05 / 06

A NAME WITH FOUR DOTS

/etc/resolv.conf · web …pwq2w
nameserver 10.96.0.10CoreDNS’s Service IP
search shop.svc.cluster.local svc.cluster.local cluster.localtried in order, the Pod’s own namespace first
options ndots:5fewer than 5 dots: search first
web0 dots · 1 query
  1. 1. web.shop.svc.cluster.local→ 10.96.192.87
example.com1 dot · 4 queries
  1. 1. example.com.shop.svc.cluster.local→ NXDOMAIN
  2. 2. example.com.svc.cluster.local→ NXDOMAIN
  3. 3. example.com.cluster.local→ NXDOMAIN
  4. 4. example.com→ answered upstream
Fig 5.5Read the search line: the accent first suffix is why the bare name web resolves inside namespace shop.

CoreDNS, the cluster’s DNS server at 10.96.0.10, answers web.shop.svc.cluster.local with 10.96.192.87. The kubelet writes each Pod’s /etc/resolv.conf with that nameserver, three search suffixes and options ndots:5. The search list is why plain web works inside namespace shop, and web.shop from any other namespace.

ndots:5 means a name with fewer than 5 dots is tried with each search suffix before it is tried as written. A lookup of example.com, with 1 dot, sends CoreDNS three cluster names first, and each comes back NXDOMAIN. A trailing dot, example.com., marks the name as complete and skips the search list.

STEP 06 / 06

GETTING IN FROM OUTSIDE

  1. ClusterIPinside the cluster only10.96.192.87:80
  2. NodePortevery node’s IP, one port from 30000–32767adds a port on all 3 nodes
  3. LoadBalanceran external IP from a cloud’s load balancerstays <pending> on plain kind
  4. Gateway API · IngressHTTP routing by host name and path, through a controllerGateway API v1.7.0 · Ingress frozen
Fig 5.6Each row builds on the one above it; the accent row, Gateway API, is where new HTTP routing goes.

A ClusterIP is reachable only from inside the cluster. A NodePort Service also opens one port from 30000–32767 on every node’s IP. A LoadBalancer Service asks a cloud controller for an external load balancer in front of those ports; on plain kind nothing answers, so its external IP stays <pending>.

Routing HTTP by host name and path needs one more layer. An Ingress object routes through an Ingress controller. The Ingress API is frozen, and its successor is Gateway API, GA at v1.0 on 2023-10-31 and at v1.7.0 since 2026-10-29. ingress-nginx, the most widely used Ingress controller, was retired: best-effort maintenance ended in March 2026, and its repository is archived.

WHAT YOU NOW KNOW

  • Pod IPs change with every replacement; Service web gives a stable virtual IP, 10.96.192.87, and the name web.shop.svc.cluster.local.
  • The EndpointSlice lists ready Pods; kube-proxy turns it into 11 iptables rules per node that pick a Pod at random.
  • Outside traffic enters through NodePort, LoadBalancer, Ingress or Gateway API. Σ writes 40.
deep dive: Service types, NetworkPolicy and service meshes

There are five kinds of Service: ClusterIP, NodePort, LoadBalancer, ExternalName (a DNS CNAME to a name outside the cluster, no proxying) and headless, clusterIP: None, whose DNS name returns the Pod IPs themselves. Chapter 06 uses a headless Service for Redis. externalTrafficPolicy: Local sends outside traffic only to Pods on the node that received it, which keeps the client’s source IP.

All Pod traffic is allowed until a NetworkPolicy selects a Pod; from then on, only what the policies allow reaches it. Enforcement is the CNI plugin’s job, so a plugin without NetworkPolicy support ignores the objects. Calico and Cilium enforce them, and Cilium can replace kube-proxy with eBPF programs.

A service mesh such as Istio or Linkerd puts a proxy beside each Pod, or one per node, to add mutual TLS, retries and weighted traffic splitting without changing the application. Clusters can also run dual-stack, giving Pods and Services both an IPv4 and an IPv6 address.

ON THE EXAM

  • stable endpoint for a changing set of Pods → Service
  • default Service type → ClusterIP
  • expose on every node’s IP at a fixed port → NodePort
  • component that programs Service routing on nodes → kube-proxy
  • cluster DNS server → CoreDNS
  • restrict which Pods may talk to each other → NetworkPolicy
  • successor to Ingress for L7 routing → Gateway API
  • mTLS between services without app changes → service mesh
  • Service that returns Pod IPs instead of one virtual IP → headless Service

Configuration, secrets and a disk that has to stay put

One kubectl apply -f redis.yaml created four objects and led to 26 writes by 9 different writers. The four were a Secret holding the Redis password, a ConfigMap holding its config file, a headless Service and a StatefulSet. Pods are replaced all the time, so none of this can live inside one: settings, passwords and data each get an object of their own, and the data gets a disk that stays on one node. Σ writes goes from 40 to 66.

STEP 01 / 05

CONFIGURATION IS AN OBJECT, NOT A FILE IN THE IMAGE

ConfigMapredis-config· namespace shop
DATA · ONE KEY
redis.conf: |
maxmemory 64mbmaxmemory-policy allkeys-lru
mounted at /etc/redis
Pod redis-0 · containerredis
FILESYSTEM
/etc/redis/redis.conf
maxmemory 64mbmaxmemory-policy allkeys-lru
read by redis-server /etc/redis/redis.conf
largest ConfigMap the API accepts1 MiB
largest request etcd accepts, any object1.5 MiB
a mounted file after the ConfigMap changesupdated in place
an environment variable after it changesold value until the Pod is recreated
Fig 6.1Follow the accent key, redis.conf, from the ConfigMap to the file path inside the redis container.

A ConfigMap is an API object that holds configuration as key-value pairs. redis-config has one key, redis.conf, whose value is two lines of Redis settings. The StatefulSet mounts the ConfigMap as a volume at /etc/redis, so each key becomes a file, and Redis reads /etc/redis/redis.conf as if it had been built into the image.

Values can also become environment variables, and the difference shows when the ConfigMap changes. The kubelet rewrites a mounted file in place on a later sync, while an environment variable keeps its old value until the Pod is recreated. A ConfigMap holds at most 1 MiB, because every object has to fit in one etcd request, and etcd’s limit is 1.5 MiB.

STEP 02 / 05

BASE64 IS NOT ENCRYPTION

KUBECTL -O YAML SHOWS
password: czNjcjN0LWV4YW1wbGU=base64, decoded by anyone with base64 -d
ETCD STORES · /REGISTRY/SECRETS/SHOP/REDIS-AUTH · 557 BYTES
  1. k8s\0prefix: protobuf follows
  2. Secret · redis-auth · shopkind, name, namespace
  3. 8ee760c7-9449-4bfc-bdb0-27aefb4c1e97uid
  4. kubectl.kubernetes.io/last-applied-configuration
  5. {…,"stringData":{"password":"s3cr3t-example"}}copy 1 · kubectl’s annotation
  6. kubectl-client-side-apply · Update · FieldsV1 {…}managedFields
  7. passworddata key
  8. s3cr3t-examplecopy 2 · data.password, raw bytes
  9. Opaquetype
plain-text copies of the password in etcd2
encryption at rest on this clusternone configured
Fig 6.2The two accent byte runs are the same password; base64 appears only in kubectl’s output, not on disk.

A Secret is a ConfigMap-shaped object meant for passwords, tokens and keys. kubectl get secret redis-auth -o yaml shows password: czNjcjN0LWV4YW1wbGU=, which is base64 for s3cr3t-example. Base64 is an encoding, not encryption: anyone allowed to read the Secret through the API can decode it with one command.

On disk it is plainer still. The API server stored the Secret in etcd as 557 bytes of protobuf, and the password appears there twice, in plain text. One copy is the raw bytes of the data field. The other sits in kubectl’s last-applied-configuration annotation: the manifest used stringData, and kubectl saves the whole manifest beside the object.

Encryption at rest is opt-in: an EncryptionConfiguration file on the API server with an aescbc, aesgcm or KMS v2 provider. It doesn’t help against someone who may create Pods in shop, because any Pod there can mount any Secret in the same namespace.

BASE64 IN KUBECTL, PLAIN TEXT IN ETCD
kubectl -n shop get secret redis-auth -o jsonpath='{.data.password}'
# czNjcjN0LWV4YW1wbGU=

kubectl -n shop get secret redis-auth -o jsonpath='{.data.password}' | base64 -d
# s3cr3t-example

kubectl -n kube-system exec etcd-kind-control-plane -- etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/shop/redis-auth --print-value-only \
  | strings | grep -c s3cr3t-example
# 2
STEP 03 / 05

A CLAIM, A CLASS, A VOLUME

PVC data-redis-0 created+0 ms · statefulset-controller · 1Gi RWO
scheduler picks kind-worker+17 ms · writes selected-node on the claim
PV pvc-247d…ff435a created+3,067 ms · local-path-provisioner · on kind-worker’s disk
redis-0 bound to kind-worker+4,022 ms · Binding by the scheduler
StorageClass standard · provisionerrancher.io/local-path
volumeBindingModeWaitForFirstConsumer
PV node affinity · kubernetes.io/hostnamekind-worker
reclaim policyDelete
Fig 6.3Read left to right: the accent PersistentVolume appears only after the scheduler has picked kind-worker.

A Pod asks for storage with a : a size and an access mode, but no backend. The StatefulSet created claim data-redis-0 for 1Gi ReadWriteOnce (one node at a time) from StorageClass standard. A StorageClass names a provisioner, here rancher.io/local-path, which creates PersistentVolumes, the objects that point at real storage.

standard sets volumeBindingMode: WaitForFirstConsumer, so no volume is made until a Pod using the claim is scheduled. The scheduler picked kind-worker 17 ms after the claim and wrote that choice onto it. The provisioner then created PersistentVolume pvc-247d41e1-…, a directory on kind-worker’s disk, 3,067 ms after the claim, and the binder bound the two together.

redis-0 got its Binding 4,022 ms after the claim, with its volume already waiting on the right node. Cloud disks and network storage plug in the same way, through CSI, the Container Storage Interface that storage drivers implement.

STEP 04 / 05

REDIS-0, NOT REDIS-7F3K9

POD · BY ORDINAL
redis-0created first, deleted last; never redis-7f3k9
CLAIM · FROM VOLUMECLAIMTEMPLATES
data-redis-0bound to PV pvc-247d41e1…, 1Gi RWO
DNS · FROM HEADLESS SERVICE REDIS
redis-0.redis.shop.svc.cluster.localclusterIP: None, so DNS returns the Pod IP
template version · ControllerRevision redis-75fc8b5997delete the StatefulSet · data-redis-0 stays
Fig 6.4Compare the names: the accent 0 in redis-0 and data-redis-0 is one ordinal, and that pairing survives every restart.

A names its Pods by ordinal instead of a random suffix: redis-0, then redis-1 if you scale up, created one at a time in order and deleted in reverse. For each ordinal it makes one claim from its volumeClaimTemplates, so redis-0 always mounts data-redis-0.

serviceName: redis points at a headless Service, one created with clusterIP: None. DNS for a headless Service returns the Pod IPs instead of one virtual IP, and each Pod gets a name of its own: redis-0.redis.shop.svc.cluster.local. Each version of the Pod template is kept as a ControllerRevision, here redis-75fc8b5997.

Deleting the StatefulSet leaves data-redis-0 and its volume in place by default. Apply the same manifest again and the new redis-0 mounts the old data.

STATEFULSET REDIS, TRIMMED: SERVICE NAME, SECRET REFERENCE, CLAIM TEMPLATE
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
  namespace: shop
spec:
  serviceName: redis         # the headless Service: redis-0.redis.shop.svc
  replicas: 1                # selector, ports and volumes cut here
  template:
    spec:
      containers:
      - name: redis
        image: redis:8.2-alpine
        env:
        - name: REDIS_PASSWORD
          valueFrom:
            secretKeyRef: {name: redis-auth, key: password}
  volumeClaimTemplates:      # one claim per ordinal: data-redis-0
  - metadata: {name: data}
    spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 1Gi}}}
STEP 05 / 05

NINE WRITERS FOR ONE REDIS

WRITES CAUSED BY KUBECTL APPLY -F REDIS.YAML, BY WRITER
statefulset-controller6 writes
persistent-volume-binder4 writes
you4 writes
kube-scheduler3 writes
kubelet kind-worker3 writes
endpoint-controller2 writes
endpointslice-controller2 writes
local-path-provisioner1 write
pvc-protection-controller1 write
Σ writes to cluster state so far66
Fig 6.5The ledger groups this chapter’s 26 writes by writer; the accent row is the one add-on among them.

Your 4 creates (Secret, ConfigMap, Service, StatefulSet) caused 22 more writes. The statefulset-controller wrote 6: a ControllerRevision, the claim, the Pod and three status updates. The persistent-volume-binder wrote 4 to bind claim and volume. The scheduler wrote 3: the node it nominated, the claim’s selected-node annotation and the Binding. The kubelet on kind-worker patched redis-0’s status 3 times.

The two endpoint controllers wrote 2 each: they created the address lists for Service redis, then added redis-0 once it was Ready. The pvc-protection-controller, which guards claims that a Pod still uses, wrote 1. Σ writes goes from 40 to 66.

The local-path-provisioner wrote the PersistentVolume. It is an ordinary Deployment in its own namespace that kind installs. Every other writer in this tutorial ships with Kubernetes.

WHAT YOU NOW KNOW

  • ConfigMaps and Secrets are API objects mounted into Pods as files or environment variables, each limited to 1 MiB.
  • A Secret is base64 in kubectl’s output and plain text in etcd unless encryption at rest is configured.
  • A StatefulSet gave redis-0 a stable name, a DNS entry and its own claim; 9 writers made 26 writes. Σ 66.
deep dive: access modes, reclaim policies and keeping Secrets secret

Access modes: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany (many nodes, which needs storage that supports it, such as NFS) and ReadWriteOncePod (exactly one Pod). The reclaim policy says what happens to a volume when its claim is deleted: Delete, this StorageClass’s setting, removes it; Retain keeps it for an admin to clean up by hand.

Static provisioning means an admin creates PersistentVolumes ahead of time and claims bind to whichever matches; dynamic provisioning, as here, creates one per claim through a StorageClass. Not every volume is persistent: emptyDir lives and dies with the Pod, hostPath mounts a node directory, and projected volumes combine ConfigMaps, Secrets and ServiceAccount tokens in one mount. CSI drivers also add volume snapshots and resizing.

A ConfigMap or Secret marked immutable: true can’t be changed, only replaced, and the kubelet stops watching it. Many teams keep the real secret elsewhere and sync it in, with the External Secrets Operator or the Secrets Store CSI Driver, which reads a cloud secret manager.

Pod Security Standards define three levels, privileged, baseline and restricted, and the built-in Pod Security Admission enforces one per namespace through a label. Inside a Pod, securityContext sets fields such as runAsNonRoot and readOnlyRootFilesystem, which the restricted level checks for.

ON THE EXAM

  • “Pod asks for storage without naming a backend” → PersistentVolumeClaim
  • “admin defines kinds of storage” → StorageClass
  • “standard interface for storage drivers” → CSI
  • “non-confidential configuration” → ConfigMap
  • “how Secret values are encoded” → base64, not encrypted by default
  • “encrypt Secrets in etcd” → EncryptionConfiguration
  • “built-in Pod security levels” → Pod Security Standards, enforced by Pod Security Admission
  • “stable per-Pod DNS for a StatefulSet” → headless Service

A new image, a dead node, and 154 writes to set things right

kubectl -n shop set image deploy/web nginx=nginx:1.31-alpine is one write; the rollout it starts is 85 more. Under a second after the rollout finished, kind-worker stopped, and the cluster spent 68 more writes and 7 minutes 29 seconds replacing what ran there. Both are handled by loops like the ones in chapter 02, comparing what should exist with what does, and together they take Σ writes from 66 to 220.

STEP 01 / 05

A NEW TEMPLATE IS A NEW REPLICASET

Deployment · template nginx:1.31-alpineweb
ReplicaSet · revision 1web-67fddbbb7nginx:1.30-alpine · kept at 0
ReplicaSet · revision 2web-d75cc9d86nginx:1.31-alpine · new template hash
REPLICAS SET BY THE DEPLOYMENT-CONTROLLER, FROM THE SET IMAGE WRITE
atweb-67fddbbb7web-d75cc9d86
before3 replicas0 replicas
+9 ms3 replicas1 replicas
+4.1 s2 replicas2 replicas
+8.2 s1 replicas3 replicas
+9.2 s0 replicas3 replicas
Fig 7.1Two ReplicaSets under one Deployment: the accent one climbs from 0 to 3 replicas as the old one falls to 0.

set image patches one field, the container image in the Deployment’s Pod template (Σ 67). A different template hashes to a different pod-template-hash, so 9 ms later the deployment-controller creates a second ReplicaSet, web-d75cc9d86. A is that controller changing two numbers, a step at a time: the new ReplicaSet’s replicas go up while the old one’s go down.

The old ReplicaSet, web-67fddbbb7, is not deleted. It stays at 0 replicas, and the Deployment keeps up to revisionHistoryLimit (10) of them. kubectl rollout undo copies revision 1’s template back into the Deployment. That template hashes to 67fddbbb7 again, so the old ReplicaSet scales back up and no new one is made.

THE ROLLOUT, AND THE ROLLBACK IT KEEPS AVAILABLE
kubectl -n shop set image deploy/web nginx=nginx:1.31-alpine
kubectl -n shop rollout status deploy/web
# deployment "web" successfully rolled out          (9.3 s)

kubectl -n shop get rs
# NAME            DESIRED   CURRENT   READY
# web-67fddbbb7   0         0         0
# web-d75cc9d86   3         3         3

kubectl -n shop rollout undo deploy/web
# would scale web-67fddbbb7 back to 3 and web-d75cc9d86 to 0
STEP 02 / 05

ONE UP, NONE DOWN

v1 nginx:1.30, Ready v2 nginx:1.31, Ready v2, not Ready yet
before3 Pods · 3 Ready
+0.01 s4 Pods · 3 Ready
+4.07 s4 Pods · 4 Ready
+4.10 s4 Pods · 3 Ready
+8.19 s4 Pods · 4 Ready
+8.20 s4 Pods · 3 Ready
+9.19 s4 Pods · 4 Ready
+9.20 s3 Pods · 3 Ready
max 4 Pods: 3 + maxSurge 1 min 3 Ready: 3 − maxUnavailable 0
Fig 7.2Count each column: the total never passes the accent ceiling of 4, and Ready never drops below 3.

Two defaults bound the rollout: maxSurge 25%, how many Pods may exist above replicas, and maxUnavailable 25%, how many may be missing. For 3 replicas, 25% is 0.75. Surge rounds up, to 1, and unavailable rounds down, to 0. So this rollout may run 4 Pods at most and must keep 3 Ready throughout.

The deployment-controller worked in three rounds. It scaled web-d75cc9d86 up by one and waited for the new Pod to pass its readiness probe. Then it scaled web-67fddbbb7 down by one and the new set up by one, in the same moment. Rounds two and three each began when a v2 Pod turned Ready, at +4.1 s and +8.2 s.

The last v2 Pod was Ready at +9.2 s and the old ReplicaSet went to 0. rollout status returned at 20:12:19.082, 9.3 s after you ran the command. The rollout took 86 writes, and Σ writes reaches 152.

STEP 03 / 05

THE HEARTBEAT IS A LEASE

Lease kind-worker · spec.renewTime20:11:32.758
11:3012:0012:3013:00
11:32.8
11:43.1
11:53.1
12:03.5
12:13.7
stopped
Unknown + taint50.5 s
44 s, 1 in 3
Lease renewalskubelet, kind-worker
node kind-workernode-lifecycle ctrl
EndpointSliceService web, pz7wt
the kubelet renews its Lease: renewTime 20:11:32.758
Fig 7.3The accent tick is the last Lease renewal; the gap after it runs 50.5 s until the node is marked Unknown.

Each kubelet shows its node is alive by renewing a Lease, a small object in namespace kube-node-lease, every 10 s. kind-worker’s renewals landed at 20:11:43.085, 20:11:53.105, 20:12:03.482 and 20:12:13.677. At 20:12:19.835 the node stopped, and the next renewal never came.

The node-lifecycle controller, inside kube-controller-manager, waits out --node-monitor-grace-period, 50 s since v1.32 (40 s before). At 20:13:04.186, 50.5 s after the last renewal, it set the node’s Ready condition to Unknown, added the taint node.kubernetes.io/unreachable with effects NoSchedule and NoExecute, and marked the node’s Pods not Ready.

A Pod that is not Ready leaves its Service’s EndpointSlice: the endpointslice-controller removed pz7wt at 20:13:04.201. Until that write, kube-proxy on every node kept sending a third of new connections for web to an address where nothing answered.

STEP 04 / 05

FIVE MINUTES OF TOLERANCE

0 = kind-worker stopped, 20:12:19.835 UTC; ≈ cuts out 4 min 42 s
20:12:1920:13:0420:18:04
stopped
Unknown, taint unreachable
node kind-workerheartbeats stop
wait 300 s
3 Pods evicted
Pods on kind-workerpz7wt, pwq2w, redis-0
created +12 ms
Ready +1.4 s
web replacementdwv6l, kind-worker2
Fig 7.4The accent bar is the 300-second toleration; the replacement web Pod appears 12 ms after it ends.

A NoExecute taint evicts running Pods unless they tolerate it. Every Pod here carried two tolerations nobody wrote, for node.kubernetes.io/not-ready and unreachable, with tolerationSeconds: 300. The DefaultTolerationSeconds admission plugin adds them to every Pod at creation (chapter 01), so a Pod stays bound to an unreachable node for five minutes.

At 20:18:04.290, 300.1 s after the taint, the taint-eviction controller deleted the 3 Pods on kind-worker: v2 pz7wt, the old v1 pwq2w that was still terminating, and redis-0. Deleting a Pod for this reason is called eviction. On a node that can’t answer, it only marks the Pods for deletion.

12 ms later the replicaset-controller counted 2 Pods against replicas 3 and created web-d75cc9d86-dwv6l. The scheduler bound it to kind-worker2, the only worker left, which already had the image, and it was Ready at 20:18:05.698. Through all of it, web never had fewer than 2 Ready Pods.

TWO TOLERATIONS NOBODY WROTE
# kubectl -n shop get pod web-d75cc9d86-pz7wt -o yaml, spec.tolerations
# added by the DefaultTolerationSeconds admission plugin; not in web.yaml
tolerations:
- effect: NoExecute
  key: node.kubernetes.io/not-ready
  operator: Exists
  tolerationSeconds: 300
- effect: NoExecute
  key: node.kubernetes.io/unreachable
  operator: Exists
  tolerationSeconds: 300
STEP 05 / 05

THE STATEFULSET WAITS FOR PROOF

Ready Pods per workload, 20:11:50 → 20:20:00 UTC; ≈ marks 4 min 20 s cut out
20:12:19 stop20:18:04 evicted20:19:48
3 Ready
2 Ready
3 Ready: dwv6l replaced pz7wt
webReplicaSet, 3 replicas
Ready
down 7 min 28.6 s
Ready
redisStatefulSet, 1 replica
Fig 7.5Compare the lanes: web’s accent replacement starts after the 300 s wait, while redis stays down until the node returns.

The eviction marked redis-0 for deletion, but a Pod on an unreachable node stays Terminating until its kubelet confirms the containers are gone. A ReplicaSet doesn’t wait for that: dwv6l started while pz7wt was still Terminating. A StatefulSet does wait, because a second redis-0 could write to data-redis-0 while the first one might still be running.

It couldn’t have moved anyway: the PersistentVolume is a directory on kind-worker’s disk, pinned to that node by its node affinity. kind-worker restarted at 20:19:44.4 and was Ready at 20:19:46.2. Its kubelet confirmed the deletion at 20:19:48.018, and 6 ms later the statefulset-controller created a new redis-0, on kind-worker, Ready at 20:19:48.442.

Redis was down for 7 min 28.6 s, from the stop to that Ready. The node failure took 68 writes, and the last one, a StatefulSet status update, brought Σ writes to 220. Force-deleting the stuck Pod would have ended the wait early; the deep dive says why the docs warn against it.

WHAT YOU NOW KNOW

  • A changed template made ReplicaSet web-d75cc9d86; the 25% defaults meant 1 extra Pod and 0 missing, 86 writes in 9.3 s.
  • A node is marked Unknown 50 s after its last Lease renewal, and its Pods are evicted 300 s after that.
  • The web replacement came 12 ms after eviction; redis-0 waited for the node to return, 7 min 29 s. Σ writes 220.
deep dive: scaling, disruption budgets and debugging a broken rollout

The Kubernetes docs call this behaviour “self-healing”, and it is exactly the loops in this chapter: a controller comparing replicas with Ready Pods, and a node controller comparing Lease renewals with a clock. Nothing else watches for failure.

kubectl scale sets replicas by hand. A HorizontalPodAutoscaler sets them from a metric: every 15 s it computes desired = ceil(current × current metric ÷ target), ignores changes within 10%, and waits 5 minutes before scaling down. It needs metrics-server, which kind doesn’t install. The VerticalPodAutoscaler changes requests instead, and node autoscalers such as Cluster Autoscaler and Karpenter add or remove nodes.

kubectl drain cordons a node (no new Pods) and then evicts its Pods. A PodDisruptionBudget limits how many Pods of an app such voluntary evictions may remove at once; it does nothing for a node that simply stops, as in this chapter.

A rollout that makes no progress for progressDeadlineSeconds (600) gets the condition ProgressDeadlineExceeded, but Kubernetes doesn’t roll it back for you. The Recreate strategy deletes every old Pod before starting new ones. Blue/green and canary releases are built from two Deployments and labels, or from weighted routes in Gateway API.

kubectl delete pod redis-0 --grace-period=0 --force removes the object without the kubelet’s confirmation. If the node was only cut off, the old redis-0 may still be running and writing to the volume when the new one starts, which is exactly what a StatefulSet exists to prevent.

A debugging order that works for every failure in this tutorial: kubectl get, then describe, then get events, then logs --previous, then exec or debug.

ERRORS THIS CHAPTER EXPLAINS

  • rollout stuck at 1 out of 3 new replicas have been updated → the new Pods never turn Ready → check the readiness probe, then kubectl rollout undo
  • ImagePullBackOff after set image → a bad tag or a private registry → kubectl rollout undo, then fix the tag
  • Pod Terminating for minutes on a NotReady node → its kubelet can’t confirm the deletion → bring the node back, or delete the Node object

ON THE EXAM

  • “update Pods without downtime” → rolling update
  • “revert a Deployment” → kubectl rollout undo
  • “scale on CPU usage” → HorizontalPodAutoscaler
  • “limit voluntary disruptions” → PodDisruptionBudget
  • “take a node out for maintenance” → kubectl drain
  • “what happens to Pods on a failed node” → evicted after tolerationSeconds 300, recreated by their controller
  • “how the control plane detects node failure” → node Leases in kube-node-lease

220 writes, and who made them

Eight minutes and two seconds of one app, counted once more. The board sorts the 220 writes by who made them, what kind of change each one was, and which chapter caused it. The last block lists what the cluster wrote in the same time and this tutorial left out.

namespace shop · 2026-11-07 20:11:46 → 20:19:48 UTC · kind · v1.37.0
20:11:4620:19:48
one tick per write; the 8 writes by the node controller in purple
BY WRITER
controllers in kube-controller-manager142 writes
replicaset 38 · deployment 32 · endpointslice 24 · endpoints 19 · statefulset 12 · node lifecycle and eviction 8 · volume binder 4 · claim protection 3 · CA publisher 1 · service accounts 1
kubelets, kind-worker2 31 and kind-worker 2758 writes
kube-scheduler11 writes
you, through kubectl8 writes
local-path-provisioner, an add-on1 write
BY KIND OF WRITE
status updates125 writes
address lists, scale changes, claims, set image48 writes
creates36 writes
deletes11 writes
WRITES BY CHAPTER
00 001 202 1703 304 1505 306 2607 154
ALSO WRITTEN IN THE SAME 8 MIN 2 S, NOT COUNTED
Lease renewals, node heartbeats and leader election626 writes
Events in namespace shop85 writes
token requests, nothing stored11 requests
updates refused with 409 Conflict, then retried2 requests
Σ writes to cluster state for one app220
Read next. AWS VPC: how one request crosses a private network for the private network a managed cluster’s nodes and load balancers sit in.
statefulset/redis status · readyReplicas 1 · resourceVersion 1933