Animated guide · Kubernetes v1.37

Kubernetes from the inside

Everything a developer meets on the way from kind create cluster to a multi-node production cluster: what each component really does, how a YAML file turns into running containers, how traffic finds your Pod, how to watch it all — and what breaks when a piece goes missing. Scroll — the diagrams start by themselves.

desired state, not magic 🧠
scroll ↓

How to read this guide

loops all the way down

Kubernetes has hundreds of object kinds and thousands of fields. Memorising them does not scale — and you don't have to. Almost everything in the system is the same small idea repeated: you write down what you want, and a loop keeps making reality match it. Once you can see those loops, a failing rollout, a Pod stuck in Pending or a Service with no traffic stops being mysterious: you ask which loop should be acting here, and what is it seeing? Every chapter below takes one mechanism apart with a diagram you can poke — and a button that breaks it on purpose.

Facts were checked against the official Kubernetes documentation for v1.37 (and the Helm, kind, Skaffold, Tilt, Argo CD, Prometheus, etcd and OpenTelemetry docs) in September 2026. Kubernetes ships new minor releases every few months, so check the release notes for anything newer.

The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 internals, L4 production. Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.

🧠 Brain power

You kubectl apply a Deployment with 3 replicas. At 3 a.m. the node running one of them loses power. Who notices, who decides what to do, who actually starts the replacement container — and which of them ever talks to etcd? Keep your guess; the answer is at the very end.

Part I

Essentials

The mental model and daily vocabulary: the control loop, the components, Pods, Deployments, Services, config, resources and kubectl. Enough to ship and debug an app.

Desired state & the reconciliation loop

you describe, it chases

Here's the one idea the rest of this page hangs on: you never tell Kubernetes what to do. You tell it what you want. Then a crowd of small programs spends the rest of the cluster's life trying to make reality match.

The Kubernetes docs use a thermostat. You set 21 °C: that's the desired state. The room is at 17 °C: that's the current state. The thermostat keeps switching the heating on or off to close the gap. It doesn't run a script called "heat the room". It just keeps comparing.

Almost every Kubernetes object carries both halves. The spec is what you want, and you write it. The status is what's true right now, and the system writes it. When you create a Deployment with replicas: 3, you're writing a spec. Nobody has started anything yet.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:              # ← you write this: desired state
  replicas: 3
  ...
status:            # ← the cluster writes this
  readyReplicas: 2 #   current state (one Pod still starting)

A controller is the thermostat. It watches one kind of object, compares spec with reality, and acts to close the gap. Then it does it again. Forever. That's the reconciliation loop: observe → diff → act.

🧠 Brain power

You run kubectl apply -f web.yaml and three containers appear a few seconds later. Which program actually started them? (Hint: it's not kubectl. By the end of chapter 2 you'll be able to name every hand the request passes through.)

That's the misconception to drop right now: "kubectl starts containers." It doesn't. kubectl sends your object to the API server, which stores it. That's it. The Deployment controller, the ReplicaSet controller, the scheduler and the kubelet each notice the new object on their own and do their part. kubectl has usually exited before the first container exists.

▶ A ReplicaSet controller, looping change the spec, kill a Pod, then kill the controller
spec (you)
3
replicas wanted
👀 observe
⚖ diff
🔧 act
controller: running
status (cluster)
3 running
The loop compares spec with reality. Change either side and watch it react.
Simplified: a real ReplicaSet controller reacts to watch events and also resyncs, creates Pods through the API server, and the scheduler and kubelet then have to place and start them. Here "act" jumps straight to a running Pod. Timing is compressed.

Notice what happened when you stopped the controller: nothing broke loudly. The spec still said 3, the status quietly said 2, and nobody closed the gap. When the controller came back, it didn't need to know what had happened while it was away. It compared, saw a gap, and acted. That's why controllers compare whole states instead of replaying a to-do list.

❓ There are no dumb questions
Q:
Does the cluster ever reach "done"?
A:
Not necessarily. The docs say it plainly: your cluster could be changing at any point and may never reach a stable state. That's fine, as long as the controllers keep running and can make useful changes.
Q:
A Deployment and a Job both create Pods. Why doesn't the Job controller delete my Deployment's Pods?
A:
Labels. Each controller only pays attention to the resources linked to its own controlling object, and labels (plus owner references) are how it tells them apart.
Q:
Can I write my own controller?
A:
Yes. Built-in ones run inside kube-controller-manager; yours can run as Pods in the cluster or outside it. Chapter 24 on operators goes there.
🔬 Go deeper: inside kube-controller-manager

Logically each controller is a separate process, but to reduce complexity they're compiled into one binary and run in one process: kube-controller-manager. The Node, Job, EndpointSlice and ServiceAccount controllers all live there, next to the Deployment and ReplicaSet controllers.

None of them read etcd. They watch the API server (a GET with ?watch is a watch) and keep client-side caches fed by those watches, which client libraries call informers. Each controller processes a bounded number of objects at a time: --concurrent-deployment-syncs and --concurrent-replicaset-syncs both default to 5.

In a highly available control plane you run several copies, but only one acts. --leader-elect defaults to true: instances compete for a Lease object, and standbys wait (--leader-elect-lease-duration, default 15 s) after the last renewal before trying to take over. Chapter 20 opens this box fully.

📌 Bullet points
  • spec = what you want (you write it). status = what's true (the cluster writes it).
  • A controller loops forever: observe → diff → act. Many small controllers, each owning one kind of object.
  • kubectl only writes objects to the API server. Controllers, the scheduler and the kubelet do the work.
  • A controller that was down just compares again when it returns; the gap is still visible, so it acts.

Architecture: the control plane & the nodes

everyone talks to the API server

A cluster is two kinds of machines. The control plane makes global decisions: where things run, what to do when something dies. The nodes (worker machines) actually run your containers. Every cluster needs at least one worker node. In production the control plane usually spans several machines too.

Here is the cast. Learn these names once; they appear in every error message you'll ever read.

ComponentWhereIts one job
kube-apiservercontrol planeServes the Kubernetes API. The front door, and the only door: everyone else goes through it.
etcdcontrol planeConsistent, highly available key-value store holding all cluster data. The API server persists objects here.
kube-schedulercontrol planeWatches for new Pods with no assigned node and picks a node for each.
kube-controller-managercontrol planeRuns the built-in controllers (Deployment, ReplicaSet, Node, Job, EndpointSlice…).
cloud-controller-managercontrol plane (optional)Cloud-specific loops: load balancers, routes, "was this node deleted in the cloud?". Absent on a laptop cluster.
kubeletevery nodeMakes sure the containers described in PodSpecs are running and healthy.
kube-proxyevery node (optional)Implements Services on the node's network. Some network plugins replace it.
container runtimeevery nodeActually runs containers (containerd, CRI-O…), driven by the kubelet over the CRI.

The shape to remember is a hub and spoke. All API usage from nodes and the Pods on them terminates at the API server; none of the other control plane components are designed to expose remote services. The scheduler doesn't call the kubelet. The controller-manager doesn't call the scheduler. They all watch the API server and write back to it. In a standard setup, the API server is the only component that talks to etcd.

Nobody gives orders. Everybody reads the shared whiteboard and does their bit.

▶ kubectl create deployment web --replicas=3 follow the request through the cluster
control plane
💻 kubectl
kube-apiserver
🗄 etcd
controller-manager
kube-scheduler
node-1
kubelet
runtime (CRI)
node-2
kubelet
runtime (CRI)
Press ▶ to send the command.
Simplified: watches are shown as dots flying from the API server; in reality each component holds an open watch and the API server streams events. etcd writes happen for every object and status update; only some are drawn. Controllers, scheduler and kube-proxy details are left out.

With the scheduler dead, the Deployment and ReplicaSet still appeared, and three Pod objects existed in etcd. They just never got a node, so no kubelet ever claimed them. They sit in Pending. A Pod whose scheduler can't place it stays unscheduled until it can be placed; nothing times out on its own.

❓ There are no dumb questions
Q:
What happens when a whole node dies at 3 a.m.?
A:
The Node controller notices the node is unreachable and sets its Ready condition to Unknown. Pods get an automatic toleration of tolerationSeconds=300 for not-ready / unreachable, so they stay bound to the dead node for 5 minutes, then get evicted. The ReplicaSet controller sees too few Pods and creates new ones; the scheduler places them on healthy nodes.
Q:
Does the kubelet run things that aren't from the API server?
A:
It takes PodSpecs "provided through various mechanisms", including static Pod files on the node's disk. It doesn't manage containers Kubernetes didn't create.
Q:
Where does DNS come from?
A:
An addon. Strictly optional, but the docs say every cluster should have cluster DNS. Pod IPs come from another addon: a CNI network plugin.
🤓 Deep dive

How is the control plane itself run? Four common ways: as plain services on dedicated machines, as static Pods that the kubelet starts from files (what kubeadm does), self-hosted inside the cluster, or hidden entirely by a managed service.

How does the control plane know a node is alive? For every Node there's a Lease object with the same name in the kube-node-lease namespace, and every kubelet heartbeat is an update to it. The same Lease API is what kube-controller-manager and kube-scheduler use for leader election. Chapter 22 follows the kubelet in detail.

📌 Bullet points
  • Control plane: API server, etcd, scheduler, controller-manager (+ cloud-controller-manager). Node: kubelet, runtime, usually kube-proxy.
  • Hub and spoke: every component talks to the API server; the API server stores objects in etcd.
  • Components never call each other. They watch objects and write objects.
  • Scheduler down → new Pods stay Pending. Running Pods keep running.

The Pod from inside

one IP, many containers

You never run a bare container in Kubernetes. The smallest thing you deploy is a Pod: one or more containers that are scheduled together, on the same node, sharing some things.

What they share is the key. Every container in a Pod shares the network namespace: the same IP address and the same port space. So two containers in one Pod reach each other on localhost, and they can't both listen on port 8080. They can also share volumes declared on the Pod, which is how a helper container reads the files your app writes.

Containers in different Pods have different IPs. They talk over the network, like strangers.

spec:
  initContainers:
  - name: migrate          # runs to completion first
    image: myapp-migrations:1.4
  - name: log-agent        # sidecar: keeps running
    image: fluent/fluent-bit:3.0
    restartPolicy: Always
  containers:
  - name: web              # your app
    image: myapp:1.4
    ports:
    - containerPort: 8080

