How to read this guide
loops all the way downKubernetes 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.
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.
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 chasesHere'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.
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.
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.
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.
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.
kubectlonly 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 serverA 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.
| Component | Where | Its one job |
|---|---|---|
kube-apiserver | control plane | Serves the Kubernetes API. The front door, and the only door: everyone else goes through it. |
etcd | control plane | Consistent, highly available key-value store holding all cluster data. The API server persists objects here. |
kube-scheduler | control plane | Watches for new Pods with no assigned node and picks a node for each. |
kube-controller-manager | control plane | Runs the built-in controllers (Deployment, ReplicaSet, Node, Job, EndpointSlice…). |
cloud-controller-manager | control plane (optional) | Cloud-specific loops: load balancers, routes, "was this node deleted in the cloud?". Absent on a laptop cluster. |
kubelet | every node | Makes sure the containers described in PodSpecs are running and healthy. |
kube-proxy | every node (optional) | Implements Services on the node's network. Some network plugins replace it. |
| container runtime | every node | Actually 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 clusterWith 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.
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.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.
- 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 containersYou 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.
10.244.1.7 · containers reach each other on localhost/var/log/app (web writes, log-agent reads)A Pod has a phase, a one-word summary. Each container inside also has a state: Waiting, Running or Terminated.
| Phase | Means |
|---|---|
Pending | Accepted, but some container isn't set up yet. Includes waiting to be scheduled and pulling images. |
Running | Bound to a node, all containers created, at least one running (or starting/restarting). |
Succeeded | All containers terminated successfully and won't restart. |
Failed | All containers terminated, at least one in failure. |
Unknown | State 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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
CrashLoopBackOff | App exits: bad config, missing env, failing liveness/startup probe, not enough memory | kubectl logs --previous, then kubectl describe pod for events |
Init:0/2 forever | First init container never finishes (waiting for something that isn't there) | kubectl logs <pod> -c <init-name> |
Init:CrashLoopBackOff | An init container fails repeatedly | Same: logs of that init container |
Pending | Not scheduled yet (see chapter 7 for resources) or images still pulling | kubectl 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.
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.
- 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: Alwaysthat keep running. - Phases: Pending → Running → Succeeded/Failed (or Unknown). Containers: Waiting / Running / Terminated.
- Restarts back off 10 s → 20 s → … → 300 s;
CrashLoopBackOffmeans "waiting". Readlogs --previous.
Deployments, ReplicaSets, rolling updates & probes
ship v2 without a gapYou 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:
| Option | What it does | Use it when |
|---|---|---|
maxSurgedefault 25%, rounded up | How many Pods above replicas may exist during the update | Raise it for faster rollouts if you have spare capacity |
maxUnavailabledefault 25%, rounded down | How many Pods below replicas may be unavailable | Set to 0 when you can't lose capacity (then maxSurge must be > 0) |
progressDeadlineSecondsdefault 600 | After this long without progress, the Deployment gets Progressing=False, reason ProgressDeadlineExceeded | Let CI fail fast on a stuck rollout |
revisionHistoryLimitdefault 10 | How many old ReplicaSets to keep for rollback | Lower 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:
| Probe | When it fails… | Use it when |
|---|---|---|
startupProbe | the 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 |
livenessProbe | the kubelet restarts the container | The process can get stuck for good (deadlock) and a restart fixes it |
readinessProbe | the Pod's IP is removed from Service endpoints; no restart | Almost 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 }
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
revisionHistoryLimit: 0) and that revision is gone.startupProbe (or initialDelaySeconds).| Symptom | Likely cause | Fix |
|---|---|---|
rollout status hangs, new Pods 0/1 READY | Readiness probe failing (wrong path/port, dependency down) | kubectl describe pod → probe events; fix, or rollout undo |
New Pods ImagePullBackOff | Typo in image or tag, missing pull secret | Fix the image, or rollout undo |
| Restart count climbs under load | Liveness probe too strict or checks dependencies | Make liveness check only the process itself |
kubectl edit of replicas started nothing new | Expected: scaling isn't a rollout | Change .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.
- Deployment → ReplicaSet (one per template, named by
pod-template-hash) → Pods. - Only
.spec.templatechanges 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 undois your job.
Services, DNS & kube-proxy
a stable name for moving targetsPods 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:
- The EndpointSlice controller (in the controller-manager) finds Pods matching the
selectorand writes their IPs into EndpointSlice objects, with a condition per Pod saying whether it's ready. - 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.
- Cluster DNS gives the Service a name:
web.shop.svc.cluster.local. From inside theshopnamespace, plainwebworks; from elsewhere,web.shop.
curl http://web from a frontend Pod send requests, then break readiness and the selectorweb-x7k2p selector app=webThe 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.
| Type | What it does | Use it when |
|---|---|---|
ClusterIP (default) | Virtual IP reachable only inside the cluster | Service-to-service traffic. Expose it outside with Ingress/Gateway (chapter 11). |
NodePort | Also opens a port on every node's IP (default range 30000–32767) | Quick external access, or an external LB you manage |
LoadBalancer | Also asks an external load balancer (usually your cloud) for an address | Exposing one Service directly on a cloud |
ExternalName | DNS CNAME to an outside hostname; no proxying at all | Giving an external database an in-cluster name |
headless (clusterIP: None) | No virtual IP; DNS returns the Pod IPs directly | Clients that pick their own backend; StatefulSets (chapter 12) |
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Connection refused/timeout to the Service, Pods look fine | Selector doesn't match Pod labels | kubectl get endpointslices -l kubernetes.io/service-name=web; compare with kubectl get pods --show-labels |
| Endpoints listed but not ready | Readiness probe failing | kubectl describe pod → probe events |
| Works by IP, not by name | Wrong namespace in the name, or DNS addon problems | Use web.<namespace>; check the DNS Pods |
| Wrong port reached | port vs targetPort mixed up | Clients 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.
- 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:
webin the same namespace,web.shopacross,web.shop.svc.<cluster-domain>fully.
ConfigMaps, Secrets, env & the downward API
same image, different settingsYour 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:
| Option | What it does on update | Use it when |
|---|---|---|
env / envFrom | Nothing. Env vars are fixed at container start; you need a Pod restart. | Settings read once at startup; you'll roll the Deployment anyway |
volumes: configMap | Files are eventually updated by the kubelet (delay: sync period + cache) | Apps that can reload a file at runtime |
volume with subPath | Never updated | Dropping one file into an existing directory, knowing it's frozen |
immutable: true | Updates are rejected; delete and recreate instead | Protect against accidents; lighter on the API server at scale |
GREETING: hello immutable$GREETINGhelloset at container start/etc/config/GREETINGhellokubelet refreshes/app/GREETINGhellopinned at startkubectl 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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Changed the ConfigMap, app still sees the old value | Consumed as env, or via subPath | kubectl rollout restart deployment/web |
| Pod stuck, events mention a missing ConfigMap/Secret | Referenced object or key doesn't exist | Create it, fix the name, or mark it optional |
| File updated but the app didn't notice | App reads config only at startup | Reload on file change, or restart |
- 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;
subPathnever 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 limitsEvery 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.
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.
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 class | Rule | What you get |
|---|---|---|
| Guaranteed | every container has CPU and memory requests and limits, and each limit equals its request | least likely to be evicted; OOM score -997 |
| Burstable | not Guaranteed, but at least one container has a CPU or memory request or limit | a floor (the request) plus room to burst |
| BestEffort | no requests or limits anywhere | uses 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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
Pod Pending, event FailedScheduling … insufficient cpu | sum of requests doesn't fit on any node | lower the request, add nodes, or remove idle reservations |
OOMKilled, exit code 137, restart count climbing | memory limit below real peak usage | raise the limit, or fix the leak |
| p99 latency spikes, CPU graph flat at the limit | CPU throttling at the limit | raise or drop the CPU limit; check the nr_throttled counter (deep dive below) |
Pods evicted under node MemoryPressure | usage above requests on a squeezed node | raise 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.
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.maxholds "$MAX $PERIOD": the group may consume up to$MAXmicroseconds of CPU in each$PERIOD. The default ismax 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). Solimits.cpu: 500mmeans 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.statreportsnr_throttledandthrottled_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.
- 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
/resizesubresource.
kubectl & manifests
YAML is a request, the server keeps the receiptEvery 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.
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.
| field | value | managers |
|---|
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:
| Option | What it does | Use it when |
|---|---|---|
kubectl explain pods --recursive | field docs straight from the API server's schema | you forgot where resizePolicy lives |
kubectl get pod my-pod -o yaml | the full object, including status | you want the truth, not your manifest |
kubectl describe pod my-pod | human summary plus recent events | a Pod is Pending or restarting |
kubectl logs -f my-pod / --previous | stream stdout; --previous shows the crashed instance | debugging a CrashLoopBackOff |
kubectl exec -it my-pod -- /bin/sh | shell inside a running container | the image actually has a shell |
kubectl debug -it my-pod --image=busybox:1.28 --target=app | adds an ephemeral debug container | distroless image, no shell |
kubectl port-forward my-pod 28015:27017 | forwards a local port to a Pod port | poking a service without exposing it |
kubectl diff -f manifest.yaml | what apply would change | before every apply to production |
kubectl apply --server-side --field-manager=ci | SSA with a named owner | CI pipelines, controllers, anything automated |
kubectl get -o yaml show way more than I wrote?status, uid, resourceVersion and more. Add --show-managed-fields to also see who owns what; by default kubectl hides managedFields.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.
- 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.
describefor events,logs --previousfor crashes,debugfor shell-less images,diffbefore apply.
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 breaksIf 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.
| Option | What it does | Getting your image in | Use it when |
|---|---|---|---|
| kind | Kubernetes in Docker: each node is a Docker (or Podman) container; multi-node via a config file | kind load docker-image my-app:dev | CI and tests; you want throwaway, multi-node clusters that start fast |
| minikube | all-in-one or multi-node local cluster on Windows, macOS and Linux | minikube 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 Docker | k3d image import or a k3d-managed registry | you want small and fast, or you run k3s at the edge |
| Docker Desktop | built-in Kubernetes: a single-node kubeadm cluster, or a multi-node kind-based one | depends on the setup; check the docs for yours | you 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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
ErrImagePull for an image you just loaded | :latest or no tag → policy Always | unique tag, or imagePullPolicy: IfNotPresent |
| code change "didn't deploy" | same tag rebuilt, node keeps its cached image | tag per build (e.g. Skaffold's inputDigest or dateTime tagger) or pin by digest |
| Skaffold pushes to a registry for a local cluster | context name isn't recognised as local | use a context name Skaffold recognises as local, or set build.local.push: false |
- 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 setimagePullPolicyexplicitly. skaffold devortilt upturns build → deploy into a watch loop; file sync /live_updateskips the rebuild.
Packaging: Kustomize vs Helm
patch it or template itYou 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.
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.
| Option | What it does | Use it when |
|---|---|---|
| Kustomize | patches plain YAML; bases + overlays; generators with hash suffixes; built into kubectl | it's your own app, a few environments, and you want the YAML to stay readable YAML |
| Helm | templating + values; packaged, versioned charts; releases with revisions and rollback | you ship software for others to install, or you consume third-party charts |
| Both | helm template renders locally; Kustomize can post-process the output | you consume a vendor chart but need a patch the chart doesn't expose |
helm rollback undo database migrations?sh.helm.release… Secret to "clean up". Now what?- 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 portA 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}
/api path rule.| Option | What it does | Use it when |
|---|---|---|
| Ingress | host + path → Service; TLS; extras via controller annotations | existing setups; simple host/path routing; your platform only offers Ingress |
| Gateway API | GatewayClass / Gateway / Routes split by role; header matching, weights, gRPC in the spec | new clusters; canaries and header routing; many teams sharing one entry point |
| Symptom | Likely cause | Fix |
|---|---|---|
| Ingress created, no address, nothing answers | no controller, or ingressClassName doesn't match any | install a controller; kubectl get ingressclass |
kubectl apply doesn't know the kind HTTPRoute | Gateway API CRDs not installed | install the CRDs / the implementation |
404 on /api/ sub-paths | pathType: Exact where you meant Prefix | use Prefix |
- 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 tagsA 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.
| Option | What it does | Use it when |
|---|---|---|
ReadWriteOnce | read-write by a single node (several Pods on that node can still share it) | the default choice for block storage |
ReadWriteOncePod | read-write by a single Pod, CSI volumes only | you need a hard guarantee of one writer |
ReadOnlyMany / ReadWriteMany | many nodes read / read-write | shared files; needs a backend that supports it (e.g. NFS) |
reclaimPolicy: Delete | PV and backing storage go away with the PVC; the StorageClass default | disposable environments |
reclaimPolicy: Retain | PV survives the PVC; reclaim manually | data you'd hate to lose to a typo |
volumeBindingMode: WaitForFirstConsumer | provision only once a Pod using the PVC is scheduled, respecting its topology | zonal 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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
PVC stuck Pending | no default StorageClass, or WaitForFirstConsumer waiting for a Pod | set storageClassName; check kubectl describe pvc events |
| Pod Pending; its volume lives in another zone | zonal volume, Pod scheduled elsewhere | WaitForFirstConsumer; keep the Pod in the volume's zone |
web-2 never starts | web-1 not Ready (OrderedReady) | fix web-1's readiness, or use Parallel |
- 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
Deletefor 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 serverSo 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.
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?
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.
| Option | What it does | Use it when |
|---|---|---|
completions / parallelism | successes needed / Pods at once (parallelism defaults to 1) | batch of N work items, fan-out |
completionMode: Indexed | each Pod gets an index 0 … completions−1 (default mode is NonIndexed) | static sharding: Pod i processes shard i |
backoffLimit | retries before the Job fails (default 6) | always — pick a number you'd defend |
backoffLimitPerIndex | per-index retry budget for Indexed Jobs (stable since 1.33) | one bad shard shouldn't burn everybody's retries |
podFailurePolicy | rules 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 |
activeDeadlineSeconds | wall-clock limit for the whole Job; beats backoffLimit | a stuck job must not run all weekend |
ttlSecondsAfterFinished | delete the finished Job (cascading) after N s | you don't want a graveyard of Jobs |
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
| Option | What it does | Use it when |
|---|---|---|
concurrencyPolicy: Allow | default — runs may overlap | runs are independent and short |
concurrencyPolicy: Forbid | skip the new run if the previous one is still going | two copies would corrupt data (most reports, syncs) |
concurrencyPolicy: Replace | kill the running Job, start the new one | only the freshest run matters |
startingDeadlineSeconds | how 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 |
timeZone | IANA 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 / failedJobsHistoryLimit | finished Jobs to keep (defaults 3 / 1) | you want more failed runs around to debug |
kubectl create job report-manual --from=cronjob/nightly-report
creates a one-off Job from the CronJob's template.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.
| Symptom | Likely cause | Fix |
|---|---|---|
Job Pods in CrashLoopBackOff, never "Failed" | restartPolicy: OnFailure restarts in place | use Never while debugging; read kubectl logs --previous |
Job has a Failed condition after several failed Pods | retries used up | fix the bug; for "don't retry" exit codes add a podFailurePolicy |
| CronJob never fires | startingDeadlineSeconds < 10, suspended, or > 100 missed schedules | raise the deadline; kubectl describe cronjob for events |
| Two report runs overlap and fight | default concurrencyPolicy: Allow | Forbid (or Replace) |
- Choose by shape: forever → Deployment, until done → Job, schedule → CronJob, per node → DaemonSet, identity → StatefulSet.
- Jobs retry with exponential back-off;
backoffLimitdefaults to 6;activeDeadlineSecondswins over it. podFailurePolicylets exit codes say "don't retry" (FailJob) or "don't count" (Ignore).- CronJobs are approximate: make jobs idempotent, set
concurrencyPolicy,startingDeadlineSecondsandtimeZone.
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: 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.
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)
- Find the target via
scaleTargetRefand select its Pods with the target's.spec.selector. - Fetch metrics: resource metrics from
metrics.k8s.io; per-Pod and object custom metrics fromcustom.metrics.k8s.io; external metrics fromexternal.metrics.k8s.io. The HPA still speaks onlymetrics.k8s.io/v1beta1. - 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. - 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.
- Apply tolerance, record the recommendation, apply stabilization window and
behaviorpolicies, 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.
| updateMode | What it does | Use it when |
|---|---|---|
Off | only writes recommendations to .status | you want advice, not action — start here |
Initial | sets requests only when Pods are created | no evictions, but new Pods get sane sizes |
Recreate | evicts Pods whose requests drift too far | workload tolerates restarts (have a PDB) |
InPlaceOrRecreate | tries an in-place resize first, falls back to eviction | fewer restarts on clusters with in-place resize (stable since 1.35) |
InPlace | in-place only, never evicts; defers if it can't (alpha in VPA 1.7.0) | experiments only |
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.
| Option | What it does | Use it when |
|---|---|---|
| Cluster Autoscaler | adds/removes nodes in pre-configured node groups (e.g. cloud instance groups) | the classic, available for most clouds; you define the shapes |
| Karpenter | auto-provisions nodes from NodePool constraints, picking instance types itself | you want it to choose node shapes (supported providers only) |
| Symptom | Likely cause | Fix |
|---|---|---|
kubectl get hpa shows no current value in the target column | no metrics-server, or no CPU request on a container | install metrics-server; set requests on every container |
| Replicas flap up and down | shortened scale-down window, noisy metric | keep the 300 s window; smooth the metric |
HPA scaled up but Pods stay Pending | no node autoscaler, or it hit its max / can't fit the request | check the autoscaler's events; lower requests or allow bigger nodes |
| Nodes never scale down | inflated requests keep nodes "busy" | right-size with VPA in Off mode |
- 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.
stdout/var/log/pods on the nodeDaemonSet
/metrics/resource
metrics.k8s.iovia API server
kubectl top · HPA/metrics: app, kubelet, API server…pulls (scrapes)
:4317
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.
| Signal | Born where | Read it with | Kept for (built-in) | Usual stack |
|---|---|---|---|---|
| Container logs | app stdout/stderr → runtime → files under /var/log/pods | kubectl logs | until rotated (default 10Mi × 5 files per container) or the Pod is evicted | node agent DaemonSet (Fluent Bit, Fluentd, OTel Collector) → Loki / Elasticsearch / OpenSearch |
| Events | scheduler, kubelet, controllers write Event objects | kubectl events, kubectl describe | 1 hour (API server --event-ttl default) | an event exporter or the OTel k8sobjects receiver |
| Resource metrics | cAdvisor inside the kubelet | kubectl top; used by HPA/VPA | no history — "not a replacement for a full monitoring pipeline" | metrics-server (add-on) |
| Component metrics | /metrics on API server, scheduler, controller-manager, kube-proxy, kubelet | PromQL | whatever your TSDB keeps | Prometheus (+ Thanos / Cortex / Mimir for long-term) |
| Object state | kube-state-metrics reads the API | PromQL (kube_pod_…) | your TSDB | kube-state-metrics + Prometheus |
| Your app's metrics | your /metrics endpoint or OTLP push | PromQL / your backend | your TSDB | Prometheus scrape, or OTel SDK → Collector |
| Traces | your app; API server & kubelet (OTLP) | trace UI | your backend | OTel 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:
| Option | What it does | Use it when |
|---|---|---|
| Node-level agent (DaemonSet) | one agent per node tails the log files of every container and ships them; no app changes | the default choice — most clusters do this |
| Streaming sidecar | a sidecar reads a log file the app writes and prints it to its own stdout, so the node agent picks it up | legacy app can't log to stdout; note it can double node disk usage |
| Sidecar with its own agent | a logging agent runs inside the Pod and ships directly | special routing/format per app; costs resources per Pod |
| App pushes to a backend | the app's logging library sends logs over the network | rarely; couples the app to the log backend |
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.
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)
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 component | What it collects | Run it as |
|---|---|---|
otlp receiver | your apps' traces, metrics, logs | DaemonSet agent and/or gateway Deployment |
filelog receiver | container logs from the node's log files | DaemonSet (preferred) |
kubeletstats receiver | node, Pod, container metrics from each kubelet | DaemonSet (preferred) |
k8s_cluster receiver | cluster-level metrics and entity events | one instance (a DaemonSet duplicates data) |
k8sobjects receiver | objects such as Events from the API server | one instance — ship Events past their 1 h TTL |
prometheus receiver | scrapes Prometheus-format endpoints | DaemonSet or Deployment |
k8sattributes processor | adds Pod, namespace, Deployment names to telemetry | everywhere — the glue that makes signals correlate |
- Log structured lines to stdout; include a request/trace id.
- Expose
/metrics(or push OTLP): request rate, errors, latency histograms, queue depth. - Instrument with an OpenTelemetry SDK; propagate context on outgoing HTTP/gRPC calls.
- Keep probes honest (chapter 4) — kube-state-metrics turns "not ready" into an alertable series.
- 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.
| Symptom | Likely cause | Fix |
|---|---|---|
kubectl logs is empty after a crash | you're reading the new container | kubectl logs --previous |
"Old" logs missing from kubectl logs | rotation — only the latest file is served | query the log store instead |
kubectl describe shows no Events for yesterday's incident | Events expire after 1 h | ship Events (exporter / k8sobjects) |
error: Metrics API not available from kubectl top | metrics-server not installed / not ready | install it; check its APIService |
| Prometheus target down with 403 | scraper's ServiceAccount lacks /metrics access | ClusterRole with nonResourceURLs |
- 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 topand 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 whomSecurity 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.
dev: get/list/watch on pods, pods/log — bound to system:serviceaccount:dev:ci-botServiceAccounts: 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
roleRef of my binding?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:
| Profile | What it allows | Use it when |
|---|---|---|
| Privileged | everything, including known privilege escalations | system components (CNI, storage, node agents) |
| Baseline | the default minimally-specified Pod; blocks known escalations (host namespaces, privileged…) | the floor for every app namespace |
| Restricted | hardening: run as non-root, no privilege escalation, drop ALL capabilities (only NET_BIND_SERVICE may be added), seccomp RuntimeDefault/Localhost | your 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}]
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.
| Symptom | Likely cause | Fix |
|---|---|---|
app's API call fails with HTTP 403 Forbidden for system:serviceaccount:dev:ci-bot | no RBAC rule allows it | kubectl auth can-i … --as; add a Role + RoleBinding |
| Deployment created, 0 Pods, ReplicaSet events show a Pod Security violation | namespace enforce level | fix securityContext (the message lists what) |
App crashes writing to /tmp | readOnlyRootFilesystem: true | mount an emptyDir at /tmp |
| NetworkPolicy has no effect | CNI plugin doesn't implement NetworkPolicy | use a plugin that does, or ask the platform team |
- One ServiceAccount per app;
automountServiceAccountToken: falseif 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.
a1b2c3
test · build
image @sha256
envs/dev · envs/prod
compare · sync
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?
| Option | What it does | Use 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 env | full isolation incl. control plane and upgrades | prod 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
| Option | What it does | Use it when |
|---|---|---|
Push (CI runs kubectl apply / helm upgrade) | pipeline holds cluster credentials and applies at the end of a run | small setups; one cluster; quick start |
| Pull / GitOps (Argo CD, Flux) | in-cluster controller watches Git and applies; CI only writes to Git and the registry | several 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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
App permanently OutOfSync on one field | something else writes that field (HPA → replicas, a mutating webhook) | remove the field from Git, or list it under ignoreDifferences |
| Deleted a manifest, resource still running | prune is off (default) | enable prune or delete manually once |
| Merged 2 minutes ago, nothing deployed | polling interval (up to ~3 min) | configure a Git webhook; or refresh the app |
| Prod runs a different build than staging tested | mutable tag re-pushed, or rebuilt per env | build once, deploy by digest |
- Build once; deploy an immutable reference (unique tag and/or digest); never
:latestin prod. - GitOps = the reconcile loop applied to Git: CI writes to Git and the registry, an in-cluster controller pulls.
- Argo CD:
pruneandselfHealare off by default; polling ~3 min unless webhooks. - Promotion is a Git change; rollback is a revert; canaries need a rollout controller plus metrics.
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 etcdIn 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 stages, one by one
| Stage | What it decides | Stops the request with |
|---|---|---|
| Authentication | Who you are. Each configured authenticator (client certs, bearer tokens, service-account JWTs, …) is tried in turn until one succeeds. | 401 |
| API Priority & Fairness | Which priority level the request belongs to, and whether it runs now, waits in a queue or is turned away. | 429 Too Many Requests |
| Authorization | Whether 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 admission | Built-in plugins, MutatingAdmissionPolicies and mutating webhooks may change the object (defaults, injected sidecars, tolerations). | rejection message |
| Validation | The API server validates the final, mutated object. | an "invalid" error listing the bad fields |
| Validating admission | ValidatingAdmissionPolicies (CEL) and validating webhooks vote on the final object. They can't change it. | rejection message |
| etcd | The 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.
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.
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).reinvocationPolicy: IfNeeded (default
Never) to run again when later mutations changed the object. That's also why every mutating
webhook must be idempotent.| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized | expired or wrong credentials | refresh the kubeconfig token / cert |
403 Forbidden | RBAC has no rule for this verb/resource | kubectl auth can-i <verb> <res> --as …, add a Role |
429 Too Many Requests | APF priority level full | back off and retry; check the PF headers |
| creates fail, error names a webhook, cluster-wide | webhook unreachable + failurePolicy: Fail | fix the webhook Service, or scope it with selectors |
ValidatingAdmissionPolicy '…' denied request | a CEL rule returned false | read the failed expression in the message |
- 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
failurePolicydefaults toFail: scope webhooks tightly or they can block the cluster. --dry-run=serverruns the whole chain without storing anything.
etcd, resourceVersion & watch
no locks, just version numbers and a streamKubernetes 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".
app-configresourceVersion: 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.
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… | Semantics | Served from |
|---|---|---|
resourceVersion unset | most 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 saw | watch cache |
resourceVersion="N" | not older than N | watch 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.
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.
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.
- Updates carry the
resourceVersionyou 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 runsChapter 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:
- A Reflector does the list-then-watch from the previous chapter for one resource type.
- 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.
- 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. - 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.
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.
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:
| Policy | What it does | Use it when |
|---|---|---|
background (default) | The owner disappears immediately; the GC deletes dependents afterwards. | almost always |
foreground | The 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" |
orphan | The 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.
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}}'
| Symptom | Likely cause | Fix |
|---|---|---|
object stuck in Terminating | a finalizer whose controller isn't running | check .metadata.finalizers; restore the controller or clean up manually |
namespace stuck in Terminating | a 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 ReplicaSet | deleted with --cascade=orphan | delete the Pods, or re-adopt them with a matching controller |
| status looks old after a spec change | observedGeneration < generation | wait, or check the controller's logs |
- 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
deletionTimestampand wait"; an orphaned finalizer means stuckTerminating. observedGeneration == generationmeans the controller has seen your latest spec.
The scheduler
filter, score, bind: one Pod at a timekube-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.
web requests 500m CPUThe 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 point | What plugins do there |
|---|---|
| PreEnqueue · QueueSort | decide if a Pod may enter the active queue (scheduling gates); order the queue (by priority by default) |
| PreFilter · Filter | precompute, then reject infeasible nodes: resources, taints, node affinity, ports, volumes, spread |
| PostFilter | runs only if no node is feasible; the default implementation is preemption |
| PreScore · Score · NormalizeScore | rank feasible nodes (spreading, affinity preferences, image locality…) |
| Reserve · Permit | hold resources for the Pod; approve, deny or make it wait |
| PreBind · Bind · PostBind | e.g. provision a volume first, then write the binding |
Telling the scheduler what you want
| Option | What it does | Use it when |
|---|---|---|
nodeSelector | hard filter: node must have these labels | simple "only on arm64 / only on GPU nodes" |
nodeAffinity | required… (filter) or preferred… (score, with weights); richer operators | "prefer zone a, never spot nodes" |
podAffinity / podAntiAffinity | place near / away from Pods with certain labels, per topologyKey | co-locate a cache with its app; keep replicas apart |
taints + tolerations | the node repels Pods; a toleration lets one through (it doesn't attract it) | dedicated or special hardware nodes |
topologySpreadConstraints | limit the skew of matching Pods across zones/nodes (maxSkew) | survive a zone outage with replicas evenly spread |
priorityClassName | queue order and the right to preempt lower-priority Pods | critical 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.
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 cause | Fix |
|---|---|---|
FailedScheduling … Insufficient cpu | requests larger than any node's free allocatable | lower requests, or add capacity (autoscaler, chapter 14) |
… untolerated taint | nodes are tainted and the Pod has no toleration | add a toleration, or target other nodes |
… didn't match Pod's node affinity/selector | label typo, or no node has the label | kubectl get nodes --show-labels |
Pod Pending, status SchedulingGated | a scheduling gate hasn't been removed | check which controller owns the gate |
| your Pods disappear when a big job starts | they were preempted by a higher PriorityClass | check events for preemption; give critical Pods a class |
- 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.
NoSchedulekeeps new Pods off;NoExecutealso 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 codeOnce 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.
--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.
- 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.
- The runtime calls the CNI plugin to give the sandbox its network and IP (chapter 23).
- 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).
- 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.
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 reference | Default 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 heartbeat | What 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 s | Pods' automatic toleration (tolerationSeconds: 300) expires; they're evicted, and their controllers create replacements elsewhere |
| never | DaemonSet 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
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.
| Symptom | Likely cause | Fix |
|---|---|---|
node NotReady/Unknown, Pods still "Running" | kubelet stopped or node partitioned; 300 s toleration not yet expired | check kubelet (journalctl -u kubelet) and the node's Lease |
ImagePullBackOff | wrong image name/tag, or missing imagePullSecrets | kubectl describe pod → Events |
Pods Evicted, node has MemoryPressure | node-pressure eviction; Pods using more than they request | set requests close to real usage (Guaranteed QoS for critical Pods) |
| a control-plane Pod won't go away | it's a static Pod | edit or remove the file in the manifest directory |
- 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
imagePullPolicyisAlwaysonly 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 goesChapter 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.
web, one Pod on node-1Cluster 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
| Mode | How it works | Status | Use it when |
|---|---|---|---|
iptables | chains 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 today | small and medium clusters, maximum compatibility |
nftables | the 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 default | new clusters on recent kernels |
ipvs | kernel IPVS hash tables with scheduling algorithms (rr, wrr, …) | deprecated since v1.35; off by default from v1.40, removed in v1.43 | don't start new clusters on it; migrate to nftables |
| no kube-proxy | some CNI plugins implement Services themselves (e.g. with eBPF) | plugin-specific | your 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
| Field | What it does | Use 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 hop | even spreading matters more than the client IP |
externalTrafficPolicy: Local | only node-local endpoints; client IP preserved, no second hop. Nodes without an endpoint drop the traffic, and fail the LB health check on healthCheckNodePort | you need the real client IP (rate limits, logs, allow-lists) |
internalTrafficPolicy: Local | in-cluster traffic only goes to endpoints on the same node; none there → dropped | talking to a per-node agent (DaemonSet) |
trafficDistribution: PreferSameZone | prefer endpoints in the client's zone, fall back cluster-wide | cut cross-zone latency and cost |
trafficDistribution: PreferSameNode | prefer 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).
| Symptom | Likely cause | Fix |
|---|---|---|
| new replicas get no traffic from one busy client | long-lived connection pinned by conntrack | client-side balancing, max connection age, or a mesh |
| app logs show node IPs, not client IPs | externalTrafficPolicy: Cluster (SNAT) | switch to Local and spread Pods |
| some NodePort requests time out | Local policy, request hit a node without a Pod | use the LB (it health-checks), or Cluster |
| external calls slow; CoreDNS busy with NXDOMAIN | ndots:5 search-list expansion | dnsConfig ndots:2, NodeLocal DNSCache |
- 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: Localkeeps client IPs but drops on nodes without a Pod.trafficDistribution/ topology hints keep traffic in-zone;Localpolicies win over them.ndots:5makes external names cost extra lookups; tune it per Pod withdnsConfig.
Extending Kubernetes: CRDs & operators
teach the API a new wordEverything 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.
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.?
Postgres/orders
/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.
| Option | What it does | Use it when |
|---|---|---|
subresources.status | exposes /status; writes to the main resource ignore .status, writes to /status ignore everything else | always, for anything a controller reports on |
subresources.scale | exposes /scale so kubectl scale and the HPA work | your resource has a replica count |
x-kubernetes-validations | CEL validation rules inside the schema (stable since 1.29) | cross-field rules like "min ≤ max" without a webhook |
x-kubernetes-preserve-unknown-fields | turns pruning off for that node | an embedded blob of arbitrary config |
additionalPrinterColumns | extra columns in kubectl get | you 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.
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.
- CRD = a new API type. Controller = the behavior. CRD + controller + domain know-how = operator.
apiextensions.k8s.io/v1requires a structural schema; unknown fields are pruned.- Enable the status subresource: users write spec, the controller writes status;
generationtracks 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 numberA 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.
--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.
| Topology | What it is | Use it when |
|---|---|---|
| Stacked etcd | each control plane node runs an etcd member that talks only to its local apiserver; kubeadm's default | you want fewer machines; run at least 3 control plane nodes |
| External etcd | etcd on its own hosts; losing a control plane node doesn't cost an etcd member | you 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}'
- 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.
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 lifeYour 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 sequence, from the docs:
- 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 asTerminating. - The kubelet runs the container's
preStophook, if any. The hook must complete before the stop signal is sent — and the grace countdown already started. - The container runtime sends SIGTERM (or the image's
STOPSIGNAL) to PID 1 in each container. - At the same time as step 2, the endpoint is marked
terminatingandready: falsein its EndpointSlices, and every kube-proxy and ingress controller reacts when its watch delivers the change. - 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.
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
| Disruption | Does 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 PDBs | RBAC, 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.
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.
- 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 handsKubernetes 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.
| Component | Allowed skew vs kube-apiserver | What it means for you |
|---|---|---|
| kube-apiserver (HA) | newest and oldest instance within 1 minor | upgrade instances one by one; never skip a minor |
| controller-manager, scheduler | not newer; may be 1 minor older | upgrade right after all apiservers |
| kubelet, kube-proxy | not newer; up to 3 minors older | nodes can trail — but must catch up before they'd fall 4 behind |
| kubectl | 1 minor older or newer | keep 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.
- Read the "Deprecated API migration guide" section for the target version.
- Find callers of deprecated APIs: the API server returns deprecation warnings to clients (kubectl prints them), and metrics and audit logs record them.
- Fix manifests and charts; the optional
kubectl convertplugin can rewrite old objects (--output-version apps/v1), possibly with non-ideal defaults. - Check admission webhooks can handle the new API versions — the skew policy lists that as a prerequisite.
- 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).
- 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 neighboursSooner 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.
Team A's quota allows 4 Pods. They scale their Deployment to 6. Does kubectl scale fail?
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.
| Policy | What it does | Use it when |
|---|---|---|
ResourceQuota | caps the namespace total (CPU, memory, storage, object counts) | every shared namespace |
LimitRange | per-container defaults and min/max | whenever you have a CPU/memory quota |
pod-security.kubernetes.io/enforce: restricted | Pod Security Admission rejects Pods violating the level; warn/audit only report | all app namespaces; baseline as a floor |
| default-deny NetworkPolicy | blocks traffic not explicitly allowed (chapter 16) — needs a CNI that enforces it | tenants mustn't reach each other |
PriorityClass | higher value = scheduled first, may preempt lower Pods; no class and no global default = 0 | critical services must win when capacity is short |
kubectl label ns team-a \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted
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.
| Option | Isolation | Cost | Use it when |
|---|---|---|---|
| Namespace per team/app | soft: names, RBAC, quota, network policy; shared nodes, CRDs, API server | low | teams in one company that trust each other |
| Namespaces + dedicated nodes | adds node isolation (taints/affinity) | medium | noisy or sensitive workloads |
| Virtual control plane per tenant | own API server, CRDs, webhooks; shared workers | medium | tenants need cluster-admin-like freedom |
| Cluster per tenant | hard: nothing shared | high (N clusters to run and upgrade) | customers, compliance, untrusted code |
- Namespace + RBAC + ResourceQuota + LimitRange + PSA labels + default-deny = a tenant starter kit.
- Quota violations →
403 Forbidden; for Deployments look forFailedCreateon 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 logsMost 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 | Likely cause | First commands | Fix |
|---|---|---|---|
| Pending | no node fits: requests too big, taints/affinity/nodeSelector, hostPort, unbound PVC | kubectl describe pod P → FailedScheduling event | lower requests, add nodes (or let the autoscaler), fix selectors/tolerations |
| ErrImagePull / ImagePullBackOff | wrong image name or tag, private registry without imagePullSecrets | kubectl describe pod P (Events) | fix the reference; add the pull secret. Retries back off up to 300 s |
| CreateContainerConfigError | a referenced ConfigMap/Secret or key doesn't exist | kubectl describe pod P; kubectl get cm,secret | create it, fix the key, or mark the reference optional |
| CrashLoopBackOff | the process exits: app error, bad env/config, failing liveness/startup probe | kubectl logs P --previous; describe → Last State, Exit Code | fix the app/config; give slow starters a startup probe (chapter 4) |
| OOMKilled (exit 137) | container exceeded its memory limit | kubectl describe pod P → Reason: OOMKilled | raise the limit or fix the leak; chapter 7 |
| Running 0/1 (not Ready) | readiness probe failing — Pod removed from Service endpoints | kubectl describe pod P (probe events) | fix the probe path/port or the dependency it checks |
| Service has no endpoints | selector ≠ Pod labels, wrong targetPort, no Ready Pods | kubectl get endpointslices -l kubernetes.io/service-name=S | align labels, ports, readiness |
| DNS fails | CoreDNS down, bad /etc/resolv.conf, egress policy blocking DNS | kubectl exec -ti dnsutils -- nslookup kubernetes.default; kubectl get pods -n kube-system -l k8s-app=kube-dns | fix 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
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.
- 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 debugwith an ephemeral container,--copy-to, ornode/…. - Deleting the Pod rarely fixes anything: the replacement uses the same spec.
Managed vs self-hosted & alternatives
the best cluster is sometimes no clusterYou 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.
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.
| Chore | Self-hosted (e.g. kubeadm) | Managed control plane |
|---|---|---|
| etcd quorum, backups, restore drills | you (chapter 25) | provider |
| 3+ control plane nodes, API load balancer | you | provider |
| control plane certificates (kubeadm: 1-year client certs) | you | provider |
| control plane upgrades, every few months | you | provider runs them; timing and controls vary by provider |
| node upgrades and drains | you | you or a managed node pool |
| deprecated API migration, PDBs, preStop, quotas, RBAC | you | still 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.
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.
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.
- 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 stuckCheat sheet
| Parameter / command | Default / what it does | Use when |
|---|---|---|
kubectl get deploy web -o yaml | shows spec (desired) and status (current) | spec and reality disagree |
kubectl get events --sort-by=.metadata.creationTimestamp | what controllers and kubelets did, in order | "nothing happened" after an apply |
kubectl get pods -A -o wide | Pods with node and IP | which node runs what; stuck Pending? |
kubectl get pods -n kube-system | control plane / addon Pods (on kubeadm-style clusters) | is the scheduler or DNS alive? |
kubectl logs POD -c CTR --previous | logs of the crashed container instance | CrashLoopBackOff |
kubectl debug -it POD --image=busybox:1.28 --target=CTR | adds an ephemeral debug container | no shell in the image |
spec.initContainers[].restartPolicy: Always | native sidecar (stable 1.33) | log shippers, proxies that must start first |
kubectl rollout status|history|undo deploy/web | watch, list, revert revisions | every deploy; stuck rollout |
strategy.rollingUpdate.maxSurge / maxUnavailable | 25% / 25% (surge rounds up, unavailable down) | trade speed vs spare capacity |
readinessProbe / livenessProbe / startupProbe | period 10 s, timeout 1 s, failureThreshold 3 | traffic / restart / slow start |
kubectl get endpointslices -l kubernetes.io/service-name=web | which Pod IPs back the Service, and are they ready | Service answers nothing |
web · web.shop · web.shop.svc.cluster.local | same-namespace · cross-namespace · full DNS name (domain may differ) | calling another Service |
spec.type: ClusterIP | NodePort | LoadBalancer | ExternalName | default ClusterIP; NodePort range 30000–32767 | internal / node port / cloud LB / CNAME |
kubectl rollout restart deploy/web | replaces Pods with the same spec | after changing a ConfigMap used as env or subPath |
immutable: true (ConfigMap/Secret) | data can't change; delete and recreate | versioned config, many mounts |
valueFrom.fieldRef.fieldPath: metadata.name | downward API: Pod facts as env or files | log Pod/node name, read own limits |
resources.requests | what the scheduler reserves | always, sized to steady-state usage |
resources.limits.memory | over it → OOMKilled (137) | cap leaks; often set = request |
kubectl patch pod P --subresource resize | in-place resize (stable 1.35) | change CPU/memory without recreating |
kubectl apply --server-side --field-manager=ci | SSA with named owner, conflicts on foreign fields | CI and automation |
kubectl debug -it P --image=busybox:1.28 --target=C | ephemeral debug container | distroless images |
kubectl logs P --previous | logs of the crashed instance | CrashLoopBackOff |
kind load docker-image img:tag | copy a local image into kind nodes | local dev without a registry |
skaffold dev / tilt up | watch → build/sync → deploy loop | inner dev loop |
kubectl apply -k overlays/prod | render + apply a kustomization | per-environment overlays |
helm upgrade --install R chart -f v.yaml | install or upgrade; right-most values win | idempotent deploys |
helm rollback R [REV] | back to previous (or given) revision | bad release |
pathType: Prefix | element-wise path prefix match | Ingress paths for sub-trees |
backendRefs[].weight | proportional split, default 1 | Gateway API canaries |
volumeBindingMode: WaitForFirstConsumer | provision after Pod scheduling | zonal storage |
persistentVolumeClaimRetentionPolicy | whenDeleted / whenScaled, default Retain | auto-clean StatefulSet PVCs |
kubectl create job x --from=cronjob/y | one-off Job from a CronJob template | run a scheduled job now |
job.spec.backoffLimit | default 6 retries; back-off 10 s, 20 s … max 6 min | cap retries for failing batch work |
cronjob.spec.concurrencyPolicy | Allow (default) / Forbid / Replace | runs must not overlap |
kubectl autoscale deploy x --cpu=50% --min=2 --max=10 | creates an HPA (sync every 15 s, 10 % tolerance) | quick CPU-based scaling |
hpa.spec.behavior.scaleDown.stabilizationWindowSeconds | default 300 | tune flapping vs. cost |
kubectl get hpa | TARGET shows current / target; no current value = no metrics | HPA isn't scaling |
kubectl logs POD --previous | logs of the last terminated container | after a crash / restart |
kubectl events --for pod/x --watch | Events, kept 1 h by default (--event-ttl) | Pod stuck, probes failing |
kubectl top pod | current CPU/memory via metrics-server | quick usage check (not monitoring) |
kubectl auth can-i VERB RES -n NS --as=system:serviceaccount:NS:SA | asks the authorizer | debug 403 Forbidden |
automountServiceAccountToken: false | no API token in the Pod | app never calls the API |
pod-security.kubernetes.io/enforce=baseline | namespace label; modes enforce / audit / warn | guardrails per namespace |
image: app:1.42.0@sha256:… | digest wins over tag for pulling | immutable deploys |
syncPolicy.automated: {prune: true, selfHeal: true} | Argo CD: both off by default | Git must win over live edits |
kubectl apply --dry-run=server | runs admission + validation, stores nothing | test a manifest against webhooks and policies |
webhooks[].failurePolicy | default Fail; timeout default 10 s | decide fail-closed vs fail-open per webhook |
ValidatingAdmissionPolicyBinding.validationActions | Deny / Warn / Audit | roll out a CEL policy with [Warn, Audit] first |
kubectl get flowschemas,prioritylevelconfigurations | APF classification | debugging 429s |
metadata.resourceVersion | opaque; stale on PUT → 409 | optimistic concurrency, resuming watches |
?watch=1&allowWatchBookmarks=true | history ≈5 min; too old → 410 Gone | writing your own watch loop |
kubectl delete … --cascade= | background (default) / foreground / orphan | controlling what happens to dependents |
metadata.finalizers | blocks deletion until emptied | stuck Terminating: find the missing controller |
topologySpreadConstraints | default: maxSkew 3/host, 5/zone, ScheduleAnyway | spread replicas over zones |
priorityClassName / preemptionPolicy | user values ≤ 1e9; PreemptLowerPriority default | critical vs batch workloads |
tolerationSeconds on unreachable | 300 s added automatically | faster (or slower) failover per Pod |
evictionHard (kubelet) | memory.available<100Mi, nodefs.available<10%, imagefs.available<15% | understanding node-pressure evictions |
crictl pods / crictl ps | talk to the runtime over CRI | debugging on the node itself |
spec.externalTrafficPolicy | Cluster (default) / Local | Local to keep the client IP |
spec.trafficDistribution | PreferSameZone / PreferSameNode | keep traffic in-zone |
dnsConfig.options: ndots | default 5 | lower to 2 for Pods calling many external hosts |
kubectl get crd / kubectl explain <kind>.spec | list custom resource types / show their schema | discovering what an operator installed |
subresources: {status: {}} | /status endpoint; spec and status written separately | every CRD with a controller |
failurePolicy (webhooks) | default Fail (fail-closed); timeout default 10 s, 1–30 | decide if a dead webhook may block writes |
kubectl -n kube-system get lease | leader-election and heartbeat Leases | who is the active scheduler / controller-manager |
kubeadm init --control-plane-endpoint LB:PORT --upload-certs | first node of an HA control plane | building an HA cluster with kubeadm |
lifecycle.preStop.sleep.seconds | pause before SIGTERM (stable since 1.34) | let endpoint removal propagate before shutdown |
terminationGracePeriodSeconds | default 30 s, then SIGKILL; includes preStop time | apps that need longer to drain |
kubectl get pdb | ALLOWED DISRUPTIONS per budget | before draining a node |
kubectl drain NODE --ignore-daemonsets / kubectl uncordon NODE | cordon + evict (respects PDBs) / allow scheduling again | node maintenance and kubelet upgrades |
kubeadm upgrade plan / apply v1.x.y / node | check, upgrade first control plane node, upgrade other nodes | one minor version at a time |
kubectl convert -f F --output-version G/V | plugin: rewrite a manifest to another API version | migrating off deprecated APIs |
kubectl describe quota -n NS | ResourceQuota used vs hard | "exceeded quota" / FailedCreate errors |
kubectl label ns NS pod-security.kubernetes.io/enforce=restricted | Pod Security Admission level per namespace | every application namespace |
kubectl logs POD --previous | logs of the last terminated container | CrashLoopBackOff |
kubectl get endpointslices -l kubernetes.io/service-name=S | which Pod IPs back a Service | Service reachable but no response |
kubectl debug -it POD --image=busybox:1.28 --target=C | ephemeral debug container sharing C's processes | images without a shell |
kubectl debug node/NODE -it --image=ubuntu | shell on a node, host fs at /host | node-level debugging |
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.