Init containers run before your app, one at a time, in order. Each must finish successfully before the next starts. If one fails, the kubelet restarts it until it succeeds (unless the Pod's restartPolicy is Never, in which case the whole Pod fails). Perfect for "wait for the database" or "run migrations".

A sidecar is an init container with restartPolicy: Always. It starts in order like other init containers, but it doesn't have to exit: once it's started, the kubelet moves on to the next one, and it keeps running next to your app. At shutdown, sidecars are stopped after the main containers, in reverse order. Native sidecars have been stable since Kubernetes 1.33.

▶ Inside one Pod start it, then make the app crash
Pod web-0—
🌐 shared network: IP 10.244.1.7 · containers reach each other on localhost
sandboxholds the namespaces—
migrateinit—
log-agentsidecar—
webapp—
📁 shared volume /var/log/app (web writes, log-agent reads)
Press ▶ to watch the kubelet bring the Pod up.
Simplified: the kubelet also pulls images, mounts volumes and runs probes. Backoff delays are the documented defaults (10 s doubling, capped at 300 s); the timeline is compressed and ignores the time each crash takes. The IP is illustrative.

A Pod has a phase, a one-word summary. Each container inside also has a state: Waiting, Running or Terminated.

PhaseMeans
PendingAccepted, but some container isn't set up yet. Includes waiting to be scheduled and pulling images.
RunningBound to a node, all containers created, at least one running (or starting/restarting).
SucceededAll containers terminated successfully and won't restart.
FailedAll containers terminated, at least one in failure.
UnknownState couldn't be obtained, usually because the node isn't answering.

When a container exits, the Pod's restartPolicy decides what happens: Always (the default), OnFailure or Never. Restarts happen in place, on the same node, in the same Pod. The kubelet waits between restarts with an exponential backoff: 10 s, 20 s, 40 s… capped at 300 s (5 minutes). After a container has run 10 minutes without problems, the backoff resets.

CrashLoopBackOff is not an error. It's the kubelet telling you "I'm in the waiting part of that backoff". The error is whatever made your container exit. Go read its logs.

⚠️ Watch it!

After a crash, kubectl logs web-0 shows the new container's logs, often empty. The stack trace you want is in the previous one: kubectl logs web-0 -c web --previous.

SymptomLikely causeFix
CrashLoopBackOffApp exits: bad config, missing env, failing liveness/startup probe, not enough memorykubectl logs --previous, then kubectl describe pod for events
Init:0/2 foreverFirst init container never finishes (waiting for something that isn't there)kubectl logs <pod> -c <init-name>
Init:CrashLoopBackOffAn init container fails repeatedlySame: logs of that init container
PendingNot scheduled yet (see chapter 7 for resources) or images still pullingkubectl describe pod → Events
🔬 Go deeper: the sandbox (a.k.a. the pause container)

Something has to own the shared network namespace while your containers crash and restart. When the kubelet starts a Pod, it asks the runtime over the CRI to create a pod sandbox first, then creates and starts the individual containers inside it.

On Linux, the cgroups and namespaces that make up a Pod need a process to keep them alive. That's the infrastructure or "pause" container: it does nothing but exist. Because it holds the network setup, your app containers can crash and restart without the Pod losing its IP. Once the sandbox and networking are ready, the kubelet sets the Pod condition PodReadyToStartContainers to True; image pulls and container creation happen after that.

With an alpha feature gate, ReduceDefaultCrashLoopBackOffDecay (alpha since 1.33), the backoff starts at 1 s and caps at 60 s instead of 10 s and 300 s. Off by default.

🤓 Deep dive: ephemeral containers

Your image has no shell and the container keeps crashing, so kubectl exec is useless. Add a temporary debugging container to the running Pod instead:

kubectl debug -it web-0 \
  --image=busybox:1.28 --target=web

--target shares the process namespace of that container, so you can see its processes. Ephemeral containers are never restarted, can't have ports or probes, can't set resources, and can't be changed or removed once added. They're added through a special ephemeralcontainers API handler, which is why kubectl edit can't do it.

📌 Bullet points
  • A Pod's containers share one IP and port space (talk over localhost) and can share volumes.
  • Init containers run in order, each to completion. Sidecars = init containers with restartPolicy: Always that keep running.
  • Phases: Pending → Running → Succeeded/Failed (or Unknown). Containers: Waiting / Running / Terminated.
  • Restarts back off 10 s → 20 s → … → 300 s; CrashLoopBackOff means "waiting". Read logs --previous.

Deployments, ReplicaSets, rolling updates & probes

ship v2 without a gap

You almost never create Pods yourself. You create a Deployment. The Deployment creates a ReplicaSet, and the ReplicaSet creates the Pods. Three objects, two controllers, each doing one small job.

Why the middle layer? Because a ReplicaSet is "N copies of this exact Pod template". When you change the template (a new image, a new env var), the Deployment creates a second ReplicaSet for the new template and slowly shifts replicas from old to new. That's a rolling update.

The ReplicaSet's name is [deployment-name]-[hash]. The hash comes from the Pod template and is stamped on the ReplicaSet and its Pods as the pod-template-hash label, so two generations never claim each other's Pods. Don't touch that label.

kubectl get rs
NAME             DESIRED   CURRENT   READY   AGE
web-7c9f6d5b8d   0         0         0       2d
web-5d8b9c7f4a   4         4         4       3m

old template kept at 0 replicas, so you can roll back (output shape as in the docs; names illustrative)

Only a change to .spec.template starts a rollout. Scaling from 4 to 10 replicas doesn't: same template, same ReplicaSet, just more Pods.

Two knobs control how aggressive a rollout is:

OptionWhat it doesUse it when
maxSurge
default 25%, rounded up
How many Pods above replicas may exist during the updateRaise it for faster rollouts if you have spare capacity
maxUnavailable
default 25%, rounded down
How many Pods below replicas may be unavailableSet to 0 when you can't lose capacity (then maxSurge must be > 0)
progressDeadlineSeconds
default 600
After this long without progress, the Deployment gets Progressing=False, reason ProgressDeadlineExceededLet CI fail fast on a stuck rollout
revisionHistoryLimit
default 10
How many old ReplicaSets to keep for rollbackLower it if old ReplicaSets clutter kubectl get rs; 0 disables undo

The rollout only moves forward when new Pods become ready. And "ready" is decided by a probe. The kubelet can run three kinds of probe against each container, and they do very different things:

ProbeWhen it fails…Use it when
startupProbethe kubelet kills the container (restart policy applies). Liveness and readiness don't run until it succeeds.Slow-starting apps: give them time without loosening liveness
livenessProbethe kubelet restarts the containerThe process can get stuck for good (deadlock) and a restart fixes it
readinessProbethe Pod's IP is removed from Service endpoints; no restartAlmost always: "don't send me traffic right now"

Here's the misconception this chapter kills: "liveness means healthy enough for traffic." No. Readiness decides traffic. Liveness decides restarts. Point a liveness probe at an endpoint that checks your database, let the database hiccup, and the kubelet restarts every replica at once. The docs warn about exactly this cascade.

readinessProbe:
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 10     # default 10
  timeoutSeconds: 1     # default 1
  failureThreshold: 3   # default 3
livenessProbe:
  httpGet: { path: /livez, port: 8080 }
▶ A rolling update, 4 replicas pick the knobs, roll v2, then roll a broken v3
Defaults for 4 replicas: 25% surge rounds up to 1, 25% unavailable rounds down to 1. Press ▶.
Simplified: one "step" here is one pass of the Deployment controller plus the time a new Pod takes to pass its readiness probe. Real rollouts overlap these. The v3 Pods fail readiness forever, as with a bad config; with a bad image they'd show ImagePullBackOff instead.

Look at what the broken v3 did not do: take your service down. It created as many new Pods as the surge allowed, they never became ready, so the controller could only remove as many old Pods as maxUnavailable permits. Then it stopped. Kubernetes doesn't roll back for you; after progressDeadlineSeconds it only reports ProgressDeadlineExceeded. You (or your CI) run:

kubectl rollout status deployment/web
kubectl rollout history deployment/web
kubectl rollout undo deployment/web
kubectl rollout undo deployment/web --to-revision=2
❓ There are no dumb questions
Q:
Where is the "previous version" stored for undo?
A:
In the old ReplicaSet. Each revision's configuration lives in its ReplicaSet; undo scales the old one back up. Delete it (or set revisionHistoryLimit: 0) and that revision is gone.
Q:
Do liveness probes wait for readiness?
A:
No. They run independently. To give a slow app time, use a startupProbe (or initialDelaySeconds).
SymptomLikely causeFix
rollout status hangs, new Pods 0/1 READYReadiness probe failing (wrong path/port, dependency down)kubectl describe pod → probe events; fix, or rollout undo
New Pods ImagePullBackOffTypo in image or tag, missing pull secretFix the image, or rollout undo
Restart count climbs under loadLiveness probe too strict or checks dependenciesMake liveness check only the process itself
kubectl edit of replicas started nothing newExpected: scaling isn't a rolloutChange .spec.template to roll
🔬 Go deeper: probe mechanics and edge cases

Defaults for every probe: periodSeconds 10, timeoutSeconds 1, failureThreshold 3, successThreshold 1 (and it must be 1 for liveness and startup). So a dead liveness endpoint is noticed after roughly failureThreshold × periodSeconds = 30 s.

A startup probe that allows failureThreshold × periodSeconds long enough for the worst-case start is the docs' recommended pattern for slow starters, instead of a huge initialDelaySeconds on liveness. Readiness keeps running for the container's whole life, so a Pod can drop out of a Service later, for example while overloaded, and come back.

📌 Bullet points
  • Deployment → ReplicaSet (one per template, named by pod-template-hash) → Pods.
  • Only .spec.template changes roll. Defaults: 25% surge (round up), 25% unavailable (round down).
  • Readiness = traffic. Liveness = restart. Startup = "not yet, don't judge me".
  • Stuck rollouts stop, they don't revert. kubectl rollout undo is your job.

Services, DNS & kube-proxy

a stable name for moving targets

Pods come and go. Every rollout from the last chapter replaced all of them, and every new Pod got a new IP. So how does your frontend find web? Not by Pod IP. By a Service: a stable name and a stable virtual IP in front of a changing set of Pods.

apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: shop
spec:
  selector:
    app: web        # which Pods are behind me
  ports:
  - port: 80        # what clients call
    targetPort: 8080  # where the Pod listens

Three moving parts make that YAML work, and none of them is the Service object itself:

  1. The EndpointSlice controller (in the controller-manager) finds Pods matching the selector and writes their IPs into EndpointSlice objects, with a condition per Pod saying whether it's ready.
  2. kube-proxy on every node watches Services and EndpointSlices and programs the node's packet forwarding (iptables rules, by default on Linux) so traffic to the Service's virtual IP lands on one of the backends.
  3. Cluster DNS gives the Service a name: web.shop.svc.cluster.local. From inside the shop namespace, plain web works; from elsewhere, web.shop.
▶ curl http://web from a frontend Pod send requests, then break readiness and the selector
frontend Podcurl http://web
cluster DNSweb → ?
ClusterIP10.96.0.20:80
kube-proxy rulespick a ready endpoint
EndpointSlice web-x7k2p selector app=web
Three Pods labelled app=web are behind the Service. Press ▶.
Simplified: kube-proxy doesn't sit in the request path as a process; it programs kernel rules ahead of time, and the kernel picks a backend. Backend choice here cycles for clarity; iptables mode picks randomly (or by session affinity). IPs and slice name are illustrative.

The two failures you just caused look identical from the client (timeouts or refused connections) but have different fixes. A readiness failure removes one Pod: the EndpointSlice controller drops its IP from every matching Service, and traffic flows to the rest. A selector typo matches nothing: the Service exists, DNS resolves, the ClusterIP is there, and there's simply nobody behind it.

TypeWhat it doesUse it when
ClusterIP (default)Virtual IP reachable only inside the clusterService-to-service traffic. Expose it outside with Ingress/Gateway (chapter 11).
NodePortAlso opens a port on every node's IP (default range 30000–32767)Quick external access, or an external LB you manage
LoadBalancerAlso asks an external load balancer (usually your cloud) for an addressExposing one Service directly on a cloud
ExternalNameDNS CNAME to an outside hostname; no proxying at allGiving an external database an in-cluster name
headless (clusterIP: None)No virtual IP; DNS returns the Pod IPs directlyClients that pick their own backend; StatefulSets (chapter 12)
⚠️ Watch it!

A Service without a selector gets no EndpointSlices automatically. That's a feature (you can point it at anything by writing slices yourself), but if you forgot the selector, it's a Service with nobody home.

SymptomLikely causeFix
Connection refused/timeout to the Service, Pods look fineSelector doesn't match Pod labelskubectl get endpointslices -l kubernetes.io/service-name=web; compare with kubectl get pods --show-labels
Endpoints listed but not readyReadiness probe failingkubectl describe pod → probe events
Works by IP, not by nameWrong namespace in the name, or DNS addon problemsUse web.<namespace>; check the DNS Pods
Wrong port reachedport vs targetPort mixed upClients use port; targetPort is the container's port
🔬 Go deeper: what's in a Pod's /etc/resolv.conf

Every container's DNS search list includes the Pod's own namespace first. For a Pod in namespace test:

nameserver 10.32.0.10
search test.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

That's why web resolves to web.test.svc.cluster.local. cluster.local is the cluster domain in the docs' examples; yours can differ. Chapter 23 shows how ndots:5 shapes lookups of external names.

🔬 Go deeper: EndpointSlice mechanics

Slices are split so no single object gets huge: by default the control plane keeps each EndpointSlice at no more than 100 endpoints (kube-controller-manager flag --max-endpoints-per-slice, up to 1000). Each slice is linked to its Service by the label kubernetes.io/service-name.

Each endpoint carries three conditions. serving maps to the Pod's Ready condition. terminating is set once the Pod gets a deletion timestamp. ready is shorthand for "serving and not terminating". Proxies normally ignore terminating endpoints, but if all endpoints are terminating they may still use ones that are serving, so traffic isn't dropped mid-rollout.

kube-proxy treats EndpointSlices as its source of truth for routing. On Linux it runs in iptables, ipvs (deprecated since v1.35) or nftables mode; if you don't set one explicitly you get the recommended default, currently iptables, and the docs warn a future version will switch the default to nftables, so pin it. Chapter 23 compares the modes.

📌 Bullet points
  • A Service = stable name + virtual IP + a label selector. Pods behind it can change freely.
  • Selector → EndpointSlices (controller) → node rules (kube-proxy) → one backend.
  • Only ready endpoints get traffic. Readiness failure removes one Pod; selector typo removes all.
  • DNS: web in the same namespace, web.shop across, web.shop.svc.<cluster-domain> fully.

ConfigMaps, Secrets, env & the downward API

same image, different settings

Your image should be the same in dev, staging and prod. What changes is configuration, and Kubernetes keeps it outside the image in two objects: a ConfigMap for ordinary settings and a Secret for sensitive ones. Both are key-value maps, and both are limited to 1 MiB. Pods use them from their own namespace.

apiVersion: v1
kind: ConfigMap
metadata:
  name: web-config
data:
  GREETING: hello
  app.properties: |
    cache.ttl=60

A container can consume it three ways without writing any Kubernetes code: in its command-line args, as environment variables, or as files in a volume. (The fourth way is your code reading the API directly.) The choice matters more than it looks, because they behave differently when the ConfigMap changes:

OptionWhat it does on updateUse it when
env / envFromNothing. Env vars are fixed at container start; you need a Pod restart.Settings read once at startup; you'll roll the Deployment anyway
volumes: configMapFiles are eventually updated by the kubelet (delay: sync period + cache)Apps that can reload a file at runtime
volume with subPathNever updatedDropping one file into an existing directory, knowing it's frozen
immutable: trueUpdates are rejected; delete and recreate insteadProtect against accidents; lighter on the API server at scale
▶ One ConfigMap, three ways in update it, wait, then restart the Pod
ConfigMap web-config GREETING: hello
env var
$GREETINGhelloset at container start
volume file
/etc/config/GREETINGhellokubelet refreshes
subPath file
/app/GREETINGhellopinned at start
All three views read the same ConfigMap. Press ✏️ to change it.
Simplified: the volume delay is "up to the kubelet sync period plus cache propagation", which depends on cluster settings; the countdown is illustrative and compressed. Restarting here means replacing the Pod, e.g. with kubectl rollout restart.

One more trap: if a Pod references a ConfigMap (or a key) that doesn't exist and the reference isn't marked optional: true, the Pod won't start.

Secrets: base64 is not encryption

A Secret looks like a ConfigMap whose values are base64-encoded. That encoding hides nothing (the docs call such values "obscured but not secret"): anyone can decode it (you can also write plain text in stringData and let the API encode it). Here's the part people miss: by default, Secrets are stored unencrypted in etcd.

⚠️ Watch it!

Anyone with API access to a Secret can read it, and so can anyone with access to etcd. And anyone allowed to create a Pod in a namespace can read every Secret in that namespace, by mounting it; that includes being able to create a Deployment. The docs' minimum: enable encryption at rest, restrict Secret access with least-privilege RBAC, limit which containers get which Secrets, and consider an external secret store.

What Kubernetes does do: a Secret is only sent to a node if a Pod on that node needs it, and the kubelet keeps mounted Secrets in tmpfs (memory), not on disk, deleting its copy when the Pod goes away.

The downward API: let the Pod know itself

Your app sometimes needs facts about its own Pod: its name for logs, its namespace, the node it landed on, its IP, its memory limit. The downward API exposes them without calling the API server:

env:
- name: POD_NAME
  valueFrom:
    fieldRef: { fieldPath: metadata.name }
- name: NODE_NAME
  valueFrom:
    fieldRef: { fieldPath: spec.nodeName }
- name: MEM_LIMIT
  valueFrom:
    resourceFieldRef: { resource: limits.memory }

Most fields work as env vars or as volume files. The exceptions: the whole metadata.labels and metadata.annotations maps are only available as volume files (single keys like metadata.labels['app'] work in env too), while spec.nodeName, status.podIP and status.hostIP are env-only.

🤓 Deep dive: projected volumes

A projected volume merges several sources into one directory: secret, configMap, downwardAPI, serviceAccountToken, clusterTrustBundle and podCertificate. All sources must be in the Pod's namespace. It's how the service-account token lands in your Pod (chapter 16).

🔬 Go deeper: why a ConfigMap volume update is slow

The kubelet checks whether a mounted ConfigMap is fresh on every periodic sync, and it reads the value from a local cache. configMapAndSecretChangeDetectionStrategy in the kubelet config picks the cache: Watch (the default), TTL-based, or straight to the API server. So the total delay can be up to the kubelet sync period (syncFrequency, default 1 minute) plus the cache's propagation delay.

Immutable ConfigMaps and Secrets (stable since 1.21) cut load on the API server, because the watches for them can be closed. The docs mention this matters for clusters with at least tens of thousands of ConfigMap-to-Pod mounts. Once immutable, always immutable: you can only delete and recreate, and existing Pods keep pointing at the deleted object, so recreate those too.

SymptomLikely causeFix
Changed the ConfigMap, app still sees the old valueConsumed as env, or via subPathkubectl rollout restart deployment/web
Pod stuck, events mention a missing ConfigMap/SecretReferenced object or key doesn't existCreate it, fix the name, or mark it optional
File updated but the app didn't noticeApp reads config only at startupReload on file change, or restart
📌 Bullet points
  • ConfigMap for settings, Secret for sensitive values; ≤ 1 MiB each, used from the Pod's own namespace.
  • Env vars never update; volume files update eventually; subPath never does.
  • Secrets are base64, stored unencrypted in etcd by default. Turn on encryption at rest and tight RBAC.
  • Downward API: Pod name, namespace, node, IP, limits as env vars (most also as files).

Resources: requests, limits and QoS

the scheduler reads requests, the kernel reads limits

Every container can declare two numbers per resource. The request is what you ask the scheduler to set aside for you. The limit is the ceiling the kernel enforces on the node. They sound like two sides of one coin. They're read by two different components at two different moments.

The scheduler only looks at requests. It adds up the requests of every Pod already placed on a node and refuses to place yours if the sum would exceed the node's capacity for CPU or memory, even if the node is idle right now. That's deliberate: the docs say it protects against a shortage later, "for example, during a daily peak in request rate."

resources:
  requests:
    cpu: 500m        # half a CPU, "500 millicpu"
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 512Mi

Units first. cpu: 0.1 and cpu: 100m are the same thing, one hundred millicpu. Memory uses suffixes: Mi and Gi are powers of two, M and G powers of ten. That trips people up exactly once.

🧠 Brain power

Your service goes over its CPU limit, and a sibling goes over its memory limit. One of them gets restarted and the other just gets slower. Which is which, and why would Kubernetes treat the two resources so differently?

Here's the answer. CPU is compressible: if you use too much, the kernel simply makes you wait. The docs call a CPU limit "a hard limit the kernel enforces", and container runtimes "don't terminate Pods or containers for excessive CPU usage." You get throttled, and your latency climbs.

Memory is not compressible. You can't hand bytes back slowly. When a container uses more than its memory limit and the kernel detects memory pressure, the OOM killer steps in, killing processes inside the container's cgroup. On cgroup v2 the kubelet sets memory.oom.group by default, so all of the container's processes are killed together (singleProcessOOMKill: true restores the old one-victim behaviour). The container dies, and kubectl describe pod shows Reason: OOMKilled and Exit Code: 137. If the container can restart, Kubernetes restarts it.

What if you set a limit but no request? Then Kubernetes copies the limit into the request, unless an admission mechanism such as a LimitRange filled in a default first. A LimitRange is a per-namespace policy object: it can set default requests and limits, enforce min/max values and cap the ratio between limit and request.

▶ One node, five Pods schedule by requests, then misbehave
CPU requests 0m / 4000m
Memory requests 0Mi / 8192Mi
Press “Schedule next Pod”.
A node with 4 CPU and 8 GiB allocatable (illustrative numbers). Bars show the sum of requests, which is what the scheduler checks, not live usage. Throttling and the OOM kill are shown as instant events; on a real node the OOM kill only happens under memory pressure and CPU throttling happens per scheduling period.

Look at the pod cards in the figure: each one got a QoS class the moment it was created, computed from its requests and limits. You never set it yourself.

QoS classRuleWhat you get
Guaranteedevery container has CPU and memory requests and limits, and each limit equals its requestleast likely to be evicted; OOM score -997
Burstablenot Guaranteed, but at least one container has a CPU or memory request or limita floor (the request) plus room to burst
BestEffortno requests or limits anywhereuses leftovers; first in line under pressure; OOM score 1000

When a node runs short of memory, the kubelet starts evicting Pods. The docs spell out the ranking: first Pods whose usage exceeds their requests (ordered by Priority, then by how far over they are), and only then Pods using less than they asked for. The kubelet doesn't read the QoS class directly, but the outcome lines up with it: BestEffort Pods, whose request is zero, are always "over", so they tend to go first.

⚠️ Watch it!

A memory request far below real usage is a trap. You schedule fine, the node looks full of free space on paper, and at the first memory squeeze your Pods are the ones "exceeding requests", so they're the first to be evicted. Set the memory request near steady-state usage. Many teams also set the memory limit equal to the request, which avoids the OOM surprise when neighbours burst.

SymptomLikely causeFix
Pod Pending, event FailedScheduling … insufficient cpusum of requests doesn't fit on any nodelower the request, add nodes, or remove idle reservations
OOMKilled, exit code 137, restart count climbingmemory limit below real peak usageraise the limit, or fix the leak
p99 latency spikes, CPU graph flat at the limitCPU throttling at the limitraise or drop the CPU limit; check the nr_throttled counter (deep dive below)
Pods evicted under node MemoryPressureusage above requests on a squeezed noderaise memory requests to match real usage
🔬 Go deeper: resizing a running Pod in place

Changing resources used to mean replacing the Pod. In-place Pod resize (feature gate InPlacePodVerticalScaling, stable since 1.35) lets you change container CPU and memory requests and limits on a running Pod through its /resize subresource:

kubectl patch pod web --subresource resize \
  --patch '{"spec":{"containers":[{"name":"app",
  "resources":{"limits":{"cpu":"800m"}}}]}}'

This needs kubectl v1.32 or newer. Each container's resizePolicy decides whether a change applies live (NotRequired, the default) or restarts that container (RestartContainer). If the node can't fit the change yet, the Pod gets a PodResizePending condition with reason Deferred (the kubelet retries) or Infeasible (it will never fit on this node). A resize can't change the Pod's QoS class. Shrinking a memory limit is best-effort: the kubelet tries to avoid an OOM kill but promises nothing.

🤓 Deep dive: what the kernel actually sees (cgroup v2)

The kubelet passes requests and limits to the container runtime, which writes them into the container's cgroup. On cgroup v2 two CPU files matter:

  • cpu.max holds "$MAX $PERIOD": the group may consume up to $MAX microseconds of CPU in each $PERIOD. The default is max 100000, meaning no limit per 100 ms. A CPU limit becomes a quota here; the kubelet's CFS period is 100 ms by default (--cpu-cfs-quota-period). So limits.cpu: 500m means roughly 50 ms of CPU time per 100 ms window. A multi-threaded app can burn through that in the first 10 ms and then sit idle for 90 ms. That's where the latency spikes come from.
  • cpu.weight (range 1–10000, default 100) is a relative share. The CPU request is turned into a weight, so under contention, bigger requests get proportionally more CPU time. With no contention, a request doesn't cap anything.
  • cpu.stat reports nr_throttled and throttled_usec. If they keep climbing, your limit is too tight.

On the memory side, the memory limit becomes the cgroup's memory limit. With cgroup v2 the runtime may also use the memory request as a hint for memory.min/memory.low. The MemoryQoS feature gate (beta since 1.37) adds throttling via memory.high for Burstable Pods before the hard limit.

📌 Bullet points
  • The scheduler reserves by requests; actual usage doesn't matter at scheduling time.
  • CPU over the limit → throttled. Memory over the limit → OOMKilled (exit 137).
  • QoS (Guaranteed / Burstable / BestEffort) is derived from your numbers and predicts who's evicted first.
  • Limit without a request? The request is copied from the limit, unless a LimitRange set a default.
  • Since 1.35 you can resize CPU and memory in place via the /resize subresource.

kubectl & manifests

YAML is a request, the server keeps the receipt

Every Kubernetes object has the same shape: apiVersion and kind say what it is, metadata says which one (name, namespace, labels, annotations), spec is what you want, and status is what the system reports back. You write spec. Controllers write status. If you're typing status: into a manifest, something went sideways.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
  labels: {app: web, tier: frontend}
  annotations:
    team.example.com/oncall: "#shop-alerts"
spec:
  replicas: 3
  selector:
    matchLabels: {app: web}
  template: …

Labels are for selecting: Services, Deployments and kubectl get -l app=web all find things by label. Selectors come in two flavours: equality-based (app=web, tier!=db) and set-based (env in (prod,staging)). There's no OR across conditions. Annotations are for everything else: non-identifying metadata that tools read and nobody selects on.

Namespaces split one cluster into named scopes. A fresh cluster starts with four: default, kube-node-lease, kube-public and kube-system. The docs advise against using default for production. Some objects, like Nodes and PersistentVolumes, aren't namespaced at all.

There are three ways to manage objects: imperative commands (kubectl create deployment …), imperative config (kubectl create -f, replace -f) and declarative config (kubectl apply -f). The docs are blunt: manage an object with one technique only, because mixing them "results in undefined behavior." For anything that lives in Git, use apply.

🔥 Fireside chat: Client-side apply vs Server-side apply

Client-side apply: I've done this for years. I stash your last manifest in an annotation, kubectl.kubernetes.io/last-applied-configuration, and diff against it next time to work out what you deleted.

Server-side apply: And when someone runs kubectl scale in between? Your annotation doesn't know they exist. I track every writer, per field, on the server, in metadata.managedFields.

Client-side apply: So what happens when two writers want the same field?

Server-side apply: I say no. That's a conflict, and nobody's change gets silently overwritten. You opt into winning with --force-conflicts.

Server-side apply (SSA, stable since 1.22) moves the merge logic into the API server. You run kubectl apply --server-side, and kubectl sends your manifest under a field manager name ("kubectl" by default; pick your own with --field-manager). The server records which manager owns which fields. If you apply a change to a field another manager owns, you get a conflict. You then have three options: force it and become the sole owner, drop the field from your manifest and give up the claim, or change your value to match theirs and share ownership.

▶ Two appliers, one Deployment watch managedFields decide
fieldvaluemanagers
Press “CI applies”.
Terminal output is simplified (real conflict errors are longer and list every conflicting field). managedFields is shown as a field → owners table; the real structure is a list of entries per manager with a fieldsV1 set.

The figure shows the classic fight: your CI pipeline applies replicas: 3 from Git while someone patches production by hand. Client-side apply would quietly flip the value back on the next deploy. SSA makes the disagreement visible. The docs' own example is the HorizontalPodAutoscaler: once an HPA owns replicas, the fix is to remove replicas from your manifest, not to force it back.

Now the daily toolbox. You'll use these more often than apply itself:

OptionWhat it doesUse it when
kubectl explain pods --recursivefield docs straight from the API server's schemayou forgot where resizePolicy lives
kubectl get pod my-pod -o yamlthe full object, including statusyou want the truth, not your manifest
kubectl describe pod my-podhuman summary plus recent eventsa Pod is Pending or restarting
kubectl logs -f my-pod / --previousstream stdout; --previous shows the crashed instancedebugging a CrashLoopBackOff
kubectl exec -it my-pod -- /bin/shshell inside a running containerthe image actually has a shell
kubectl debug -it my-pod --image=busybox:1.28 --target=appadds an ephemeral debug containerdistroless image, no shell
kubectl port-forward my-pod 28015:27017forwards a local port to a Pod portpoking a service without exposing it
kubectl diff -f manifest.yamlwhat apply would changebefore every apply to production
kubectl apply --server-side --field-manager=ciSSA with a named ownerCI pipelines, controllers, anything automated
❓ There are no dumb questions
Q:
Why does kubectl get -o yaml show way more than I wrote?
A:
Because the server filled in defaults, added status, uid, resourceVersion and more. Add --show-managed-fields to also see who owns what; by default kubectl hides managedFields.
Q:
Can I switch an existing object from client-side to server-side apply?
A:
Yes. The docs describe the upgrade path: run kubectl apply --server-side on it, and field ownership is migrated from the last-applied annotation.
🔬 Go deeper: where field managers come from

Every write records a manager. For Apply requests (PATCH with the apply content type) the fieldManager query parameter is required. For other writes (update, patch) the API server infers a manager name from the User-Agent header. That's why controllers show up in managedFields under their own names. Each entry also says whether it came from Apply or Update. Only Apply entries take part in conflicts: an Update overwrites fields and takes ownership without asking.

📌 Bullet points
  • Objects = apiVersion, kind, metadata, spec (yours), status (the controllers').
  • Labels are for selecting, annotations for tools. Namespaces scope names.
  • Pick one management technique per object; for Git-managed objects, that's apply.
  • Server-side apply tracks owners per field and raises conflicts instead of silently overwriting.
  • describe for events, logs --previous for crashes, debug for shell-less images, diff before apply.
Part II

Practitioner

The developer's working life: a fast local loop, packaging, ingress, storage, batch work, autoscaling, observability, security and the road from commit to production.

The local dev loop

seconds, not coffee breaks

If every code change means build, push to a registry, deploy and wait for a pull, you'll stop testing in Kubernetes and start testing in production. The fix has two parts: a cluster on your laptop, and a tool that watches your files and does the boring steps for you.

For the laptop cluster, four tools cover almost everyone. They differ mostly in how they run the nodes and how your freshly built image gets into the cluster.

OptionWhat it doesGetting your image inUse it when
kindKubernetes in Docker: each node is a Docker (or Podman) container; multi-node via a config filekind load docker-image my-app:devCI and tests; you want throwaway, multi-node clusters that start fast
minikubeall-in-one or multi-node local cluster on Windows, macOS and Linuxminikube image load my-app:dev, or build inside it after eval $(minikube docker-env)you want a batteries-included dev cluster
k3d"a lightweight wrapper to run k3s" (Rancher's minimal distribution) in Dockerk3d image import or a k3d-managed registryyou want small and fast, or you run k3s at the edge
Docker Desktopbuilt-in Kubernetes: a single-node kubeadm cluster, or a multi-node kind-based onedepends on the setup; check the docs for yoursyou already live in Docker Desktop and want zero extra installs

That image-loading column is where the most common local bug lives. Say you build my-app:latest, load it into kind and deploy. You get ErrImagePull. Why? If you don't set imagePullPolicy and the tag is :latest (or missing), Kubernetes sets the policy to Always. The kubelet goes to Docker Hub looking for an image that only exists on your laptop.

⚠️ Watch it!

The kind docs give the fix: don't use :latest for loaded images, and/or set imagePullPolicy: IfNotPresent (or Never). The mirror-image bug also happens: you rebuild under the same non-latest tag, the policy is IfNotPresent, and the node happily keeps running the old image. Unique tags per build fix both.

Now the loop itself. Skaffold's skaffold dev watches your source files. On a change it rebuilds the image (or syncs files into the running container), pushes if needed, and redeploys. It spots a local cluster from the kubectl context name (kind-…, k3d-…, minikube, docker-desktop) and loads the image instead of pushing it. Its default tagger is gitCommit (uncommitted changes get a -dirty suffix, so two dirty builds can share a tag), but Skaffold never references images by tag alone in the manifests it deploys: pushed images get tag@sha256:…, and images loaded into a local cluster get an extra immutable tag named after the image ID. That neatly avoids the stale-tag trap.

Tilt does the same job with a Tiltfile and tilt up. Its live_update copies changed files into the container (sync steps) and runs commands (run steps) instead of rebuilding. Files that match fall_back_on trigger a full rebuild. Skaffold has the equivalent in its sync rules. Either way, a template or script change lands in a second or two.

▶ Race: one code change, two loops seconds are illustrative
Pick a change to race.
Step durations are invented for illustration. Real numbers depend on image size, build cache and registry latency. The naive lane assumes a remote registry; the Skaffold lane assumes a kind cluster, where it loads the image instead of pushing, and uses file sync for the template change.

Don't forget the last mile: kubectl port-forward svc/web 8080:80 gets you to the app without an Ingress. Both Skaffold and Tilt can set up port-forwards for you.

SymptomLikely causeFix
ErrImagePull for an image you just loaded:latest or no tag → policy Alwaysunique tag, or imagePullPolicy: IfNotPresent
code change "didn't deploy"same tag rebuilt, node keeps its cached imagetag per build (e.g. Skaffold's inputDigest or dateTime tagger) or pin by digest
Skaffold pushes to a registry for a local clustercontext name isn't recognised as localuse a context name Skaffold recognises as local, or set build.local.push: false
📌 Bullet points
  • kind, minikube, k3d and Docker Desktop all give you a real API server locally; they differ in node model and image loading.
  • Load images, don't push them. Then avoid :latest, or set imagePullPolicy explicitly.
  • skaffold dev or tilt up turns build → deploy into a watch loop; file sync / live_update skips the rebuild.

Packaging: Kustomize vs Helm

patch it or template it

You have one app and three environments. Copy-pasting the YAML three times works for about a week. Then two tools compete for the job, and they take opposite approaches: Kustomize patches plain YAML, while Helm renders templates from values.

Kustomize is built into kubectl (since 1.14). A directory with a kustomization.yaml is a unit you can render with kubectl kustomize dir/ or apply with kubectl apply -k dir/. A base holds the shared resources. An overlay is another kustomization that lists the base in resources and adds changes on top. The base doesn't know its overlays exist.

# overlays/prod/kustomization.yaml
resources:
- ../../base
namePrefix: prod-
images:
- name: shop/web
  newTag: "1.4.2"
patches:
- path: replicas.yaml

Kustomize's generators earn their keep. A configMapGenerator creates a ConfigMap whose name ends in a hash of its content, and Kustomize rewrites every reference to it. Change a value and you get a new name, so the Deployment's Pod template changes and a rollout happens. That matters because ConfigMaps consumed as environment variables are not updated in running Pods: they need a restart.

▶ Overlay builder base + overlay → rendered YAML

      
Running Pods
Pick an overlay.
Rendered output is trimmed to the lines that change. Hash suffixes are made up; real ones come from the content. Highlighted lines are what the overlay or generator changed.

Helm treats your app as a chart: Chart.yaml, a values.yaml of defaults and a templates/ directory of Go templates. helm install or helm upgrade --install renders the templates with your values and creates a release. Every upgrade adds a revision. helm history lists them and helm rollback web goes back to the previous one (or to a revision number you pass).

Where does Helm keep that history? In the cluster: by default, release information is stored in Secrets in the release's namespace (HELM_DRIVER can switch that to ConfigMaps or SQL). Values merge with the right-most wins: several -f files, then --set overrides. Helm 4 defaults to server-side apply for new releases, so the field-ownership rules from the kubectl chapter apply to Helm too.

OptionWhat it doesUse it when
Kustomizepatches plain YAML; bases + overlays; generators with hash suffixes; built into kubectlit's your own app, a few environments, and you want the YAML to stay readable YAML
Helmtemplating + values; packaged, versioned charts; releases with revisions and rollbackyou ship software for others to install, or you consume third-party charts
Bothhelm template renders locally; Kustomize can post-process the outputyou consume a vendor chart but need a patch the chart doesn't expose
❓ There are no dumb questions
Q:
Does helm rollback undo database migrations?
A:
No. It re-applies an older rendered manifest as a new revision. Anything your app did to external state stays done.
Q:
I deleted the sh.helm.release… Secret to "clean up". Now what?
A:
Helm no longer knows the release exists, but the objects are still running. Don't treat those Secrets as clutter.
📌 Bullet points
  • Kustomize: base + overlays + patches, kubectl apply -k. No templating language.
  • Generated ConfigMaps/Secrets get content-hash names, so a config change triggers a rollout.
  • Helm: charts + values → releases with revisions, stored as Secrets by default; upgrade --install, rollback.
  • Your own app → Kustomize is often enough. Distributing or consuming packages → Helm.

Ingress & Gateway API

a YAML file doesn't open a port

A Service gets traffic to your Pods from inside the cluster. To route HTTP from outside by host name and path, you need an L7 router. Kubernetes has two APIs for describing one. Neither API routes a single packet on its own.

That's the misconception to kill first: an Ingress object does nothing by itself. The docs: "You must have an Ingress controller to satisfy an Ingress. Only creating an Ingress resource has no effect." The controller (NGINX, Traefik, HAProxy, a cloud load balancer…) watches Ingress objects and configures a real proxy. With no controller installed, your Ingress sits in etcd and nobody reads it.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: {name: shop}
spec:
  ingressClassName: nginx
  rules:
  - host: shop.example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service: {name: api, port: {number: 80}}

Each path needs a pathType: Exact, Prefix (matches whole path elements split by /, so /api matches /api/v1 but not /apix) or ImplementationSpecific. When several paths match, the longest one wins. ingressClassName picks the controller; leave it out and the default IngressClass applies, if there is one.

Anything beyond host and path (header matching, traffic splitting, rewrites) was left to controller-specific annotations, and each controller spelled them differently. And the Ingress API is now frozen: it's GA and won't be removed, but it gets no new features. The project recommends Gateway API instead.

Gateway API is an add-on: CRDs you install (often with your chosen implementation). Its kinds are modelled on roles (infrastructure provider, cluster operator, application developer). A GatewayClass names the controller that implements it. A Gateway is an instance of traffic-handling infrastructure, such as a cloud load balancer, with its listeners. Application developers typically attach HTTPRoutes (or GRPCRoutes) to it with parentRefs. Header matching and weighted traffic are part of the spec, not annotations.

kind: HTTPRoute
spec:
  parentRefs: [{name: shop-gw}]
  hostnames: ["shop.example.com"]
  rules:
  - backendRefs:
    - {name: web-v1, port: 80, weight: 90}
    - {name: web-v2, port: 80, weight: 10}
▶ Route by host, path and weight drag the canary
Gatewaycontroller: running
Press “Send 10 requests”.
Requests are deterministic, and the weight split uses smooth weighted round-robin so 10 requests show the exact ratio. Real proxies split statistically. The routing rules correspond to the HTTPRoute above plus an /api path rule.
OptionWhat it doesUse it when
Ingresshost + path → Service; TLS; extras via controller annotationsexisting setups; simple host/path routing; your platform only offers Ingress
Gateway APIGatewayClass / Gateway / Routes split by role; header matching, weights, gRPC in the specnew clusters; canaries and header routing; many teams sharing one entry point
SymptomLikely causeFix
Ingress created, no address, nothing answersno controller, or ingressClassName doesn't match anyinstall a controller; kubectl get ingressclass
kubectl apply doesn't know the kind HTTPRouteGateway API CRDs not installedinstall the CRDs / the implementation
404 on /api/ sub-pathspathType: Exact where you meant Prefixuse Prefix
📌 Bullet points
  • Ingress and Gateway are descriptions; a controller makes them real.
  • Ingress: frozen but GA. Host/path, pathType, longest match wins.
  • Gateway API: role-oriented CRDs (GatewayClass → Gateway → HTTPRoute), with weights and header matching built in.

Storage & StatefulSets

pets get name tags

A container's filesystem is disposable: when it crashes, the kubelet restarts it with a clean state. Volumes are how data outlives that. The simplest volume, emptyDir, survives container crashes but is "deleted permanently" when the Pod leaves the node. Fine for scratch space and caches, useless for a database.

For real persistence Kubernetes splits the problem in three. A PersistentVolume (PV) is a piece of storage in the cluster; it isn't namespaced. A PersistentVolumeClaim (PVC) is your namespaced request for storage: "10Gi, ReadWriteOnce". A StorageClass describes a kind of storage and the provisioner that creates it. Create a PVC that names a class (or rely on the default class) and a PV is provisioned dynamically and bound to it.

OptionWhat it doesUse it when
ReadWriteOnceread-write by a single node (several Pods on that node can still share it)the default choice for block storage
ReadWriteOncePodread-write by a single Pod, CSI volumes onlyyou need a hard guarantee of one writer
ReadOnlyMany / ReadWriteManymany nodes read / read-writeshared files; needs a backend that supports it (e.g. NFS)
reclaimPolicy: DeletePV and backing storage go away with the PVC; the StorageClass defaultdisposable environments
reclaimPolicy: RetainPV survives the PVC; reclaim manuallydata you'd hate to lose to a typo
volumeBindingMode: WaitForFirstConsumerprovision only once a Pod using the PVC is scheduled, respecting its topologyzonal disks; the default Immediate can create a volume in the wrong zone

Now put a database in a Deployment and scale it to three. All three replicas mount the same PVC, have random names and come up in any order. Databases hate that. A StatefulSet gives each Pod a sticky identity instead: an ordinal (web-0, web-1, …), a stable DNS name through a headless Service (web-0.nginx.default.svc.cluster.local), and its own PVC created from volumeClaimTemplates (www-web-0, www-web-1, …). You have to create the headless Service (clusterIP: None) yourself.

By default (podManagementPolicy: OrderedReady) Pods are created in order 0..N-1, each only after its predecessors are Running and Ready, and they're removed in reverse order. Parallel drops the ordering when you don't need it.

▶ StatefulSet web, replicas 3 identity sticks to the name
Press “Scale 0 → 3”.
One PVC per Pod from volumeClaimTemplates; the retention policy is the default Retain. "Rows" stand for whatever your app writes to disk. Timing is compressed. Real Pods wait for readiness, and a volume attach can take seconds to minutes.

Notice what survived the scale-down: the PVCs. Deleting or scaling down a StatefulSet does not delete its volumes, on purpose ("data safety … is generally more valuable than an automatic purge"). If you want cleanup, persistentVolumeClaimRetentionPolicy (stable since 1.32) lets you set whenDeleted and whenScaled to Delete. Both default to Retain.

⚠️ Watch it!

A StatefulSet gives you identity and storage, not replication, backups or failover. web-1 doesn't know it's a replica of web-0. Your database (or its operator) has to do that. The docs also warn against terminationGracePeriodSeconds: 0 on StatefulSets and against casually force-deleting their Pods.

🔬 Go deeper: what happens on node failure

The retention policies only apply when Pods are removed because the StatefulSet was deleted or scaled down. If a node dies and the control plane creates a replacement Pod, the existing PVC is kept and the volume is attached to the node where the new Pod lands. With ReadWriteOnce zonal disks, that node must be in the same zone as the volume, which is where WaitForFirstConsumer and topology constraints matter. .spec.ordinals.start (stable since 1.31) lets numbering start at something other than 0.

SymptomLikely causeFix
PVC stuck Pendingno default StorageClass, or WaitForFirstConsumer waiting for a Podset storageClassName; check kubectl describe pvc events
Pod Pending; its volume lives in another zonezonal volume, Pod scheduled elsewhereWaitForFirstConsumer; keep the Pod in the volume's zone
web-2 never startsweb-1 not Ready (OrderedReady)fix web-1's readiness, or use Parallel
📌 Bullet points
  • emptyDir dies with the Pod. A PVC outlives it; a StorageClass provisions PVs on demand.
  • Access modes are about nodes (RWO) or Pods (RWOP). Reclaim policy defaults to Delete for dynamic volumes.
  • StatefulSet = stable name + stable DNS via a headless Service + one PVC per ordinal, ordered start/stop.
  • PVCs are retained on scale-down and delete unless you set persistentVolumeClaimRetentionPolicy.

Jobs, CronJobs, DaemonSets — choosing the workload

not everything is a web server

So far everything you deployed was a Deployment: a process that should run forever, and if it exits, something is wrong. But a lot of real work doesn't look like that. A database migration should run once and stop. A report should run every night at 2 a.m. A log shipper should run on every machine. Each of those shapes has its own controller, and picking the wrong one is how you get a migration that runs forever in a restart loop.

Takeaway: pick the controller by the shape of the work — forever (Deployment), until done (Job), on a clock (CronJob), once per node (DaemonSet), with a stable identity (StatefulSet). The misconception to kill: "a CronJob runs exactly once per tick". The docs say it runs approximately once, and sometimes twice or not at all — so your jobs must be idempotent.

🧠 Brain power

Your migration container exits with code 1 because the database isn't reachable yet. You wrapped it in a Deployment. What does Kubernetes do next — and next — and next?

▶ Pick the workload, then run a Job completions 3 · parallelism 2 · backoffLimit 3
Deploymentkeep N replicas running forever
Jobrun Pods until N succeed
CronJobcreate a Job on a schedule
DaemonSetone Pod per (matching) node
StatefulSetstable names + own volumes
succeeded 0/3
failed 0
retries left 3
Job —
Pick a scenario, or press ▶ to run a Job.
Simplified: the Job controller and the kubelet are merged into one animation, and time is compressed. The retry delays (10 s, 20 s, 40 s … capped at six minutes) and the meaning of backoffLimit ("number of retries before considering a Job as failed") are from the Job docs; which Pod fails is scripted.

A Job counts successes. You say how many Pods must finish successfully (completions) and how many may run at once (parallelism, default 1). When a Pod fails, the Job controller makes a new one after an exponential back-off (10 s, 20 s, 40 s … capped at six minutes), and gives up once it has used backoffLimit retries — 6 by default.

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-db
spec:
  completions: 1
  backoffLimit: 3
  activeDeadlineSeconds: 600   # hard stop
  ttlSecondsAfterFinished: 3600 # auto-delete
  template:
    spec:
      restartPolicy: Never     # Never or OnFailure
      containers:
      - name: migrate
        image: registry.example.com/app:1.42.0
        args: ["migrate", "--to", "latest"]

Two fields save you from surprises. activeDeadlineSeconds is a wall-clock limit for the whole Job and takes precedence over backoffLimit. ttlSecondsAfterFinished lets the TTL controller delete the finished Job (and its Pods) for you, so completed Jobs don't pile up.

OptionWhat it doesUse it when
completions / parallelismsuccesses needed / Pods at once (parallelism defaults to 1)batch of N work items, fan-out
completionMode: Indexedeach Pod gets an index 0 … completions−1 (default mode is NonIndexed)static sharding: Pod i processes shard i
backoffLimitretries before the Job fails (default 6)always — pick a number you'd defend
backoffLimitPerIndexper-index retry budget for Indexed Jobs (stable since 1.33)one bad shard shouldn't burn everybody's retries
podFailurePolicyrules on exit codes / Pod conditions: FailJob, Ignore, Count, FailIndex (stable since 1.31; needs restartPolicy: Never)exit code 42 = "bad input, don't retry"; disruptions shouldn't count
activeDeadlineSecondswall-clock limit for the whole Job; beats backoffLimita stuck job must not run all weekend
ttlSecondsAfterFinisheddelete the finished Job (cascading) after N syou don't want a graveyard of Jobs
⚠️ Watch it!

With restartPolicy: OnFailure the kubelet restarts the container in place, and when the backoff limit is reached the Pod is terminated — taking the logs of the failed attempts with it. The Job docs suggest restartPolicy: Never while debugging (every failure leaves a failed Pod you can kubectl logs), or shipping logs somewhere else.

CronJob: a Job factory with a clock

A CronJob doesn't run your code — it creates a Job on a cron schedule, and the Job creates Pods. That extra layer is where the interesting knobs live.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  timeZone: "Europe/Warsaw"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
          - name: report
            image: registry.example.com/report:2.3.1
OptionWhat it doesUse it when
concurrencyPolicy: Allowdefault — runs may overlapruns are independent and short
concurrencyPolicy: Forbidskip the new run if the previous one is still goingtwo copies would corrupt data (most reports, syncs)
concurrencyPolicy: Replacekill the running Job, start the new oneonly the freshest run matters
startingDeadlineSecondshow late a missed run may still start; unset = no deadline. Under 10 s it may never start (the controller checks every 10 s)a 2 a.m. job must not start at 9 a.m. after an outage
timeZoneIANA zone for the schedule (stable since 1.27); CRON_TZ= inside schedule is not supported"2 a.m." should mean your 2 a.m.
successfulJobsHistoryLimit / failedJobsHistoryLimitfinished Jobs to keep (defaults 3 / 1)you want more failed runs around to debug
❓ There are no dumb questions
Q:
Can I trigger a CronJob by hand, right now?
A:
Yes: kubectl create job report-manual --from=cronjob/nightly-report creates a one-off Job from the CronJob's template.
Q:
Why would a CronJob run twice?
A:
The docs are candid: a CronJob creates a Job "approximately once" per scheduled time, and there are circumstances where two Jobs — or none — get created. Make the job idempotent (upserts, "already processed" markers) and don't rely on the count.
Q:
My cluster was down for a day. Will it replay 24 hourly runs?
A:
Don't count on it. The controller counts missed schedules since the last run; with more than 100 it logs "too many missed start times" and doesn't start the Job. Setting startingDeadlineSeconds makes it count only within that window — and decides how late a run may still start.

DaemonSet: one per node

A DaemonSet makes sure every node (or every node matching a nodeSelector / affinity) runs one copy of a Pod. Nodes join → Pods appear; nodes leave → Pods are garbage-collected. The docs' examples are exactly the things you'll meet in chapter 15: a log collector, a node monitoring agent, a storage daemon. As an app developer you rarely write one, but you'll see them in every kubectl get pods -A.

SymptomLikely causeFix
Job Pods in CrashLoopBackOff, never "Failed"restartPolicy: OnFailure restarts in placeuse Never while debugging; read kubectl logs --previous
Job has a Failed condition after several failed Podsretries used upfix the bug; for "don't retry" exit codes add a podFailurePolicy
CronJob never firesstartingDeadlineSeconds < 10, suspended, or > 100 missed schedulesraise the deadline; kubectl describe cronjob for events
Two report runs overlap and fightdefault concurrencyPolicy: AllowForbid (or Replace)
📌 Bullet points
  • Choose by shape: forever → Deployment, until done → Job, schedule → CronJob, per node → DaemonSet, identity → StatefulSet.
  • Jobs retry with exponential back-off; backoffLimit defaults to 6; activeDeadlineSeconds wins over it.
  • podFailurePolicy lets exit codes say "don't retry" (FailJob) or "don't count" (Ignore).
  • CronJobs are approximate: make jobs idempotent, set concurrencyPolicy, startingDeadlineSeconds and timeZone.

Autoscaling: Pods, sizes, nodes

three dials, one input: requests

"Kubernetes autoscales" is true in the same way "a car drives itself" is true if you count cruise control. There are three separate autoscalers, and each turns a different dial: the HorizontalPodAutoscaler (HPA) changes how many Pods, the VerticalPodAutoscaler (VPA) changes how big each Pod's requests are, and a node autoscaler (Cluster Autoscaler or Karpenter) changes how many machines there are.

Takeaway: they chain. HPA adds Pods; if they don't fit, they go Pending; the node autoscaler sees Pending Pods and adds a node. And every link in that chain reasons about resource requests, not real usage. The misconception to kill: "the cluster autoscaler adds nodes when CPU is high". It doesn't look at CPU at all — it adds nodes when Pods can't be scheduled.

▶ HPA → Pending → new node target 50 % CPU · request 500m · min 2 · max 10
load 500m
avg util 50%
replicas 2
desired 2
desired = ceil(2 × 50% / 50%) = 2
Pending: none
Two Pods, each at 50 % of its CPU request — exactly on target.
Illustrative numbers: each node fits four Pods by requests, load is split evenly, time is compressed. The formula, the 10 % tolerance, the default scale-up limit (the larger of 100 % or 4 Pods per 15 s) and the 5-minute scale-down stabilization window are from the HPA docs; node provisioning/consolidation is simplified to one node at a time.

HPA: a control loop with a formula

The HPA is a controller inside kube-controller-manager. Every 15 seconds (by default) it reads the metric, compares it to your target, and computes:

desiredReplicas = ceil( currentReplicas × currentMetric / targetMetric )

For CPU with a utilization target, "currentMetric" is the Pods' average usage as a percentage of their CPU request. That's the first place requests matter: if a container has no CPU request, utilization is undefined and the HPA takes no action for that metric. If the ratio is within 10 % of 1.0 (the default tolerance) nothing happens, so the replica count doesn't jitter.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50

The one-liner equivalent from the docs' walkthrough is kubectl autoscale deployment php-apache --cpu=50% --min=1 --max=10, and kubectl get hpa shows the live target as TARGET 0% / 50%-style columns.

🤓 Deep dive

Scaling is asymmetric on purpose. The default behavior lets the HPA scale up immediately (no stabilization window) by the larger of 100 % or 4 Pods every 15 s. Scaling down uses a 300-second stabilization window: the controller remembers its recommendations and picks the highest one from the last 5 minutes, so a brief dip doesn't kill Pods you'll need again in a minute. You can override both per HPA under spec.behavior, including a per-direction tolerance (field stable since 1.37).

🔬 Go deeper: inside one HPA sync (every 15 s)
  1. Find the target via scaleTargetRef and select its Pods with the target's .spec.selector.
  2. Fetch metrics: resource metrics from metrics.k8s.io; per-Pod and object custom metrics from custom.metrics.k8s.io; external metrics from external.metrics.k8s.io. The HPA still speaks only metrics.k8s.io/v1beta1.
  3. Set aside Pods with missing metrics, and — for CPU — Pods that aren't ready yet or whose last sample predates readiness (--horizontal-pod-autoscaler-initial-readiness-delay, default 30 s; --horizontal-pod-autoscaler-cpu-initialization-period, default 5 min). They're added back conservatively (as 0 % of the target when scaling up, 100 % when scaling down) so they can only dampen the move.
  4. With several metrics, compute a desired count for each and take the largest. If some metric can't be read and the others suggest scaling down, skip scaling.
  5. Apply tolerance, record the recommendation, apply stabilization window and behavior policies, then set the new replica count on the target.

The loop period is --horizontal-pod-autoscaler-sync-period on kube-controller-manager; the scale-down window is --horizontal-pod-autoscaler-downscale-stabilization.

Where the numbers come from

CPU and memory come from the Metrics API (metrics.k8s.io), which is usually served by the metrics-server add-on — installed separately. Anything else (requests per second, queue length) needs an adapter that serves custom.metrics.k8s.io or external.metrics.k8s.io, typically from your monitoring system. Chapter 15 shows the whole pipeline.

VPA: right-size the requests

The VerticalPodAutoscaler is not part of core Kubernetes: it's a CRD (autoscaling.k8s.io/v1) plus three components you install — a Recommender that studies historical usage, an Updater that acts on drift, and an admission controller that writes the recommended requests into new Pods.

updateModeWhat it doesUse it when
Offonly writes recommendations to .statusyou want advice, not action — start here
Initialsets requests only when Pods are createdno evictions, but new Pods get sane sizes
Recreateevicts Pods whose requests drift too farworkload tolerates restarts (have a PDB)
InPlaceOrRecreatetries an in-place resize first, falls back to evictionfewer restarts on clusters with in-place resize (stable since 1.35)
InPlacein-place only, never evicts; defers if it can't (alpha in VPA 1.7.0)experiments only
⚠️ Watch it!

Don't point VPA and HPA at the same resource metric. The VPA project lists it as a known limitation: VPA "should not be used with the Horizontal Pod Autoscaler (HPA) on the same resource metric (CPU or memory)". HPA on CPU plus VPA on memory, or HPA on a custom metric plus VPA, is fine.

Node autoscaling: make Pending Pods schedulable

A node autoscaler watches for Pods the scheduler can't place and provisions nodes for them; it also consolidates — removes underutilized nodes when their Pods would fit elsewhere. Both decisions use Pod requests, not real usage. Too-low requests: a new node doesn't actually help the Pod run. Too-high requests: nodes that could be removed never are.

OptionWhat it doesUse it when
Cluster Autoscaleradds/removes nodes in pre-configured node groups (e.g. cloud instance groups)the classic, available for most clouds; you define the shapes
Karpenterauto-provisions nodes from NodePool constraints, picking instance types itselfyou want it to choose node shapes (supported providers only)
SymptomLikely causeFix
kubectl get hpa shows no current value in the target columnno metrics-server, or no CPU request on a containerinstall metrics-server; set requests on every container
Replicas flap up and downshortened scale-down window, noisy metrickeep the 300 s window; smooth the metric
HPA scaled up but Pods stay Pendingno node autoscaler, or it hit its max / can't fit the requestcheck the autoscaler's events; lower requests or allow bigger nodes
Nodes never scale downinflated requests keep nodes "busy"right-size with VPA in Off mode
📌 Bullet points
  • HPA: every 15 s, ceil(replicas × current/target), 10 % tolerance, fast up, 5-minute-smoothed down.
  • CPU utilization is relative to the request; no request → no scaling on that metric.
  • VPA is an add-on; start with Off; never share a CPU/memory metric with HPA.
  • Node autoscalers react to Pending Pods and requests — not to CPU graphs.

Observability: built-in signals and the usual stack

Kubernetes emits. You collect.

Here's the thing nobody tells you on day one: Kubernetes produces a lot of telemetry, but it keeps almost none of it. Container logs live in files on the node until they're rotated or the Pod is evicted. Events disappear after an hour. The Metrics API only answers "how much CPU right now?". A monitoring system is something you — or your platform team — add.

Takeaway: think of it as three layers. (1) Sources that Kubernetes and your app already expose (stdout, Events, /metrics, spans). (2) Collectors that you run in the cluster, usually as DaemonSets or small Deployments (a log agent, Prometheus, kube-state-metrics, an OpenTelemetry Collector). (3) Backends that store and query (a log store, a time-series database, a trace store). The misconception to kill: "kubectl logs and kubectl top are my monitoring". They are windows onto short-lived, node-local data — the moment a Pod is evicted or a node dies, that data is gone.

▶ Where each signal goes logs · resource metrics · Prometheus · traces
logs
app stdout
/var/log/pods on the node
node agent
DaemonSet
log store
resource metrics
cAdvisor in kubelet
/metrics/resource
metrics-server
metrics.k8s.io
via API server
kubectl top · HPA
full metrics
/metrics: app, kubelet, API server…
kube-state-metrics
Prometheus
pulls (scrapes)
dashboards · alerts
traces
app + API server spans
OTLP gRPC
:4317
OTel Collector
trace store
unshipped 0
shipped 0
lost 0
agent running
Press a button to follow one signal from source to backend.
Simplified: one Pod, one node, one line per click; real agents batch and retry, and Prometheus scrapes on an interval. Which collector you run (Fluent Bit, Fluentd, an OTel Collector…) and which backend (Loki, Elasticsearch, Tempo, Jaeger…) varies by cluster — the paths shown follow the Kubernetes observability docs.

The signal map

Every question you'll ask in an incident maps to one of these rows. Learn the "kept for" column: it's why the postmortem can't find anything.

SignalBorn whereRead it withKept for (built-in)Usual stack
Container logsapp stdout/stderr → runtime → files under /var/log/podskubectl logsuntil rotated (default 10Mi × 5 files per container) or the Pod is evictednode agent DaemonSet (Fluent Bit, Fluentd, OTel Collector) → Loki / Elasticsearch / OpenSearch
Eventsscheduler, kubelet, controllers write Event objectskubectl events, kubectl describe1 hour (API server --event-ttl default)an event exporter or the OTel k8sobjects receiver
Resource metricscAdvisor inside the kubeletkubectl top; used by HPA/VPAno history — "not a replacement for a full monitoring pipeline"metrics-server (add-on)
Component metrics/metrics on API server, scheduler, controller-manager, kube-proxy, kubeletPromQLwhatever your TSDB keepsPrometheus (+ Thanos / Cortex / Mimir for long-term)
Object statekube-state-metrics reads the APIPromQL (kube_pod_…)your TSDBkube-state-metrics + Prometheus
Your app's metricsyour /metrics endpoint or OTLP pushPromQL / your backendyour TSDBPrometheus scrape, or OTel SDK → Collector
Tracesyour app; API server & kubelet (OTLP)trace UIyour backendOTel Collector → Jaeger / Tempo / Zipkin

Logs: stdout is the API

Your job as an app developer is simple: write logs to stdout and stderr. The container runtime captures both streams and writes them — in the standardized CRI logging format — to files under /var/log/pods on the node. The kubelet rotates those files (containerLogMaxSize, default 10Mi; containerLogMaxFiles, default 5) and serves them through the API when you run kubectl logs.

kubectl logs deploy/api            # one Pod of the Deployment
kubectl logs api-7c9f -c app -f    # one container, follow
kubectl logs api-7c9f --previous   # the crashed instance
kubectl logs -l app=api --since=10m

Two built-in limits bite everyone once. kubectl logs only reads the latest log file, so after a rotation older lines aren't visible through it (write 40 MiB with a 10 MiB rotation and you get at most 10 MiB back). And the kubelet keeps only one terminated container's logs per container — that's what --previous shows. If the Pod is evicted, its logs go with it.

Kubernetes has no built-in cluster-level log store, so the docs list the patterns you choose from:

OptionWhat it doesUse it when
Node-level agent (DaemonSet)one agent per node tails the log files of every container and ships them; no app changesthe default choice — most clusters do this
Streaming sidecara sidecar reads a log file the app writes and prints it to its own stdout, so the node agent picks it uplegacy app can't log to stdout; note it can double node disk usage
Sidecar with its own agenta logging agent runs inside the Pod and ships directlyspecial routing/format per app; costs resources per Pod
App pushes to a backendthe app's logging library sends logs over the networkrarely; couples the app to the log backend
⚠️ Watch it!

Logging to a file inside the container (say /app/logs/app.log) makes your logs invisible to kubectl logs and to the node agent, and they vanish with the container. If a framework insists on files, point it at /dev/stdout or add a streaming sidecar. And log in a structured format (JSON lines): the agent can then parse fields instead of guessing.

Events: the cluster's short-term memory

Events are API objects that components write when something happens: Scheduled, Pulling, BackOff, FailedScheduling, Unhealthy (probe failed). They're the first thing to read when a Pod misbehaves — and the API server deletes them after --event-ttl, one hour by default.

kubectl events --for pod/api-7c9f --watch
kubectl describe pod api-7c9f   # Events at the bottom

Metrics: two pipelines, not one

The resource metrics pipeline is tiny on purpose. cAdvisor, built into the kubelet, measures CPU and memory per container; metrics-server (an add-on) pulls them from each kubelet's /metrics/resource endpoint and serves them as the metrics.k8s.io API. That's what kubectl top, the HPA and the VPA read. Memory is the working set; CPU is average core usage. It deliberately offers the minimum for autoscaling.

The full metrics pipeline is where monitoring actually happens. Every Kubernetes component exposes metrics in the Prometheus text format on /metrics (the kubelet adds /metrics/cadvisor, /metrics/resource and /metrics/probes). Prometheus uses a pull model: it discovers targets through the Kubernetes API (kubernetes_sd_configs with roles like node, pod, service, endpointslice) and scrapes each one every scrape_interval (global default 1m). Short batch jobs that die before a scrape can push through an intermediary gateway instead.

🧠 Brain power

The kubelet's metrics tell you a container is using 0 CPU. Is it idle — or has its Deployment been stuck at 0 of 3 ready replicas for an hour? Which component would even know the answer?

That's the gap kube-state-metrics fills. It doesn't measure usage at all: it watches the API server and turns object state into metrics — labels and annotations, start and termination times, status and phase (every container, for example, gets a kube_pod_container_info series). The docs' own example query finds every Pod that isn't ready:

count(kube_pod_status_ready{condition="false"})
  by (namespace, pod)
🔥 Fireside chat

Prometheus: I come to you. Every interval I scrape your /metrics, so if you're down, I notice — the scrape fails.

OTel Collector: And I let you come to me. Apps push OTLP — traces, metrics, logs — and I enrich them with Pod metadata, sample, redact, and forward them to whatever backend you like. I can even scrape Prometheus endpoints myself.

Prometheus: Then you still need somewhere to store it.

OTel Collector: Exactly. I'm a pipeline, not a database. Lots of clusters run both of us.

Traces: following one request

For your own services, tracing means instrumenting the app with an OpenTelemetry SDK, propagating the trace context on every outgoing call, and exporting spans over OTLP to a Collector. Kubernetes can join in: the API server and the kubelet export their own spans over OTLP/gRPC (default port 4317; both stable since 1.34) and the API server propagates W3C Trace Context, so a slow kubectl apply can be followed through the API server to its calls to webhooks and etcd.

OTel Collector componentWhat it collectsRun it as
otlp receiveryour apps' traces, metrics, logsDaemonSet agent and/or gateway Deployment
filelog receivercontainer logs from the node's log filesDaemonSet (preferred)
kubeletstats receivernode, Pod, container metrics from each kubeletDaemonSet (preferred)
k8s_cluster receivercluster-level metrics and entity eventsone instance (a DaemonSet duplicates data)
k8sobjects receiverobjects such as Events from the API serverone instance — ship Events past their 1 h TTL
prometheus receiverscrapes Prometheus-format endpointsDaemonSet or Deployment
k8sattributes processoradds Pod, namespace, Deployment names to telemetryeverywhere — the glue that makes signals correlate
🧰 Workflow: make your app observable
  1. Log structured lines to stdout; include a request/trace id.
  2. Expose /metrics (or push OTLP): request rate, errors, latency histograms, queue depth.
  3. Instrument with an OpenTelemetry SDK; propagate context on outgoing HTTP/gRPC calls.
  4. Keep probes honest (chapter 4) — kube-state-metrics turns "not ready" into an alertable series.
  5. Ask the platform team where logs, metrics and traces land; add dashboards and alerts next to your code.
🔬 Go deeper: the metrics APIs, stability levels and trace config

Aggregated metrics APIs. metrics.k8s.io isn't served by the API server itself: it goes through the API aggregation layer, which proxies /apis/metrics.k8s.io/… to metrics-server (or another implementation). Try kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes". metrics-server v0.6.0+ reads the kubelet's /metrics/resource (older versions the Summary API /stats/summary). kubectl top understands both v1 and v1beta1; the HPA controller still only reads v1beta1. custom.metrics.k8s.io and external.metrics.k8s.io are the same trick, served by vendor adapters.

Scraping components needs RBAC. /metrics is a non-resource URL, so Prometheus' ServiceAccount needs a ClusterRole with nonResourceURLs: ["/metrics"] and verb get.

Metric stability. Component metrics go Alpha → Beta → Stable → Deprecated → Hidden → Deleted. A deprecated STABLE metric is hidden after at least 3 releases or 9 months (whichever is longer), BETA after 1 release or 4 months, and ALPHA metrics may disappear in the release that deprecates them. Build alerts on STABLE metrics; --show-hidden-metrics-for-version buys time during upgrades.

Tracing config. The API server takes --tracing-config-file pointing at a TracingConfiguration with an endpoint (default localhost:4317) and samplingRatePerMillion — 100 means 1 in 10,000 requests.

SymptomLikely causeFix
kubectl logs is empty after a crashyou're reading the new containerkubectl logs --previous
"Old" logs missing from kubectl logsrotation — only the latest file is servedquery the log store instead
kubectl describe shows no Events for yesterday's incidentEvents expire after 1 hship Events (exporter / k8sobjects)
error: Metrics API not available from kubectl topmetrics-server not installed / not readyinstall it; check its APIService
Prometheus target down with 403scraper's ServiceAccount lacks /metrics accessClusterRole with nonResourceURLs
📌 Bullet points
  • Three layers: sources (stdout, Events, /metrics, spans) → collectors you run → backends you query.
  • Built-in retention is tiny: logs until rotation/eviction, Events 1 h, resource metrics no history.
  • metrics-server feeds kubectl top and autoscalers; Prometheus + kube-state-metrics is monitoring.
  • Traces and app telemetry: OpenTelemetry SDK → OTLP → Collector → backend; the API server and kubelet can join in.

Security for developers

who may call what, run as whom, talk to whom

Security in Kubernetes answers three different questions, with three different tools. Who may call the API, and do what? — ServiceAccounts and RBAC. What may my container do on the node? — securityContext, checked by Pod Security Standards. Who may open a network connection to my Pod? — NetworkPolicy.

Takeaway: every one of those starts either closed or wide open, and you need to know which. RBAC denies by default. NetworkPolicy allows by default. The misconception to kill: "I created a NetworkPolicy, so traffic is restricted". Without a network plugin that enforces it, the object has no effect at all.

▶ Two gates RBAC for API calls · NetworkPolicy for packets
Role pod-reader in dev: get/list/watch on pods, pods/log — bound to system:serviceaccount:dev:ci-bot
No NetworkPolicy — every Pod accepts every connection.
Press ▶ to send four API requests through RBAC.
Simplified: authentication and admission are skipped, and the network panel assumes a CNI plugin that enforces NetworkPolicy. RBAC semantics (additive rules, deny when nothing matches, 403) and NetworkPolicy semantics (non-isolated by default, additive allow rules) are from the Kubernetes docs.

ServiceAccounts: your Pod's identity

Every namespace gets a ServiceAccount named default, and a Pod that doesn't say otherwise runs as it. It has no permissions beyond API discovery. Pick your own with spec.serviceAccountName; Kubernetes then mounts a short-lived, automatically rotated token at /var/run/secrets/kubernetes.io/serviceaccount/token (with ca.crt and namespace next to it). If your app never talks to the API server, turn that off:

spec:
  serviceAccountName: api
  automountServiceAccountToken: false
🔬 Go deeper: bound, projected tokens

Since v1.22 the token isn't a Secret any more. The ServiceAccount admission controller adds a projected volume (kube-api-access-…) with three sources: a serviceAccountToken, the cluster CA from the kube-root-ca.crt ConfigMap, and the namespace via the downward API.

The kubelet fetches the token through the TokenRequest API. It's a JWT bound to the Pod (the Pod's name and UID are private claims) with the API server as audience, valid for 1 hour by default (expirationSeconds, minimum 600). When the Pod is deleted — or its UID doesn't match — the API server stops accepting the token. The kubelet refreshes it when it's older than 80 % of its TTL or 24 hours, so your app must reload the file rather than caching the token at startup — the docs say re-reading it on a schedule (say every 5 minutes) is often good enough.

The old style — a long-lived token stored in a Secret — doesn't expire or rotate. Before v1.24 one was created for every ServiceAccount automatically; today you only get one if you create it explicitly.

RBAC: additive allow-lists

RBAC has four objects. A Role (namespaced) or ClusterRole (cluster-wide) lists verbs on resources. A RoleBinding or ClusterRoleBinding grants a role to subjects — users, groups, ServiceAccounts. Rules are purely additive: there are no deny rules, and a request that no rule allows is denied with HTTP 403.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: pod-reader, namespace: dev}
rules:
- apiGroups: [""]
  resources: ["pods", "pods/log"]
  verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: ci-reads-pods, namespace: dev}
subjects:
- kind: ServiceAccount
  name: ci-bot
  namespace: dev
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

Test instead of guessing — kubectl auth can-i asks the authorizer directly, and --as impersonates someone else (if you're allowed to impersonate):

kubectl auth can-i list pods -n dev \
  --as=system:serviceaccount:dev:ci-bot    # yes
kubectl auth can-i delete secrets -n dev \
  --as=system:serviceaccount:dev:ci-bot    # no
❓ There are no dumb questions
Q:
Role or ClusterRole?
A:
A ClusterRole can be reused: bind it with a RoleBinding and it only grants rights inside that binding's namespace. Bind it with a ClusterRoleBinding and it applies everywhere. Write common roles once as ClusterRoles, grant them per namespace.
Q:
Why can't I edit the roleRef of my binding?
A:
It's immutable by design — delete and recreate the binding. That way, someone allowed to update a binding's subjects can't quietly swap in a more powerful role.

securityContext: what the process may do

Unless you say otherwise, a container runs as whatever user its image specifies, and allowPrivilegeEscalation defaults to true when unset. You can set these at Pod level and override per container:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile: {type: RuntimeDefault}
  containers:
  - name: app
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities: {drop: ["ALL"]}

That block passes the Restricted Pod Security Standard. The standards are three cumulative profiles, enforced per namespace by the built-in Pod Security admission controller via labels:

ProfileWhat it allowsUse it when
Privilegedeverything, including known privilege escalationssystem components (CNI, storage, node agents)
Baselinethe default minimally-specified Pod; blocks known escalations (host namespaces, privileged…)the floor for every app namespace
Restrictedhardening: run as non-root, no privilege escalation, drop ALL capabilities (only NET_BIND_SERVICE may be added), seccomp RuntimeDefault/Localhostyour own services — aim here
kubectl label ns dev \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/warn=restricted

Modes: enforce rejects violating Pods, audit annotates the audit log, warn prints a warning to the client. Subtle point: enforce checks Pods, not Deployments — so a bad Deployment is accepted and its ReplicaSet then fails to create Pods. warn and audit also check workload templates, which is why you want warn on.

NetworkPolicy: firewall rules by label

By default every Pod accepts every connection and may connect anywhere. A Pod becomes isolated for a direction as soon as any policy selecting it lists that direction in policyTypes; from then on only what some policy allows gets through, and policies combine as a union. The usual recipe: default-deny per namespace, then allow what you need.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny-ingress}
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: api-from-frontend}
spec:
  podSelector: {matchLabels: {app: api}}
  ingress:
  - from:
    - podSelector: {matchLabels: {app: frontend}}
    ports: [{port: 8080}]
⚠️ Watch it!

A default-deny egress policy also blocks DNS — every Pod in the namespace suddenly "can't resolve anything". Add a policy that allows egress to your cluster's DNS service together with the deny.

SymptomLikely causeFix
app's API call fails with HTTP 403 Forbidden for system:serviceaccount:dev:ci-botno RBAC rule allows itkubectl auth can-i … --as; add a Role + RoleBinding
Deployment created, 0 Pods, ReplicaSet events show a Pod Security violationnamespace enforce levelfix securityContext (the message lists what)
App crashes writing to /tmpreadOnlyRootFilesystem: truemount an emptyDir at /tmp
NetworkPolicy has no effectCNI plugin doesn't implement NetworkPolicyuse a plugin that does, or ask the platform team
📌 Bullet points
  • One ServiceAccount per app; automountServiceAccountToken: false if it doesn't call the API.
  • RBAC is additive and default-deny; test with kubectl auth can-i --as.
  • Aim for the Restricted profile: non-root, no escalation, drop ALL, seccomp RuntimeDefault.
  • NetworkPolicy is default-allow, additive, and only works if the CNI enforces it; egress deny needs a DNS exception.

From commit to production

Git says what should run. A controller makes it so.

You've seen the core idea of Kubernetes many times now: you declare desired state, a controller reconciles. GitOps applies that same idea one level up. The desired state of the whole cluster lives in a Git repository, and a controller inside the cluster (Argo CD, Flux) keeps pulling it and applying it.

Takeaway: CI builds and tests an immutable image; a commit to a config repo declares which image runs where; a GitOps controller pulls and reconciles. Promotion is a Git change, rollback is a Git revert, and drift is something the controller detects. The misconception to kill: "CD means the CI job runs kubectl apply against prod". That works, but it gives CI cluster-admin keys and nothing notices when reality drifts from Git.

▶ Commit → image → Git → cluster Argo CD-style reconciliation
commit
a1b2c3
CI
test · build
registry
image @sha256
config repo
envs/dev · envs/prod
Argo CD
compare · sync
dev
prod
Press ▶ to take a commit all the way to dev.
Simplified: one app, two environments as directories in one config repo, replicas and image tag as the only fields. Argo CD's behaviors shown (polling about every 3 minutes unless a webhook fires, OutOfSync, selfHeal reverting live changes, selfHeal off by default) are from its docs; times are compressed.

Immutable images: tags lie, digests don't

A tag is a movable pointer; a digest (@sha256:…) is the content hash and never changes. The Kubernetes docs say to avoid :latest in production — you can't tell which version is running and can't roll back properly — and to use a meaningful tag such as v1.42.0 and/or a digest. If you give both (app:1.42.0@sha256:…), only the digest is used for pulling.

So the CI rule is: build once, tag with something unique (a version or the commit SHA), push, and promote that same image from environment to environment. Never rebuild for prod.

Environments: namespaces or clusters?

OptionWhat it doesUse it when
Namespace per env (one cluster)separate names, RBAC, quotas; shares nodes, control plane and cluster-scoped objects (CRDs, Nodes, PVs)dev/preview environments, small teams, cost matters
Cluster per envfull isolation incl. control plane and upgradesprod vs non-prod — a bad CRD or upgrade in staging can't hurt prod

A common shape (a pattern, not a rule): namespaces for dev and previews, a separate cluster for production. Per-env differences live in Kustomize overlays or Helm values (chapter 10).

Push vs pull

OptionWhat it doesUse it when
Push (CI runs kubectl apply / helm upgrade)pipeline holds cluster credentials and applies at the end of a runsmall setups; one cluster; quick start
Pull / GitOps (Argo CD, Flux)in-cluster controller watches Git and applies; CI only writes to Git and the registryseveral clusters/envs, audit trail, drift detection
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: api-prod, namespace: argocd}
spec:
  project: default
  source:
    repoURL: https://git.example.com/deploy.git
    targetRevision: main
    path: envs/prod/api
  destination:
    server: https://kubernetes.default.svc
    namespace: api
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Two flags in that automated block are off by default, and both are safety catches. Without prune, a resource you deleted from Git stays in the cluster. Without selfHeal, a manual change in the cluster makes the app OutOfSync but isn't reverted.

🤓 Deep dive

How Argo CD reconciles. The repo server clones the repo into a cache and renders manifests (plain YAML, Kustomize, Helm values). The application controller is a Kubernetes controller that continuously compares that target state to the live objects and marks the app Synced or OutOfSync; it also runs PreSync/Sync/PostSync hooks. The API server serves the UI/CLI and receives Git webhooks.

Repositories are polled every three minutes by default: timeout.reconciliation in the argocd-cm ConfigMap (120 s) plus up to 60 s jitter. A Git webhook removes that delay. Automated sync triggers only when the app is OutOfSync, and attempts one sync per unique commit SHA + parameters — unless selfHeal is on, in which case it re-syncs after a short self-heal delay (the docs describe --self-heal-timeout-seconds; recent controllers use an exponential backoff set by --self-heal-backoff-* flags). Rollback from the UI isn't possible while automated sync is on — revert in Git instead.

🔥 Fireside chat

Engineer on call: Prod is on fire, I just bumped replicas to 10 with kubectl scale.

Argo CD: I saw. Git says 3. With selfHeal on, I put it back to 3 a few seconds later.

Engineer on call: …so how do I scale in an emergency?

Argo CD: Commit replicas: 10 — or let an HPA own replicas and leave that field out of Git. Everything else goes through a pull request, which is also your audit log.

Promotion and progressive delivery

Promotion is just moving the same image reference from envs/dev to envs/prod — by a pull request a human approves, or by a bot after dev's checks pass. Flux ships image-automation controllers aimed at automating that tag update.

Progressive delivery limits the blast radius of that change. A Deployment's rolling update already replaces Pods gradually and gates on readiness (chapter 4). For canaries with traffic weights and automatic analysis, tools like Argo Rollouts (a controller + CRDs for blue-green, canary and canary analysis) shift traffic step by step and use canary analysis — the metrics from chapter 15 — to decide whether to go on. Traffic-weighted canaries need something that can split traffic, like the weighted routes of chapter 11.

SymptomLikely causeFix
App permanently OutOfSync on one fieldsomething else writes that field (HPA → replicas, a mutating webhook)remove the field from Git, or list it under ignoreDifferences
Deleted a manifest, resource still runningprune is off (default)enable prune or delete manually once
Merged 2 minutes ago, nothing deployedpolling interval (up to ~3 min)configure a Git webhook; or refresh the app
Prod runs a different build than staging testedmutable tag re-pushed, or rebuilt per envbuild once, deploy by digest
📌 Bullet points
  • Build once; deploy an immutable reference (unique tag and/or digest); never :latest in prod.
  • GitOps = the reconcile loop applied to Git: CI writes to Git and the registry, an in-cluster controller pulls.
  • Argo CD: prune and selfHeal are off by default; polling ~3 min unless webhooks.
  • Promotion is a Git change; rollback is a revert; canaries need a rollout controller plus metrics.
Part III

Internals

Open the box: the API server pipeline, etcd and watches, controllers, the scheduler, the kubelet, the network data path, extension points and a highly available control plane.

Life of a request in kube-apiserver

seven checkpoints between kubectl and etcd

In chapter 2 you learned that only the API server talks to etcd. Now open that box. Every write you send, whether from kubectl apply, a controller or a Helm release, walks through the same ordered pipeline. Any stage can stop it, and each stage fails with its own HTTP status code.

Takeaway: the order is authentication → flow control → authorization → mutating admission → validation → validating admission → etcd. The misconception to kill: "the API server just stores what I send". By the time your object reaches etcd, mutating admission may have added fields to it, and several policies you never wrote may have voted on it.

▶ The request pipeline send a request, watch where it stops
Press ▶ to send a create request through every stage.
Simplified: stages are drawn as one lane, the policy (replicas ≤ 5) is the example from the ValidatingAdmissionPolicy docs, and the webhook is imaginary. Status codes (401, 429, 403) and the failurePolicy semantics come from the Kubernetes docs; the placement of flow control right after authentication follows the kube-apiserver handler chain. Timings are illustrative.

The stages, one by one

StageWhat it decidesStops the request with
AuthenticationWho you are. Each configured authenticator (client certs, bearer tokens, service-account JWTs, …) is tried in turn until one succeeds.401
API Priority & FairnessWhich priority level the request belongs to, and whether it runs now, waits in a queue or is turned away.429 Too Many Requests
AuthorizationWhether that user may do this verb on this resource. The first authorizer that approves or denies wins; if all have no opinion, the answer is deny.403 Forbidden
Mutating admissionBuilt-in plugins, MutatingAdmissionPolicies and mutating webhooks may change the object (defaults, injected sidecars, tolerations).rejection message
ValidationThe API server validates the final, mutated object.an "invalid" error listing the bad fields
Validating admissionValidatingAdmissionPolicies (CEL) and validating webhooks vote on the final object. They can't change it.rejection message
etcdThe object is stored and gets a new resourceVersion (next chapter).409 Conflict if your version was stale

Admission only runs on requests that create, modify, delete or connect to an object. Plain reads (get, list, watch) stop after authorization. Admission is also stricter than authentication and authorization in one way: if any admission controller rejects, the whole request is rejected immediately.

Mutating controllers run one after another, each seeing the previous one's changes. Validating webhooks run in parallel, because they only say yes or no. That's why policy that must see the final object belongs in the validating phase.

Policies without a server: ValidatingAdmissionPolicy

Webhooks work, but each one is a TLS server you have to run and keep alive. A ValidatingAdmissionPolicy (stable since v1.30) is a CEL expression that the API server evaluates in-process. It takes two objects: the policy itself and a binding that says where it applies and what to do on failure.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: max-replicas.example.com
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: ["apps"]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["deployments"]
  validations:
  - expression: "object.spec.replicas <= 5"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: max-replicas-binding.example.com
spec:
  policyName: max-replicas.example.com
  validationActions: [Deny]

A binding's validationActions can be Deny, Warn (the client gets a warning) or Audit (recorded in the audit event). Deny and Warn can't be combined, so a safe rollout is [Warn, Audit] first, then [Deny]. The mutating twin, MutatingAdmissionPolicy, is stable since v1.36.

⚠️ Watch it! A webhook can lock you out

failurePolicy decides what happens when the API server can't reach a webhook (network error, timeout, non-2xx, garbage response). Fail rejects the request; Ignore skips the webhook. The default is Fail, and the default timeout is 10 seconds.

The classic outage: a webhook that intercepts Pod creation runs as Pods itself. Its node dies, its replacement Pods need admission, admission needs the webhook… The docs call this a deadlock. Exclude the webhook's own namespace and kube-system with a namespaceSelector, keep timeouts short, and choose Fail only where letting a request through unchecked is worse than blocking it.

Note: a webhook that answers "denied" is a rejection, not a failure. failurePolicy only applies when the call itself goes wrong.

🔬 Go deeper: API Priority and Fairness (APF)

APF (stable since v1.29) protects the API server from a noisy client. Each request is classified by a FlowSchema and assigned to a PriorityLevelConfiguration. Each priority level has its own share of the server's concurrency (the sum of --max-requests-inflight and --max-mutating-requests-inflight), so a flood in one level can't starve another. The default configuration keeps leader election, built-in controllers and workload Pods in separate levels.

Within a level, fair queuing with shuffle sharding keeps one flow from starving the others. When a level is full, its type decides: Reject answers 429 at once, Queue holds the request briefly. A request can occupy several "seats" (a big list costs more than a get).

Watches are subject to APF. Long-running requests such as exec and log tailing aren't. When debugging, every response carries X-Kubernetes-PF-FlowSchema-UID and X-Kubernetes-PF-PriorityLevel-UID headers; match them against kubectl get flowschemas and kubectl get prioritylevelconfigurations.

❓ There are no dumb questions
Q:
How do I test a manifest against all of this without creating anything?
A:
kubectl apply --dry-run=server -f x.yaml. The request goes through the admission chain and validation, and the API server guarantees nothing is persisted. Webhooks see dryRun: true and must skip their side effects (they declare that with sideEffects).
Q:
Mutating webhooks run in order. What if webhook B changes something webhook A cares about?
A:
A webhook can set reinvocationPolicy: IfNeeded (default Never) to run again when later mutations changed the object. That's also why every mutating webhook must be idempotent.
SymptomLikely causeFix
401 Unauthorizedexpired or wrong credentialsrefresh the kubeconfig token / cert
403 ForbiddenRBAC has no rule for this verb/resourcekubectl auth can-i <verb> <res> --as …, add a Role
429 Too Many RequestsAPF priority level fullback off and retry; check the PF headers
creates fail, error names a webhook, cluster-widewebhook unreachable + failurePolicy: Failfix the webhook Service, or scope it with selectors
ValidatingAdmissionPolicy '…' denied requesta CEL rule returned falseread the failed expression in the message
📌 Bullet points
  • authn (401) → APF (429) → authz (403) → mutating admission → validation → validating admission → etcd.
  • Mutators run serially and may change your object; validators run in parallel and only vote.
  • Prefer ValidatingAdmissionPolicy (CEL, in-process) over a webhook for simple rules.
  • Webhook failurePolicy defaults to Fail: scope webhooks tightly or they can block the cluster.
  • --dry-run=server runs the whole chain without storing anything.

etcd, resourceVersion & watch

no locks, just version numbers and a stream

Kubernetes has hundreds of clients writing to the same objects at once, and no locks. Two ideas make that safe and fast: every object carries a resourceVersion, and nobody polls, everybody watches.

Takeaway: an update carrying a stale resourceVersion is rejected with 409 Conflict, so you re-read and retry. And a watch is a replay from a version number, not a live feed that exists forever: history is kept for a limited time, and if you fall behind you get 410 Gone and must list again. The misconception to kill: "when two clients update the same object, the last write wins".

▶ Optimistic concurrency and the watch stream ConfigMap app-config
Press ▶ to let two clients update the same ConfigMap.
Simplified: resourceVersions are small made-up numbers (real ones are opaque strings), and a 10-minute outage (longer than the history window) is compressed into a second. The 409 and 410 behaviours, the ~5-minute default history window and the list → watch → relist loop are from the Kubernetes API docs.

resourceVersion: a compare-and-swap token

Every object has metadata.resourceVersion, the version of that object as stored in etcd. When you update (HTTP PUT), you send back the version you read. If someone changed the object in the meantime, the API server answers 409 Conflict instead of silently overwriting their change. The right reaction is: GET again, re-apply your change, PUT again. client-go ships a helper for exactly this loop (retry.RetryOnConflict).

A patch without a resourceVersion is unconditional; add one if you need lost-update detection. Server-side apply (chapter 8) handles conflicts per field manager instead.

⚠️ Watch it! resourceVersion is opaque

Pass it back to the server unmodified. Versions of the same resource type can be compared for order (the docs define how: the longer decimal string is greater, equal lengths compare lexically), but don't do arithmetic on them, and never compare a Pod's version with a Deployment's.

List, then watch

Every controller, and kubectl get -w, does the same two-step dance. The list response has a collection resourceVersion; the watch starts from it and streams every change after it:

GET /api/v1/namespaces/test/pods
→ 200 {"metadata":{"resourceVersion":"10245"},
       "items":[...]}

GET /api/v1/namespaces/test/pods?watch=1
    &resourceVersion=10245
→ {"type":"ADDED",   "object":{... "10596" ...}}
  {"type":"MODIFIED","object":{... "11020" ...}}

If the connection drops, the client resumes the watch from the last version it saw. No events are missed, as long as that version is still in history. The docs say clusters using etcd 3 keep changes for the last 5 minutes by default. Ask for anything older and you get 410 Gone: clear your cache, list again, and watch from the new version.

Bookmarks soften this. With allowWatchBookmarks=true the server may send BOOKMARK events that carry only a fresh resourceVersion, meaning "you're up to date until here", so a quiet watch still has a recent version to resume from. Don't rely on their timing: the server may send them at any interval, or not at all.

🔬 Go deeper: where reads are served from (the watch cache)

The API server keeps a watch cache: an in-memory mirror of what's in etcd, fed by its own watch on etcd. Watches are served from it, and so are many lists. What you get depends on the resourceVersion parameter:

list with…SemanticsServed from
resourceVersion unsetmost recent (consistent)etcd quorum read, or the watch cache when it can prove it's current (etcd ≥ 3.4.31 / 3.5.13)
resourceVersion="0"any: may be older than something you already sawwatch cache
resourceVersion="N"not older than Nwatch cache

Consistent reads from the cache (ConsistentListFromCache) are stable since v1.34: the API server asks etcd for a progress notification to prove the cache has caught up, which takes a lot of load off etcd for big lists. A newer option, sendInitialEvents=true on a watch (the WatchList feature, beta), streams the initial state as synthetic ADDED events followed by a bookmark, so a client doesn't need one huge list response.

🤓 Deep dive: why history expires

etcd is a multi-version store. Its key space has a single revision counter, starting at 1, and each atomic write creates a new revision; old values stay addressable by revision. That's what makes "watch from version N" possible.

Keeping every revision forever would fill the disk, so old history is compacted: etcd discards event history and superseded keys before a given revision. kube-apiserver requests compaction every --etcd-compaction-interval (default 5m). A watch that wants a compacted revision can't be replayed, and that's your 410 Gone.

✏️ Sharpen your pencil

Your operator reads a ConfigMap, spends 2 seconds calling an external API, then updates the ConfigMap with the result. In production it sometimes logs 409 Conflict. Is that a bug in Kubernetes? What should the code do?

Answer

No: it's Kubernetes protecting another writer's change that landed during those 2 seconds. Re-GET the object, re-apply your change on top of the fresh copy, and PUT again (bounded retries). If your change doesn't depend on the old value, use a patch instead.

📌 Bullet points
  • Updates carry the resourceVersion you read; stale → 409 Conflict → re-read and retry.
  • Clients list, then watch from the list's resourceVersion, and resume from the last seen version.
  • History is short (≈5 min by default); too old → 410 Gone → relist.
  • Bookmarks keep a quiet watch's version fresh, but come with no timing guarantee.
  • The API server's watch cache serves watches and most lists; resourceVersion="0" may be stale.

Controllers inside: informers, workqueues, owners & finalizers

what "reconcile" actually runs

Chapter 1 drew the controller as a loop: observe, diff, act. Here is the machinery inside that loop, and the two metadata fields that decide what happens when you delete something: ownerReferences and finalizers.

Takeaway: a controller doesn't react to events, it reacts to state. An event only tells it which object to look at again. Deletion is a process, not an instant: owners, dependents and finalizers decide its order. The misconception to kill: "kubectl delete removes the object right away". With a finalizer on it, the object can sit in Terminating forever.

Informer → workqueue → reconcile

The client-go machinery that almost every controller uses has four parts:

  1. A Reflector does the list-then-watch from the previous chapter for one resource type.
  2. The informer keeps a local, indexed cache of every object and calls your event handlers on add/update/delete. All reads in reconcile come from this cache, not from the API server.
  3. The handlers don't do work. They compute the object's key (namespace/name) and put it on a workqueue. The queue is "stingy": a key added five times before a worker picks it up is processed once, and never by two workers at the same time.
  4. Workers pop a key and call reconcile: read the current object from the cache, compare desired with actual, act. On error the key goes back in with per-item exponential back-off.

That's what level-triggered means. The Kubernetes API conventions say behaviour should be level-based rather than edge-based: if replicas goes 2 → 5 → 3 while your controller is busy, it only ever needs to see 3. Missed or merged events are harmless, because reconcile always looks at the whole current state.

🤓 Deep dive: generation vs observedGeneration

metadata.generation is a sequence number the API server bumps whenever the spec changes. A controller writes the generation it last acted on into status.observedGeneration.

So "is the controller done with my change?" becomes a comparison: observedGeneration < generation means it hasn't processed your latest spec yet, and the rest of status may describe the old one. Deployments report it too, and with the PodObservedGenerationTracking feature the kubelet sets it for Pods.

▶ Deleting a Deployment pick a propagation policy
Press ▶ to delete the Deployment with the default (background) policy.
Simplified: one ReplicaSet and three Pods; the garbage collector's work is shown step by step. The three policies, the foregroundDeletion finalizer, deletionTimestamp and the stuck-finalizer behaviour follow the Kubernetes docs. The finalizer name example.com/cleanup is made up.

Owners and dependents

A ReplicaSet created by a Deployment has metadata.ownerReferences pointing at the Deployment; each Pod points at its ReplicaSet. Controllers set these automatically, together with blockOwnerDeletion: true. Owner references can't cross namespaces: a namespaced dependent may have a namespaced owner in the same namespace or a cluster-scoped owner.

When you delete an owner, the garbage collector (a controller in kube-controller-manager) follows those references:

PolicyWhat it doesUse it when
background (default)The owner disappears immediately; the GC deletes dependents afterwards.almost always
foregroundThe owner gets a deletionTimestamp and the foregroundDeletion finalizer, stays visible, and is deleted only after dependents with blockOwnerDeletion=true are gone.a script must know that "owner gone" means "everything gone"
orphanThe owner is deleted; dependents stay and lose that owner reference.replacing a controller object without restarting its Pods

Finalizers: "not yet, I have cleanup to do"

A finalizer is just a string in metadata.finalizers. When you delete an object that has one, the API server doesn't remove it. It sets deletionTimestamp, returns 202 Accepted, and waits. The controller that owns the finalizer sees the timestamp, does its cleanup (release a cloud load balancer, detach a volume) and removes its string. When the list is empty, the object is gone.

A real example: kubernetes.io/pvc-protection keeps a PersistentVolumeClaim in Terminating while a Pod still uses it. Once an object is being deleted, you can remove finalizers but not add new ones.

⚠️ Watch it! Stuck in Terminating

If the controller that owns a finalizer is gone (uninstalled operator, deleted CRD controller), nobody will ever remove the string, and the object stays in Terminating. The tempting fix is to patch the finalizers away. The docs warn against doing that by reflex: the finalizer exists because some cleanup hasn't happened. First find out what it protects, do that cleanup yourself, then remove it:

kubectl get cm app -o jsonpath='{.metadata.finalizers}'
kubectl patch cm app --type=merge \
  -p '{"metadata":{"finalizers":null}}'
SymptomLikely causeFix
object stuck in Terminatinga finalizer whose controller isn't runningcheck .metadata.finalizers; restore the controller or clean up manually
namespace stuck in Terminatinga finalizer nobody removes (on the namespace, or on objects still being deleted inside it)inspect the namespace's finalizers and the remaining objects
Pods survive after deleting their ReplicaSetdeleted with --cascade=orphandelete the Pods, or re-adopt them with a matching controller
status looks old after a spec changeobservedGeneration < generationwait, or check the controller's logs
📌 Bullet points
  • Informer (list+watch+cache) → handlers enqueue keys → workqueue (dedups, back-off) → reconcile.
  • Level-triggered: reconcile reads the whole current state, so missed events don't matter.
  • ownerReferences + the garbage collector: background (default), foreground, orphan.
  • Finalizers turn delete into "set deletionTimestamp and wait"; an orphaned finalizer means stuck Terminating.
  • observedGeneration == generation means the controller has seen your latest spec.

The scheduler

filter, score, bind: one Pod at a time

kube-scheduler has exactly one job: for each Pod with no nodeName, pick a node and write that choice back to the API server as a binding. It never starts a container. The kubelet on the chosen node sees the Pod through its watch and does the rest (next chapter).

Takeaway: every attempt runs the same funnel. Filter drops the nodes that can't run the Pod (the rest are feasible), score ranks the feasible ones, and bind commits the winner; ties are broken at random. The misconception to kill: "the scheduler puts Pods where CPU is idle". The resource filter compares a node's free capacity with the Pod's requests (chapter 7). Live usage doesn't enter the decision.

▶ Filter → score → bind Pod web requests 500m CPU
Press ▶ to schedule one Pod onto four nodes.
Simplified: four nodes, one resource (CPU), and scores invented to make the point (real scores come from many plugins, normalized and combined by configured weights). The event text is shortened. The filter → score → bind order, taint filtering, and preemption as a PostFilter step follow the Kubernetes docs.

The scheduling framework

Internally, each attempt is split into a scheduling cycle (choose a node) and a binding cycle (apply the choice). Scheduling cycles run one Pod at a time; binding cycles may run concurrently. Every step is a named extension point where plugins hook in, and the built-in behaviour is itself a set of plugins:

Extension pointWhat plugins do there
PreEnqueue · QueueSortdecide if a Pod may enter the active queue (scheduling gates); order the queue (by priority by default)
PreFilter · Filterprecompute, then reject infeasible nodes: resources, taints, node affinity, ports, volumes, spread
PostFilterruns only if no node is feasible; the default implementation is preemption
PreScore · Score · NormalizeScorerank feasible nodes (spreading, affinity preferences, image locality…)
Reserve · Permithold resources for the Pod; approve, deny or make it wait
PreBind · Bind · PostBinde.g. provision a volume first, then write the binding

Telling the scheduler what you want

OptionWhat it doesUse it when
nodeSelectorhard filter: node must have these labelssimple "only on arm64 / only on GPU nodes"
nodeAffinityrequired… (filter) or preferred… (score, with weights); richer operators"prefer zone a, never spot nodes"
podAffinity / podAntiAffinityplace near / away from Pods with certain labels, per topologyKeyco-locate a cache with its app; keep replicas apart
taints + tolerationsthe node repels Pods; a toleration lets one through (it doesn't attract it)dedicated or special hardware nodes
topologySpreadConstraintslimit the skew of matching Pods across zones/nodes (maxSkew)survive a zone outage with replicas evenly spread
priorityClassNamequeue order and the right to preempt lower-priority Podscritical services vs batch work
spec:
  topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels: {app: web}
  tolerations:
  - key: gpu
    operator: Exists
    effect: NoSchedule

The "IgnoredDuringExecution" in affinity names means what it says: if node labels change after the Pod is placed, the Pod keeps running. Taints are different: a NoSchedule taint only affects new Pods, but a NoExecute taint evicts running Pods that don't tolerate it. (PreferNoSchedule is the soft version.) Even without your own constraints, the scheduler applies default spreading: maxSkew: 3 per hostname and maxSkew: 5 per zone, both ScheduleAnyway.

⚠️ Watch it! Inter-pod affinity is expensive

Pod affinity and anti-affinity need a lot of processing, and the docs don't recommend them in clusters larger than several hundred nodes. For "keep replicas apart", topologySpreadConstraints is usually the better tool.

Priority and preemption

A PriorityClass maps a name to an integer (user classes go up to 1,000,000,000; the built-in system-cluster-critical and system-node-critical sit above that). Pods without a class get the globalDefault class's value, or 0. Higher-priority Pods are queued first.

If no node fits a Pod, PostFilter tries preemption: find a node where evicting lower-priority Pods would make room, set the Pod's status.nominatedNodeName, and delete the victims, which get their normal graceful termination. PodDisruptionBudgets are respected on a best-effort basis only. A class with preemptionPolicy: Never jumps the queue but never evicts anyone.

🔬 Go deeper: big clusters and not-ready-yet Pods

Not every node is scored. percentageOfNodesToScore lets the scheduler stop looking once it has found enough feasible nodes. The automatic value is a linear formula: 50 % for a 100-node cluster, 10 % for 5000 nodes, never below 5 %. "The best node" really means "the best of the nodes it looked at".

Scheduling gates. A Pod created with spec.schedulingGates stays out of the active queue (status SchedulingGated) until something removes the gates. That's how an external controller, for example a quota or batch system, can hold Pods back without them burning scheduler cycles.

Symptom (kubectl describe pod)Likely causeFix
FailedScheduling … Insufficient cpurequests larger than any node's free allocatablelower requests, or add capacity (autoscaler, chapter 14)
… untolerated taintnodes are tainted and the Pod has no tolerationadd a toleration, or target other nodes
… didn't match Pod's node affinity/selectorlabel typo, or no node has the labelkubectl get nodes --show-labels
Pod Pending, status SchedulingGateda scheduling gate hasn't been removedcheck which controller owns the gate
your Pods disappear when a big job startsthey were preempted by a higher PriorityClasscheck events for preemption; give critical Pods a class
📌 Bullet points
  • Queue (by priority) → Filter (feasible) → Score → Bind; ties broken at random.
  • Fit is judged on requests, not on live usage.
  • Placement tools: nodeSelector, node affinity, pod (anti-)affinity, taints/tolerations, topology spread.
  • NoSchedule keeps new Pods off; NoExecute also evicts running ones.
  • No feasible node → preemption of lower-priority Pods (PDBs best effort) → nominatedNodeName.

Kubelet, CRI & the node

the only component that actually runs your code

Once a Pod has a nodeName, the control plane is done with it. From here on it's the kubelet on that node: it turns a PodSpec into running containers, reports status back, and keeps telling the control plane "I'm alive".

Takeaway: the kubelet reconciles Pods on its node through the CRI, and proves it's alive with a Lease renewed every 10 seconds. When that stops, the control plane marks the node Unknown after 50 s, taints it, and evicts its Pods only after their 300-second toleration runs out. The misconception to kill: "when a node dies, its Pods are rescheduled right away". With defaults it's closer to six minutes.

▶ Heartbeats and node failure time is compressed
t = 0 s
Press ▶ to watch node-2's kubelet renew its Lease.
Simplified: one node, two Pods, and the clock runs fast. Defaults from the docs: Lease renewed every 10 s (duration 40 s), --node-monitor-grace-period 50 s, automatic unreachable:NoExecute toleration of 300 s. Real clusters add a few seconds of detection and rate limiting on top.

From PodSpec to processes

The kubelet runs a sync loop: it collects work for the Pods assigned to its node (from its API watch, and from static Pod files), and per-Pod workers drive each one towards its spec. To actually run anything it's a gRPC client of the container runtime (containerd, CRI-O) through the Container Runtime Interface. Since v1.26 the runtime must speak the v1 CRI API.

  1. Ask the runtime for a pod sandbox: the shared network namespace and cgroup parent. On Linux it's held by the pause container you met in chapter 3.
  2. The runtime calls the CNI plugin to give the sandbox its network and IP (chapter 23).
  3. Pull images as needed, run init containers in order, then start the app containers with the cgroup limits from your requests and limits (chapter 7).
  4. The PLEG (Pod Lifecycle Event Generator) periodically polls the runtime for container state and wakes the sync loop, which writes Pod status to the API server. That polling is why status can lag reality slightly.
🤓 Deep dive: cgroup v2 is now the baseline

cgroup v1 is deprecated since v1.35, and by default the kubelet no longer starts on a cgroup v1 node (an admin can override that with failCgroupV1: false). Check a node with stat -fc %T /sys/fs/cgroup/: cgroup2fs means v2. The kubelet and the runtime should both use the systemd cgroup driver.

Image pulls

If you omit imagePullPolicy, the API server fills it in once, at creation:

Image referenceDefault policy
app@sha256:… (digest)IfNotPresent
app:1.4.2 (tag other than latest)IfNotPresent
app:latest or app (no tag)Always

It isn't recomputed when you later change the tag, so switching a Deployment to :latest keeps IfNotPresent. The kubelet pulls images one at a time by default (serializeImagePulls: false allows parallel pulls). A failing pull ends in ImagePullBackOff, with retries backing off up to 300 seconds.

Heartbeats and what happens when they stop

Every Node has a Lease with the same name in kube-node-lease (chapter 2). The kubelet renews it every 10 s (lease duration 40 s), which is a cheap update. The heavier Node .status is only posted when something changes or every 5 minutes. The node controller checks nodes every 5 s:

After the last heartbeatWhat happens
+50 s (--node-monitor-grace-period)Ready condition becomes Unknown; node gets the node.kubernetes.io/unreachable taint with effect NoExecute (a node reporting Ready=False gets not-ready instead)
+50 s + 300 sPods' automatic toleration (tolerationSeconds: 300) expires; they're evicted, and their controllers create replacements elsewhere
neverDaemonSet Pods: they tolerate these taints with no time limit

Evictions are rate-limited (by default one node per 10 seconds) and slow down or stop when a large part of a zone is unhealthy, so a network blip doesn't empty the cluster. You can tune the wait per Pod: a shorter tolerationSeconds fails over faster, a longer one keeps stateful Pods in place through short partitions.

tolerations:
- key: node.kubernetes.io/unreachable
  operator: Exists
  effect: NoExecute
  tolerationSeconds: 60
⚠️ Watch it! "Evicted" doesn't mean "stopped"

If the node is unreachable, the API server can't tell its kubelet to stop anything. The Pods may keep running on the partitioned node while replacements start elsewhere. For a StatefulSet or anything holding a lock, design for two copies running briefly, or use fencing.

Node-pressure eviction

The kubelet also evicts Pods on its own when the node runs short. Default hard thresholds on Linux: memory.available<100Mi, nodefs.available<10%, imagefs.available<15%, nodefs.inodesFree<5%, imagefs.inodesFree<5%. It sets a pressure condition (the node gets e.g. a memory-pressure taint), then ranks Pods: first those whose usage exceeds their requests, by priority and by how far over they are. Hard evictions use a 0 s grace period, so your preStop hook doesn't get its usual time. Soft thresholds come with a grace period you configure.

🔬 Go deeper: static Pods and mirror Pods

The kubelet also runs Pods that no API server asked for: manifests in its staticPodPath directory (often /etc/kubernetes/manifests). It scans that directory periodically, and every file not starting with a dot counts, whatever its extension, so after cp kube-apiserver.yaml kube-apiserver.yaml.backup the kubelet reads both, and the backup's outdated spec can silently win. This is how kubeadm runs the control plane itself.

For each static Pod the kubelet creates a mirror Pod in the API so you can see it with kubectl. Deleting the mirror Pod doesn't stop the static Pod; it keeps running. To stop a static Pod, remove its file.

Debugging below the API: crictl pods and crictl ps on the node talk to the same CRI endpoint as the kubelet.

SymptomLikely causeFix
node NotReady/Unknown, Pods still "Running"kubelet stopped or node partitioned; 300 s toleration not yet expiredcheck kubelet (journalctl -u kubelet) and the node's Lease
ImagePullBackOffwrong image name/tag, or missing imagePullSecretskubectl describe pod → Events
Pods Evicted, node has MemoryPressurenode-pressure eviction; Pods using more than they requestset requests close to real usage (Guaranteed QoS for critical Pods)
a control-plane Pod won't go awayit's a static Podedit or remove the file in the manifest directory
📌 Bullet points
  • kubelet sync loop → CRI (gRPC, v1) → sandbox → CNI → images → containers; PLEG reports back.
  • Lease every 10 s; 50 s silence → Unknown + unreachable:NoExecute; +300 s → eviction.
  • Omitted imagePullPolicy is Always only for :latest/no tag.
  • Default hard eviction thresholds: memory 100Mi, nodefs 10 %, imagefs 15 %, inodes 5 %; hard = 0 s grace.
  • Static Pods live in files; their mirror Pods are shadows you can't control through the API.

Networking deep dive

where the packet actually goes

Chapter 5 gave you the model: a Service is a stable virtual IP, kube-proxy programs rules, DNS gives it a name. Now follow a packet, and see which knobs change its path.

Takeaway: a Service IP isn't a host or a proxy process. It's a rule in every node's kernel that rewrites the destination (DNAT) to one Pod IP when a connection starts. Where that Pod is, whether the source IP survives, and how many DNS lookups happen first are all configurable. The misconception to kill: "kube-proxy load-balances every request". It picks a backend per connection, and a long-lived connection stays on that one Pod.

The pod network model

Kubernetes demands three things and leaves the implementation to a plugin: every Pod gets its own cluster-wide unique IP; Pods can reach all other Pods, on any node, directly, without NAT; and agents on a node (like the kubelet) can reach all Pods on that node. A CNI plugin builds this (bridges, routes, overlays or cloud-native routing). The container runtime loads the CNI plugins; since v1.24 the kubelet no longer manages them. NetworkPolicy (chapter 16) is also enforced by the plugin, or not at all.

▶ Packet walk and DNS lookups NodePort Service web, one Pod on node-1
Press ▶ to send a packet to a node that doesn't run the Pod.
Simplified: one Pod, two nodes, illustrative IPs. Behaviour for Cluster vs Local (source IP hidden vs kept, drop on a node with no local endpoint) follows the "Using Source IP" tutorial; the DNS search order follows the Pod's resolv.conf and the resolv.conf man page. Some resolvers also query AAAA, doubling the count.

kube-proxy modes

ModeHow it worksStatusUse it when
iptableschains per Service and per endpoint; a random endpoint is picked and DNAT'ed. Tens of thousands of rules in big clusters make updates slow.default on Linux todaysmall and medium clusters, maximum compatibility
nftablesthe successor API: faster rule updates, more efficient packet processing (visible with tens of thousands of Services). Needs kernel 5.13+.stable since v1.33; the docs say a future version will make it the defaultnew clusters on recent kernels
ipvskernel IPVS hash tables with scheduling algorithms (rr, wrr, …)deprecated since v1.35; off by default from v1.40, removed in v1.43don't start new clusters on it; migrate to nftables
no kube-proxysome CNI plugins implement Services themselves (e.g. with eBPF)plugin-specificyour CNI documents it and you want one data plane

In the iptables example from the docs, the per-Service rules jump to per-endpoint rules, which DNAT to the chosen backend when the client connects, without rewriting the client IP for in-cluster traffic. The kernel's conntrack table remembers that translation for the rest of the connection. So a gRPC or HTTP/2 client with one long-lived connection sends every request to the same Pod, and scaling up doesn't help it until it reconnects. Balance per request in the client or in a proxy/mesh.

🔬 Go deeper: conntrack limits and knobs

kube-proxy sizes the conntrack table: --conntrack-max-per-core (default 32768) with a floor of --conntrack-min (131072). UDP DNS is a classic way to fill it: UDP entries linger until a timeout (the kernel default nf_conntrack_udp_timeout is 30 s), and the DNAT path has had race conditions. NodeLocal DNSCache runs a DNS cache on every node so Pods skip DNAT and conntrack for DNS entirely.

Kernels before 6.1 have a bug that can reset long-lived connections to Service IPs; iptables mode installs a workaround, nftables mode doesn't by default (--conntrack-tcp-be-liberal is the knob if you need it).

Session affinity: sessionAffinity: ClientIP pins a client IP to one backend (default None; the default stickiness timeout is 10800 s, 3 hours).

Traffic policies and topology

FieldWhat it doesUse it when
externalTrafficPolicy: Cluster (default)any node forwards external traffic to any ready endpoint; source IP is replaced by the node's (SNAT); possible second hopeven spreading matters more than the client IP
externalTrafficPolicy: Localonly node-local endpoints; client IP preserved, no second hop. Nodes without an endpoint drop the traffic, and fail the LB health check on healthCheckNodePortyou need the real client IP (rate limits, logs, allow-lists)
internalTrafficPolicy: Localin-cluster traffic only goes to endpoints on the same node; none there → droppedtalking to a per-node agent (DaemonSet)
trafficDistribution: PreferSameZoneprefer endpoints in the client's zone, fall back cluster-widecut cross-zone latency and cost
trafficDistribution: PreferSameNodeprefer the same node, then zone, then anywhere (stable since v1.35)node-local caches without hard drops

A Local traffic policy beats trafficDistribution for its traffic type. The older topology-aware routing (annotation service.kubernetes.io/topology-mode: Auto) lets the EndpointSlice controller put zone hints on endpoints, allocated in proportion to each zone's allocatable CPU; it backs off when endpoints can't be allocated evenly.

CoreDNS and ndots:5

You saw the Pod's resolv.conf in chapter 5: three search domains and options ndots:5. The resolv.conf man page defines ndots as the number of dots a name needs before it is tried as an absolute name first. api.stripe.com has two dots, fewer than five, so the resolver walks the search list first:

api.stripe.com.shop.svc.cluster.local  NXDOMAIN
api.stripe.com.svc.cluster.local       NXDOMAIN
api.stripe.com.cluster.local           NXDOMAIN
api.stripe.com                         ✓

Kubernetes picks 5 so that every name it generates (like web.shop.svc.cluster.local, four dots) still goes through the search list. The price is extra queries for every external name. For a Pod that calls external APIs a lot, lower it:

spec:
  dnsConfig:
    options:
    - name: ndots
      value: "2"

With ndots:2, web and web.shop still expand, while api.stripe.com is tried as-is first. Many resolvers also treat a name with a trailing dot (api.stripe.com.) as fully qualified and skip the search list; test that with your language's resolver before relying on it. Pods with hostNetwork: true need dnsPolicy: ClusterFirstWithHostNet to use cluster DNS; ClusterFirst is the default (the policy named Default is not).

🔥 Fireside chat: Cluster vs Local
Cluster:
I spread load across every Pod, whatever node the load balancer picked. Fair and simple.
Local:
And you hide who the client was. Your app sees a node IP. I keep the real address and save a hop.
Cluster:
Until a node with three replicas gets the same share as a node with one. You're lopsided.
Local:
Fair point. Spread my Pods evenly (topology spread!) and I'm the better deal for anything that logs client IPs.
SymptomLikely causeFix
new replicas get no traffic from one busy clientlong-lived connection pinned by conntrackclient-side balancing, max connection age, or a mesh
app logs show node IPs, not client IPsexternalTrafficPolicy: Cluster (SNAT)switch to Local and spread Pods
some NodePort requests time outLocal policy, request hit a node without a Poduse the LB (it health-checks), or Cluster
external calls slow; CoreDNS busy with NXDOMAINndots:5 search-list expansiondnsConfig ndots:2, NodeLocal DNSCache
📌 Bullet points
  • Pod model: unique IP per Pod, Pod-to-Pod without NAT; a CNI plugin (loaded by the runtime) makes it real.
  • kube-proxy: iptables (default), nftables (stable, the future default), ipvs (deprecated). Backends picked per connection.
  • externalTrafficPolicy: Local keeps client IPs but drops on nodes without a Pod.
  • trafficDistribution / topology hints keep traffic in-zone; Local policies win over them.
  • ndots:5 makes external names cost extra lookups; tune it per Pod with dnsConfig.

Extending Kubernetes: CRDs & operators

teach the API a new word

Everything you have met so far — Pods, Deployments, Services — is a type the API server knows. But the API is not a closed list. You can add your own nouns: a Postgres, a Certificate, a CronTab. And once the API knows the noun, you can write the control loop from chapter 1 that makes it true.

Takeaway: a CustomResourceDefinition (CRD) adds a new resource type — storage, REST endpoints, kubectl get, watch, RBAC — and a custom controller gives it behavior. The pair is what people call an operator. The misconception to kill: "installing a CRD makes something happen". In the docs' words, on their own custom resources "let you store and retrieve structured data". No controller, no action.

🧠 Brain power

You kubectl apply a Postgres object with replicas: 3. Something must create a StatefulSet, a Service and a Secret. Who? And what happens when someone deletes that Service by hand at 3 a.m.?

▶ An operator's reconcile loop kind: Postgres · group db.example.com
custom resource
Postgres/orders
spec.replicas —
metadata.generation —
status.observedGeneration —
status.phase —
operator (a Deployment)
watch → diff → act
children (ownerReferences → CR)
Press ▶ to apply a custom resource.
Simplified: one operator, one CR, and instant API calls; a real operator uses informers (chapter 20) and a work queue. The /status and generation rules shown are the documented CRD status subresource behavior; object names are invented.

A CRD is itself just an object. Apply it, and a few seconds later the API server serves a new endpoint such as /apis/stable.example.com/v1/namespaces/*/crontabs/…. The CRD's name must be <plural>.<group>. Here is the docs' example, trimmed:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: crontabs.stable.example.com
spec:
  group: stable.example.com
  scope: Namespaced
  names:
    plural: crontabs
    singular: crontab
    kind: CronTab
    shortNames: [ct]
  versions:
  - name: v1
    served: true
    storage: true   # exactly one version
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              cronSpec: {type: string}
              replicas: {type: integer}

That schema must be structural in apiextensions.k8s.io/v1: every field has a type, and the logical junctors (allOf, anyOf…) can only restrict fields that are also declared outside them. The payoff is pruning: any field the schema doesn't know is removed before the object is stored. A typo like replcas: 5 doesn't lurk in etcd — it simply disappears. If you really want free-form JSON somewhere, mark that node x-kubernetes-preserve-unknown-fields: true.

OptionWhat it doesUse it when
subresources.statusexposes /status; writes to the main resource ignore .status, writes to /status ignore everything elsealways, for anything a controller reports on
subresources.scaleexposes /scale so kubectl scale and the HPA workyour resource has a replica count
x-kubernetes-validationsCEL validation rules inside the schema (stable since 1.29)cross-field rules like "min ≤ max" without a webhook
x-kubernetes-preserve-unknown-fieldsturns pruning off for that nodean embedded blob of arbitrary config
additionalPrinterColumnsextra columns in kubectl getyou want PHASE or AGE at a glance

The status subresource is the contract between you and the controller. With it enabled, you own .spec and the controller owns .status: a PUT to the resource ignores status changes, and a PUT to /status ignores everything except status. Also, .metadata.generation increments on every change except to .metadata or .status — so a controller can copy the generation it last acted on into status.observedGeneration, exactly as the built-in Deployment does, and you can tell "done" from "not looked at yet".

The operator pattern, per the docs, captures "the key aim of a human operator who is managing a service": the know-how of installing, scaling, backing up and repairing, written as a controller. It usually runs in your cluster as an ordinary Deployment and marks the objects it creates with ownerReferences, so that deleting the CR garbage-collects its children. You don't need to write one from scratch: kubebuilder, the Operator Framework, the Java Operator SDK, Kopf (Python), kube-rs (Rust) and others are listed on the docs page.

🔬 Go deeper: the other two extension points — webhooks and aggregation

Admission webhooks (chapter 18) are HTTP callbacks the API server calls on writes. Mutating ones run first and can change the object; validating ones run after and can only accept or reject. A policy that must see the final object belongs in a validating webhook. The default timeout is 10 seconds (allowed range 1–30), and failurePolicy: Fail — fail-closed — is the default, so a dead webhook can block writes to everything it matches. For simple rules, a CEL-based ValidatingAdmissionPolicy (stable since 1.30) runs inside the API server with no webhook to keep alive.

The aggregation layer runs in-process with kube-apiserver. You register an APIService object that "claims" a URL path, and the API server proxies those requests to your own extension API server — metrics-server is the classic example (it serves metrics.k8s.io, which the HPA reads). CRDs need no programming; aggregation is for when you need your own storage or behavior no CRD can express.

⚠️ Watch it!

A CRD is a cluster-scoped object, and so are webhook configurations. Deleting a CRD deletes every object of that type in every namespace. Uninstalling "just the operator's Helm chart" can do exactly that if the chart owns the CRD.

📌 Bullet points
  • CRD = a new API type. Controller = the behavior. CRD + controller + domain know-how = operator.
  • apiextensions.k8s.io/v1 requires a structural schema; unknown fields are pruned.
  • Enable the status subresource: users write spec, the controller writes status; generation tracks spec changes.
  • Children carry ownerReferences so deleting the CR cleans them up; a controller recreates deleted children.
  • Webhooks: mutating then validating, 10 s default timeout, fail-closed by default. Aggregation: proxy a whole API group.

HA control plane & multi-node clusters

three is the magic number

A single control plane node is a single point of failure — not for your running Pods (they keep running), but for every change. No scheduling, no scaling, no rollouts, no self-healing. High availability is about keeping the brain online when a machine dies.

Takeaway: an HA control plane is three different kinds of redundancy stacked together: etcd replicates by quorum, kube-apiserver runs active-active behind a load balancer, and controller-manager and scheduler run active-standby using a Lease as a lock. The misconception to kill: "more etcd members means more fault tolerance". Four members survive exactly as many failures as three.

▶ Quorum calculator click a member to kill / revive it
members:
alive 3/3
majority (n/2)+1 2
can lose 1
writes ✓ accepted
kube-scheduler Lease · holder cp-1
Pick a member count, then click members to kill them.
Stacked topology: member k is etcd + apiserver + controller-manager + scheduler on node cp-k. The majority table is from the etcd FAQ; the 15 s Lease takeover uses the scheduler's default --leader-elect-lease-duration. Leader election for etcd itself (Raft) is not animated.

Quorum math. etcd needs a majority — (n/2)+1, integer division — to agree on every write. Three members need two, so you can lose one. Four need three, so you can still lose only one. Five survive two. That's why the etcd docs recommend odd sizes and "probably … no more than seven": each extra member adds replication work to every write. Lose the majority and etcd "cannot accept more writes"; per the Kubernetes docs, running Pods may keep running, but no new Pods can be scheduled.

TopologyWhat it isUse it when
Stacked etcdeach control plane node runs an etcd member that talks only to its local apiserver; kubeadm's defaultyou want fewer machines; run at least 3 control plane nodes
External etcdetcd on its own hosts; losing a control plane node doesn't cost an etcd memberyou can afford twice the hosts (3 + 3 minimum) for less coupled failure

kube-apiserver is stateless — all state is in etcd — so every instance serves traffic. Workers and kubectl reach them through a load balancer. With kubeadm, you create a TCP load balancer first and pass its address to kubeadm init --control-plane-endpoint "LB_DNS:PORT" --upload-certs; further control plane nodes join with kubeadm join … --control-plane --certificate-key …. (The uploaded certificates are deleted after two hours as a safeguard.)

Controllers must not run twice. Two schedulers both binding the same Pod would be chaos, so kube-controller-manager and kube-scheduler use leader election: each instance tries to hold a Lease object in kube-system. The holder renews it (by default every 2 s retry, giving up if it can't renew within 10 s); the standbys wait until the lease has gone unrenewed for 15 s and then take it. The same Lease API powers node heartbeats (chapter 22).

kubectl -n kube-system get lease kube-scheduler \
  -o jsonpath='{.spec.holderIdentity}'
❓ There are no dumb questions
Q: Why not two control plane nodes? It's cheaper.
A: A 2-member etcd needs both members for a majority — failure tolerance zero. Two nodes are strictly worse than one for write availability: twice the chance something breaks.
Q: Do my worker nodes need to be HA too?
A: Workers are cattle: run more than one replica of your app, spread across nodes (chapter 21), and a dead node's Pods are replaced elsewhere. HA for workers is just "N+1 capacity".
📌 Bullet points
  • etcd: majority (n/2)+1; 3 tolerates 1, 5 tolerates 2; even sizes buy nothing.
  • No quorum = no writes: running Pods may keep running, but nothing new is scheduled.
  • apiserver: active-active behind a load balancer (--control-plane-endpoint).
  • controller-manager and scheduler: active-standby via a Lease (15 s / 10 s / 2 s defaults).
  • Stacked etcd = fewer hosts, coupled failure; external etcd = 3 + 3 hosts, less coupling.
Part IV

Production

Keep it running: zero-downtime deploys, upgrades, multi-tenancy, a troubleshooting playbook, and when (not) to run Kubernetes yourself.

Zero-downtime deploys

the last three seconds of a Pod's life

Your rolling update (chapter 4) waits for readiness before sending traffic to new Pods. So why do you still see a handful of 502s on every deploy? Because the dangerous part is not the start of a Pod. It's the end.

Takeaway: when a Pod is deleted, two things start at the same time: the kubelet begins shutting the containers down, and the control plane starts taking the Pod out of Service endpoints. If your app exits before every load balancer and node has heard the news, requests still arrive at a closed door. The misconception to kill: "a rolling update is zero-downtime by default". It is, only if your app shuts down politely.

▶ The termination race requests keep arriving until routing catches up
no preStop — app exits on SIGTERM
preStop sleep 5 s, then SIGTERM
0 s10 s20 s30 s40 s
request servedrequest refusedrouting updated everywhere
Press ▶ to delete a Pod while traffic is flowing.
Illustrative timings: how long endpoint removal takes to reach every kube-proxy and load balancer depends on your cluster (here 3 s). The 30 s default grace period, preStop-before-TERM ordering and SIGKILL at expiry are from the Pod lifecycle docs.

The termination sequence, from the docs:

  1. You delete the Pod (or a rollout, drain or scale-down does). The API server records a deadline — default grace period 30 s (terminationGracePeriodSeconds) — and the Pod shows as Terminating.
  2. The kubelet runs the container's preStop hook, if any. The hook must complete before the stop signal is sent — and the grace countdown already started.
  3. The container runtime sends SIGTERM (or the image's STOPSIGNAL) to PID 1 in each container.
  4. At the same time as step 2, the endpoint is marked terminating and ready: false in its EndpointSlices, and every kube-proxy and ingress controller reacts when its watch delivers the change.
  5. When the grace period runs out, anything still alive gets SIGKILL. (If a preStop hook is still running, the kubelet grants a one-off 2 s extension first.)

The fix is a pause. Keep accepting requests for a few seconds after deletion, so routing can catch up, then drain gracefully: stop taking new connections, finish in-flight requests, exit. The simplest way is a preStop sleep — since 1.34 the built-in sleep action is stable, so you don't even need a sleep binary in your image:

spec:
  terminationGracePeriodSeconds: 45
  containers:
  - name: api
    lifecycle:
      preStop:
        sleep:
          seconds: 5   # let routing catch up

Budget the grace period as preStop + your slowest drain. The docs' own example: grace 60 s, hook 55 s, app needs 10 s to stop → killed before it finishes. And make sure SIGTERM actually reaches your process: if PID 1 is a shell script that doesn't forward signals, your app never hears it and dies by SIGKILL at the deadline.

⚠️ Watch it!

How long "a few seconds" is depends on your cluster's size and your load balancers; 5 s is a common starting point, not a number from the Kubernetes docs. Measure: watch your error rate during a deploy with and without the hook.

PodDisruptionBudgets protect you from voluntary disruptions that go through the Eviction API — above all kubectl drain, which cordons a node and then evicts its Pods. A PDB says how many Pods of an app must stay up (minAvailable) or may be down (maxUnavailable), as a number or a percentage. If an eviction would break the budget, the API answers 429 Too Many Requests and drain retries until it can proceed or times out.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: api-pdb}
spec:
  maxUnavailable: 1
  selector:
    matchLabels: {app: api}
  unhealthyPodEvictionPolicy: AlwaysAllow
DisruptionDoes a PDB stop it?What protects you instead
kubectl drain, node upgrades via eviction✓ yes (Eviction API)—
Deployment rolling update✗ not limited (but counts against the budget)maxUnavailable/maxSurge in the Deployment
kubectl delete pod / delete Deployment✗ bypasses PDBsRBAC, care
Node crash, node out of resources✗ involuntary (counts against the budget)replicas + spreading (chapter 21)

unhealthyPodEvictionPolicy: AlwaysAllow (stable since 1.31) is the docs' recommendation: without it, a PDB whose Pods are already crash-looping waits for them to become healthy, and your node drain hangs on a broken app.

✏️ Sharpen your pencil

5 replicas, PDB minAvailable: 5. You run kubectl drain on a node hosting one of them. What happens?

Answer

Drain can never evict it — the budget allows zero disruptions — so it retries until its timeout. minAvailable equal to the replica count (or maxUnavailable: 0) blocks every voluntary eviction; use it only with a plan.

📌 Bullet points
  • Delete → preStop → SIGTERM → (grace, default 30 s) → SIGKILL; endpoint removal runs in parallel.
  • Add a short preStop sleep, then drain in-flight requests on SIGTERM.
  • terminationGracePeriodSeconds ≥ preStop + drain time.
  • PDBs guard evictions (drain), not rolling updates or plain deletes. Consider AlwaysAllow.

Upgrades & version skew

brain first, then the hands

Kubernetes ships a new minor version every few months, and each release branch gets patch support for only about fourteen months (the patch-releases page lists roughly four active branches at a time). Put those facts together: if you never upgrade, you're out of support in little over a year. Upgrading is not a project, it's a habit.

Takeaway: upgrade one minor version at a time, control plane first (kube-apiserver, then controller-manager and scheduler), then the kubelets node by node with a drain. Components may lag behind the API server, never lead it. The misconception to kill: "I can jump 1.34 → 1.37 in one go, or start with the nodes". kube-apiserver can't skip minor versions, and a kubelet newer than its API server is unsupported.

▶ Upgrade 1.36 → 1.37 with a skew checker HA pair + 2 worker nodes
Press ▶ to take the first step.
The skew rules are from the Kubernetes version skew policy (kubernetes.io/releases/version-skew-policy); the order follows its "supported component upgrade order". kube-proxy (same rules as the kubelet) and etcd are left out to keep the figure small.
ComponentAllowed skew vs kube-apiserverWhat it means for you
kube-apiserver (HA)newest and oldest instance within 1 minorupgrade instances one by one; never skip a minor
controller-manager, schedulernot newer; may be 1 minor olderupgrade right after all apiservers
kubelet, kube-proxynot newer; up to 3 minors oldernodes can trail — but must catch up before they'd fall 4 behind
kubectl1 minor older or newerkeep your laptop's kubectl in step

With kubeadm, the ritual on the first control plane node is kubeadm upgrade plan then kubeadm upgrade apply v1.37.x; other control plane nodes and every worker run kubeadm upgrade node. For each kubelet: drain, upgrade the kubelet package, restart it, uncordon. The kubeadm docs are blunt about it: for a minor upgrade you must drain the node first. That drain is where chapter 26's PodDisruptionBudgets and preStop hooks pay for themselves.

kubectl drain node-a --ignore-daemonsets
# upgrade kubelet + kubectl packages, then:
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl uncordon node-a

Deprecated APIs are the real upgrade risk. Kubernetes removes old beta API versions on a schedule: a beta version is deprecated at most 9 months or 3 releases after it appears, and stops being served 9 months or 3 releases after that (whichever is longer). GA versions are never removed within a major version. When 1.25 stopped serving batch/v1beta1 CronJob and policy/v1beta1 PodDisruptionBudget, manifests still using those versions simply failed to apply. Your YAML, charts and tools are what break first.

🧰 Workflow: before every minor upgrade
  1. Read the "Deprecated API migration guide" section for the target version.
  2. Find callers of deprecated APIs: the API server returns deprecation warnings to clients (kubectl prints them), and metrics and audit logs record them.
  3. Fix manifests and charts; the optional kubectl convert plugin can rewrite old objects (--output-version apps/v1), possibly with non-ideal defaults.
  4. Check admission webhooks can handle the new API versions — the skew policy lists that as a prerequisite.
  5. Upgrade a staging cluster first; then control plane, then nodes.
🔬 Go deeper: what "kubeadm upgrade" backs up

kubeadm upgrade writes a kubeadm-backup-etcd folder with a backup of the local etcd member data on each control plane node (empty with external etcd). Treat it as a convenience, not your backup strategy: the production docs tell you to back up etcd regularly regardless. kubeadm client certificates also expire after one year; kubeadm renews them during a control plane upgrade — one more reason to upgrade at least yearly (the docs: regular upgrades, under a year apart, keep certificates renewed).

📌 Bullet points
  • A minor release every few months; each branch gets ~14 months of patches.
  • One minor at a time; apiserver → controller-manager/scheduler → kubelets (drain, upgrade, uncordon).
  • Nothing may be newer than kube-apiserver; kubelets may trail by up to 3 minors.
  • Hunt deprecated API versions before you upgrade; GA APIs stay, beta ones leave on a schedule.

Multi-tenancy & policy

good fences, good neighbours

Sooner or later a cluster is shared: several teams, several apps, maybe several customers. A namespace looks like the obvious fence. It's a start — but on its own it is a folder, not a wall.

Takeaway: a tenant boundary is a namespace plus the policies you attach to it: RBAC (chapter 16), a ResourceQuota, a LimitRange, Pod Security Admission labels and a default-deny NetworkPolicy. The misconception to kill: "namespaces isolate workloads". By default Pods in different namespaces can talk to each other, share the same nodes and compete for the same capacity.

🧠 Brain power

Team A's quota allows 4 Pods. They scale their Deployment to 6. Does kubectl scale fail?

▶ Quota simulator namespace team-a · hard: pods 4, requests.cpu 2
pods
requests.cpu
Add Pods and watch the quota fill.
Error text follows the format in the ResourceQuota docs, lightly shortened; the "no requests" message is simplified. The LimitRange here injects a default request of 400m. Quota is checked at admission only.

ResourceQuota caps a namespace's total: sums of requests.cpu, limits.memory, storage, and object counts like count/deployments.apps or pods. A create that would exceed it is rejected with 403 Forbidden and a message naming the constraint. Two surprises: a quota on CPU or memory means every Pod must declare those values or it may be rejected; and a Deployment over quota is created fine — it's the ReplicaSet's Pod creations that fail, so the error shows up as a ReplicaFailure / FailedCreate condition, not in your terminal.

apiVersion: v1
kind: ResourceQuota
metadata: {name: team-a, namespace: team-a}
spec:
  hard:
    pods: "4"
    requests.cpu: "2"
    requests.memory: 4Gi
    limits.memory: 8Gi

LimitRange fixes the first surprise: it applies default requests and limits to containers that don't set them, and enforces per-container min/max — at admission only, never on running Pods (chapter 7 showed how defaults interact with limits). Quota plus LimitRange is the standard pair.

PolicyWhat it doesUse it when
ResourceQuotacaps the namespace total (CPU, memory, storage, object counts)every shared namespace
LimitRangeper-container defaults and min/maxwhenever you have a CPU/memory quota
pod-security.kubernetes.io/enforce: restrictedPod Security Admission rejects Pods violating the level; warn/audit only reportall app namespaces; baseline as a floor
default-deny NetworkPolicyblocks traffic not explicitly allowed (chapter 16) — needs a CNI that enforces ittenants mustn't reach each other
PriorityClasshigher value = scheduled first, may preempt lower Pods; no class and no global default = 0critical services must win when capacity is short
kubectl label ns team-a \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted
⚠️ Watch it!

A PriorityClass value is any 32-bit integer up to one billion; bigger numbers are reserved for built-in system classes. If every team gives itself "the highest priority", preemption becomes a fight. Keep the classes few and controlled by the platform team.

Namespaces or clusters? Some things can't be split by namespace at all: CRDs, webhooks, nodes and the API server itself are shared. The docs describe isolation as a spectrum from "soft" to "hard"; for tenants that don't trust each other, a dedicated cluster — or a virtual control plane per tenant — may be the easier answer.

OptionIsolationCostUse it when
Namespace per team/appsoft: names, RBAC, quota, network policy; shared nodes, CRDs, API serverlowteams in one company that trust each other
Namespaces + dedicated nodesadds node isolation (taints/affinity)mediumnoisy or sensitive workloads
Virtual control plane per tenantown API server, CRDs, webhooks; shared workersmediumtenants need cluster-admin-like freedom
Cluster per tenanthard: nothing sharedhigh (N clusters to run and upgrade)customers, compliance, untrusted code
📌 Bullet points
  • Namespace + RBAC + ResourceQuota + LimitRange + PSA labels + default-deny = a tenant starter kit.
  • Quota violations → 403 Forbidden; for Deployments look for FailedCreate on the ReplicaSet/Deployment.
  • A CPU/memory quota means Pods must set requests — a LimitRange supplies defaults.
  • CRDs, webhooks and nodes are cluster-wide: stronger isolation means dedicated nodes, virtual control planes or clusters.

Troubleshooting playbook

read the status, then the events, then the logs

Most Kubernetes incidents look scary and turn out to be one of seven things. The trick is to stop guessing and follow the same path every time: what state is the Pod in? → what do the events say? → what did the container say before it died?

Takeaway: the STATUS column tells you which layer is failing — scheduler (Pending), kubelet/registry (ImagePullBackOff), config (CreateContainerConfigError), your process (CrashLoopBackOff, OOMKilled) or networking (Running but unreachable). The misconception to kill: "CrashLoopBackOff is the error". It's only the kubelet saying "I'm waiting before restarting this again". The error is in the previous container's logs and exit code.

▶ Symptom → decision walker pick what kubectl get pods shows
Pick a symptom.
Terminal output is abbreviated from the formats in the Kubernetes debugging docs (columns and some lines trimmed); Pod and node names are invented. Real Pods can show several problems at once.
SymptomLikely causeFirst commandsFix
Pendingno node fits: requests too big, taints/affinity/nodeSelector, hostPort, unbound PVCkubectl describe pod P → FailedScheduling eventlower requests, add nodes (or let the autoscaler), fix selectors/tolerations
ErrImagePull / ImagePullBackOffwrong image name or tag, private registry without imagePullSecretskubectl describe pod P (Events)fix the reference; add the pull secret. Retries back off up to 300 s
CreateContainerConfigErrora referenced ConfigMap/Secret or key doesn't existkubectl describe pod P; kubectl get cm,secretcreate it, fix the key, or mark the reference optional
CrashLoopBackOffthe process exits: app error, bad env/config, failing liveness/startup probekubectl logs P --previous; describe → Last State, Exit Codefix the app/config; give slow starters a startup probe (chapter 4)
OOMKilled (exit 137)container exceeded its memory limitkubectl describe pod P → Reason: OOMKilledraise the limit or fix the leak; chapter 7
Running 0/1 (not Ready)readiness probe failing — Pod removed from Service endpointskubectl describe pod P (probe events)fix the probe path/port or the dependency it checks
Service has no endpointsselector ≠ Pod labels, wrong targetPort, no Ready Podskubectl get endpointslices -l kubernetes.io/service-name=Salign labels, ports, readiness
DNS failsCoreDNS down, bad /etc/resolv.conf, egress policy blocking DNSkubectl exec -ti dnsutils -- nslookup kubernetes.default; kubectl get pods -n kube-system -l k8s-app=kube-dnsfix CoreDNS; allow DNS egress (chapter 16)

Back-off is a clock, not a verdict. After a crash, the kubelet restarts the container with an exponential back-off delay; if the container then runs fine for a while (the docs' example: 10 minutes), the back-off resets. Image pulls behave the same way, with the delay capped at 300 seconds. That's why "it fixed itself after five minutes" often just means the next retry finally happened after you fixed the registry.

kubectl debug is for the day your image has no shell (distroless, scratch). It adds an ephemeral container (stable since 1.25) to a running Pod: it can share the target container's process namespace, has no ports or probes, and is never restarted.

# shell next to a running container
kubectl debug -it api-7c9f --image=busybox:1.28 \
  --target=api
# copy of the Pod with a different command
kubectl debug api-7c9f -it --copy-to=api-dbg \
  --container=api -- sh
# a shell on the node; its root fs is at /host
kubectl debug node/node-a -it --image=ubuntu
🤓 Deep dive: why the copy trick

A crash-looping container dies before you can attach to it, so --target doesn't help. --copy-to creates a new Pod from the spec, where you can override the command (to sh) or swap images with --set-image, and poke around at leisure. Remember to delete the copy afterwards; it's an ordinary Pod.

📌 Bullet points
  • Always: get → describe (Events) → logs --previous.
  • Status names the failing layer: scheduler, registry, config, process, network.
  • No endpoints? Compare the Service selector with Pod labels, then check readiness.
  • No shell? kubectl debug with an ephemeral container, --copy-to, or node/….
  • Deleting the Pod rarely fixes anything: the replacement uses the same spec.

Managed vs self-hosted & alternatives

the best cluster is sometimes no cluster

You now know what's inside the box: etcd quorum, certificates, leader election, version skew, a steady stream of upgrades. Somebody has to own all of that. The last decision in this guide is who — and whether you need the box at all.

Takeaway: a managed control plane takes the brain off your plate; your workloads, manifests, policies and API migrations remain yours. And for a small app, Kubernetes' flexibility can cost more than it gives. The misconception to kill: "managed Kubernetes means no ops" — deprecated APIs, PDBs, quotas and graceful shutdown still land on your desk.

▶ Where should this run? heuristic — not a benchmark
Pick a scenario. Bars are fit on a 0–5 heuristic scale.
Heuristic: the scores are the author's opinion for typical cases, not measurements. The chore list is from the production-environment and kubeadm docs.

The docs' own list of what you can hand off: a managed control plane ("let the provider manage the scale and availability of the cluster's control plane, as well as handle patches and upgrades"), managed worker nodes, integrations with storage, registries and identity, or going fully serverless — running workloads "without managing a cluster at all", paying for CPU, memory and disk requests.

ChoreSelf-hosted (e.g. kubeadm)Managed control plane
etcd quorum, backups, restore drillsyou (chapter 25)provider
3+ control plane nodes, API load balanceryouprovider
control plane certificates (kubeadm: 1-year client certs)youprovider
control plane upgrades, every few monthsyouprovider runs them; timing and controls vary by provider
node upgrades and drainsyouyou or a managed node pool
deprecated API migration, PDBs, preStop, quotas, RBACyoustill you

When is Kubernetes overkill? This part is opinion, so weigh it as such. The docs describe Kubernetes as "not a traditional, all-inclusive PaaS": it "provides the building blocks for building developer platforms". If you have one or two services and no platform team, a PaaS, a serverless container service, or Docker Compose on a single VM gives you most of the value — deploys, restarts, a URL — with a fraction of the concepts. Kubernetes starts paying off when you have many services, several teams, a need for portability, or workloads (batch, stateful, GPU) that simple platforms don't cover.

🔥 Fireside chat

Compose: I start in one command, fit in one file, and your whole team already understands me.

Kubernetes: And when your VM dies at 2 a.m.? I reschedule to another node before anyone wakes up.

Compose: For a side project, "restart the VM in the morning" is an SLA people happily live with.

Kubernetes: Fair. Call me when you have ten services and three teams stepping on each other.

Multi-cluster is the other end of the scale. Reasons to run more than one cluster: hard tenant isolation (chapter 28), limiting blast radius, and staying up when a region fails — the production docs suggest spreading one cluster across zones in the same region, and a cluster like that shares its region's fate. The cost: every cluster has its own upgrades, add-ons and policies to keep in sync, which is why GitOps (chapter 17) becomes non-optional once you have several.

😌 Relax

You don't have to decide forever. The manifests, probes, requests and graceful shutdown you learned work the same on a laptop kind cluster, a managed service, or bare metal — that portability is the point.

📌 Bullet points
  • Managed control plane = no etcd, certificates, control plane HA or control plane upgrades to run yourself.
  • Still yours: workloads, API migrations, PDBs, graceful shutdown, quotas, RBAC.
  • Kubernetes is a platform-building kit; for one or two services a PaaS/serverless/Compose may be enough (opinion).
  • Multi-cluster buys isolation and regional resilience, at the price of N× operations — automate it.

Exam + cheat sheet

check what stuck
Score: 0 / 0

Cheat sheet

Parameter / commandDefault / what it doesUse when
kubectl get deploy web -o yamlshows spec (desired) and status (current)spec and reality disagree
kubectl get events --sort-by=.metadata.creationTimestampwhat controllers and kubelets did, in order"nothing happened" after an apply
kubectl get pods -A -o widePods with node and IPwhich node runs what; stuck Pending?
kubectl get pods -n kube-systemcontrol plane / addon Pods (on kubeadm-style clusters)is the scheduler or DNS alive?
kubectl logs POD -c CTR --previouslogs of the crashed container instanceCrashLoopBackOff
kubectl debug -it POD --image=busybox:1.28 --target=CTRadds an ephemeral debug containerno shell in the image
spec.initContainers[].restartPolicy: Alwaysnative sidecar (stable 1.33)log shippers, proxies that must start first
kubectl rollout status|history|undo deploy/webwatch, list, revert revisionsevery deploy; stuck rollout
strategy.rollingUpdate.maxSurge / maxUnavailable25% / 25% (surge rounds up, unavailable down)trade speed vs spare capacity
readinessProbe / livenessProbe / startupProbeperiod 10 s, timeout 1 s, failureThreshold 3traffic / restart / slow start
kubectl get endpointslices -l kubernetes.io/service-name=webwhich Pod IPs back the Service, and are they readyService answers nothing
web · web.shop · web.shop.svc.cluster.localsame-namespace · cross-namespace · full DNS name (domain may differ)calling another Service
spec.type: ClusterIP | NodePort | LoadBalancer | ExternalNamedefault ClusterIP; NodePort range 30000–32767internal / node port / cloud LB / CNAME
kubectl rollout restart deploy/webreplaces Pods with the same specafter changing a ConfigMap used as env or subPath
immutable: true (ConfigMap/Secret)data can't change; delete and recreateversioned config, many mounts
valueFrom.fieldRef.fieldPath: metadata.namedownward API: Pod facts as env or fileslog Pod/node name, read own limits
resources.requestswhat the scheduler reservesalways, sized to steady-state usage
resources.limits.memoryover it → OOMKilled (137)cap leaks; often set = request
kubectl patch pod P --subresource resizein-place resize (stable 1.35)change CPU/memory without recreating
kubectl apply --server-side --field-manager=ciSSA with named owner, conflicts on foreign fieldsCI and automation
kubectl debug -it P --image=busybox:1.28 --target=Cephemeral debug containerdistroless images
kubectl logs P --previouslogs of the crashed instanceCrashLoopBackOff
kind load docker-image img:tagcopy a local image into kind nodeslocal dev without a registry
skaffold dev / tilt upwatch → build/sync → deploy loopinner dev loop
kubectl apply -k overlays/prodrender + apply a kustomizationper-environment overlays
helm upgrade --install R chart -f v.yamlinstall or upgrade; right-most values winidempotent deploys
helm rollback R [REV]back to previous (or given) revisionbad release
pathType: Prefixelement-wise path prefix matchIngress paths for sub-trees
backendRefs[].weightproportional split, default 1Gateway API canaries
volumeBindingMode: WaitForFirstConsumerprovision after Pod schedulingzonal storage
persistentVolumeClaimRetentionPolicywhenDeleted / whenScaled, default Retainauto-clean StatefulSet PVCs
kubectl create job x --from=cronjob/yone-off Job from a CronJob templaterun a scheduled job now
job.spec.backoffLimitdefault 6 retries; back-off 10 s, 20 s … max 6 mincap retries for failing batch work
cronjob.spec.concurrencyPolicyAllow (default) / Forbid / Replaceruns must not overlap
kubectl autoscale deploy x --cpu=50% --min=2 --max=10creates an HPA (sync every 15 s, 10 % tolerance)quick CPU-based scaling
hpa.spec.behavior.scaleDown.stabilizationWindowSecondsdefault 300tune flapping vs. cost
kubectl get hpaTARGET shows current / target; no current value = no metricsHPA isn't scaling
kubectl logs POD --previouslogs of the last terminated containerafter a crash / restart
kubectl events --for pod/x --watchEvents, kept 1 h by default (--event-ttl)Pod stuck, probes failing
kubectl top podcurrent CPU/memory via metrics-serverquick usage check (not monitoring)
kubectl auth can-i VERB RES -n NS --as=system:serviceaccount:NS:SAasks the authorizerdebug 403 Forbidden
automountServiceAccountToken: falseno API token in the Podapp never calls the API
pod-security.kubernetes.io/enforce=baselinenamespace label; modes enforce / audit / warnguardrails per namespace
image: app:1.42.0@sha256:…digest wins over tag for pullingimmutable deploys
syncPolicy.automated: {prune: true, selfHeal: true}Argo CD: both off by defaultGit must win over live edits
kubectl apply --dry-run=serverruns admission + validation, stores nothingtest a manifest against webhooks and policies
webhooks[].failurePolicydefault Fail; timeout default 10 sdecide fail-closed vs fail-open per webhook
ValidatingAdmissionPolicyBinding.validationActionsDeny / Warn / Auditroll out a CEL policy with [Warn, Audit] first
kubectl get flowschemas,prioritylevelconfigurationsAPF classificationdebugging 429s
metadata.resourceVersionopaque; stale on PUT → 409optimistic concurrency, resuming watches
?watch=1&allowWatchBookmarks=truehistory ≈5 min; too old → 410 Gonewriting your own watch loop
kubectl delete … --cascade=background (default) / foreground / orphancontrolling what happens to dependents
metadata.finalizersblocks deletion until emptiedstuck Terminating: find the missing controller
topologySpreadConstraintsdefault: maxSkew 3/host, 5/zone, ScheduleAnywayspread replicas over zones
priorityClassName / preemptionPolicyuser values ≤ 1e9; PreemptLowerPriority defaultcritical vs batch workloads
tolerationSeconds on unreachable300 s added automaticallyfaster (or slower) failover per Pod
evictionHard (kubelet)memory.available<100Mi, nodefs.available<10%, imagefs.available<15%understanding node-pressure evictions
crictl pods / crictl pstalk to the runtime over CRIdebugging on the node itself
spec.externalTrafficPolicyCluster (default) / LocalLocal to keep the client IP
spec.trafficDistributionPreferSameZone / PreferSameNodekeep traffic in-zone
dnsConfig.options: ndotsdefault 5lower to 2 for Pods calling many external hosts
kubectl get crd / kubectl explain <kind>.speclist custom resource types / show their schemadiscovering what an operator installed
subresources: {status: {}}/status endpoint; spec and status written separatelyevery CRD with a controller
failurePolicy (webhooks)default Fail (fail-closed); timeout default 10 s, 1–30decide if a dead webhook may block writes
kubectl -n kube-system get leaseleader-election and heartbeat Leaseswho is the active scheduler / controller-manager
kubeadm init --control-plane-endpoint LB:PORT --upload-certsfirst node of an HA control planebuilding an HA cluster with kubeadm
lifecycle.preStop.sleep.secondspause before SIGTERM (stable since 1.34)let endpoint removal propagate before shutdown
terminationGracePeriodSecondsdefault 30 s, then SIGKILL; includes preStop timeapps that need longer to drain
kubectl get pdbALLOWED DISRUPTIONS per budgetbefore draining a node
kubectl drain NODE --ignore-daemonsets / kubectl uncordon NODEcordon + evict (respects PDBs) / allow scheduling againnode maintenance and kubelet upgrades
kubeadm upgrade plan / apply v1.x.y / nodecheck, upgrade first control plane node, upgrade other nodesone minor version at a time
kubectl convert -f F --output-version G/Vplugin: rewrite a manifest to another API versionmigrating off deprecated APIs
kubectl describe quota -n NSResourceQuota used vs hard"exceeded quota" / FailedCreate errors
kubectl label ns NS pod-security.kubernetes.io/enforce=restrictedPod Security Admission level per namespaceevery application namespace
kubectl logs POD --previouslogs of the last terminated containerCrashLoopBackOff
kubectl get endpointslices -l kubernetes.io/service-name=Swhich Pod IPs back a ServiceService reachable but no response
kubectl debug -it POD --image=busybox:1.28 --target=Cephemeral debug container sharing C's processesimages without a shell
kubectl debug node/NODE -it --image=ubuntushell on a node, host fs at /hostnode-level debugging
🎓 Congratulations

Back to the 3 a.m. power cut. The dead node's kubelet stops renewing its heartbeat Lease. The node controller inside kube-controller-manager notices after the grace period (--node-monitor-grace-period, default 50s) and sets the node's Ready condition to Unknown; the node gets the node.kubernetes.io/unreachable taint. Your Pods tolerate that taint for 300 seconds by default, then the taint-eviction controller evicts them. The ReplicaSet controller now counts 2 Pods where the spec says 3 and creates a new Pod object; the scheduler picks a healthy node and writes the binding; that node's kubelet starts the containers through the CRI runtime. Nobody called anybody: each loop only watched the API server and wrote its own small change back — and kube-apiserver is the only one of them that talks to etcd. Next step: kind create cluster, deploy something, and break it on purpose, one chapter at a time.