From kubectl apply to running Kubernetes in production
Learn Kubernetes the way senior engineers use it at work. You deploy one small API twice, first with plain YAML and kubectl apply, then as a Helm 4 chart. Then you break it on purpose ten different ways, fix it, and operate the cluster underneath: drains, etcd backups, certificates, upgrades.
- 19 stations
- 3 to 4 weeks at an hour a day
- Real outputs from Kubernetes 1.37 and Helm 4.2
- Free, MIT licensed
Welcome aboard
- explain what this course covers and how it is organised;
- run a real three-node Kubernetes cluster on your own machine;
- load the lab image into it, ready for the first lab.
Kubernetes has a reputation for being hard. Most of that comes from learning it backwards: copying a Helm command from a README, watching it work, and having no idea what to do the day it does not. This course goes the other way round. You start from the idea that makes everything else click (you describe a desired state, and controllers make it happen), you meet every object with its reason to exist, and you write them by hand before letting Helm generate them.
Each station follows the same rhythm: a plain-language explanation with an analogy, the precise technical version, how real companies use it, where it hurts, and a short quiz. The deep reference lives in concept sheets you can open when you need them (104 of them, all searchable from the concept index).
Your route
- Understand the model and the machine. Stations 1 to 10: the declarative model, the cluster's architecture, kubectl, and every family of objects.
- Lab 1, plain YAML. Deploy the
kubelabAPI with eleven hand-written manifests andkubectl apply, update it, roll it back. - Lab 2, Helm. Deploy the same app as a chart: values, templates, hooks, tests, upgrades, rollbacks, an OCI registry.
- Break it and fix it. Ten failure scenarios taken from real production incidents, with the real symptoms.
- Run the cluster. Drain nodes, back up etcd, check certificates, plan an upgrade.
- Prove it. Enterprise review checklist, CKA/CKAD/CKS strategy, and a final exam.
Every output on this page was captured on a real three-node Kubernetes 1.37.1 cluster (kind, containerd 2.3, Helm 4.2.3) running exactly this lab. Your pod names, hashes and IPs will differ: they are generated. The numbered pins under each terminal explain the important lines. The app and chart greet you in French (the author is French-speaking): Bonjour depuis Kubernetes simply means "Hello from Kubernetes".
Set up your lab
You need four tools: Docker (or Podman), kind 0.33 or newer, kubectl 1.36 or newer, and Helm 4. A laptop with 8 GB of RAM is enough. Then:
$ git clone https://github.com/georgesmomo/k8s-helm-lab && cd k8s-helm-lab $ kind create cluster --name lab --config kind/cluster.yaml $ docker build -t kubelab:1.0.0 --build-arg VERSION=1.0.0 app $ docker build -t kubelab:1.1.0 --build-arg VERSION=1.1.0 app $ kind load docker-image kubelab:1.0.0 kubelab:1.1.0 --name lab $ kubectl get nodes
kind create cluster starts three Docker containers (one control plane, two workers) and runs kubeadm inside them, so what you get is a genuine cluster. kind load copies your local image into the container runtime of every node: no registry needed. Here is what that looked like on the cluster used for this course:
$ kind create cluster --name lab --image kindest/node:v1.37.1 --config sandbox/cluster.yaml1 Creating cluster "lab" ... • Ensuring node image (kindest/node:v1.37.1) 🖼️ ... ✓ Ensuring node image (kindest/node:v1.37.1) 🖼️ • Preparing nodes 📦 📦 📦 ... ✓ Preparing nodes 📦 📦 📦 • Writing configuration 📜 ... ✓ Writing configuration 📜 • Starting control-plane 🕹️ ...2 ✓ Starting control-plane 🕹️ • Installing CNI 🔌 ...3 ✓ Installing CNI 🔌 • Installing StorageClass 💾 ... ✓ Installing StorageClass 💾 • Joining worker nodes 🚜 ...4 ✓ Joining worker nodes 🚜 Set kubectl context to "kind-lab"5 You can now use your cluster with: kubectl cluster-info --context kind-lab Not sure what to do next? 😅 Check out https://kind.sigs.k8s.io/docs/user/quick-start/ $ kind load docker-image kubelab:1.0.0 --name lab Image: "kubelab:1.0.0" with ID "sha256:43d4df4f6144d0c9566116de29de84574a2d4297e643b59266b14b9f6d2fb481" not yet present on node "lab-worker", loading...6 Image: "kubelab:1.0.0" with ID "sha256:43d4df4f6144d0c9566116de29de84574a2d4297e643b59266b14b9f6d2fb481" not yet present on node "lab-control-plane", loading... Image: "kubelab:1.0.0" with ID "sha256:43d4df4f6144d0c9566116de29de84574a2d4297e643b59266b14b9f6d2fb481" not yet present on node "lab-worker2", loading...
- Sandbox config. The captures used a copy of
kind/sandbox-cluster.yaml. On your machine usekind/cluster.yaml: same topology, same results. - kubeadm init. kind runs kubeadm inside the control-plane container.
- CNI. kindnet is installed. Without a CNI the nodes would stay NotReady.
- kubeadm join. The two workers join the cluster.
- Context. Your kubeconfig is updated automatically.
- kind load. Your local image is copied into the containerd of every node: no registry needed.
The outputs were produced in an environment without access to public registries. The repository's kind/sandbox-cluster.yaml adds three settings specific to that environment (a cgroup v1 host, a containerd OOM setting, a local registry mirror). On your machine use kind/cluster.yaml: same topology, same results. The only visible difference is the OS image your nodes report.
# kind create cluster --name lab --config kind/cluster.yaml # 1 control plane + 2 workers: enough to see scheduling, drains and PDBs in action. kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane extraPortMappings: # HTTP access from your machine to the cluster (NodePort 30080) - containerPort: 30080 hostPort: 8080 - role: worker - role: worker
One more component makes the resources station and the admin station come alive: metrics-server, which feeds kubectl top and the HPA. kind does not install it. Its kubelets use self-signed serving certificates, hence the extra flag (fine for a lab, not for production):
$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml $ kubectl -n kube-system patch deployment metrics-server --type=json -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' $ kubectl top nodes
Hands-onCheck your toolbox
-
kind version,kubectl version --clientandhelm versionall answer (Helm must print v4.x). -
kubectl get nodesshows three nodes inReadystate. - Both images are loaded:
docker exec lab-worker crictl images | grep kubelab.
Show a hint
Nodes stuck in NotReady for more than a minute usually mean Docker is short of memory. Give Docker Desktop at least 6 GB, then kind delete cluster --name lab and create it again.
The declarative model and the reconciliation loop
- explain the difference between giving orders and declaring a state;
- describe what a controller does, and why that makes Kubernetes self-healing;
- use that idea as your first debugging question.
You never tell a heating system "burn gas for 12 minutes". You set 21 °C and walk away. The thermostat measures the room, compares it with what you asked for, and acts on the difference, forever. Open a window and it simply works harder. Kubernetes is a building full of thermostats.
With Kubernetes you do not give orders ("start three containers"). You describe a state ("I want three replicas of this image") and store it in the cluster. Small programs called controllers constantly compare that spec with the real status and fix the gap. That endless loop is called reconciliation, and it is why Kubernetes heals itself: a crashed pod, a lost node, a deleted object are all just gaps to close.
It is also the key to every debugging session you will ever have. When something does not work, the question is never "what command failed?" but "which controller cannot converge, and why?"
Four words to own
- Object
- An entry in the API (Pod, Deployment, Service...) with
apiVersion,kind,metadata,spec(what you want) andstatus(what is). - Controller
- A program that watches one kind of object and acts until
statusmatchesspec. Operators are controllers written for one particular piece of software. - Labels
- The glue of the system: a Service finds its pods, a Deployment its ReplicaSets, through label selectors.
- Idempotence
- Applying the same file twice changes nothing. That is what makes GitOps, Helm and CI/CD safe to run again and again.
The desired state does not live in someone's terminal history: it lives in git. A pipeline or a GitOps agent such as Argo CD applies it, and any manual change in the cluster is treated as drift to be corrected. You will see why in the labs.
Inside a cluster
- name every control-plane and node component and say what it does;
- explain why everything goes through the API server;
- find the control plane's files and containers on a kubeadm node.
The control tower (the control plane) never flies a plane. It keeps the official flight board (etcd), answers every radio call at a single desk (the API server), assigns gates (the scheduler) and has supervisors checking that every scheduled flight actually exists (the controllers). At each gate, a ground crew (the kubelet) does the real work and reports back to the tower.
A cluster has two kinds of machines. The control plane stores state and makes decisions; the worker nodes run your pods. On the lab's kind cluster each "machine" is a Docker container, but everything else is identical to a real kubeadm cluster.
Look at the lab cluster from three angles. First the nodes, then the system pods (the control plane runs as pods too), then the control plane from inside its own node:
$ kubectl get nodes -o wide NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME1 lab-control-plane Ready control-plane 3m37s v1.37.1 172.18.0.3 <none> Ubuntu 24.04 LTS 6.18.44-fc-v139 (amd64) containerd://2.3.62 lab-worker Ready <none> 3m26s v1.37.1 172.18.0.4 <none> Ubuntu 24.04 LTS 6.18.44-fc-v139 (amd64) containerd://2.3.63 lab-worker2 Ready <none> 3m26s v1.37.1 172.18.0.2 <none> Ubuntu 24.04 LTS 6.18.44-fc-v139 (amd64) containerd://2.3.6
- Wide columns.
-o wideadds the internal IP, OS, kernel and container runtime (containerd 2.3). Every kubelet runs v1.37.1. - Control plane. Runs the API server, etcd, the scheduler and the controller manager. It carries a
NoScheduletaint, so your apps do not land there. - Workers. The nodes that run your applications. An empty ROLES column just means the node has no role label.
$ kubectl get pods -n kube-system -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES coredns-559f6c778d-s6929 1/1 Running 0 3m27s 10.244.0.2 lab-control-plane <none> <none>1 coredns-559f6c778d-v8djj 1/1 Running 0 3m27s 10.244.0.4 lab-control-plane <none> <none> etcd-lab-control-plane 1/1 Running 0 3m34s 172.18.0.3 lab-control-plane <none> <none>2 kindnet-lhdzr 1/1 Running 0 3m26s 172.18.0.4 lab-worker <none> <none>3 kindnet-sf4p2 1/1 Running 0 3m26s 172.18.0.2 lab-worker2 <none> <none> kindnet-tvmvn 1/1 Running 0 3m28s 172.18.0.3 lab-control-plane <none> <none> kube-apiserver-lab-control-plane 1/1 Running 0 3m34s 172.18.0.3 lab-control-plane <none> <none> kube-controller-manager-lab-control-plane 1/1 Running 0 3m34s 172.18.0.3 lab-control-plane <none> <none> kube-proxy-dx7f2 1/1 Running 0 3m26s 172.18.0.4 lab-worker <none> <none>4 kube-proxy-lqv5z 1/1 Running 0 3m28s 172.18.0.3 lab-control-plane <none> <none> kube-proxy-x5zbp 1/1 Running 0 3m26s 172.18.0.2 lab-worker2 <none> <none> kube-scheduler-lab-control-plane 1/1 Running 0 3m34s 172.18.0.3 lab-control-plane <none> <none> metrics-server-84c99cb944-vz687 1/1 Running 0 2m30s 10.244.1.2 lab-worker2 <none> <none>5
- CoreDNS. Two replicas (a Deployment) that answer DNS queries for Services.
- Static pods. etcd, kube-apiserver, controller-manager and scheduler. The suffix is the node name: the kubelet starts them from files in
/etc/kubernetes/manifests. - kindnet (CNI). A DaemonSet: one pod per node, using the node's network (its IP is the node IP). It programs routes between nodes.
- kube-proxy. A DaemonSet that writes the Service rules on every node.
- metrics-server. Added for
kubectl topand the HPA. It has a pod IP (10.244.x.x), not a node IP.
$ docker exec lab-control-plane ls -l /etc/kubernetes/manifests total 16 -rw------- 1 root root 2602 Oct 19 15:32 etcd.yaml -rw------- 1 root root 3968 Oct 19 15:32 kube-apiserver.yaml1 -rw------- 1 root root 3263 Oct 19 15:32 kube-controller-manager.yaml -rw------- 1 root root 1726 Oct 19 15:32 kube-scheduler.yaml $ docker exec lab-control-plane crictl ps --name kube-apiserver CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE 8db08e2d84ce5 4c77c50dd64d9 3 minutes ago Running kube-apiserver 0 b65688f6e7a05 kube-apiserver-lab-control-plane kube-system2 $ docker exec lab-control-plane sh -c "grep -E -- '--(etcd-servers|service-cluster-ip-range|authorization-mode|enable-admission-plugins)' /etc/kubernetes/manifests/kube-apiserver.yaml" - --authorization-mode=Node,RBAC3 - --enable-admission-plugins=NodeRestriction - --etcd-servers=https://127.0.0.1:23794 - --service-cluster-ip-range=10.96.0.0/165
- Static manifests. Editing one of these files restarts that component: this is how you change an API server flag on a kubeadm cluster.
- crictl. The container as the runtime sees it, even when the API is down. Your last-resort tool.
- Authorization. Node (each kubelet only touches its own pods) plus RBAC (role-based rules).
- etcd. The API server is the only client of etcd.
- Service range. ClusterIPs are taken from 10.96.0.0/16.
On EKS, AKS or GKE you never see the control-plane nodes: the cloud provider runs the API server and etcd and gives you an endpoint. You still need to know these components, because their symptoms (an API that refuses requests, a scheduler that cannot place pods, a kubelet that stops reporting) are what you debug every week.
Hands-onVisit the control tower
On your kind cluster, list the static pod manifests and find the API server container with the runtime's own client.
Show the solution
$ docker exec lab-control-plane ls -l /etc/kubernetes/manifests $ docker exec lab-control-plane crictl ps --name kube-apiserver $ docker exec lab-control-plane grep -- --authorization-mode /etc/kubernetes/manifests/kube-apiserver.yaml
Bonus: kubectl get --raw '/readyz?verbose' lists every health check of the API server.
kube-apiserverThe single front door of the cluster: a REST API that checks, authorizes and stores every object in etcd.
- Why it exists
- Everything goes through it: kubectl, controllers, kubelets, Helm, Argo CD. No component talks to etcd directly, except the API server.
- In real companies
- In production it runs as several copies behind a load balancer (high availability). On EKS, AKS or GKE the provider runs it for you: you only see its URL.
- How it works
- Path of a request: authentication (client certificate, OIDC token, ServiceAccount token) → authorization (RBAC) → mutating admission (mutating webhooks and policies) → schema validation → validating admission (validating webhooks, ValidatingAdmissionPolicy, Pod Security) → write to etcd. Health:
kubectl get --raw /livezand/readyz. - Limits and pitfalls
- If it goes down, running pods keep running but nothing can change any more: no deployments, no rescheduling. A broken admission webhook with
failurePolicy: Failcan block the whole cluster. - Exam tip
- CKA: be able to read its static manifest
/etc/kubernetes/manifests/kube-apiserver.yamland its flags (--etcd-servers,--service-cluster-ip-range,--authorization-mode).
etcdA distributed key-value database (Raft consensus) that stores the whole state of the cluster: objects, Secrets, configuration.
- Why it exists
- It is the cluster's memory. Losing etcd without a backup means losing the definition of every application.
- In real companies
- 3 or 5 members (an odd number) to survive 1 or 2 failures. Scheduled snapshot backups, encryption of Secrets at rest, fast SSD disks (write latency is critical).
- How it works
etcdctl snapshot save/etcdutl snapshot restore, with the certificates from/etc/kubernetes/pki/etcd/. On kubeadm, etcd is a static pod of the control plane.- Limits and pitfalls
- Never edit it by hand. High disk latency slows the whole cluster down. Secrets are stored in plain text in etcd (base64 is only how the API shows them) unless an EncryptionConfiguration is enabled.
- Exam tip
- Backing up and restoring etcd is a CKA classic: the right certificates, a new
--data-dirfor the restore, then point the etcd manifest at it.
kube-schedulerThe component that picks the node on which each new pod will run.
- Why it exists
- Place pods where there is room (based on requests), while respecting constraints and preferences (affinities, taints, spreading).
- In real companies
- Understanding why a pod stays Pending, spreading replicas across zones, isolating GPU or sensitive workloads on dedicated nodes.
- How it works
- Two phases: filtering (which nodes are possible) then scoring (which one is best). It writes
spec.nodeName; the kubelet of that node does the rest. Reasons for failure appear in theFailedSchedulingevent. - Limits and pitfalls
- It never moves a pod that is already placed (the descheduler, a separate project, can). It reasons on requests, not on real usage.
- Exam tip
- A pod with
spec.nodeNameset by hand bypasses the scheduler. So do static pods.
kube-controller-managerOne process that bundles the built-in controllers: Deployment, ReplicaSet, Node, Job, EndpointSlice, ServiceAccount, garbage collector...
- Why it exists
- Make the real state converge towards the desired state, in a loop: a pod is missing? create one. A node stopped answering? mark its pods for replacement.
- In real companies
- It does the work when you change an image or a replica count. Operators (CRD + controller) use exactly the same model for other software.
- How it works
- Loop: observe → compare → act, always through the API. Several instances in HA with leader election (a Lease object in kube-system).
- Limits and pitfalls
- It only acts through the API: if it stops, nothing heals on its own (no new pod is ever created).
cloud-controller-managerThe controllers that are specific to a cloud provider, kept outside the Kubernetes core.
- Why it exists
- Create a real load balancer for a
LoadBalancerService, fill in node zones and addresses, remove nodes that were destroyed in the cloud. - In real companies
- Present on EKS, AKS, GKE and OpenStack; absent on kind (which is why a LoadBalancer Service stays
<pending>there without cloud-provider-kind or MetalLB). - How it works
- Deployed and run by the provider on managed services. On premises, MetalLB or kube-vip play the load balancer role.
- Limits and pitfalls
- The annotations of LoadBalancer Services are different for every cloud.
kubeletThe agent installed on every node: it receives the pods assigned to its node and makes them exist through the container runtime.
- Why it exists
- Start and watch containers, run probes, mount volumes, report node and pod status to the API.
- In real companies
- First suspect when a node is NotReady:
systemctl status kubelet,journalctl -u kubelet. It is also the kubelet that evicts pods when the node runs short of memory or disk. - How it works
- A systemd service (not a pod). Configuration in
/var/lib/kubelet/config.yaml. It also runs the static pods found in/etc/kubernetes/manifests: that is how a kubeadm control plane boots. - Limits and pitfalls
- If the kubelet stops, containers keep running, but the node turns NotReady and, after a while, its pods are recreated elsewhere.
- Exam tip
- Troubleshooting a NotReady node: kubelet stopped, expired certificate, missing runtime, swap, bad configuration.
kube-proxyA DaemonSet that programs each node's network (iptables, nftables or IPVS) so that a Service's virtual IP leads to its pods.
- Why it exists
- Make Services reachable and spread the load across their ready pods.
- In real companies
- Modern CNIs (Cilium in eBPF mode) can replace it entirely for better performance.
- How it works
- It watches Services and EndpointSlices and rewrites the rules. The mode is visible in the
kube-proxyConfigMap in kube-system. The nftables mode is GA since Kubernetes 1.33 (iptables is still the default). - Limits and pitfalls
- It is not an application proxy: no retries, no HTTP awareness. For layer-7 routing you need a Gateway, an Ingress or a service mesh.
Container runtime (CRI, containerd)The software that pulls images and actually starts containers, driven by the kubelet through the CRI interface.
- Why it exists
- Keep Kubernetes independent of the implementation: containerd and CRI-O are the two standard runtimes.
- In real companies
- Since dockershim was removed (1.24), Docker is no longer the node runtime; images built with Docker still work (OCI format).
- How it works
crictl ps,crictl images,crictl logson the node to look underneath Kubernetes. The low-level runtime (runc) creates the namespaces and cgroups.- Limits and pitfalls
docker pson a modern Kubernetes node shows nothing about pods: usecrictl.
CNI (pod networking)The network plugin that gives every pod an IP and connects pods across nodes: Cilium, Calico, Flannel, kindnet, cloud CNIs (VPC CNI, Azure CNI).
- Why it exists
- Guarantee the Kubernetes network model: every pod has its own IP and can reach every other pod without NAT.
- In real companies
- The CNI decides whether NetworkPolicies are enforced, network observability (Hubble), encryption, performance. An architecture choice that is hard to change later.
- How it works
- Installed right after
kubeadm init(until then, nodes stay NotReady). Config in/etc/cni/net.d, binaries in/opt/cni/bin. - Limits and pitfalls
- A NetworkPolicy only has an effect if the CNI supports it (Flannel alone does not). Pod IP ranges must not overlap with the company network.
CoreDNSThe cluster DNS server, deployed in kube-system.
- Why it exists
- Resolve
service.namespace.svc.cluster.localto the Service IP, and external names through the upstream DNS. - In real companies
- DNS latency or errors show up as application timeouts everywhere. NodeLocal DNSCache is often added on large clusters.
- How it works
- Configured by the
corednsConfigMap (Corefile). Test:kubectl run dns --rm -it --image=busybox:1.36 --restart=Never -- nslookup kubernetes.default. - Limits and pitfalls
ndots:5in the pods'/etc/resolv.confmultiplies queries for external names; use fully qualified names ending with a dot for heavy external calls.
CSI (storage drivers)The standard interface for storage drivers: EBS, Azure Disk, Persistent Disk, Ceph, NetApp, local-path...
- Why it exists
- Create, attach and mount volumes on demand when an application asks for them.
- In real companies
- Databases on StatefulSets with cloud disks, volume snapshots for backups, encrypted disks.
- How it works
- The driver is deployed (a controller plus a DaemonSet) and referenced by a StorageClass (
provisioner: ebs.csi.aws.com). - Limits and pitfalls
- A cloud disk lives in one zone: a pod that moves to another zone cannot follow it (hence
volumeBindingMode: WaitForFirstConsumer).
kubectl, kubeconfig and the API
- switch clusters and namespaces safely;
- discover any field of the API without leaving the terminal;
- generate, preview and apply YAML the way professionals (and exam takers) do.
kubectl is just an HTTP client for the API server, configured by your kubeconfig file. Everything it does, Helm, Argo CD or a Python script can do through the same API. That is liberating: once you understand the API, every tool is just a different way of talking to it.
$ kubectl config get-contexts CURRENT NAME CLUSTER AUTHINFO NAMESPACE * kind-lab kind-lab kind-lab 1 $ kubectl cluster-info Kubernetes control plane is running at https://127.0.0.1:431292 CoreDNS is running at https://127.0.0.1:43129/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
- Current context. The star marks the active context: cluster kind-lab, user kind-lab, default namespace (empty).
- Endpoint. The API server, published by kind on a local port.
$ kubectl api-resources --api-group=apps NAME SHORTNAMES APIVERSION NAMESPACED KIND controllerrevisions apps/v1 true ControllerRevision daemonsets ds apps/v1 true DaemonSet deployments deploy apps/v1 true Deployment1 replicasets rs apps/v1 true ReplicaSet statefulsets sts apps/v1 true StatefulSet $ kubectl api-resources --namespaced=false | head -12 NAME SHORTNAMES APIVERSION NAMESPACED KIND componentstatuses cs v1 false ComponentStatus namespaces ns v1 false Namespace nodes no v1 false Node2 persistentvolumes pv v1 false PersistentVolume mutatingadmissionpolicies admissionregistration.k8s.io/v1 false MutatingAdmissionPolicy3 mutatingadmissionpolicybindings admissionregistration.k8s.io/v1 false MutatingAdmissionPolicyBinding mutatingwebhookconfigurations admissionregistration.k8s.io/v1 false MutatingWebhookConfiguration validatingadmissionpolicies admissionregistration.k8s.io/v1 false ValidatingAdmissionPolicy validatingadmissionpolicybindings admissionregistration.k8s.io/v1 false ValidatingAdmissionPolicyBinding validatingwebhookconfigurations admissionregistration.k8s.io/v1 false ValidatingWebhookConfiguration customresourcedefinitions crd,crds apiextensions.k8s.io/v1 false CustomResourceDefinition
- Short name.
deploy,rs,sts,ds: use them everywhere (kubectl get deploy). - Cluster-scoped objects. Node, PersistentVolume, Namespace, CRD... live at cluster level:
-nhas no effect on them. - Admission policies. MutatingAdmissionPolicy (CEL) is now v1: you can mutate objects without writing a webhook.
$ kubectl explain deployment.spec.strategy.rollingUpdate GROUP: apps KIND: Deployment VERSION: v1 FIELD: rollingUpdate <RollingUpdateDeployment> DESCRIPTION: Rolling update config params. Present only if DeploymentStrategyType = RollingUpdate. Spec to control the desired behavior of rolling update. FIELDS: maxSurge <IntOrString>1 The maximum number of pods that can be scheduled above the desired number of pods. Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). This can not be 0 if MaxUnavailable is 0. Absolute number is calculated from percentage by rounding up. Defaults to 25%. Example: when this is set to 30%, the new ReplicaSet can be scaled up immediately when the rolling update starts, such that the total number of old and new pods do not exceed 130% of desired pods. Once old pods have been killed, new ReplicaSet can be scaled up further, ensuring that total number of pods running at any time during the update is at most 130% of desired pods. maxUnavailable <IntOrString>2 The maximum number of pods that can be unavailable during the update. Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). Absolute number is calculated from percentage by rounding down. This can not be 0 if MaxSurge is 0. Defaults to 25%. Example: when this is set to 30%, the old ReplicaSet can be scaled down to 70% of desired pods immediately when the rolling update starts. Once new pods are ready, old ReplicaSet can be scaled down further, followed by scaling up the new ReplicaSet, ensuring that the total number of pods available at all times during the update is at least 70% of desired pods.
- maxSurge. How many extra pods may exist during the update. A percentage is rounded up.
- maxUnavailable. How many pods may be missing. A percentage is rounded down. The two cannot both be 0.
The commands you will type a thousand times
| You want to | Command |
|---|---|
| List with more detail | kubectl get pods -o wide -n kubelab, -A for all namespaces |
| See everything of one app | kubectl get all,cm,secret,pvc,ing -n kubelab -l app.kubernetes.io/name=kubelab |
| Understand a state | kubectl describe pod <pod>, then read the Events section |
| Generate YAML | kubectl create deploy web --image=nginx --replicas=2 --dry-run=client -o yaml |
| Look before you apply | kubectl diff -f manifests/, then kubectl apply -f manifests/ |
| Extract one value | kubectl get pod x -o jsonpath='{.status.podIP}' |
| Wait for a state | kubectl wait --for=condition=Ready pod -l app=x --timeout=60s |
| Follow live | kubectl get pods -w, kubectl events -w |
| Check a permission | kubectl auth can-i create deployments -n kubelab --as=jane |
alias k=kubectl, shell completion (source <(kubectl completion bash)), and export do="--dry-run=client -o yaml" so that k create deploy x --image=y $do > x.yaml gives you a starting file in two seconds. Every exam task names the host to ssh into: do that first, and come back to the base machine afterwards.
ChallengeAnswer with one command each
- Which image does every pod of the cluster run? One line per pod, no other tool than kubectl.
- Which objects of the API are cluster-scoped?
- What does the field
spec.template.spec.topologySpreadConstraints.whenUnsatisfiableaccept?
Show the solution
$ kubectl get pods -A -o custom-columns=NS:.metadata.namespace,POD:.metadata.name,IMAGE:.spec.containers[*].image $ kubectl api-resources --namespaced=false $ kubectl explain deployment.spec.template.spec.topologySpreadConstraints.whenUnsatisfiable
kubeconfig and contextsThe file (~/.kube/config) that tells kubectl which clusters exist, which identity to use, and the current context (cluster + user + namespace).
- Why it exists
- Switch between clusters (dev, staging, prod) without mistakes.
- In real companies
- In companies your identity comes from the directory (OIDC via kubelogin,
aws eks get-token, gcloud): no shared admin certificate. - How it works
kubectl config get-contexts·use-context·set-context --current --namespace=kubelab· theKUBECONFIGvariable merges several files.- Limits and pitfalls
- Pitfall number one: running a destructive command against the wrong context. Show the context in your prompt (kube-ps1) and name contexts clearly.
- Exam tip
- Every exam task names a host to connect to (
ssh <host>) before you work, then you return to the base machine (exit). Working on the wrong host loses the points.
NamespacesName spaces that partition a cluster: object names only need to be unique inside a namespace.
- Why it exists
- Separate teams, applications and environments, and attach RBAC, quotas, LimitRanges, NetworkPolicies and Pod Security to them.
- In real companies
- One namespace per application and environment (kubelab-dev, kubelab-prod) or per team. System namespaces (kube-system) are for admins only.
- How it works
kubectl create ns,-n <ns>,-Afor all of them. Some objects are not namespaced: Node, PersistentVolume, StorageClass, ClusterRole, CRD (kubectl api-resources --namespaced=false).- Limits and pitfalls
- A namespace is not a network boundary by default: without a NetworkPolicy every pod can talk to every pod. Deleting a namespace deletes everything inside it.
kubectl explain and api-resourcesThe API documentation built into kubectl.
- Why it exists
- Find the exact name and structure of a field without leaving the terminal.
- In real companies
- Writing a manifest by hand, checking that a field exists in the API version of your cluster.
- How it works
kubectl explain deployment.spec.strategy --recursive·kubectl api-resources(short names, group, namespaced or not) ·kubectl api-versions.- Limits and pitfalls
- explain describes the schema, not the best practices.
- Exam tip
- Essential in the exam: the official docs are allowed, but explain is faster.
kubectl apply (declarative)Send the desired state written in YAML files; Kubernetes computes the difference and applies it.
- Why it exists
- Manage infrastructure as code: files versioned in git, reviewed in pull requests, applied by CI or GitOps.
- In real companies
- Everything that reaches production goes through apply (or Helm, or Argo CD), never through manual
kubectl edit. - How it works
kubectl apply -f folder/(or-kfor Kustomize).--server-sidefor Server-Side Apply (each field has an owner, the "field manager").kubectl diff -fbefore applying.- Limits and pitfalls
createfails if the object exists,applyupdates it. Removing a file from the folder does not delete the object from the cluster (unless you use--prune, carefully).- Exam tip
- For speed in the exam, use imperative generation:
kubectl create deployment ... --dry-run=client -o yaml > f.yaml, then edit and apply.
Labels, selectors, annotationsLabels are key/value pairs used to identify and select objects. Annotations are metadata you cannot select on (for tools and documentation).
- Why it exists
- Labels are the glue of Kubernetes: a Service finds its pods, a Deployment its ReplicaSets, a NetworkPolicy its targets, all through label selectors.
- In real companies
- Standard labels
app.kubernetes.io/name,instance,version,component,part-of,managed-byon every resource: costs, inventory, filters. - How it works
kubectl get pods -l app.kubernetes.io/name=kubelab,tier!=db·kubectl label·kubectl get pods --show-labels.- Limits and pitfalls
- A Deployment's selector is immutable: never put the version in it. A typo in a Service selector gives a Service with no endpoints (scenario 06).
Output formats (yaml, jsonpath, custom-columns)kubectl's -o options, to extract exactly the information you need.
- Why it exists
- Script, extract an IP, a status, an image, without extra tools.
- In real companies
- Audit scripts: list every image running in production, find pods without a memory limit.
- How it works
-o wide·-o yaml·-o jsonpath='{.items[*].spec.containers[*].image}'·-o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName·--sort-by=.metadata.creationTimestamp.- Limits and pitfalls
- jsonpath does not support every expression; for complex filtering use
-o json | jq. - Exam tip
- A classic task: "write the names of the pods, sorted by ..., into /opt/x.txt".
--dry-run and kubectl diffSimulate a creation (client side or server side) and see the difference with the live cluster before applying.
- Why it exists
- Generate YAML quickly and check a change with no risk.
- In real companies
- In CI,
kubectl apply --dry-run=servervalidates against the real cluster's schemas and webhooks; the diff goes into the pull request. - How it works
--dry-run=client -o yaml(generate) ·--dry-run=server(validate in the API) ·kubectl diff -f file.yaml.- Limits and pitfalls
- Client mode checks neither that CRDs exist nor admission policies.
KustomizeA tool built into kubectl (-k) that assembles and patches YAML manifests without templates: a base plus overlays.
- Why it exists
- Adapt the same application per environment (replicas, image, namespace) while keeping plain YAML.
- In real companies
- Widely used with Argo CD and Flux. An alternative to Helm for your own apps, often combined (Helm for third-party software, Kustomize for adjustments).
- How it works
kustomization.yaml: resources, namespace, images, patches, configMapGenerator.kubectl apply -k overlays/prod·kubectl kustomizeto see the result.- Limits and pitfalls
- No logic (no conditions, no loops), no notion of release or rollback: the deployment tool (Argo CD) owns the lifecycle.
- Exam tip
- In the CKAD curriculum (Application Deployment) for years, and in the CKA since the February 2025 update.
Workloads: Pod, Deployment, StatefulSet, DaemonSet, Job
- pick the right controller for any kind of application;
- read a pod's lifecycle and know what each waiting reason points to;
- predict exactly how a rolling update will proceed, and how to undo it.
You almost never create a pod by hand. You choose the controller that matches the kind of application, and the controller creates and recreates pods for you. Choosing well is half of a good design review.
| Your application is... | Object | Typical company example |
|---|---|---|
| Stateless, N interchangeable copies | Deployment | API, front end, queue worker |
| Stateful, each instance needs its own identity and disk | StatefulSet | PostgreSQL (through an operator), Kafka, Elasticsearch |
| Needed once on every node | DaemonSet | Fluent Bit, node-exporter, the CNI, Falco |
| A task that must finish | Job | Schema migration, import, smoke test |
| A task on a schedule | CronJob | Nightly backup, purge |
The life of a pod
Probes are how you tell Kubernetes what "started", "ready" and "alive" mean for your app. The lab Deployment uses all three:
startupProbe: httpGet: {path: /healthz, port: http} periodSeconds: 2 failureThreshold: 30 readinessProbe: # removes the pod from the Service when it fails httpGet: {path: /readyz, port: http} periodSeconds: 5 livenessProbe: # restarts the container when it fails httpGet: {path: /healthz, port: http} periodSeconds: 10 failureThreshold: 3
The startup probe asks "is the kitchen open yet?". The readiness probe asks "can you take a new table right now?" (if not, the host stops seating people there, but nobody is fired). The liveness probe asks "is anyone actually in the kitchen?" (if not, the shift is replaced). Asking the liveness question about the supplier's delivery truck would be absurd, and that is exactly what a liveness probe on your database does.
PodThe smallest deployable unit: one or more containers that share the same IP, network and volumes, always scheduled together on one node.
- Why it exists
- Run an application process with its environment (variables, volumes, probes, resources, identity).
- In real companies
- You almost never create a bare pod: a controller (Deployment, StatefulSet, Job) creates and recreates it. A pod is ephemeral and disposable.
- How it works
- Lifecycle: Pending → Running → Succeeded/Failed.
kubectl get pod -o wide,describe,logs,exec. Containers have their own states (Waiting, Running, Terminated) with a reason. - Limits and pitfalls
- A deleted pod without a controller is not recreated. Its IP changes every time it is recreated: never hard-code it, use a Service.
Probes (startup, readiness, liveness)Checks run by the kubelet. Startup: has the app started? Readiness: can it receive traffic? Liveness: is it stuck?
- Why it exists
- Never send traffic to a pod that is not ready, restart a frozen container, give slow apps time to start.
- In real companies
- Readiness on an endpoint that checks the dependencies the app really needs; liveness on a very simple endpoint (the process answers). Startup for slow starters such as JVMs.
- How it works
httpGet,tcpSocket,execorgrpc;periodSeconds,timeoutSeconds,failureThreshold. Readiness fails → the pod leaves the Service endpoints. Liveness fails → the container is killed and restarted. While a startupProbe has not succeeded, the other two are not run.- Limits and pitfalls
- A liveness probe that tests the database: one database outage restarts your whole fleet. Liveness without a startupProbe on a slow app: restart loop (scenario 10).
- Exam tip
- Be able to write all three probes and predict the effect of each.
Init containersContainers that run one after the other, each until it succeeds, before the main containers of the pod start.
- Why it exists
- Prepare things: wait for a dependency, download configuration, fix permissions on a volume.
- In real companies
- Wait for the database to answer before starting; fetch certificates from Vault.
- How it works
spec.initContainers. StatusInit:0/1inkubectl get pods; logs with-c <name>.- Limits and pitfalls
- A failing init container blocks the pod in
Init:CrashLoopBackOff. Prefer apps that know how to retry their own connections.
Native sidecarsHelper containers that run for the whole life of the pod: proxy, log agent, file sync.
- Why it exists
- Add a cross-cutting feature without changing the application.
- In real companies
- Service-mesh proxies, collection agents, token renewal.
- How it works
- Since 1.29 (GA in 1.33): an initContainer with
restartPolicy: Alwaysbecomes a sidecar, started before and stopped after the main containers. - Limits and pitfalls
- They use resources in every pod. "Sidecar-less" meshes (Istio ambient, Cilium) are gaining ground.
Deployments and rolling updates
A Deployment never touches pods itself. Each version of its pod template gets its own ReplicaSet; a rolling update scales the new ReplicaSet up and the old one down, within two limits you choose: maxSurge (how many extra pods you may add) and maxUnavailable (how many may be missing). The old ReplicaSet is kept at 0 replicas, which is what makes kubectl rollout undo instant.
The lab uses maxSurge: 1 and maxUnavailable: 0: a new pod must be Ready before an old one leaves, so capacity never drops below 3. You will watch the real thing in lab 1.
ReplicaSetThe controller that keeps a given number of identical pods running.
- Why it exists
- Make sure there are always N copies: a pod dies, it creates another.
- In real companies
- You do not handle it directly: every version of a Deployment creates a ReplicaSet, and the history of ReplicaSets is what makes rollback possible.
- How it works
kubectl get rs: DESIRED / CURRENT / READY. Name =<deployment>-<hash of the pod template>.- Limits and pitfalls
- Editing a ReplicaSet owned by a Deployment is pointless: the Deployment will put it back.
DeploymentThe standard controller for stateless applications: it manages ReplicaSets to roll out, update and roll back.
- Why it exists
- Updates without downtime, rollback, scaling.
- In real companies
- Web APIs, workers, front ends: the large majority of company workloads.
- How it works
- Changing the pod template (image, env, config reference) triggers a rolling update.
kubectl rollout status / history / undo / restart.strategy: RollingUpdate(maxSurge, maxUnavailable) orRecreate. - Limits and pitfalls
- Changing a mounted ConfigMap does not restart the pods: that is why the Helm chart has a checksum annotation.
replicasin the YAML fights with an HPA: let the HPA own it. - Exam tip
rollout undo --to-revision,set image,scale: quick manipulations expected in the exam.
Rolling update and rollbackReplacing old pods with new ones progressively, and going back to a previous version.
- Why it exists
- Ship continuously without interrupting the service.
- In real companies
maxUnavailable: 0+maxSurge: 1+ a reliable readiness probe = capacity never drops. For advanced canary or blue/green: Argo Rollouts, Flagger, or two Deployments behind the Gateway API.- How it works
kubectl rollout status deploy/x(waits for the end) ·rollout history·rollout undo [--to-revision=2]·rollout pause/resume.- Limits and pitfalls
- Rollback restores the previous pod template, not your data nor ConfigMaps changed on the side. A stuck rollout (readiness failing) waits for
progressDeadlineSecondsand is then marked as failed, without rolling back on its own.
StatefulSetThe controller for stateful applications: stable names (app-0, app-1), one volume per pod, ordered startup and shutdown.
- Why it exists
- Databases, message queues, clustered software (Kafka, Elasticsearch, PostgreSQL with an operator).
- In real companies
- Companies avoid running a database on Kubernetes by hand, without an operator (CloudNativePG, Strimzi); many prefer a managed database.
- How it works
serviceName(a headless Service),volumeClaimTemplates. Per-pod DNS:app-0.app.ns.svc.cluster.local. RollingUpdate goes in reverse order.- Limits and pitfalls
- Deleting a StatefulSet does not delete its PVCs (data protection). Scaling does not replicate data: the application must do that itself.
DaemonSetOne pod per node (or per selected node), added automatically to every new node.
- Why it exists
- Infrastructure agents: log collection, node monitoring, CNI, kube-proxy, CSI drivers, runtime security (Falco).
- In real companies
- Fluent Bit and node-exporter run as DaemonSets on every production cluster.
- How it works
kubectl get ds -A. Add tolerations if it must also run on control-plane nodes.- Limits and pitfalls
- Every DaemonSet uses resources on every node: count them when sizing nodes.
JobRun one or more pods until they succeed.
- Why it exists
- One-off tasks: schema migration, import, smoke test, batch processing.
- In real companies
- Database migration before a release (a Helm hook), post-deployment smoke test, parallel batch computation (
completions,parallelism). - How it works
backoffLimit(retries),activeDeadlineSeconds(max duration),ttlSecondsAfterFinished(clean-up),restartPolicy: NeverorOnFailure.kubectl wait --for=condition=complete job/x.- Limits and pitfalls
- Without
ttlSecondsAfterFinished, finished Jobs pile up. A task must be idempotent: it may run again.
CronJobA Job scheduled with a cron expression.
- Why it exists
- Backups, purges, reports, nightly checks.
- In real companies
- Database backup at 3 a.m., weekly clean-up of temporary files.
- How it works
schedule,timeZone,concurrencyPolicy(Allow, Forbid, Replace),startingDeadlineSeconds, history limits.kubectl create job --from=cronjob/x testruns it now.- Limits and pitfalls
- Without
timeZone, the time is the controller's (often UTC).Forbidprevents two long runs from overlapping.
Jobs in the lab
The smoke test is a Job, the nightly check a CronJob. Notice the security settings even on a five-second task, and the role: client label: you will find out why in the networking station.
# Job: run a task until it succeeds. CronJob: schedule it. apiVersion: batch/v1 kind: Job metadata: {name: smoke-test, namespace: kubelab} spec: backoffLimit: 2 activeDeadlineSeconds: 120 ttlSecondsAfterFinished: 600 template: metadata: {labels: {role: client}} spec: restartPolicy: Never containers: - name: check image: kubelab:1.0.0 args: ["check", "http://kubelab.kubelab.svc.cluster.local/", "Bonjour"] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}} securityContext: {runAsNonRoot: true, runAsUser: 65532, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}, seccompProfile: {type: RuntimeDefault}} --- apiVersion: batch/v1 kind: CronJob metadata: {name: nightly-check, namespace: kubelab} spec: schedule: "0 3 * * *" timeZone: Europe/Paris concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 jobTemplate: spec: backoffLimit: 1 template: metadata: {labels: {role: client}} spec: restartPolicy: Never containers: - name: check image: kubelab:1.0.0 args: ["check", "http://kubelab.kubelab.svc.cluster.local/healthz"] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}} securityContext: {runAsNonRoot: true, runAsUser: 65532, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}, seccompProfile: {type: RuntimeDefault}}
Hands-onRun tonight's check right now
After lab 1 is deployed, trigger the CronJob immediately instead of waiting for 3 a.m., then find its pod's log.
Show a hint
A Job can be created from a CronJob's template.
Show the solution
$ kubectl -n kubelab create job --from=cronjob/nightly-check manual-check $ kubectl -n kubelab wait --for=condition=complete job/manual-check --timeout=60s $ kubectl -n kubelab logs job/manual-check
ConfigMap, Secret and the Downward API
- inject configuration as variables or as files, and know when each updates;
- explain why a Secret is not encrypted by default, and what protects it;
- describe how companies keep secrets out of git.
The same image must run in development, staging and production. Everything that changes between environments comes from outside: variables, mounted files, secrets. That is the rule that makes "build once, deploy everywhere" possible, and the rule a reviewer checks first.
# A variable from a ConfigMap env: - name: MESSAGE valueFrom: configMapKeyRef: {name: kubelab-config, key: MESSAGE} # A variable from a Secret - name: DATABASE_URL valueFrom: secretKeyRef: {name: kubelab-secret, key: DATABASE_URL} # The pod knows its own name (Downward API) - name: POD_NAME valueFrom: fieldRef: {fieldPath: metadata.name}
Read once, when the container starts. Change the ConfigMap and running pods keep the old value until they restart.
volumeMounts: - name: config mountPath: /etc/kubelab readOnly: true volumes: - name: config configMap: name: kubelab-config items: - {key: app.properties, path: app.properties} # gives /etc/kubelab/app.properties, refreshed automatically # (after a short delay) when the ConfigMap changes, unless you use subPath
Updated in place after a delay, which only helps if your app re-reads the file.
The lab's two objects. Read the warning at the top of the Secret: it is the most important line of the file.
# Non-sensitive configuration: injected as environment variables and mounted as a file. apiVersion: v1 kind: ConfigMap metadata: name: kubelab-config namespace: kubelab labels: app.kubernetes.io/name: kubelab data: MESSAGE: "Bonjour depuis Kubernetes" app.properties: | log.level=info feature.newCheckout=false
# Teaching example only. In a real company NEVER commit a plain-text Secret: # use External Secrets Operator (Vault, AWS/GCP Secret Manager) or Sealed Secrets/SOPS. apiVersion: v1 kind: Secret metadata: name: kubelab-secret namespace: kubelab labels: app.kubernetes.io/name: kubelab type: Opaque stringData: DATABASE_URL: "postgres://kubelab:change-me@db.kubelab.svc:5432/kubelab"
A Secret is only base64-encoded in the API, and stored in plain text in etcd unless the API server has an EncryptionConfiguration. Anyone allowed to read Secrets, or to create a pod that mounts them, can read them. Protect them with RBAC, encryption at rest, and an external vault.
Hands-onRead a "secret" in one line
Once lab 1 is deployed, print the database URL stored in kubelab-secret. Then think about who in your company can run that same command.
Show the solution
$ kubectl -n kubelab get secret kubelab-secret -o jsonpath='{.data.DATABASE_URL}' | base64 -d
ConfigMapAn object that stores non-sensitive configuration (key/value pairs or whole files).
- Why it exists
- Keep configuration out of the image: same image in dev and prod, different configuration.
- In real companies
- Log level, URLs of other services, application config files, simple feature flags.
- How it works
- Injected as variables (
env/envFrom) or as files (volume).kubectl create configmap x --from-file=... --from-literal=... - Limits and pitfalls
- Environment variables are frozen when the container starts; mounted files are updated (after a delay) except with
subPath. Max size 1 MiB.immutable: truefor performance and safety.
SecretAn object for sensitive data: passwords, tokens, TLS certificates, registry credentials.
- Why it exists
- Give secrets to pods without putting them in the image or in the application YAML.
- In real companies
- Never in plain text in git: External Secrets Operator (Vault, AWS/GCP/Azure secret managers), Sealed Secrets or SOPS. Encryption at rest enabled for etcd.
- How it works
- Types:
Opaque,kubernetes.io/tls,kubernetes.io/dockerconfigjson.datais base64,stringDatais plain text on write. Mount as a volume (preferred) or as env. - Limits and pitfalls
- base64 is not encryption. Anyone who can create a pod in the namespace can read the Secrets it can mount: the RBAC right "get secrets" is sensitive.
Downward APIThe way for a pod to learn about itself: name, namespace, node, labels, limits.
- Why it exists
- Richer logs, instance identification, adapting to the memory you were given.
- In real companies
- The lab injects
POD_NAMEandNODE_NAME: the API's answer tells you which pod replied. - How it works
env.valueFrom.fieldRef(metadata.name,spec.nodeName,status.podIP) orresourceFieldRef(limits.memory).- Limits and pitfalls
- Read-only, and limited to a handful of fields.
External Secrets OperatorAn operator that syncs secrets from an external vault (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) into Kubernetes Secrets.
- Why it exists
- Keep the vault as the source of truth, with rotation and audit, and no secret in git.
- In real companies
- The de facto standard in companies. The kubelab chart in production uses
existingSecret: kubelab-db, created by ESO. - How it works
- A SecretStore / ClusterSecretStore (how to reach the vault, ideally with workload identity) plus an ExternalSecret (which keys, which target Secret,
refreshInterval). - Limits and pitfalls
- The Kubernetes Secret still exists in etcd: encryption at rest and RBAC are still required.
Networking: Services, DNS, Gateway API, NetworkPolicy
- follow a request from a client pod to a backend pod, hop by hop;
- choose between ClusterIP, NodePort, LoadBalancer, headless and the Gateway API;
- write a NetworkPolicy and predict what it blocks.
The Kubernetes network model has three rules. Every pod gets its own IP. Every pod can reach every other pod without NAT. And because pods come and go, a Service gives a group of them a stable address and name.
| Service type | Reachable from | Typical use |
|---|---|---|
ClusterIP | Inside the cluster only | Every internal service (the default) |
NodePort | Each node's IP, port 30000-32767 | Labs, a manual external load balancer |
LoadBalancer | An IP from the cloud (or MetalLB) | Exposing the Gateway, or a TCP service |
clusterIP: None (headless) | DNS straight to the pods | StatefulSets, clients that pick their pod |
ExternalName | A DNS alias (CNAME) | Naming an external service from inside |
# Stable address (virtual IP + DNS name kubelab.kubelab.svc.cluster.local) in front of the ready pods. apiVersion: v1 kind: Service metadata: name: kubelab namespace: kubelab labels: app.kubernetes.io/name: kubelab spec: type: ClusterIP selector: app.kubernetes.io/name: kubelab ports: - name: http port: 80 targetPort: http
Service (ClusterIP)A stable virtual IP and DNS name in front of a group of pods chosen by a label selector.
- Why it exists
- Decouple clients from pods: pods come and go, the Service address stays.
- In real companies
- Every internal component (API, database, cache) is reached through its Service:
http://kubelab.kubelab.svc.cluster.local. - How it works
selector+ports(Serviceport→ podtargetPort, preferably by name).kubectl get svc,kubectl get endpointslices -l kubernetes.io/service-name=x.- Limits and pitfalls
- Only ready pods (readiness passing) receive traffic. A Service IP usually does not answer ping in iptables/nftables mode (it is not a real interface): do not use ping as a test.
NodePort ServiceA Service opened on one port (30000-32767) of every node.
- Why it exists
- Simple exposure outside the cluster, without a load balancer.
- In real companies
- Labs, kind, or behind an external load balancer configured by hand. Rare as direct production exposure.
- How it works
type: NodePort,nodePortoptional. Access:<any node IP>:<nodePort>.- Limits and pitfalls
- High port numbers, a node IP you must know, no TLS and no HTTP routing.
LoadBalancer ServiceA Service for which the cloud creates a real load balancer with a public or internal IP.
- Why it exists
- Expose a TCP/UDP service outside the cluster.
- In real companies
- Expose the Gateway or ingress controller (one LB for all HTTP apps), or a non-HTTP service (MQTT, a database).
- How it works
type: LoadBalancerplus cloud annotations (internal, subnet...). EXTERNAL-IP appears inkubectl get svc.- Limits and pitfalls
- One LB per Service is expensive: for HTTP, share one through the Gateway API. On premises: MetalLB or kube-vip.
Headless ServiceA Service with clusterIP: None: no virtual IP, DNS returns the pod IPs directly.
- Why it exists
- Let the client choose the pod (clustered databases) and give each StatefulSet pod its own DNS name.
- In real companies
kubelab-db-0.kubelab-db.kubelab.svc.cluster.localin the StatefulSet lab.- How it works
clusterIP: None+ selector; referenced by the StatefulSet'sserviceName.- Limits and pitfalls
- No load balancing by kube-proxy.
EndpointSlicesThe objects listing the addresses of the pods behind a Service, with their state (ready, serving, terminating).
- Why it exists
- The concrete link between a Service and its pods, read by kube-proxy and ingress/gateway controllers.
- In real companies
- First reflex when a Service "does not answer": does it have endpoints?
- How it works
kubectl get endpointslices -l kubernetes.io/service-name=kubelab -o wide. (The old Endpoints object is deprecated since 1.33.)- Limits and pitfalls
- Empty means: wrong selector, pods not ready, or wrong namespace.
Service DNSAutomatic naming: <service>.<namespace>.svc.cluster.local, plus <pod>.<service>... for headless Services.
- Why it exists
- Reach a service by name from any pod.
- In real companies
- In the same namespace,
kubelabis enough; from another one,kubelab.kubelab. - How it works
- Test with
nslookup kubelab.kubelabfrom a debug pod. The cluster domain (cluster.local) is chosen at install time. - Limits and pitfalls
- DNS problems look like network problems: always test name resolution before the connection.
Exposing HTTP: Ingress yesterday, Gateway API today
Ingress gave us one HTTP entry point with host and path routing, but every advanced feature lived in controller-specific annotations. The Gateway API splits the job by role, so each team owns its own object:
# HTTP exposure with the Gateway API (the successor of Ingress). Requires a controller # (Envoy Gateway, Traefik, Cilium, NGINX Gateway Fabric...) and a Gateway named "public". apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: kubelab namespace: kubelab spec: parentRefs: - name: public namespace: gateway-system hostnames: ["kubelab.localtest.me"] rules: - matches: - path: {type: PathPrefix, value: /} backendRefs: - name: kubelab port: 80
The community ingress-nginx project, the most widely deployed ingress controller, was retired in March 2026. Existing Ingress objects keep working with other controllers, but new platforms are built on the Gateway API, which is also part of the CKA curriculum.
IngressThe historical resource for exposing HTTP(S) services with host and path routing, implemented by an ingress controller.
- Why it exists
- One HTTP entry point, central TLS, routing to many services.
- In real companies
- Still everywhere. Note: the ingress-nginx project (the most widely used controller) was retired in March 2026; new platforms move to the Gateway API.
- How it works
rules(host, paths) +tls(a Secret) +ingressClassName. The controller (Traefik, HAProxy, F5 NGINX, cloud controllers) reads the objects and configures its proxy.- Limits and pitfalls
- Advanced features come through controller-specific annotations: not portable.
- Exam tip
- Still in the CKA/CKAD curriculum next to the Gateway API.
Gateway APIThe successor of Ingress: separate resources per role (GatewayClass for infrastructure, Gateway for the platform team, HTTPRoute for application teams).
- Why it exists
- Rich, portable HTTP, gRPC and TCP routing (headers, weights for canaries, redirects) without proprietary annotations.
- In real companies
- The standard for new platforms: Envoy Gateway, Cilium, Istio, Traefik, NGINX Gateway Fabric, cloud controllers. The platform team owns the Gateway, every team owns its HTTPRoutes.
- How it works
- Install the CRDs and a controller, create a Gateway (listeners, TLS), then HTTPRoutes (
parentRefs,hostnames,rules,backendRefswithweight). Seemanifests/10-httproute.yamland the chart. - Limits and pitfalls
- The CRDs are not installed by default. Routes across namespaces need explicit consent (
allowedRoutes, ReferenceGrant). - Exam tip
- In the CKA curriculum since 2025.
A firewall between pods
Without a NetworkPolicy, any compromised pod can talk to every other pod in the cluster. The lab namespace starts from zero trust: deny all incoming traffic, then allow only pods labelled role: client to reach port 8080.
# Zero-trust networking: deny all ingress, then open only what is needed. apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-ingress namespace: kubelab spec: podSelector: {} policyTypes: [Ingress] --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-http-to-kubelab namespace: kubelab spec: podSelector: matchLabels: app.kubernetes.io/name: kubelab policyTypes: [Ingress] ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kubelab podSelector: matchLabels: role: client ports: - port: 8080 protocol: TCP
Hands-onTalk to the API from your laptop
With lab 1 deployed, open a tunnel to the Service and call it. Why does this work even though the NetworkPolicy denies all ingress?
Show the solution
$ kubectl -n kubelab port-forward svc/kubelab 8080:80 $ curl -s localhost:8080/
port-forward does not enter the pod through the pod network: the kubelet connects to the container's port from inside the pod's network namespace, so NetworkPolicies are not involved. It still needs the RBAC right pods/portforward, which is why the lab's developer Role grants it explicitly.
NetworkPolicyA firewall at pod level: which pods may talk to which pods, on which ports.
- Why it exists
- Apply network least privilege and limit how far a compromise can spread.
- In real companies
- Per namespace: default-deny for ingress (often egress too), then explicit openings (gateway → app, app → database, everyone → DNS).
- How it works
podSelector(targets),policyTypes,ingress.from/egress.to(podSelector, namespaceSelector, ipBlock),ports. Rules add up: no order, no explicit deny.- Limits and pitfalls
- Without a compatible CNI they are silently ignored. A default-deny egress often forgets DNS (port 53 UDP/TCP to kube-system).
- Exam tip
- Writing a policy that only allows a given label on a given port is a CKA/CKAD classic.
kubectl port-forwardA tunnel from your machine to a pod or Service, through the API.
- Why it exists
- Test an internal application without exposing it.
- In real companies
- Reach an admin console (Grafana, Argo CD) or an internal API while debugging.
- How it works
kubectl -n kubelab port-forward svc/kubelab 8080:80, thencurl localhost:8080.- Limits and pitfalls
- For debugging only: one pod is targeted (even through
svc/..., a pod is chosen at start), no load balancing, cut when you close the terminal or when that pod goes away.
Storage: PVC, PV, StorageClass, CSI
- explain how a pod's request for storage becomes a real disk;
- pick access modes and reclaim policies on purpose;
- run a StatefulSet and reach each of its pods by name.
A guest (the pod) fills in a request card: "one room, two beds" (the PersistentVolumeClaim). The hotel's room types (the StorageClass) say how such rooms are prepared, and housekeeping (the CSI driver) gets one ready. The room itself is the PersistentVolume. The guest never needs to know which floor it is on.
The lab's StatefulSet asks for one small volume per pod through volumeClaimTemplates, and a headless Service gives every pod its own DNS name:
# StatefulSet: stable identity (kubelab-db-0, -1...), one volume per pod, guaranteed startup order. apiVersion: v1 kind: Service metadata: {name: kubelab-db, namespace: kubelab} spec: clusterIP: None # "headless" Service: one DNS record per pod selector: {app: kubelab-db} ports: [{name: http, port: 8080}] --- apiVersion: apps/v1 kind: StatefulSet metadata: {name: kubelab-db, namespace: kubelab} spec: serviceName: kubelab-db replicas: 2 selector: {matchLabels: {app: kubelab-db}} template: metadata: {labels: {app: kubelab-db}} spec: securityContext: {runAsNonRoot: true, runAsUser: 65532, fsGroup: 65532, seccompProfile: {type: RuntimeDefault}} containers: - name: app image: kubelab:1.0.0 env: - {name: DATA_DIR, value: /data} - name: POD_NAME valueFrom: {fieldRef: {fieldPath: metadata.name}} ports: [{name: http, containerPort: 8080}] volumeMounts: [{name: data, mountPath: /data}] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}} securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}} volumeClaimTemplates: - metadata: {name: data} spec: accessModes: [ReadWriteOnce] resources: {requests: {storage: 100Mi}}
$ kubectl apply -f workloads/statefulset.yaml service/kubelab-db created statefulset.apps/kubelab-db created $ kubectl -n kubelab rollout status sts/kubelab-db --timeout=180s Waiting for 2 pods to be ready...1 Waiting for 2 pods to be ready... Waiting for 1 pods to be ready... Waiting for 1 pods to be ready... partitioned roll out complete: 2 new pods have been updated... $ kubectl -n kubelab get sts kubelab-db NAME READY AGE kubelab-db 2/2 8s $ kubectl -n kubelab get pods -l app=kubelab-db -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES kubelab-db-0 1/1 Running 0 8s 10.244.1.19 lab-worker2 <none> <none>2 kubelab-db-1 1/1 Running 0 3s 10.244.2.37 lab-worker <none> <none> $ kubectl -n kubelab get pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE data-kubelab-db-0 Bound pvc-6b687f17-ad35-4f93-a09c-dd82609b8536 100Mi RWO standard <unset> 8s3 data-kubelab-db-1 Bound pvc-5eed3485-848e-427e-b0c7-97b860f4d476 100Mi RWO standard <unset> 4s $ kubectl get pv NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE4 pvc-5eed3485-848e-427e-b0c7-97b860f4d476 100Mi RWO Delete Bound kubelab/data-kubelab-db-1 standard <unset> 1s pvc-6b687f17-ad35-4f93-a09c-dd82609b8536 100Mi RWO Delete Bound kubelab/data-kubelab-db-0 standard <unset> 5s
- Order. kubelab-db-0 starts and becomes Ready before kubelab-db-1.
- Stable names. Always -0, -1: recreated with the same name and the same volume.
- volumeClaimTemplates. One PVC per pod, named
<template>-<pod>. - PV. Created on the fly by local-path-provisioner (StorageClass
standard), reclaim policy Delete.
To test the per-pod DNS name, the capture used a throw-away busybox pod in a separate tools namespace. Why not in kubelab? Because busybox runs as root, and the kubelab namespace enforces the restricted Pod Security profile: it would be rejected (you will see that rejection in the security station).
$ kubectl -n tools run dns --image=busybox:1.36 --restart=Never -- nslookup kubelab-db-0.kubelab-db.kubelab.svc.cluster.local pod/dns created $ kubectl -n tools logs dns Server: 10.96.0.10 Address: 10.96.0.10:53 Name: kubelab-db-0.kubelab-db.kubelab.svc.cluster.local1 Address: 10.244.1.192
- Per-pod DNS.
<pod>.<headless service>.<namespace>.svc.cluster.local - Pod IP. No virtual IP: DNS returns the pod IP directly.
Hands-onKill a database pod
Delete kubelab-db-0. What name does the replacement get? Which PVC does it mount? What happens to the PVCs if you delete the StatefulSet itself?
Show the solution
$ kubectl -n kubelab delete pod kubelab-db-0 $ kubectl -n kubelab get pods -l app=kubelab-db -w $ kubectl -n kubelab get pod kubelab-db-0 -o jsonpath='{.spec.volumes[0].persistentVolumeClaim.claimName}'
The new pod is called kubelab-db-0 again and mounts data-kubelab-db-0 again: same identity, same data. Deleting the StatefulSet keeps the PVCs on purpose; you delete them yourself when you are sure.
PersistentVolume and PersistentVolumeClaimA PV is a piece of storage in the cluster. A PVC is a pod's request ("1 GiB, read-write"). Kubernetes binds one to the other.
- Why it exists
- Separate what the application asks for from how storage is provided.
- In real companies
- Dynamic provisioning: the PVC creates the disk automatically through the StorageClass. Manual PVs remain for existing NFS shares.
- How it works
kubectl get pvc,pv. PVC states: Pending, Bound, Lost. PV states: Available, Bound, Released, Failed.accessModes,resources.requests.storage,storageClassName.- Limits and pitfalls
- A Pending PVC keeps its pod Pending (scenario 08). Growing a volume needs
allowVolumeExpansionon the StorageClass; shrinking is impossible.
StorageClassA storage "profile": which CSI driver, which disk type, which policy.
- Why it exists
- Offer several tiers (standard, SSD, encrypted, replicated) and provision on demand.
- In real companies
- One default StorageClass (annotation
is-default-class), a fast one for databases, one withreclaimPolicy: Retainfor critical data. - How it works
provisioner,parameters,reclaimPolicy(Delete or Retain),volumeBindingMode(WaitForFirstConsumer recommended),allowVolumeExpansion.- Limits and pitfalls
reclaimPolicy: Deletedeletes the disk with the PVC: beware of namespace deletions.
Access modes and reclaim policyAccess modes: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany (many nodes), ReadWriteOncePod (a single pod). Reclaim policy: what happens to the volume when its PVC is deleted.
- Why it exists
- Pick the right storage for the sharing you need.
- In real companies
- Cloud disks are RWO; sharing between pods needs RWX (NFS, EFS, Azure Files, CephFS).
- How it works
accessModesin the PVC;reclaimPolicyin the StorageClass or the PV.- Limits and pitfalls
- RWO does not mean a single pod: several pods on the same node can mount it. Use RWOP to guarantee exclusivity.
- Exam tip
- Part of the CKA Storage domain (10%).
emptyDir and ephemeral volumesAn empty directory created with the pod and destroyed with it.
- Why it exists
- Cache, temporary files, sharing files between containers of one pod; essential with
readOnlyRootFilesystem. - In real companies
/tmpas an emptyDir for an app whose image is read-only.- How it works
volumes: - name: tmp, emptyDir: {sizeLimit: 100Mi}(ormedium: Memory).- Limits and pitfalls
- Counted in the node's ephemeral storage: without
sizeLimit, one pod can fill the disk and cause evictions.
VolumeSnapshotsVolume snapshots through the API (VolumeSnapshot, VolumeSnapshotClass), when the CSI driver supports them.
- Why it exists
- Consistent backups and data cloning (create a PVC from a snapshot).
- In real companies
- A snapshot before a database migration; Velero uses them for volume backups.
- How it works
- Install the CRDs and the snapshot-controller, then create a VolumeSnapshot that points to a PVC.
- Limits and pitfalls
- A disk snapshot is not necessarily application-consistent (a database in the middle of a write).
Resources, placement and elasticity
- set requests and limits on purpose and predict a pod's QoS class;
- steer placement with affinity, taints and spreading;
- scale with the HPA and keep availability with PDBs, quotas and LimitRanges.
The scheduler only reasons about requests, never about real usage. Get them wrong and everything downstream goes wrong: pods that never get placed, nodes that are full of idle reservations, an autoscaler that cannot compute anything.
Requests on everything, a memory limit on everything, and usually no CPU limit: spare CPU is shared fairly by requests, while a CPU limit throttles your app even when the node is idle. The values come from measurement (kubectl top, Prometheus, the VPA in recommendation mode), not from guesses.
Requests and limitsRequests: what the scheduler reserves for the container. Limits: the ceiling it cannot go above.
- Why it exists
- Place pods correctly, stop one pod from starving its neighbours, plan capacity and costs.
- In real companies
- Always set CPU and memory requests; set a memory limit (going over = OOMKilled); often no CPU limit (spare CPU is simply shared, a limit causes throttling).
- How it works
resources.requests/limits, unitsm(millicores) andMi/Gi. Compare with real usage withkubectl top pods; the VPA in recommendation mode helps calibrate.- Limits and pitfalls
- Requests too high: Pending pods and wasted money. Too low: overloaded nodes and evictions. A memory limit too tight: OOMKilled (scenario 03).
- Exam tip
- Know the chain requests → QoS class → eviction order.
QoS classesGuaranteed (requests = limits for CPU and memory in every container), Burstable (at least one request or limit), BestEffort (nothing at all). Computed automatically.
- Why it exists
- Decide who gets evicted first when a node runs out of memory.
- In real companies
- Databases and critical components in Guaranteed; web apps in Burstable; never BestEffort in production (a LimitRange prevents it).
- How it works
kubectl get pod x -o jsonpath='{.status.qosClass}'.- Limits and pitfalls
- Eviction under memory pressure: BestEffort first, then Burstable pods using more than their requests.
nodeSelector and nodeAffinityForce (required) or nudge (preferred) a pod's placement according to node labels.
- Why it exists
- Send workloads to the right hardware or zone: GPU, ARM, spot nodes, availability zone.
- In real companies
- ML jobs on
gpu=truenodes; sensitive apps on a dedicated node pool. - How it works
nodeSelector: {disktype: ssd}(simple) oraffinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecutionwith operators In, NotIn, Exists.- Limits and pitfalls
- "IgnoredDuringExecution": changing a node's labels does not move pods already running. An impossible constraint = Pending.
Pod affinity and anti-affinityPlace a pod close to (or away from) other pods, according to their labels and a topology (node, zone).
- Why it exists
- Put a cache next to its app, or keep replicas apart for high availability.
- In real companies
- A "preferred" anti-affinity between replicas of one app; topologySpreadConstraints often replaces it with finer control.
- How it works
affinity.podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution,labelSelector,topologyKey: kubernetes.io/hostnameortopology.kubernetes.io/zone.- Limits and pitfalls
- A "required" anti-affinity with more replicas than nodes leaves pods Pending. Expensive to compute on big clusters.
Taints and tolerationsA taint pushes pods away from a node; only pods that tolerate it may go there.
- Why it exists
- Reserve nodes (GPU, control plane, dedicated pool) and handle unhealthy nodes.
- In real companies
- GPU nodes tainted
gpu=true:NoSchedule; the control plane carriesnode-role.kubernetes.io/control-plane:NoSchedule; Kubernetes addsnode.kubernetes.io/not-ready:NoExecuteto failing nodes. - How it works
kubectl taint nodes n1 dedicated=batch:NoSchedule(add-at the end to remove it). Effects: NoSchedule, PreferNoSchedule, NoExecute (also evicts pods already there).- Limits and pitfalls
- A toleration allows, it does not attract: combine it with nodeAffinity to force placement.
- Exam tip
- CKA classic: run a pod on the control plane, or dedicate a node.
topologySpreadConstraintsA rule that spreads pods evenly across nodes, zones or regions.
- Why it exists
- Survive the loss of a node or a zone without losing every replica.
- In real companies
- Every production app:
maxSkew: 1ontopology.kubernetes.io/zoneand onkubernetes.io/hostname. The kubelab chart enables it by default. - How it works
maxSkew,topologyKey,whenUnsatisfiable(DoNotSchedule or ScheduleAnyway),labelSelector.- Limits and pitfalls
- DoNotSchedule can leave pods Pending when one zone is short of capacity.
PriorityClass and preemptionA numeric priority given to pods; a high-priority pod can evict lower-priority pods to find room.
- Why it exists
- Make sure critical components start even on a full cluster.
- In real companies
- Security and monitoring agents get a high priority; batch jobs a low one.
- How it works
- A PriorityClass (
value,preemptionPolicy), thenpriorityClassNamein the pod.system-cluster-criticalandsystem-node-criticalalready exist. - Limits and pitfalls
- Misused, preemption causes cascades of evictions.
Elasticity and guardrails
The lab's HorizontalPodAutoscaler keeps average CPU at 70% of the requests, between 3 and 6 replicas. Under load from the app's /stress/cpu endpoint, each pod burned about 300m for a 50m request, and the HPA went straight to its maximum:
# Horizontal autoscaling on CPU utilisation (requires metrics-server). apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: kubelab namespace: kubelab spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: kubelab minReplicas: 3 maxReplicas: 6 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 # % of the CPU requests behavior: scaleDown: stabilizationWindowSeconds: 60
$ kubectl -n kubelab top pods -l app.kubernetes.io/name=kubelab NAME CPU(cores) MEMORY(bytes) kubelab-6b777469b9-4mn6b 297m 1Mi 1 kubelab-6b777469b9-5ws4r 317m 2Mi kubelab-6b777469b9-b5454 306m 1Mi kubelab-6b777469b9-gb5fz 265m 1Mi kubelab-6b777469b9-w9hj4 289m 2Mi kubelab-6b777469b9-xtcdh 313m 1Mi $ kubectl -n kubelab get hpa kubelab NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE kubelab Deployment/kubelab cpu: 595%/70% 3 6 6 4m18s2 $ kubectl -n kubelab get deploy kubelab NAME READY UP-TO-DATE AVAILABLE AGE kubelab 6/6 6 6 4m18s3 $ kubectl -n kubelab get events --field-selector reason=SuccessfulRescale -o custom-columns=AGE:.lastTimestamp,OBJET:.involvedObject.name,MESSAGE:.message AGE OBJET MESSAGE 2026-10-19T15:38:45Z kubelab New size: 6; reason: cpu resource utilization (percentage of request) above target4
- Usage. About 300m per pod, for 50m of requests.
- Target exceeded. 595% of the requests on average, for a 70% target.
- Scaled out. The HPA took the Deployment to its maximum (maxReplicas 6).
- Event. SuccessfulRescale with its reason, also visible in
kubectl describe hpa.
Three namespace guardrails complete the picture: a PodDisruptionBudget keeps at least 2 pods during drains, a ResourceQuota caps what the namespace may reserve, and a LimitRange fills in defaults for containers that forgot them.
# During a node drain or a cluster upgrade: keep at least 2 pods running. apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: kubelab namespace: kubelab spec: minAvailable: 2 selector: matchLabels: app.kubernetes.io/name: kubelab
# Namespace guardrails: an overall ceiling (quota) and per-container defaults (LimitRange). apiVersion: v1 kind: ResourceQuota metadata: name: kubelab-quota namespace: kubelab spec: hard: requests.cpu: "2" requests.memory: 2Gi limits.memory: 4Gi pods: "20" persistentvolumeclaims: "5" --- apiVersion: v1 kind: LimitRange metadata: name: kubelab-defaults namespace: kubelab spec: limits: - type: Container defaultRequest: cpu: 50m memory: 32Mi default: memory: 128Mi max: memory: 1Gi
ChallengeMake the HPA scale out
Generate CPU load on the running pods and watch the HPA react. The app burns CPU for a few seconds on /stress/cpu?seconds=4, and its own binary can call it from inside each pod (the image has no shell).
Show a hint
First make sure metrics-server runs (see Welcome aboard): without it the HPA shows <unknown> forever. Then loop over the pods returned by kubectl get pods -o name, and call the endpoint with kubectl exec <pod> -- /kubelab check <url> again and again. Then watch kubectl get hpa -w.
Show the solution
$ for p in $(kubectl -n kubelab get pods -l app.kubernetes.io/name=kubelab -o name); do (for i in $(seq 40); do kubectl -n kubelab exec $p -- /kubelab check 'http://localhost:8080/stress/cpu?seconds=4' >/dev/null; done) & done $ kubectl -n kubelab get hpa kubelab -w
Stop watching once REPLICAS reaches 6. When the load stops, the HPA waits for its scale-down stabilization window before removing pods.
HorizontalPodAutoscalerA controller that adjusts the replica count from metrics (CPU, memory, business metrics).
- Why it exists
- Absorb traffic peaks without paying for peak capacity all the time.
- In real companies
- Web API: CPU at 70% of requests, min 3, max 20. Business metrics (queue length, requests per second) via KEDA or a Prometheus adapter.
- How it works
autoscaling/v2:scaleTargetRef,minReplicas,maxReplicas,metrics,behavior(stabilization). Needs metrics-server for CPU and memory.kubectl get hpashows TARGETS.- Limits and pitfalls
- Without CPU requests the HPA cannot compute a percentage (
<unknown>). Do not also setreplicasin the Deployment (conflict on every apply).
VerticalPodAutoscalerAn add-on that recommends or adjusts requests and limits from observed usage.
- Why it exists
- Calibrate resources without guessing.
- In real companies
- The Off mode (recommendations only) is widely used to choose the right values for charts.
- How it works
- Install the VPA, create a VerticalPodAutoscaler object targeting a Deployment, read
status.recommendation. In-place pod resize is GA since 1.35. - Limits and pitfalls
- Do not combine VPA and HPA on the same CPU metric.
Cluster Autoscaler and KarpenterComponents that add nodes when pods stay Pending for lack of room, and remove nodes that are under-used.
- Why it exists
- Pay for the capacity you need, when you need it.
- In real companies
- Cluster Autoscaler on node groups (AKS, GKE, EKS); Karpenter (AWS, Azure) directly picks the best machine type.
- How it works
- Configured by the provider or installed with Helm, with min/max bounds per pool.
- Limits and pitfalls
- A pod without requests does not trigger scale-up. PDBs that are too strict prevent nodes from being removed.
PodDisruptionBudgetThe minimum number of pods to keep available (or the maximum to take down) during a voluntary disruption.
- Why it exists
- Protect availability during drains, cluster upgrades and node scale-down.
- In real companies
- Every app with several replicas:
minAvailable: 2out of 3, ormaxUnavailable: 1. - How it works
policy/v1PodDisruptionBudget with a selector.kubectl get pdb: ALLOWED DISRUPTIONS.kubectl drainrespects PDBs (eviction API).- Limits and pitfalls
- A PDB with minAvailable equal to the replica count blocks every drain forever. It does not protect against a node crash (an involuntary disruption).
ResourceQuotaCeilings per namespace: CPU, memory, number of pods, Services, PVCs, LoadBalancers...
- Why it exists
- Share a cluster between teams without one of them eating everything.
- In real companies
- Every team namespace has a negotiated quota; the platform team follows it on its dashboards.
- How it works
spec.hard;kubectl describe quotashows Used / Hard. With a quota onrequests.cpu, every pod must declare requests (hence the LimitRange).- Limits and pitfalls
- A pod refused by the quota never appears: the ReplicaSet carries the
FailedCreateevent (kubectl describe rs).
LimitRangeDefault values and min/max bounds for resources per container in a namespace.
- Why it exists
- Give requests and limits to pods that have none, and forbid extreme values.
- In real companies
- Paired with the ResourceQuota in every team namespace.
- How it works
type: Containerwithdefault,defaultRequest,min,max.- Limits and pitfalls
- Applied at creation only: it does not change existing pods.
Security: identities, permissions, pods, network, images
- grant least-privilege access with RBAC and prove it with
auth can-i; - harden pods to pass the restricted Pod Security profile;
- explain admission, encryption at rest, supply-chain checks and auditing.
Security in Kubernetes is layered. Who may call the API (RBAC), what a pod may do on its node (securityContext and Pod Security Admission), whom it may talk to (NetworkPolicy), which images may run (supply chain), and what is recorded (audit). Each layer assumes the previous one might fail.
# RBAC: a developer can read everything and restart Deployments in kubelab, but cannot read Secrets. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: {name: developer, namespace: kubelab} rules: - apiGroups: ["", "apps", "batch", "autoscaling", "policy"] resources: ["pods", "pods/log", "services", "endpoints", "configmaps", "deployments", "replicasets", "statefulsets", "jobs", "cronjobs", "horizontalpodautoscalers", "poddisruptionbudgets"] verbs: ["get", "list", "watch"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["patch"] # kubectl rollout restart - apiGroups: [""] resources: ["pods/portforward"] verbs: ["create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: {name: developers, namespace: kubelab} subjects: - kind: Group # group coming from the company OIDC provider (Entra ID, Keycloak, Okta...) name: team-kubelab apiGroup: rbac.authorization.k8s.io roleRef: kind: Role name: developer apiGroup: rbac.authorization.k8s.io
$ kubectl apply -f workloads/rbac-developer.yaml role.rbac.authorization.k8s.io/developer created1 rolebinding.rbac.authorization.k8s.io/developers created $ kubectl auth can-i list pods -n kubelab --as=jane --as-group=team-kubelab yes $ kubectl auth can-i patch deployments -n kubelab --as=jane --as-group=team-kubelab2 yes $ kubectl auth can-i get secrets -n kubelab --as=jane --as-group=team-kubelab3 no $ kubectl auth can-i delete pods -n kubelab --as=jane --as-group=team-kubelab no $ kubectl auth can-i list pods -n kube-system --as=jane --as-group=team-kubelab4 no
- Role + RoleBinding. The team-kubelab group gets the developer Role in kubelab.
- Allowed.
patchon deployments is whatkubectl rollout restartneeds. - Denied. No access to Secrets, on purpose.
- Out of scope. A RoleBinding only grants rights inside its own namespace.
Pods get their own identity: a ServiceAccount. The lab's app never calls the Kubernetes API, so it does not even get a token mounted:
# The application's own identity. No token mounted: the app never calls the Kubernetes API. apiVersion: v1 kind: ServiceAccount metadata: name: kubelab namespace: kubelab labels: app.kubernetes.io/name: kubelab automountServiceAccountToken: false
Pod Security in action
The kubelab namespace enforces the restricted profile through three labels. Try to sneak in a plain busybox pod and the API server refuses it, listing everything that is missing:
# One namespace per application and environment: isolates permissions, quotas and network policies. apiVersion: v1 kind: Namespace metadata: name: kubelab labels: # Pod Security Admission: rejects privileged pods, root, hostPath... pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: latest pod-security.kubernetes.io/warn: restricted
$ kubectl apply -f workloads/psa-test.yaml Error from server (Forbidden): error when creating "workloads/psa-test.yaml": pods "root-shell" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "sh" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "sh" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "sh" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "sh" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")1
- Rejected. The pod is never created. Every missing setting is listed: it is your checklist to fix it.
ChallengeMake root-shell acceptable
Edit a copy of workloads/psa-test.yaml so that the restricted profile accepts it and the container actually starts. Careful: busybox's image runs as root by default.
Show a hint
The error message lists four settings. Setting runAsNonRoot: true alone is not enough for an image whose default user is root: the kubelet would refuse to start it. Give it a user ID too.
Show the solution
apiVersion: v1 kind: Pod metadata: name: root-shell namespace: kubelab spec: securityContext: runAsNonRoot: true runAsUser: 65532 seccompProfile: {type: RuntimeDefault} containers: - name: sh image: busybox:1.36 command: ["sleep", "3600"] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 32Mi}} securityContext: allowPrivilegeEscalation: false capabilities: {drop: ["ALL"]}
The resources are there because the namespace's quota covers requests.cpu, requests.memory and limits.memory (the LimitRange would fill them in otherwise, but writing them is the good habit). The container image itself would be pulled from Docker Hub.
Encryption at rest, in one file
Remember that etcd stores Secrets in plain text by default. On a kubeadm cluster, this file passed to the API server with --encryption-provider-config changes that (a cloud KMS provider is the better choice in production):
apiVersion: apiserver.config.k8s.io/v1 kind: EncryptionConfiguration resources: - resources: [secrets] providers: - aescbc: keys: - name: key1 secret: <base64 of a random 32-byte key> - identity: {} # still able to read old, unencrypted Secrets
Then rewrite the existing Secrets so they get encrypted: kubectl get secrets -A -o json | kubectl replace -f -.
RBACRole-based access control: Roles (or ClusterRoles) list verbs on resources, RoleBindings give them to users, groups or ServiceAccounts.
- Why it exists
- Apply least privilege to every human and every program.
- In real companies
- Directory groups (OIDC) bound to Roles per namespace: developers read and restart, ops edit, cluster-admin kept rare and audited. One ServiceAccount per application.
- How it works
- Role/RoleBinding (namespace) · ClusterRole/ClusterRoleBinding (cluster).
kubectl auth can-i list pods --as=... -n ...;kubectl auth can-i --list. The ClusterRolesview,edit,adminalready exist. - Limits and pitfalls
- There is no "deny" rule: everything adds up. get/list on secrets, create on pods/exec, escalate/bind and impersonate are often as good as admin rights.
- Exam tip
- Create a Role + RoleBinding for a ServiceAccount and check it with
auth can-i: almost certain to show up in the exam.
ServiceAccountThe identity of a pod towards the Kubernetes API (and, through identity federation, towards the cloud).
- Why it exists
- Give an application exactly the rights it needs, and none to those that need none.
- In real companies
- One ServiceAccount per app,
automountServiceAccountToken: falseif it never calls the API. Workload Identity (GKE), IRSA / Pod Identity (EKS), Workload Identity (AKS) to reach the cloud without keys. - How it works
serviceAccountNamein the pod. Projected tokens are short-lived and audience-bound.kubectl create token <sa>for a temporary token.- Limits and pitfalls
- The
defaultServiceAccount exists in every namespace: never give it rights.
Pod Security AdmissionThe built-in admission check that applies the Pod Security Standards (privileged, baseline, restricted) per namespace, through labels.
- Why it exists
- Block dangerous pods: privileged, root, hostPath, hostNetwork, extra Linux capabilities.
- In real companies
restrictedin enforce mode for application namespaces, at leastbaselineelsewhere;privilegedonly for system namespaces (CNI, node monitoring).- How it works
- Labels
pod-security.kubernetes.io/enforce|audit|warn: restrictedon the namespace (seemanifests/00-namespace.yaml). Warnings are printed bykubectl apply. - Limits and pitfalls
- No per-pod exceptions: for finer rules use Kyverno, Gatekeeper or ValidatingAdmissionPolicy.
securityContextThe Linux security settings of a pod or container: user, group, capabilities, file system, seccomp.
- Why it exists
- Reduce what an attacker can do after compromising the container.
- In real companies
runAsNonRoot,allowPrivilegeEscalation: false,capabilities.drop: [ALL],seccompProfile: RuntimeDefault: the baseline required by the restricted profile.readOnlyRootFilesystem: trueon top is recommended by most audits, but restricted does not require it.- How it works
spec.securityContext(pod: runAsUser, fsGroup, seccomp) andcontainers[].securityContext. The lab and the chart apply all of it.- Limits and pitfalls
readOnlyRootFilesystembreaks apps that write to/tmp: mount an emptyDir there.
Admission control and policiesThe steps that modify (mutating) or validate (validating) every object before it is stored: built-in plugins, webhooks, ValidatingAdmissionPolicy (CEL), Kyverno, OPA Gatekeeper.
- Why it exists
- Enforce company rules automatically: allowed registries, mandatory labels, required limits, signed images.
- In real companies
- Kyverno or Gatekeeper on every cluster, policies versioned in git, audit mode first and enforce later.
- How it works
- Order: mutating admission → schema validation → validating admission. ValidatingAdmissionPolicy (GA since 1.30) gives CEL rules with no webhook; Kyverno uses ClusterPolicy objects in YAML. Test with
kubectl apply --dry-run=server. - Limits and pitfalls
- An unavailable webhook with
failurePolicy: Failblocks creations: exclude kube-system and run it highly available.
Encrypting Secrets at restThe API server setting that encrypts chosen resources (Secrets) before writing them to etcd.
- Why it exists
- So that a stolen etcd backup or disk does not reveal your secrets.
- In real companies
- On by default or available as an option on managed services (cloud KMS). On kubeadm you configure it yourself.
- How it works
- An
EncryptionConfiguration(providerkmsv2 preferably, oraescbc) passed with--encryption-provider-config; then rewrite the existing Secrets. - Limits and pitfalls
- Secrets created before you enabled it stay in plain text until they are rewritten (
kubectl get secrets -A -o json | kubectl replace -f -). - Exam tip
- A CKS topic.
Supply chain securityControls on images: provenance, signature (cosign), vulnerability scanning, allowed registries, digests.
- Why it exists
- Only run images that your CI built and checked.
- In real companies
- Pipeline: build → scan (Trivy) → sign with cosign → deploy by digest; Kyverno
verifyImagesrejects unsigned images. This is exactly what the GitHub and GitLab courses of this series build. - How it works
image: registry/app@sha256:...; an admission policy that verifies signatures;imagePullSecretsfor private registries.- Limits and pitfalls
- A tag (even 1.0.0) can be overwritten; only the digest is immutable.
- Exam tip
- The Supply Chain Security domain of the CKS (20%).
API auditingThe log of every request made to the API server: who, what, when, and the answer.
- Why it exists
- Investigate after an incident, prove compliance, detect abuse of permissions.
- In real companies
- Audit logs shipped to the SIEM; on clouds, enabled in the managed cluster options.
- How it works
- An audit policy (levels None, Metadata, Request, RequestResponse) passed with
--audit-policy-fileon kubeadm. - Limits and pitfalls
- RequestResponse on everything produces huge volumes and can contain secrets.
Admission policies can refuse unsigned images, but someone must sign them first. The two other courses of this series build exactly that supply chain, with scanning, SBOMs and cosign signatures: GitHub Actions DevSecOps and GitLab CI/CD DevSecOps.
CRDs, operators and the lifecycle of objects
- explain what a CRD adds to the API, and the risk of deleting one;
- describe what an operator automates;
- follow owner references and unblock a stuck deletion safely.
Every serious platform tool (the Gateway API, cert-manager, Argo CD, Prometheus) adds its own object types to the API with a CustomResourceDefinition, plus a controller that gives them meaning. That is the same reconciliation idea as the built-in controllers, extended to anything. The lab cluster has the Gateway API CRDs installed:
$ kubectl get crd NAME SCOPE VERSIONS CREATED AT backendtlspolicies.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:19Z gatewayclasses.gateway.networking.k8s.io Cluster v1(storage),v1beta1 2026-10-19T15:40:19Z gateways.gateway.networking.k8s.io Namespaced v1(storage),v1beta1 2026-10-19T15:40:19Z grpcroutes.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:19Z httproutes.gateway.networking.k8s.io Namespaced v1(storage),v1beta1 2026-10-19T15:40:20Z1 listenersets.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:21Z referencegrants.gateway.networking.k8s.io Namespaced v1,v1beta1(storage) 2026-10-19T15:40:21Z tcproutes.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:21Z tlsroutes.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:21Z udproutes.gateway.networking.k8s.io Namespaced v1(storage) 2026-10-19T15:40:21Z $ kubectl api-resources --api-group=gateway.networking.k8s.io NAME SHORTNAMES APIVERSION NAMESPACED KIND backendtlspolicies btlspolicy gateway.networking.k8s.io/v1 true BackendTLSPolicy gatewayclasses gc gateway.networking.k8s.io/v1 false GatewayClass2 gateways gtw gateway.networking.k8s.io/v1 true Gateway grpcroutes gateway.networking.k8s.io/v1 true GRPCRoute httproutes gateway.networking.k8s.io/v1 true HTTPRoute listenersets lset gateway.networking.k8s.io/v1 true ListenerSet referencegrants refgrant gateway.networking.k8s.io/v1 true ReferenceGrant tlsroutes gateway.networking.k8s.io/v1 true TLSRoute $ kubectl explain httproute.spec.rules.backendRefs.weight GROUP: gateway.networking.k8s.io KIND: HTTPRoute VERSION: v1 FIELD: weight <integer>3 DESCRIPTION: Weight specifies the proportion of requests forwarded to the referenced backend. This is computed as weight/(sum of all weights in this BackendRefs list). For non-zero values, there may be some epsilon from the exact proportion defined here depending on the precision an implementation supports. Weight is not a percentage and the sum of weights does not need to equal 100. If only one backend is specified and it has a weight greater than 0, 100% of the traffic is forwarded to that backend. If weight is set to 0, no traffic should be forwarded for this entry. If unspecified, weight defaults to 1. Support for this field varies based on the context where used.
- New types. Each CRD adds one object type to the API.
- Scope. GatewayClass is cluster-wide; Gateway and HTTPRoute are namespaced.
- explain. CRDs that ship a schema are documented like built-in objects.
weightis what canary releases use.
Hands-onWho owns whom?
Follow the chain of owners from one kubelab pod up to its Deployment, using only -o jsonpath.
Show the solution
$ kubectl -n kubelab get pods -l app.kubernetes.io/name=kubelab -o jsonpath='{.items[0].metadata.ownerReferences[0].name}{"\n"}' $ kubectl -n kubelab get rs -o jsonpath='{.items[0].metadata.ownerReferences[0].kind}/{.items[0].metadata.ownerReferences[0].name}{"\n"}'
Pod → ReplicaSet → Deployment. Delete the Deployment and the garbage collector removes the rest; --cascade=orphan would leave the children running.
CustomResourceDefinition (CRD)The way to add new object types to the Kubernetes API (Certificate, HTTPRoute, ExternalSecret, Application...).
- Why it exists
- Extend Kubernetes with your own concepts, managed with the same tools (kubectl, RBAC, GitOps).
- In real companies
- Every platform tool (cert-manager, Argo CD, Prometheus Operator, Gateway API) ships its CRDs.
- How it works
kubectl get crd;kubectl explain <type>works when a schema is provided. CRDs are cluster-wide.- Limits and pitfalls
- Deleting a CRD deletes every object of that type. Helm does not upgrade the CRDs in a chart's
crds/folder: manage them separately. - Exam tip
- "Understand CRDs, and install and configure operators" is in the CKA curriculum.
OperatorsA custom controller plus CRDs that automate running a piece of software: install, upgrade, backup, failover.
- Why it exists
- Encode an administrator's know-how (for a database, for Kafka...) in code that runs in a loop.
- In real companies
- CloudNativePG (PostgreSQL), Strimzi (Kafka), cert-manager, Prometheus Operator, External Secrets.
- How it works
- Usually installed with Helm; you then create objects of the type it provides (
kind: Clusterfor CloudNativePG). - Limits and pitfalls
- An operator is critical software you must upgrade and monitor: if it stops, nothing is managed any more.
OwnerReferences, finalizers, garbage collectionAn OwnerReference links a parent to a child (Deployment → ReplicaSet → Pod). A finalizer is a marker that delays deletion until some clean-up is done.
- Why it exists
- Delete in cascade cleanly, and let controllers clean up external resources (load balancer, disk).
- In real companies
- A namespace stuck in Terminating for hours: often a finalizer of a controller that was uninstalled.
- How it works
metadata.ownerReferences,metadata.finalizers.kubectl delete --cascade=orphankeeps the children.- Limits and pitfalls
- Removing a finalizer by hand skips the clean-up: an external resource may be left orphaned.
Lab 1: deploy with plain YAML and kubectl apply
- deploy an application with every object a production app should have, written by hand;
- observe a rollout, call the app from inside the cluster, and prove the NetworkPolicy works;
- update the image and roll back, and explain what happened to the ReplicaSets.
The goal: deploy the kubelab API in raw YAML, so you understand every piece before Helm generates them for you. You created the cluster and loaded the images in the first station; if not, do that now.
Step 1: read the manifests
| File | Object | Good practice it shows |
|---|---|---|
00-namespace.yaml | Namespace | Pod Security restricted enforced |
01-configmap.yaml | ConfigMap | Configuration as a variable and as a file |
02-secret.yaml | Secret | Teaching example only: in real life, External Secrets |
03-serviceaccount.yaml | ServiceAccount | A dedicated identity, no token mounted |
04-deployment.yaml | Deployment | Three probes, requests and limits, a full securityContext, topology spread, a rollout that never loses capacity, the Downward API |
05-service.yaml | Service | targetPort by name |
06-pdb.yaml, 07-hpa.yaml | PDB, HPA | Availability during drains, elasticity |
08-networkpolicy.yaml | NetworkPolicy | Default deny, then a targeted opening |
09-quota-limitrange.yaml | ResourceQuota, LimitRange | Namespace guardrails |
10-httproute.yaml | HTTPRoute | Gateway API exposure (needs a Gateway controller, so it is left out of the kustomization) |
kustomization.yaml | Kustomize | One kubectl apply -k for everything |
The Deployment is the file worth reading line by line:
apiVersion: apps/v1 kind: Deployment metadata: name: kubelab namespace: kubelab labels: app.kubernetes.io/name: kubelab app.kubernetes.io/version: "1.0.0" app.kubernetes.io/part-of: k8s-helm-lab spec: replicas: 3 revisionHistoryLimit: 5 selector: matchLabels: # immutable after creation: must match the template labels app.kubernetes.io/name: kubelab strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 # never fewer than 3 ready pods during an update maxSurge: 1 template: metadata: labels: app.kubernetes.io/name: kubelab app.kubernetes.io/version: "1.0.0" spec: serviceAccountName: kubelab automountServiceAccountToken: false terminationGracePeriodSeconds: 30 securityContext: runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 fsGroup: 65532 seccompProfile: type: RuntimeDefault topologySpreadConstraints: # spread the replicas across nodes - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app.kubernetes.io/name: kubelab containers: - name: kubelab image: kubelab:1.0.0 # loaded into kind; in a company: private registry + immutable tag or digest imagePullPolicy: IfNotPresent ports: - name: http containerPort: 8080 env: - name: MESSAGE valueFrom: configMapKeyRef: name: kubelab-config key: MESSAGE - name: DATABASE_URL valueFrom: secretKeyRef: name: kubelab-secret key: DATABASE_URL - name: CONFIG_FILE value: /etc/kubelab/app.properties - name: POD_NAME # Downward API: the pod knows its name and node valueFrom: fieldRef: fieldPath: metadata.name - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName resources: requests: # what the scheduler reserves cpu: 50m memory: 32Mi limits: # ceiling: above the memory limit, OOMKilled memory: 128Mi startupProbe: httpGet: {path: /healthz, port: http} periodSeconds: 2 failureThreshold: 30 readinessProbe: # removes the pod from the Service when it fails httpGet: {path: /readyz, port: http} periodSeconds: 5 livenessProbe: # restarts the container when it fails httpGet: {path: /healthz, port: http} periodSeconds: 10 failureThreshold: 3 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] volumeMounts: - name: config mountPath: /etc/kubelab readOnly: true volumes: - name: config configMap: name: kubelab-config items: - key: app.properties path: app.properties
- selector.matchLabels
- Immutable. It must match the template's labels or the API refuses the Deployment. Never put the version in it.
- strategy
maxUnavailable: 0+maxSurge: 1: a new pod must be Ready before an old one leaves.- securityContext
- Non-root, no privilege escalation, read-only root file system, no Linux capabilities, seccomp: compliant with the restricted profile, and stricter than it requires.
- topologySpreadConstraints
- Spreads the 3 replicas over the nodes: losing one node does not take the service down.
- env valueFrom
- ConfigMap, Secret and Downward API: the image itself contains no configuration.
- resources
- Requests for the scheduler and the HPA; a memory limit only (no CPU limit, to avoid throttling).
- startup, readiness, liveness
- Three probes, three jobs: let it start, decide when it gets traffic, restart it if it hangs.
- volumeMounts
- The ConfigMap mounted as a read-only file.
Step 2: apply and watch
$ kubectl apply -k manifests/ $ kubectl -n kubelab rollout status deploy/kubelab --timeout=120s $ kubectl -n kubelab get deploy,rs,svc,endpointslices,pdb,hpa,networkpolicy,resourcequota
$ kubectl apply -k manifests/ namespace/kubelab created1 resourcequota/kubelab-quota created serviceaccount/kubelab created configmap/kubelab-config created secret/kubelab-secret created service/kubelab created limitrange/kubelab-defaults created deployment.apps/kubelab created2 poddisruptionbudget.policy/kubelab created horizontalpodautoscaler.autoscaling/kubelab created networkpolicy.networking.k8s.io/allow-http-to-kubelab created networkpolicy.networking.k8s.io/default-deny-ingress created3
- Order. The namespace first, then everything that lives in it: Kustomize sorts by kind.
- Deployment. The controller now creates a ReplicaSet, which creates the pods.
- NetworkPolicy. Two policies: deny everything, then allow the clients.
$ kubectl -n kubelab rollout status deploy/kubelab --timeout=120s Waiting for deployment "kubelab" rollout to finish: 1 of 3 updated replicas are available...1 Waiting for deployment "kubelab" rollout to finish: 2 of 3 updated replicas are available... deployment "kubelab" successfully rolled out2 $ kubectl -n kubelab get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES kubelab-6b777469b9-hwdlc 1/1 Running 0 3s 10.244.1.3 lab-worker2 <none> <none>3 kubelab-6b777469b9-jtkqn 1/1 Running 0 3s 10.244.2.2 lab-worker <none> <none> kubelab-6b777469b9-p22xl 1/1 Running 0 3s 10.244.2.3 lab-worker <none> <none>
- Progress.
rollout statuswaits until all 3 pods are available (probes passing). - Success. Exit code 0: you can use this command as a gate in CI.
- Placement. topologySpreadConstraints spread the pods over both workers; none on the control plane (taint).
$ kubectl -n kubelab get deploy,rs,svc,endpointslices,pdb,hpa,networkpolicy,resourcequota NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/kubelab 3/3 3 3 3s NAME DESIRED CURRENT READY AGE replicaset.apps/kubelab-6b777469b9 3 3 3 3s1 NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kubelab ClusterIP 10.96.10.133 <none> 80/TCP 3s2 NAME ADDRESSTYPE PORTS ENDPOINTS AGE endpointslice.discovery.k8s.io/kubelab-klsxs IPv4 8080 10.244.1.3,10.244.2.2,10.244.2.3 3s3 NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE4 poddisruptionbudget.policy/kubelab 2 N/A 1 3s NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE horizontalpodautoscaler.autoscaling/kubelab Deployment/kubelab cpu: <unknown>/70% 3 6 3 3s5 NAME POD-SELECTOR AGE networkpolicy.networking.k8s.io/allow-http-to-kubelab app.kubernetes.io/name=kubelab 3s networkpolicy.networking.k8s.io/default-deny-ingress <none> 3s NAME REQUEST LIMIT AGE resourcequota/kubelab-quota persistentvolumeclaims: 0/5, pods: 3/20, requests.cpu: 150m/2, requests.memory: 96Mi/2Gi limits.memory: 384Mi/4Gi 3s6
- ReplicaSet. Created by the Deployment; the suffix is a hash of the pod template.
- Service. A stable virtual IP (10.96.x.x) on port 80.
- EndpointSlice. The three IPs of the ready pods behind the Service.
- PDB. 3 pods, minimum 2: one voluntary eviction allowed at a time.
- HPA.
<unknown>for the first seconds, until metrics-server reports measurements. - Quota. Namespace consumption: 3 pods x 50m of CPU requests.
Step 3: call the API from inside the cluster
The NetworkPolicy only lets in pods labelled role: client. The smoke-test Job carries that label and calls the Service by its DNS name; the check passes only if the answer contains "Bonjour":
$ kubectl apply -f workloads/job-cronjob.yaml job.batch/smoke-test created cronjob.batch/nightly-check created $ kubectl -n kubelab wait --for=condition=complete job/smoke-test --timeout=120s job.batch/smoke-test condition met $ kubectl -n kubelab get jobs,cronjobs NAME STATUS COMPLETIONS DURATION AGE job.batch/smoke-test Complete 1/1 3s 3s1 NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE cronjob.batch/nightly-check 0 3 * * * Europe/Paris False 0 <none> 3s2 $ kubectl -n kubelab logs job/smoke-test {"time":"2026-10-19T15:55:34.353214468Z","level":"INFO","msg":"check OK","url":"http://kubelab.kubelab.svc.cluster.local/","status":200,"body":"{\"message\":\"Bonjour depuis Kubernetes\",\"node\":\"lab-worker\",\"pod\":\"kubelab-6b777469b9-5ws4r\",\"version\":\"1.0.0\",\"visits\":305}"}3
- Job finished. COMPLETIONS 1/1: the pod exited with code 0.
- CronJob. Scheduled at 3 a.m. Paris time (the
timeZonefield). - Answer. The message comes from the ConfigMap, the pod and node names from the Downward API.
Now the proof that the firewall works: two identical pods, except that only one has the label.
# Two identical test pods, except for the label role: client. # With the lab NetworkPolicy, only probe-allowed may reach the kubelab Service. apiVersion: v1 kind: Pod metadata: name: probe-allowed namespace: kubelab labels: {role: client} spec: restartPolicy: Never securityContext: {runAsNonRoot: true, runAsUser: 65532, seccompProfile: {type: RuntimeDefault}} containers: - name: check image: kubelab:1.0.0 args: ["check", "http://kubelab/healthz"] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}} securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}} --- apiVersion: v1 kind: Pod metadata: name: probe-denied namespace: kubelab spec: restartPolicy: Never securityContext: {runAsNonRoot: true, runAsUser: 65532, seccompProfile: {type: RuntimeDefault}} containers: - name: check image: kubelab:1.0.0 args: ["check", "http://kubelab/healthz"] resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}} securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
$ kubectl apply -f workloads/netpol-test.yaml pod/probe-allowed created pod/probe-denied created $ kubectl -n kubelab wait --for=jsonpath='{.status.phase}'=Succeeded pod/probe-allowed --timeout=120s pod/probe-allowed condition met1 $ kubectl -n kubelab wait --for=jsonpath='{.status.phase}'=Failed pod/probe-denied --timeout=240s pod/probe-denied condition met2 $ kubectl -n kubelab logs probe-allowed {"time":"2026-10-19T15:59:34.888417485Z","level":"INFO","msg":"check OK","url":"http://kubelab/healthz","status":200,"body":"ok"} $ kubectl -n kubelab logs probe-denied {"time":"2026-10-19T16:00:43.914955553Z","level":"ERROR","msg":"check KO","url":"http://kubelab/healthz","err":"Get \"http://kubelab/healthz\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"}3
- Allowed. probe-allowed carries
role: client, so theallow-http-to-kubelabrule lets it in. - Denied. probe-denied ends in Failed: default-deny blocks the connection.
- Typical symptom. A packet dropped by a NetworkPolicy shows up as a timeout, not as an immediate refusal.
This one comes from the repository's CI (kind on a GitHub-hosted runner), which checks this behaviour on every push. The kernel of the sandbox used for the other captures did not let kindnet enforce network policies. On your laptop, recent kind versions enforce them.
Step 4: update, then roll back
$ kubectl -n kubelab set image deploy/kubelab kubelab=kubelab:1.1.0 $ kubectl -n kubelab rollout status deploy/kubelab --timeout=180s $ kubectl -n kubelab rollout undo deploy/kubelab
$ kubectl -n kubelab set image deploy/kubelab kubelab=kubelab:1.1.0 deployment.apps/kubelab image updated1 $ kubectl -n kubelab rollout status deploy/kubelab --timeout=180s Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated...2 Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 1 old replicas are pending termination... Waiting for deployment "kubelab" rollout to finish: 1 old replicas are pending termination... Waiting for deployment "kubelab" rollout to finish: 1 old replicas are pending termination... deployment "kubelab" successfully rolled out $ kubectl -n kubelab get rs -o wide NAME DESIRED CURRENT READY AGE CONTAINERS IMAGES SELECTOR kubelab-6b777469b9 0 0 0 117s kubelab kubelab:1.0.0 app.kubernetes.io/name=kubelab,pod-template-hash=6b777469b93 kubelab-9bc495dc8 3 3 3 9s kubelab kubelab:1.1.0 app.kubernetes.io/name=kubelab,pod-template-hash=9bc495dc84 $ kubectl -n kubelab rollout history deploy/kubelab deployment.apps/kubelab REVISION CHANGE-CAUSE5 1 <none> 2 <none>
- Trigger. Changing the pod template (here the image) creates a new revision.
- One at a time. maxSurge 1, maxUnavailable 0: one new pod, it becomes Ready, one old pod leaves.
- Old ReplicaSet. Kept at 0 replicas so you can roll back.
- New ReplicaSet. The three pods now run 1.1.0.
- History. CHANGE-CAUSE is empty: fill it with the
kubernetes.io/change-causeannotation.
$ kubectl -n kubelab rollout undo deploy/kubelab deployment.apps/kubelab rolled back1 Warning: resource deployments/kubelab was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.2 $ kubectl -n kubelab rollout status deploy/kubelab --timeout=180s Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 1 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 2 out of 3 new replicas have been updated... Waiting for deployment "kubelab" rollout to finish: 1 old replicas are pending termination... Waiting for deployment "kubelab" rollout to finish: 1 old replicas are pending termination... deployment "kubelab" successfully rolled out $ kubectl -n kubelab get rs NAME DESIRED CURRENT READY AGE kubelab-6b777469b9 3 3 3 2m6s3 kubelab-9bc495dc8 0 0 0 18s $ kubectl -n kubelab get deploy kubelab -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}' kubelab:1.0.04
- undo. The Deployment takes the pod template of the previous revision again.
- Warning. Your YAML file still says 1.0.0... but the next
applywill overwrite the live state. In production, fix it in git. - Switch. The old ReplicaSet goes back to 3 and the new one to 0: nothing is rebuilt.
- Check. The running image is 1.0.0 again.
Changing the image created a new ReplicaSet; the old one stayed at 0 replicas, ready for a rollback. kubectl rollout undo is instant because nothing is rebuilt: Kubernetes just moves the replicas from one ReplicaSet to the other. Note kubectl's warning, though: in production you would fix git, not the cluster.
Hands-onYour lab 1 checklist
-
kubectl apply -k manifests/, thenrollout statusends with "successfully rolled out". - The EndpointSlice lists 3 pod IPs and the PDB allows 1 disruption.
-
kubectl apply -f workloads/job-cronjob.yaml; the smoke-test Job is Complete. -
kubectl apply -f workloads/netpol-test.yaml: probe-allowed succeeds, probe-denied fails after about a minute. - Also apply
workloads/statefulset.yaml,workloads/rbac-developer.yaml(test withauth can-i) andworkloads/psa-test.yaml(it must be refused). -
set imageto 1.1.0, watch the ReplicaSets, thenrollout undo.
Helm: why, and how it works
- say which three problems Helm solves that plain YAML does not;
- explain charts, values, templates, releases, revisions, hooks and tests;
- use Helm 4 correctly, including its renamed flags.
Lab 1 needed eleven files for one small app, with the same names and labels repeated everywhere, nothing to tell you what is installed, and no clean way to roll the whole app back. Helm solves those three problems: it parameterises (values), it packages (a versioned chart, published to an OCI registry), and it tracks (a release with revisions, upgrades, rollbacks, hooks and tests).
A chart is a recipe with blanks ("salt: to taste"). Values fill the blanks for tonight's guests. A release is the meal actually served at table 4, and Helm keeps a notebook of every version of that meal it served, so it can serve the previous one again.
| Criterion | kubectl apply (plain YAML) | Kustomize | Helm |
|---|---|---|---|
| Parameters | Copy and paste | Overlays and patches | Values and templates |
| Versioned, shareable package | No | No (a git folder) | Yes (chart, OCI) |
| Knows what is installed | No | No | Release and revisions |
| Rollback | Per Deployment only | By git revert | helm rollback of the whole app |
| Hooks (migrations), tests | No | No | Yes |
| Third-party software | Rarely provided | Sometimes | The market standard |
| Readability | Highest | Good | Depends on the chart |
Helm 4 in one table
Helm 4 was released in November 2025; Helm 3 receives security fixes until February 2027. Charts written for Helm 3 (apiVersion: v2) work unchanged, but pipelines need a look:
| Helm 3 | Helm 4 | What it means for you |
|---|---|---|
| Client-side three-way merge | --server-side=true by default on install; auto on upgrade | The API server tracks field ownership; conflicts with other managers become visible |
--wait with built-in checks | --wait = watcher strategy (kstatus) | More accurate readiness for all kinds, including custom resources |
--atomic | --rollback-on-failure | Rename it in your pipelines |
--force | --force-replace | Same: rename it |
| Unsigned plugins | Plugins verified by signature (new system, WASM support) | Install from published archives; --verify=false only in a lab |
Helm chartA package for a Kubernetes application: YAML templates, default values and metadata, versioned together.
- Why it exists
- Install a complete application (Deployment, Service, RBAC, HPA...) with one command, configurable per environment.
- In real companies
- Almost all third-party software (Prometheus, cert-manager, Argo CD, databases) installs from a chart; teams also package their own apps as internal charts pushed to an OCI registry.
- How it works
- Layout:
Chart.yaml(chartversion,appVersion),values.yaml,values.schema.json,templates/(with_helpers.tpl,NOTES.txt,tests/),charts/(dependencies),crds/. - Limits and pitfalls
version(of the chart) andappVersion(of the app) are two different things: bumpversionon every chart change.
Releases and revisionsA named installation of a chart in a namespace. Every install, upgrade or rollback creates a revision.
- Why it exists
- Know what is deployed, with which values, and go back.
- In real companies
- One release per app and environment (kubelab-staging, kubelab-prod), often driven by Argo CD or Flux rather than by hand.
- How it works
helm list -A,helm status,helm history,helm get values|manifest|notes. State is stored in Secrets namedsh.helm.release.v1.<name>.v<N>in the release namespace.- Limits and pitfalls
- Deleting those Secrets makes Helm "forget" the release without deleting the objects. A release stuck in
pending-upgradeblocks every new upgrade (helm rollbackunblocks it).
ValuesThe parameters of a chart: defaults (values.yaml) overridden by files (-f) or --set.
- Why it exists
- One chart, many environments.
- In real companies
values.yaml(safe defaults) +values-staging.yaml+values-prod.yamlversioned in git; never secrets in them.- How it works
- Priority:
values.yaml<-f file1<-f file2<--set.helm get values <release> --allshows the result.values.schema.jsonvalidates types and required fields. - Limits and pitfalls
--setwith lists or dots in keys is tricky (--set-string,--set-json).--reuse-valuesreuses old values and ignores new defaults: prefer complete files.
Templates (Go templates + Sprig)The YAML files in templates/ contain {{ }} expressions evaluated with .Values, .Release, .Chart, .Capabilities.
- Why it exists
- Generate manifests that adapt to the values: conditions, loops, defaults.
- In real companies
- Always check the rendered output in CI (
helm template | kubeconform) before installing. - How it works
{{ .Values.x }},if/else,range,with,include,toYaml,nindent,default,required,quote,b64enc,sha256sum. A dash ({{-) trims whitespace.helm templateandhelm install --dry-run=servershow the result.- Limits and pitfalls
- YAML indentation is the first source of errors:
nindentis your friend. A template that is too clever becomes unreadable: keep charts simple.
_helpers.tpl and standard labelsA file of reusable functions (define/include): full name, labels, selector, ServiceAccount name, image.
- Why it exists
- Consistent names and labels across every resource of the chart.
- In real companies
app.kubernetes.io/*labels everywhere; selectors limited to name + instance (both stable).- How it works
{{- define "kubelab.labels" -}} ... {{- end }}, then{{- include "kubelab.labels" . | nindent 4 }}.- Limits and pitfalls
- Never put the version in the selector labels: a Deployment's selector is immutable, so the upgrade would fail.
HooksResources (often Jobs) run at a precise moment of the lifecycle: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, pre-rollback, test.
- Why it exists
- Migrate a database before the new pods, prepare or clean up.
- In real companies
- The kubelab chart runs a migration Job as a pre-install/pre-upgrade hook: if the migration fails, the new pods are not rolled out.
- How it works
- Annotations
helm.sh/hook,helm.sh/hook-weight(order),helm.sh/hook-delete-policy(before-hook-creation, hook-succeeded, hook-failed). - Limits and pitfalls
- Hooks are not part of the release:
helm uninstalldoes not delete them unless a delete policy does. Argo CD maps them to its own hooks (check the behaviour).
helm testPods annotated helm.sh/hook: test, run on demand to check that a release works.
- Why it exists
- A standard smoke test right after installing.
- In real companies
- In CI after
helm upgrade --wait:helm test --logs; a failure raises an alert or triggers a rollback. - How it works
templates/tests/*.yaml;helm test <release> -n <ns> --logs.- Limits and pitfalls
- It is not a functional test suite: keep the test short and reliable. Keep the test pod (no
hook-succeededdelete policy) if you want--logsto find it.
Dependencies (subcharts)Charts included in another one through the dependencies section of Chart.yaml.
- Why it exists
- Ship an app with its components (a cache, a dev database) or build an "umbrella" chart.
- In real companies
- An umbrella chart that assembles a platform's services;
condition: redis.enabledto switch one on. - How it works
dependencies: name, version, repository (https://oroci://), condition.helm dependency updatedownloads them intocharts/and writesChart.lock. A subchart's values live under its key (redis.auth...).- Limits and pitfalls
- A production database should not be a subchart of the app (its lifecycle is different).
Chart repositories and OCI registriesWhere charts come from: classic HTTP repositories (index.yaml) or OCI registries (the same ones as for images).
- Why it exists
- Distribute and version charts like images.
- In real companies
- OCI is now the standard: internal charts pushed to Harbor, GitLab, GHCR, ECR, ACR, Artifact Registry, with the same permissions and scanning as images.
- How it works
helm package chart/→helm push kubelab-0.1.0.tgz oci://registry/charts→helm install x oci://registry/charts/kubelab --version 0.1.0. HTTP repos:helm repo add,helm repo update,helm search repo.- Limits and pitfalls
- Always pin
--version: without it Helm takes the latest published version.
helm upgrade, --install, --wait, rollbackupgrade applies a new chart version or new values; rollback goes back to a revision.
- Why it exists
- Deploy repeatably and go back with one command.
- In real companies
- In CI/CD:
helm upgrade --install x chart -f values-prod.yaml --wait --timeout 5m --rollback-on-failure(Helm 3's old--atomic);helm diff upgradeearlier, in the pull request. - How it works
--waitwaits for resources to be ready (Helm 4 relies on kstatus).helm rollback x 3.helm historyto choose the revision.- Limits and pitfalls
- Without
--wait, Helm reports success as soon as the API accepted the objects, even if the pods crash a minute later.
The helm-diff pluginA plugin that shows the difference between the installed release and what an upgrade would apply.
- Why it exists
- Review an infrastructure change like a code diff.
- In real companies
- A mandatory step of manual deployment pipelines; Helmfile and Argo CD offer the same view.
- How it works
- With Helm 4, plugins are verified by signature: install from the published archive (
helm plugin install https://github.com/databus23/helm-diff/releases/latest/download/helm-diff-linux-amd64.tgz, maintainer key imported) or, in a lab only, from the git URL with--verify=false. Thenhelm diff upgrade x chart -f values-prod.yaml. - Limits and pitfalls
- Hide Secrets in CI output (
--suppress-secrets).
Helmfile and GitOpsHelmfile describes a set of releases and their values in YAML; Argo CD and Flux deploy charts from git continuously.
- Why it exists
- Stop running helm by hand: the desired state of a whole cluster lives in git.
- In real companies
- Company platforms: an Argo CD Application (OCI chart + values) or a Flux HelmRelease; Helmfile for teams without GitOps.
- How it works
- Argo CD renders the chart (
helm template), applies it and watches for drift; Flux uses the Helm SDK and keeps a real release. - Limits and pitfalls
- With Argo CD,
helm listdoes not show the release (no Helm Secret): Argo CD is the source of truth.
Helm 4The major version released in November 2025, succeeding Helm 3 (which gets security fixes until February 2027).
- Why it exists
- Modernize Helm: Server-Side Apply by default for new installs, readiness waiting through kstatus, a new plugin system (WASM), content-based chart cache.
- In real companies
- Helm 3 charts (
apiVersion: v2) work as they are. Migrate pipelines (renamed flags, plugins) before Helm 3 support ends. - How it works
helm version→ v4.x (the lab uses 4.2.3).install:--server-side=trueby default;upgrade:--server-side=auto(keeps the release's method).--waitalone = watcher strategy (kstatus); without--wait, Helm only waits for hooks.--atomicbecomes--rollback-on-failure,--forcebecomes--force-replace.- Limits and pitfalls
- Check the compatibility of plugins and of tools that embed the Helm SDK (Argo CD, Flux, the Terraform helm provider).
Lab 2: the same app as a Helm chart
- read a production-grade chart and explain every helper;
- validate, install, test, upgrade and roll back a release;
- package a chart and push it to an OCI registry.
| Chart file | Role |
|---|---|
Chart.yaml | Name, chart version (0.1.0), appVersion (1.0.0, the default image tag), minimum kubeVersion |
values.yaml | Safe defaults: 2 replicas, securityContext, probes, PDB, migrations on |
values-prod.yaml | Production: 3 replicas, HPA, external Secret, NetworkPolicy, HTTPRoute |
values.schema.json | Refuses invalid values before anything is rendered |
templates/_helpers.tpl | Names, standard labels, image (digest wins over tag) |
templates/deployment.yaml | The checksum/config annotation: a ConfigMap change rolls the pods |
templates/migration-job.yaml | pre-install / pre-upgrade hook |
templates/tests/test-connection.yaml | The test run by helm test |
templates/NOTES.txt | The message printed after install |
apiVersion: v2 name: kubelab description: The kubelab API packaged with enterprise best practices (probes, security, HPA, PDB, NetworkPolicy, hooks, tests). type: application version: 0.1.0 # version of the CHART (bump it on every chart change) appVersion: "1.0.0" # version of the APPLICATION (default image tag) kubeVersion: ">=1.29.0-0" keywords: [kubernetes, helm, lab] maintainers: - name: Georges MOMO annotations: artifacthub.io/license: MIT
{{/* Short app name (truncated to 63 characters: the DNS limit for Kubernetes names). */}} {{- define "kubelab.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Full name: <release>-<chart>, unless the release name already contains the chart name. */}} {{- define "kubelab.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{- define "kubelab.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Recommended labels (app.kubernetes.io/*) set on every resource. */}} {{- define "kubelab.labels" -}} helm.sh/chart: {{ include "kubelab.chart" . }} {{ include "kubelab.selectorLabels" . }} app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/part-of: k8s-helm-lab {{- end }} {{/* Selector labels: stable, never put the version here (a Deployment selector is immutable). */}} {{- define "kubelab.selectorLabels" -}} app.kubernetes.io/name: {{ include "kubelab.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{- define "kubelab.serviceAccountName" -}} {{- if .Values.serviceAccount.create }} {{- default (include "kubelab.fullname" .) .Values.serviceAccount.name }} {{- else }} {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} {{- define "kubelab.secretName" -}} {{- if .Values.secret.existingSecret }}{{ .Values.secret.existingSecret }}{{ else }}{{ include "kubelab.fullname" . }}{{ end }} {{- end }} {{/* Image: the digest, when provided, wins over the tag (immutable deployment). */}} {{- define "kubelab.image" -}} {{- if .Values.image.digest }} {{- printf "%s@%s" .Values.image.repository .Values.image.digest }} {{- else }} {{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }} {{- end }} {{- end }}
Why two sets of labels? kubelab.labels, set on every object, contains the version, so it changes with each release. kubelab.selectorLabels, used by the Deployment and Service selectors, only contains the name and the instance, so it never changes: a Deployment's selector is immutable.
Why trunc 63? Kubernetes names follow DNS rules: 63 characters at most.
Why the digest? When image.digest is set, the chart deploys kubelab@sha256:...: exactly the image your CI scanned and signed, even if someone moves the tag.
# Default values. Override per environment: -f values-prod.yaml or --set key=value. replicaCount: 2 image: repository: kubelab tag: "" # empty = .Chart.AppVersion pullPolicy: IfNotPresent digest: "" # e.g. sha256:... (wins over the tag when set) imagePullSecrets: [] nameOverride: "" fullnameOverride: "" message: "Bonjour depuis Helm" config: app.properties: | log.level=info feature.newCheckout=false # Secret: either created by the chart (dev) or pre-existing (prod, managed by External Secrets). secret: create: true existingSecret: "" databaseUrl: "postgres://kubelab:change-me@db:5432/kubelab" serviceAccount: create: true name: "" annotations: {} # e.g. IRSA / Workload Identity: cloud role bound to the ServiceAccount automountToken: false podAnnotations: {} podLabels: {} podSecurityContext: runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 fsGroup: 65532 seccompProfile: type: RuntimeDefault securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] service: type: ClusterIP port: 80 resources: requests: cpu: 50m memory: 32Mi limits: memory: 128Mi probes: startup: {path: /healthz, periodSeconds: 2, failureThreshold: 30} readiness: {path: /readyz, periodSeconds: 5} liveness: {path: /healthz, periodSeconds: 10, failureThreshold: 3} autoscaling: enabled: false minReplicas: 2 maxReplicas: 6 targetCPUUtilizationPercentage: 70 pdb: enabled: true minAvailable: 1 networkPolicy: enabled: false allowFromNamespaces: [] # e.g. [gateway-system] httpRoute: enabled: false gateway: {name: public, namespace: gateway-system} hostnames: [] migrations: enabled: true # pre-install/pre-upgrade hook: migration Job before the new pods nodeSelector: {} tolerations: [] affinity: {} topologySpread: true
# Production overrides: more replicas, autoscaling, external secret, HTTP exposure. replicaCount: 3 message: "kubelab en production" secret: create: false existingSecret: kubelab-db # created by External Secrets Operator autoscaling: enabled: true minReplicas: 3 maxReplicas: 10 pdb: minAvailable: 2 networkPolicy: enabled: true allowFromNamespaces: [gateway-system] httpRoute: enabled: true hostnames: ["kubelab.example.com"] resources: requests: {cpu: 100m, memory: 64Mi} limits: {memory: 256Mi}
Step 1: validate without installing
$ helm lint chart/kubelab ==> Linting chart/kubelab [INFO] Chart.yaml: icon is recommended 1 chart(s) linted, 0 chart(s) failed1 $ helm lint chart/kubelab --set image.digest=sha256:abc ==> Linting chart/kubelab [INFO] Chart.yaml: icon is recommended [ERROR] values.yaml: - at '/image/digest': 'sha256:abc' does not match pattern '^(sha256:[a-f0-9]{64})?$'2 [ERROR] templates/: values don't meet the specifications of the schema(s) in the following chart(s): kubelab: - at '/image/digest': 'sha256:abc' does not match pattern '^(sha256:[a-f0-9]{64})?$' Error: 1 chart(s) linted, 1 chart(s) failed
- Lint passes. Structure, templates and rendering are valid.
- Schema.
values.schema.jsonrejects a malformed digest before anything is deployed.
$ helm template demo chart/kubelab -n kubelab-helm -s templates/deployment.yaml | head -45 --- # Source: kubelab/templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: demo-kubelab1 labels: helm.sh/chart: kubelab-0.1.0 app.kubernetes.io/name: kubelab app.kubernetes.io/instance: demo app.kubernetes.io/version: "1.0.0" app.kubernetes.io/managed-by: Helm2 app.kubernetes.io/part-of: k8s-helm-lab spec: replicas: 2 revisionHistoryLimit: 5 selector: matchLabels:3 app.kubernetes.io/name: kubelab app.kubernetes.io/instance: demo strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 template: metadata: annotations: # Change quand la ConfigMap change : force un rolling update (sinon les pods gardent l'ancienne config). checksum/config: 7510d67ee6d29dc984ee7d962aecdb70cfb23ac984939e91e9336fdf630184994 labels: helm.sh/chart: kubelab-0.1.0 app.kubernetes.io/name: kubelab app.kubernetes.io/instance: demo app.kubernetes.io/version: "1.0.0" app.kubernetes.io/managed-by: Helm app.kubernetes.io/part-of: k8s-helm-lab spec: serviceAccountName: demo-kubelab automountServiceAccountToken: false securityContext: fsGroup: 65532 runAsGroup: 65532 runAsNonRoot: true runAsUser: 65532
- Full name.
<release>-<chart>, built bykubelab.fullname. - Standard labels. Generated by
kubelab.labels. - Stable selector. Only name and instance: never the version.
- Checksum. A hash of the rendered ConfigMap: it changes when the configuration changes. (The comment above it was still in French when this was captured.)
Step 2: install and test
$ helm install demo chart/kubelab -n kubelab-helm --create-namespace --wait --timeout 3m $ helm test demo -n kubelab-helm --logs
$ helm install demo chart/kubelab -n kubelab-helm --create-namespace --wait --timeout 3m NAME: demo LAST DEPLOYED: Mon Oct 19 17:40:23 2026 NAMESPACE: kubelab-helm STATUS: deployed1 REVISION: 12 DESCRIPTION: Install complete NOTES:3 kubelab 1.0.0 déployé dans le namespace kubelab-helm (release « demo », révision 1). 1. Suivre le déploiement : kubectl -n kubelab-helm rollout status deploy/demo-kubelab 2. Tester en local : kubectl -n kubelab-helm port-forward svc/demo-kubelab 8080:80 curl http://localhost:8080/ 3. Lancer les tests du chart : helm test demo -n kubelab-helm
- Status.
deployed:--waitwaited until the resources were ready. - Revision. First revision, stored in a Secret of the namespace.
- NOTES.txt. The chart's message, rendered with the real values (namespace, release). The lab chart writes it in French: it tells you how to follow the rollout, port-forward and run the tests.
$ helm test demo -n kubelab-helm --logs NAME: demo LAST DEPLOYED: Mon Oct 19 17:40:23 2026 NAMESPACE: kubelab-helm STATUS: deployed REVISION: 1 DESCRIPTION: Install complete TEST SUITE: demo-kubelab-test Last Started: Mon Oct 19 17:40:35 2026 Last Completed: Mon Oct 19 17:40:39 2026 Phase: Succeeded1 POD LOGS: demo-kubelab-test {"time":"2026-10-19T15:40:38.18790639Z","level":"INFO","msg":"check OK","url":"http://demo-kubelab:80/","status":200,"body":"{\"message\":\"Bonjour depuis Helm\",\"node\":\"lab-worker2\",\"pod\":\"demo-kubelab-5b77db878d-sm4bq\",\"version\":\"1.0.0\",\"visits\":3}"}2
- Result. The test pod finished successfully.
- Value. "Hello from Helm": the message comes from
values.yaml, different from lab 1.
$ helm list -n kubelab-helm NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION demo kubelab-helm 1 2026-10-19 17:40:23.462594653 +0200 CEST deployed kubelab-0.1.0 1.0.0 1 $ kubectl -n kubelab-helm get all -l app.kubernetes.io/instance=demo NAME READY STATUS RESTARTS AGE pod/demo-kubelab-5b77db878d-ffgjp 1/1 Running 0 9s pod/demo-kubelab-5b77db878d-sm4bq 1/1 Running 0 9s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/demo-kubelab ClusterIP 10.96.229.73 <none> 80/TCP 9s NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/demo-kubelab 2/2 2 2 9s2 NAME DESIRED CURRENT READY AGE replicaset.apps/demo-kubelab-5b77db878d 2 2 2 9s $ kubectl -n kubelab-helm get secrets -l owner=helm NAME TYPE DATA AGE sh.helm.release.v1.demo.v1 helm.sh/release.v1 1 16s3 $ helm get values demo -n kubelab-helm --all | head -12 COMPUTED VALUES:4 affinity: {} autoscaling: enabled: false maxReplicas: 6 minReplicas: 2 targetCPUUtilizationPercentage: 70 config: app.properties: | log.level=info feature.newCheckout=false fullnameOverride: ""
- helm list. Release, revision, chart and app version.
- Resources. 2 replicas (from values.yaml) instead of 3.
- Storage. One Secret per revision: this is Helm's memory.
- Effective values. Defaults plus overrides: what was really used.
The migration hook is an ordinary Job with three annotations. Helm runs it before installing or upgrading anything else, and waits for it:
{{- if .Values.migrations.enabled }} # Hook: Helm creates this Job BEFORE installing/upgrading the Deployment and waits for it to succeed. # If the migration fails, the release fails and the new pods are not rolled out. apiVersion: batch/v1 kind: Job metadata: name: {{ include "kubelab.fullname" . }}-migrate labels: {{- include "kubelab.labels" . | nindent 4 }} annotations: "helm.sh/hook": pre-install,pre-upgrade "helm.sh/hook-weight": "0" "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded spec: backoffLimit: 1 ttlSecondsAfterFinished: 300 template: metadata: labels: {{- include "kubelab.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: migration spec: restartPolicy: Never automountServiceAccountToken: false securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: migrate image: {{ include "kubelab.image" . | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: ["migrate"] env: - name: FROM_VERSION value: {{ .Release.Revision | quote }} resources: requests: {cpu: 10m, memory: 16Mi} limits: {memory: 64Mi} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} {{- end }}
$ kubectl -n kubelab-helm get events --field-selector involvedObject.kind=Job --sort-by=.lastTimestamp LAST SEEN TYPE REASON OBJECT MESSAGE 16m Normal SuccessfulCreate job/demo-kubelab-migrate Created pod: demo-kubelab-migrate-28mbs1 16m Normal Completed job/demo-kubelab-migrate Job completed 15m Normal SuccessfulCreate job/demo-kubelab-migrate Created pod: demo-kubelab-migrate-qhrlb 15m Normal Completed job/demo-kubelab-migrate Job completed
- Hook. A pre-install/pre-upgrade Job, deleted after success by its hook-delete-policy.
Step 3: upgrade, history, rollback
$ helm upgrade demo chart/kubelab -n kubelab-helm --set message="Version 2" --wait Release "demo" has been upgraded. Happy Helming!1 NAME: demo LAST DEPLOYED: Mon Oct 19 17:40:40 2026 NAMESPACE: kubelab-helm STATUS: deployed REVISION: 2 DESCRIPTION: Upgrade complete NOTES: kubelab 1.0.0 déployé dans le namespace kubelab-helm (release « demo », révision 2). 1. Suivre le déploiement : kubectl -n kubelab-helm rollout status deploy/demo-kubelab 2. Tester en local : kubectl -n kubelab-helm port-forward svc/demo-kubelab 8080:80 curl http://localhost:8080/ 3. Lancer les tests du chart : helm test demo -n kubelab-helm $ helm history demo -n kubelab-helm REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION 1 Mon Oct 19 17:40:23 2026 superseded kubelab-0.1.0 1.0.0 Install complete 2 Mon Oct 19 17:40:40 2026 deployed kubelab-0.1.0 1.0.0 Upgrade complete2 $ kubectl -n kubelab-helm get rs NAME DESIRED CURRENT READY AGE demo-kubelab-5b77db878d 0 0 0 24s demo-kubelab-76949d8956 2 2 2 6s3
- Upgrade. New revision 2.
- History. Revision 1 becomes
superseded. - New ReplicaSet. The ConfigMap changed, so the checksum changed, so new pods rolled out, with nothing else to do.
$ helm rollback demo 1 -n kubelab-helm --wait Rollback was a success! Happy Helming!1 $ helm history demo -n kubelab-helm REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION 1 Mon Oct 19 17:40:23 2026 superseded kubelab-0.1.0 1.0.0 Install complete 2 Mon Oct 19 17:40:40 2026 superseded kubelab-0.1.0 1.0.0 Upgrade complete 3 Mon Oct 19 17:40:55 2026 deployed kubelab-0.1.0 1.0.0 Rollback to 1 2 $ kubectl -n kubelab-helm get cm demo-kubelab -o jsonpath='{.data.MESSAGE}{"\n"}' Bonjour depuis Helm3
- Rollback. Every resource goes back to revision 1.
- New revision. Revision 3 is a copy of revision 1: history is never rewritten.
- Check. The ConfigMap has its original value again.
Step 4: package and publish to an OCI registry
$ helm package chart/kubelab -d /tmp Successfully packaged chart and saved it to: /tmp/kubelab-0.1.0.tgz1 $ helm push /tmp/kubelab-0.1.0.tgz oci://localhost:5001/charts --plain-http Pushed: localhost:5001/charts/kubelab:0.1.02 Digest: sha256:c2c9080d4a8d908bcf8b8f45e6631bd8912cfcac8f7d8503e44a104903ec8e3b3 $ helm show chart oci://localhost:5001/charts/kubelab --version 0.1.0 --plain-http Pulled: localhost:5001/charts/kubelab:0.1.0 Digest: sha256:c2c9080d4a8d908bcf8b8f45e6631bd8912cfcac8f7d8503e44a104903ec8e3b annotations: artifacthub.io/license: MIT apiVersion: v2 appVersion: 1.0.0 description: API kubelab packagée avec les bonnes pratiques d'entreprise (sondes, sécurité, HPA, PDB, NetworkPolicy, hooks, tests). keywords: - kubernetes - helm - lab kubeVersion: '>=1.29.0-0' maintainers: - name: Georges MOMO name: kubelab type: application version: 0.1.0
- Package. A versioned archive of the chart (version 0.1.0).
- OCI push. The same kind of registry as for images.
--plain-httponly because this local registry has no TLS. - Digest. You can install by digest for an immutable deployment.
CI runs helm lint, helm template | kubeconform, helm package and helm push oci://... on every tag. Deployment is then done by Argo CD, or by a job running helm upgrade --install --wait --rollback-on-failure, with one values file per environment versioned in git. This repository's own CI lints and renders the chart with both values files on every push.
Boss levelWrite a template yourself
Add an optional classic Ingress to the chart: ingress.enabled, ingress.className, ingress.host (mandatory when enabled) and ingress.tlsSecret, with the host protected by the JSON schema. Prove it with helm lint and helm template.
Show a hint
Copy the shape of httproute.yaml: an if around the whole file, include "kubelab.fullname" for the name, include "kubelab.labels" . | nindent 4 for the labels. Use with for optional fields and required for the host. Inside a with block, reach the root context with $.
Show the solution
{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "kubelab.fullname" . }} labels: {{- include "kubelab.labels" . | nindent 4 }} spec: {{- with .Values.ingress.className }} ingressClassName: {{ . }} {{- end }} {{- with .Values.ingress.tlsSecret }} tls: - hosts: [{{ $.Values.ingress.host | quote }}] secretName: {{ . }} {{- end }} rules: - host: {{ required "ingress.host is required" .Values.ingress.host | quote }} http: paths: - path: / pathType: Prefix backend: service: name: {{ include "kubelab.fullname" . }} port: number: {{ .Values.service.port }} {{- end }}
# values.yaml ingress: enabled: false className: "" # e.g. traefik host: "" # e.g. kubelab.example.com tlsSecret: "" # a kubernetes.io/tls Secret, e.g. issued by cert-manager # values.schema.json, inside "properties" "ingress": {"type": "object", "properties": { "enabled": {"type": "boolean"}, "className": {"type": "string"}, "tlsSecret": {"type": "string"}, "host": {"type": "string", "pattern": "^([a-z0-9-]+\\.)*[a-z0-9-]*$"}}}
$ helm lint chart/kubelab --set ingress.enabled=true --set ingress.host=kubelab.example.com $ helm template demo chart/kubelab --set ingress.enabled=true --set ingress.host=kubelab.example.com --set ingress.tlsSecret=kubelab-tls -s templates/ingress.yaml $ helm template demo chart/kubelab --set ingress.enabled=true $ helm lint chart/kubelab --set ingress.host=Bad_Host
The third command must fail with "ingress.host is required", the fourth with a schema error on the host pattern.
Hands-onPreview production
Without installing anything, list which kinds of objects the production values add compared with the defaults, and how many replicas the Deployment asks for in each case.
Show the solution
$ helm template demo chart/kubelab | grep -E '^kind:|replicas:' $ helm template demo chart/kubelab -f chart/kubelab/values-prod.yaml | grep -E '^kind:|replicas:'
Production adds a HorizontalPodAutoscaler, a NetworkPolicy and an HTTPRoute, and removes the Secret (it comes from External Secrets). The Deployment loses its replicas field entirely: with autoscaling enabled, the HPA owns the replica count.
Debug like a senior
- follow a method instead of guessing: symptom, events, logs, then one floor down;
- recognise the ten most common failures from their first line of output;
- debug distroless containers and nodes with
kubectl debugandcrictl.
A senior engineer does not guess. They start from the visible symptom (the pod's STATUS, the Service's endpoints), read the events, then the logs, and only go one floor down (node, network, storage) when that is where the evidence points. The ten failures below were triggered on the lab cluster; the debug/ folder of the repository lets you replay every one of them.
The map: from STATUS to the first command
What kubectl get pods shows | First command | Usual causes |
|---|---|---|
Pending | describe pod → Events | Requests too high, PVC Pending, taint without toleration, impossible affinity; a quota (look at the ReplicaSet) if the pod does not even exist |
ImagePullBackOff | describe pod → Events | Tag that does not exist, private registry without imagePullSecrets, wrong image name, registry unreachable |
CreateContainerConfigError | describe pod | Missing ConfigMap, missing Secret or key, runAsNonRoot on an image that runs as root |
CrashLoopBackOff | logs --previous | Configuration error, missing variable, OOMKilled (137), liveness too strict, wrong command |
Running but 0/1 | describe pod → probes | Wrong probe path or port, a dependency down, slow startup |
| Ready, but no traffic | get endpointslices | Selector does not match labels, wrong targetPort, NetworkPolicy, DNS or namespace |
| Nothing obvious | one floor down | kubectl get events -A --sort-by=.lastTimestamp, describe node, kubectl top, kubectl debug, and on the node crictl, journalctl -u kubelet |
| Exit code | Meaning |
|---|---|
0 | Normal end (a Job that succeeded, or an app that stops when it should not) |
1, 2... | An application error: read the logs |
126 / 127 | Command not executable / not found (command, args, image) |
137 | Killed by SIGKILL (128 + 9): OOMKilled if that is the Reason, or the end of the grace period |
143 | SIGTERM received (128 + 15): Kubernetes asked it to stop |
Replay the scenarios
$ kubectl apply -f debug/00-namespace.yaml $ kubectl apply -f debug/02-crashloopbackoff.yaml $ kubectl -n debug get pods -w
Apply one file at a time and diagnose it before reading the comment at the top of the file (it gives the answer). Clean up with kubectl delete namespace debug.
01. ImagePullBackOff
The Deployment asks for kubelab:9.9.9, a tag nobody ever built.
$ kubectl -n debug get pods -l app=bad-image NAME READY STATUS RESTARTS AGE bad-image-57b9b799f6-7xbsn 0/1 ImagePullBackOff 0 83s1 $ kubectl -n debug describe pod -l app=bad-image | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 83s default-scheduler Successfully assigned debug/bad-image-57b9b799f6-7xbsn to lab-worker Normal Pulling 38s (x3 over 81s) kubelet spec.containers{app}: Pulling image "kubelab:9.9.9" Warning Failed 38s (x3 over 81s) kubelet spec.containers{app}: Failed to pull image "kubelab:9.9.9": rpc error: code = NotFound desc = failed to pull and unpack image "docker.io/library/kubelab:9.9.9": failed to resolve reference "docker.io/library/kubelab:9.9.9": docker.io/library/kubelab:9.9.9: not found2 Warning Failed 38s (x3 over 81s) kubelet spec.containers{app}: Error: ErrImagePull Normal BackOff 11s (x4 over 80s) kubelet spec.containers{app}: Back-off pulling image "kubelab:9.9.9"3 Warning Failed 11s (x4 over 80s) kubelet spec.containers{app}: Error: ImagePullBackOff
- Symptom. Kubernetes keeps retrying with a growing delay.
- Cause. The registry says "not found". (On Docker Hub, a repository that does not exist usually gives "pull access denied" instead.) Fix: the right tag, or
imagePullSecretsfor a private registry. - Back-off. x4: the delay doubles after each failure, up to 5 minutes.
02. CrashLoopBackOff
The app requires a database URL and someone forgot to pass it. The current container has not written anything yet: the answer is in the previous one.
$ kubectl -n debug get pods -l app=crashloop NAME READY STATUS RESTARTS AGE crashloop-d6d7fcb65-hgfm7 0/1 CrashLoopBackOff 7 (3m33s ago) 14m1 $ kubectl -n debug logs deploy/crashloop --previous {"time":"2026-10-19T15:51:59.38233263Z","level":"ERROR","msg":"variable DATABASE_URL absente","hint":"vérifie le Secret référencé par le Deployment"}2 $ kubectl -n debug describe pod -l app=crashloop | grep -A6 'Last State' Last State: Terminated Reason: Error Exit Code: 23 Started: Mon, 19 Oct 2026 17:51:59 +0200 Finished: Mon, 19 Oct 2026 17:51:59 +0200 Ready: False Restart Count: 7
- Symptom. RESTARTS keeps growing, with a longer wait between restarts.
- Cause. "DATABASE_URL variable missing":
logs --previousshows the log of the container that crashed. Fix: add the variable (secretKeyRef). - Exit code. 2 = an application error (not an OOM kill, not a signal).
03. OOMKilled
A memory limit of 48 Mi, and a request that makes the app hold 100 MiB. Trigger: kubectl -n debug exec deploy/oom -- /kubelab check 'http://localhost:8080/stress/memory?mb=100'.
$ kubectl -n debug exec deploy/oom -- /kubelab check 'http://localhost:8080/stress/memory?mb=100' command terminated with exit code 1371 $ kubectl -n debug get pods -l app=oom NAME READY STATUS RESTARTS AGE oom-66c5d4b56b-bfdvw 1/1 Running 1 (20s ago) 82s2 $ kubectl -n debug describe pod -l app=oom | grep -A6 'Last State' Last State: Terminated Reason: OOMKilled3 Exit Code: 137 Started: Mon, 19 Oct 2026 17:41:08 +0200 Finished: Mon, 19 Oct 2026 17:42:07 +0200 Ready: True Restart Count: 1
- Killed. The request made the app allocate 100 MiB: the kernel killed the process.
- Restart. The pod stays, the container restarts.
- Cause. Fix: raise
limits.memoryor fix the leak. Measure withkubectl top.
04. Pending: nowhere to go
The pod asks for 64 CPUs. The scheduler explains, node by node, why none fits.
$ kubectl -n debug get pods -l app=too-big NAME READY STATUS RESTARTS AGE too-big-774d56759-lp89m 0/1 Pending 0 83s1 $ kubectl -n debug describe pod -l app=too-big | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning FailedScheduling 83s default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu. preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.2 $ kubectl describe node lab-worker | sed -n '/Allocated resources/,/Events/p' Allocated resources: (Total limits may be over 100 percent, i.e., overcommitted.) Resource Requests Limits -------- -------- ------ cpu 380m (19%) 0 (0%)3 memory 306Mi (3%) 1008Mi (12%) ephemeral-storage 0 (0%) 0 (0%) hugepages-1Gi 0 (0%) 0 (0%) hugepages-2Mi 0 (0%) 0 (0%) Events:
- Symptom. The pod is never placed.
- Cause. FailedScheduling explains node by node: 1 taint (the control plane) and 2 without enough CPU. Fix: realistic requests, or more nodes.
- Capacity.
describe nodeshows what the requests already reserve.
05. Running, never Ready
A readiness probe on /ready, while the app serves /readyz. The pod runs, but the Service never sends it a request.
$ kubectl -n debug get pods -l app=not-ready NAME READY STATUS RESTARTS AGE not-ready-ff679bbcb-fgtbt 0/1 Running 0 83s1 $ kubectl -n debug describe pod -l app=not-ready | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 83s default-scheduler Successfully assigned debug/not-ready-ff679bbcb-fgtbt to lab-worker Normal Pulled 81s kubelet spec.containers{app}: Container image "kubelab:1.0.0" already present on machine and can be accessed by the pod Normal Created 81s kubelet spec.containers{app}: Container created Normal Started 81s kubelet spec.containers{app}: Container started Warning Unhealthy 81s kubelet spec.containers{app}: Readiness probe failed: Get "http://10.244.2.26:8080/ready": dial tcp 10.244.2.26:8080: connect: connection refused Warning Unhealthy 11s (x24 over 80s) kubelet spec.containers{app}: Readiness probe failed: HTTP probe failed with statuscode: 4042
- Symptom. The container runs but is never ready, so the Service sends it no traffic.
- Cause. 404 on the probe path. Fix:
path: /readyz.
06. The Service does not answer
The pods are fine. The Service's selector has a typo.
$ kubectl -n debug get svc web NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web ClusterIP 10.96.139.124 <none> 80/TCP 83s $ kubectl -n debug get endpointslices -l kubernetes.io/service-name=web NAME ADDRESSTYPE PORTS ENDPOINTS AGE web-sdrhs IPv4 <unset> <unset> 83s1 $ kubectl -n debug get pods -l app=web --show-labels NAME READY STATUS RESTARTS AGE LABELS web-5b97c5d84d-5n7ls 1/1 Running 0 83s app=web,pod-template-hash=5b97c5d84d2 web-5b97c5d84d-bmp6f 1/1 Running 0 83s app=web,pod-template-hash=5b97c5d84d $ kubectl -n debug get svc web -o jsonpath='{.spec.selector}{"\n"}' {"app":"website"}3 $ kubectl -n debug patch svc web -p '{"spec":{"selector":{"app":"web"}}}' service/web patched $ kubectl -n debug get endpointslices -l kubernetes.io/service-name=web NAME ADDRESSTYPE PORTS ENDPOINTS AGE web-sdrhs IPv4 8080 10.244.1.13,10.244.2.25 83s4
- Symptom. Empty EndpointSlice: the Service has nowhere to send traffic.
- Pod labels. The pods carry app=web...
- Cause. ...but the selector looks for app=website.
- Fixed. After the patch the two pods appear. In real life: fix the YAML in git.
07. CreateContainerConfigError
The pod loads its environment from a ConfigMap that does not exist.
$ kubectl -n debug get pods -l app=no-config NAME READY STATUS RESTARTS AGE no-config-5484465b65-bxx2z 0/1 CreateContainerConfigError 0 82s1 $ kubectl -n debug describe pod -l app=no-config | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 82s default-scheduler Successfully assigned debug/no-config-5484465b65-bxx2z to lab-worker Normal Pulled 4s (x8 over 80s) kubelet spec.containers{app}: Container image "kubelab:1.0.0" already present on machine and can be accessed by the pod Warning Failed 4s (x8 over 80s) kubelet spec.containers{app}: Error: configmap "app-settings" not found2
- Symptom. The container cannot be created.
- Cause. Create the ConfigMap (or mark the reference
optional: trueif it really is optional).
08. PVC Pending
The claim asks for a fast-ssd StorageClass that this cluster does not have.
$ kubectl -n debug get pvc data NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE data Pending fast-ssd <unset> 14m1 $ kubectl -n debug get pod needs-volume NAME READY STATUS RESTARTS AGE needs-volume 0/1 Pending 0 14m2 $ kubectl -n debug describe pvc data | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Warning ProvisioningFailed 4m18s (x42 over 14m) persistentvolume-controller storageclass.storage.k8s.io "fast-ssd" not found3 $ kubectl get storageclass NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE standard (default) rancher.io/local-path Delete WaitForFirstConsumer false 23m
- PVC Pending. The volume is never provisioned...
- Pod Pending. ...so the pod waits too.
- Cause. Fix:
storageClassName: standard, or leave it out to get the default class.
09. Forbidden
A ServiceAccount that was never given any Role. The fix is in debug/fixes/09-rbac.yaml.
$ kubectl get pods -n debug --as=system:serviceaccount:debug:reader Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:debug:reader" cannot list resource "pods" in API group "" in the namespace "debug"1 $ kubectl auth can-i list pods -n debug --as=system:serviceaccount:debug:reader no $ kubectl apply -f debug/fixes/09-rbac.yaml role.rbac.authorization.k8s.io/pod-reader created2 rolebinding.rbac.authorization.k8s.io/reader-can-read-pods created $ kubectl auth can-i list pods -n debug --as=system:serviceaccount:debug:reader yes $ kubectl auth can-i delete pods -n debug --as=system:serviceaccount:debug:reader3 no
- Symptom. A 403 from the API, naming the user, the verb, the resource and the namespace.
- Fix. A Role (get, list, watch on pods) and a RoleBinding to the ServiceAccount.
- Least privilege. Read-only: deleting is still refused.
10. Restarts although the app is healthy
The app needs 8 seconds to start; the liveness probe gives it 4 (every 2 s, 2 failures) and checks the wrong endpoint.
$ kubectl -n debug get pods -l app=slow-start NAME READY STATUS RESTARTS AGE slow-start-7b95c566f8-hvphb 0/1 CrashLoopBackOff 4 (26s ago) 82s1 $ kubectl -n debug describe pod -l app=slow-start | sed -n '/^Events:/,$p' Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 82s default-scheduler Successfully assigned debug/slow-start-7b95c566f8-hvphb to lab-worker Normal Pulled 28s (x5 over 81s) kubelet spec.containers{app}: Container image "kubelab:1.0.0" already present on machine and can be accessed by the pod Normal Created 28s (x5 over 81s) kubelet spec.containers{app}: Container created Normal Started 28s (x5 over 81s) kubelet spec.containers{app}: Container started Warning Unhealthy 26s (x10 over 80s) kubelet spec.containers{app}: Liveness probe failed: HTTP probe failed with statuscode: 5032 Normal Killing 26s (x5 over 78s) kubelet spec.containers{app}: Container app failed liveness probe, will be restarted3 Warning BackOff 25s (x6 over 70s) kubelet spec.containers{app}: Back-off restarting failed container app in pod slow-start-7b95c566f8-hvphb_debug(dc3ef925-c9b6-49ea-aff0-f1ae6397bcf3)
- Symptom. The app is not broken: Kubernetes is killing it.
- Cause. The probe fails while the app is still starting...
- Consequence. ...so the kubelet restarts the container before it is ever ready. Fix: a startupProbe, and liveness on
/healthz.
# Symptom: regular restarts although the app is fine. Cause: an aggressive liveness probe # (1 s timeout, no startupProbe) on an app that takes 8 s to start. apiVersion: apps/v1 kind: Deployment metadata: {name: slow-start, namespace: debug} spec: replicas: 1 selector: {matchLabels: {app: slow-start}} template: metadata: {labels: {app: slow-start}} spec: containers: - name: app image: kubelab:1.0.0 env: [{name: STARTUP_DELAY_SECONDS, value: "8"}] livenessProbe: httpGet: {path: /readyz, port: 8080} # mistake: liveness on /readyz initialDelaySeconds: 0 periodSeconds: 2 failureThreshold: 2 resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {memory: 64Mi}}
Add a startupProbe on /healthz with enough failureThreshold × periodSeconds to cover the slowest start, and point liveness at /healthz (the process is alive), keeping /readyz for readiness (it can take traffic). That is exactly what manifests/04-deployment.yaml does.
Advanced tools
The app's image is distroless: no shell, so kubectl exec -- sh is impossible. An ephemeral container brings the tools in, sharing the pod's network and, with --target, its processes:
$ kubectl -n debug debug pod/web-5b97c5d84d-5n7ls -c dbg --image=busybox:1.36 --target=app -- sh -c "ps; wget -qO- localhost:8080/; nslookup web.debug.svc.cluster.local"1 Targeting container "app". If you don't see processes from this container it may be because the container runtime doesn't support this feature. $ kubectl -n debug logs pod/web-5b97c5d84d-5n7ls -c dbg PID USER COMMAND 1 65532 /kubelab2 18 root sh -c ps; wget -qO- localhost:8080/; nslookup web.debug.svc.cluster.local 28 root {ps} sh -c ps; wget -qO- localhost:8080/; nslookup web.debug.svc.cluster.local {"message":"Hello from kubelab","node":"","pod":"web-5b97c5d84d-5n7ls","version":"1.0.0","visits":1}3 Server: 10.96.0.10 Address: 10.96.0.10:53 Name: web.debug.svc.cluster.local Address: 10.96.139.1244
- Target. Shares the process namespace of the
appcontainer. - Processes. From the debug container you can see the app process (uid 65532).
- Pod network.
localhost:8080answers: same network as the app. - DNS. The Service name resolves to its ClusterIP.
And when you arrive on a cluster where many things are wrong at once, start with every warning, sorted by time:
$ kubectl get events -n debug --field-selector type=Warning --sort-by=.lastTimestamp | tail -14 LAST SEEN TYPE REASON OBJECT MESSAGE 95s Warning FailedScheduling pod/too-big-774d56759-lp89m 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu. preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.1 94s Warning FailedScheduling pod/needs-volume 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found 93s Warning Unhealthy pod/not-ready-ff679bbcb-fgtbt Readiness probe failed: Get "http://10.244.2.26:8080/ready": dial tcp 10.244.2.26:8080: connect: connection refused2 49s Warning BackOff pod/crashloop-d6d7fcb65-hgfm7 Back-off restarting failed container app in pod crashloop-d6d7fcb65-hgfm7_debug(912fa39c-38f6-4196-8115-a2a6d38c2fbf) 37s Warning Unhealthy pod/slow-start-7b95c566f8-hvphb Liveness probe failed: HTTP probe failed with statuscode: 503 36s Warning BackOff pod/slow-start-7b95c566f8-hvphb Back-off restarting failed container app in pod slow-start-7b95c566f8-hvphb_debug(dc3ef925-c9b6-49ea-aff0-f1ae6397bcf3) 24s Warning Failed pod/bad-image-57b9b799f6-7xbsn Error: ImagePullBackOff 23s Warning Unhealthy pod/not-ready-ff679bbcb-fgtbt Readiness probe failed: HTTP probe failed with statuscode: 404 12s Warning BackOff pod/oom-66c5d4b56b-bfdvw Back-off restarting failed container app in pod oom-66c5d4b56b-bfdvw_debug(8c34a5b5-25b1-434e-b2f9-3748075f391d) 11s Warning ProvisioningFailed persistentvolumeclaim/data storageclass.storage.k8s.io "fast-ssd" not found3 10s Warning Failed pod/bad-image-57b9b799f6-7xbsn Failed to pull image "kubelab:9.9.9": rpc error: code = NotFound desc = failed to pull and unpack image "docker.io/library/kubelab:9.9.9": failed to resolve reference "docker.io/library/kubelab:9.9.9": docker.io/library/kubelab:9.9.9: not found 10s Warning Failed pod/bad-image-57b9b799f6-7xbsn Error: ErrImagePull 3s Warning Failed pod/no-config-5484465b65-bxx2z Error: configmap "app-settings" not found4
- Scheduling. Pending: resources, PVC.
- Probes. Unhealthy: readiness or liveness.
- Storage. Volume provisioning.
- Configuration. A missing reference.
Events (kubectl get events)Messages emitted by controllers, the scheduler and the kubelet: scheduling, image pulls, probes, evictions, volume errors.
- Why it exists
- Understand what happens to an object without digging through component logs.
- In real companies
- First reflex during an incident:
kubectl get events -n ns --sort-by=.lastTimestamp, or the Events section ofkubectl describe. - How it works
kubectl get events -A --field-selector type=Warning;kubectl events --for pod/x(dedicated command).- Limits and pitfalls
- Events expire (after 1 hour by default): ship them to your logging platform to keep history.
kubectl describeA readable view of an object with its detailed state and recent events.
- Why it exists
- Diagnose: why a container is waiting, its last state, probes, mounts, node, events.
- In real companies
describe podfor any pod that is not Running/Ready;describe nodefor memory pressure and allocations.- How it works
kubectl describe pod x; read State, Last State (Reason, Exit Code), Conditions, and Events at the bottom.- Limits and pitfalls
- Long output: know where to look. Exit code 137 = killed (OOM or SIGKILL), 1 or 2 = application error, 143 = SIGTERM.
kubectl logsThe standard output (stdout/stderr) of containers.
- Why it exists
- Read application errors, including those of the previous container that crashed.
- In real companies
- In production, logs are collected (Fluent Bit, Vector, OpenTelemetry) into Loki, Elasticsearch or the cloud;
kubectl logsis for immediate diagnosis. - How it works
kubectl logs pod [-c container] [--previous] [-f] [--since=10m] [--tail=100];kubectl logs deploy/x;-l app=x --all-containers --prefix.- Limits and pitfalls
--previousis essential in CrashLoopBackOff: the current container has often written nothing yet.
kubectl execRun a command inside a running container.
- Why it exists
- Check a mounted file, a variable, a connection from inside the pod.
- In real companies
- Guarded by RBAC (
pods/exec) and audited in production; often forbidden outside break-glass procedures. - How it works
kubectl exec -it pod -- sh;kubectl exec deploy/x -- env.- Limits and pitfalls
- Impossible on distroless images (no shell): use
kubectl debug.
kubectl debug and ephemeral containersAdd a debug container (with a shell and tools) to a running pod, or copy a pod, or open a shell on a node.
- Why it exists
- Debug minimal (distroless) images without changing them.
- In real companies
- A tools image (busybox, netshoot) attached to the pod to test DNS and networking from the pod's own network namespace.
- How it works
kubectl debug -it pod --image=busybox:1.36 --target=app(shares the process namespace);kubectl debug node/n1 -it --image=...(shell on the node, host file system under/host).- Limits and pitfalls
- The ephemeral container stays attached until the pod is deleted. Pick a
--profilecompatible with the namespace's Pod Security level.
kubectl top and metrics-serverCurrent CPU and memory usage of nodes and pods, provided by metrics-server.
- Why it exists
- Find who consumes what, calibrate requests and limits, feed the HPA.
- In real companies
- metrics-server is installed on nearly every cluster; Prometheus keeps the history.
- How it works
kubectl top nodes;kubectl top pods -A --sort-by=memory;--containers.- Limits and pitfalls
- Instant values, no history. "Metrics API not available" = metrics-server is missing or failing.
crictl (on the node)The container runtime's client, used directly on a node.
- Why it exists
- Debug when the API or the kubelet no longer answers: see containers, their logs, images.
- In real companies
- NotReady node, kubeadm control plane that does not start (static pods are visible with crictl even without the API).
- How it works
crictl ps -a;crictl logs <id>;crictl images;crictl inspect.- Limits and pitfalls
- Do not create containers by hand: the kubelet would remove them.
ChallengeTen failures, three minutes each
- Apply all ten scenarios at once:
for f in debug/0[1-9]-*.yaml debug/10-*.yaml; do kubectl apply -f "$f"; done. - Name the cause of each one from
get,describe,logsandeventsonly, without opening the files. - Fix each one in its YAML and re-apply until every pod is Ready. Some fields cannot be changed in place: for 08, delete the pod and the PVC before re-applying. Scenario 09 is fixed with RBAC, not with a pod.
- Do it again a week later, timing yourself.
Administer a cluster
- take a node out for maintenance and bring it back without downtime;
- back up etcd and explain how a restore works;
- check certificates and run a kubeadm upgrade in the right order.
On a managed service (EKS, AKS, GKE) the provider runs the control plane; you keep the nodes, the upgrades, the security and the application backups. On kubeadm (on premises, or kind for practice) everything is yours. Every command below was run on the lab cluster.
Node maintenance: cordon, drain, uncordon
Before patching or replacing a node, you drain it: no new pods arrive, and the existing ones are evicted through the eviction API, which respects every PodDisruptionBudget. At capture time the HPA had scaled kubelab to 6 pods, so the PDB (minimum 2) allowed 4 evictions:
$ kubectl -n kubelab get pods -l app.kubernetes.io/name=kubelab -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES kubelab-6b777469b9-4mn6b 1/1 Running 0 4m43s 10.244.2.8 lab-worker <none> <none> kubelab-6b777469b9-5ws4r 1/1 Running 0 3m55s 10.244.2.14 lab-worker <none> <none> kubelab-6b777469b9-b5454 1/1 Running 0 3m55s 10.244.1.10 lab-worker2 <none> <none> kubelab-6b777469b9-gb5fz 1/1 Running 0 3m55s 10.244.2.13 lab-worker <none> <none> kubelab-6b777469b9-w9hj4 1/1 Running 0 4m40s 10.244.1.6 lab-worker2 <none> <none> kubelab-6b777469b9-xtcdh 1/1 Running 0 4m37s 10.244.1.7 lab-worker2 <none> <none> $ kubectl -n kubelab get pdb kubelab NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE kubelab 2 N/A 4 6m40s $ kubectl drain lab-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=180s node/lab-worker2 cordoned1 evicting pod debug/web-5b97c5d84d-bmp6f evicting pod kubelab/kubelab-6b777469b9-xtcdh2 evicting pod kubelab/kubelab-db-1 evicting pod kube-system/metrics-server-84c99cb944-vz687 evicting pod kubelab-helm/demo-kubelab-5b77db878d-f2bb8 evicting pod kubelab/kubelab-6b777469b9-b5454 evicting pod kubelab/kubelab-6b777469b9-w9hj4 pod/kubelab-db-1 evicted pod/kubelab-6b777469b9-xtcdh evicted pod/metrics-server-84c99cb944-vz687 evicted pod/demo-kubelab-5b77db878d-f2bb8 evicted pod/web-5b97c5d84d-bmp6f evicted pod/kubelab-6b777469b9-b5454 evicted pod/kubelab-6b777469b9-w9hj4 evicted node/lab-worker2 drained Warning: ignoring DaemonSet-managed Pods: kube-system/kindnet-sf4p2, kube-system/kube-proxy-x5zbp3 $ kubectl get nodes NAME STATUS ROLES AGE VERSION lab-control-plane Ready control-plane 10m v1.37.1 lab-worker Ready <none> 10m v1.37.1 lab-worker2 Ready,SchedulingDisabled <none> 10m v1.37.14 $ kubectl -n kubelab get pods -l app.kubernetes.io/name=kubelab -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES kubelab-6b777469b9-4mn6b 1/1 Running 0 4m46s 10.244.2.8 lab-worker <none> <none> kubelab-6b777469b9-59bch 0/1 Running 0 2s 10.244.2.34 lab-worker <none> <none>5 kubelab-6b777469b9-5ws4r 1/1 Running 0 3m58s 10.244.2.14 lab-worker <none> <none> kubelab-6b777469b9-g5ljw 1/1 Running 0 3s 10.244.2.30 lab-worker <none> <none> kubelab-6b777469b9-gb5fz 1/1 Running 0 3m58s 10.244.2.13 lab-worker <none> <none> kubelab-6b777469b9-pk6zq 0/1 Running 0 3s 10.244.2.33 lab-worker <none> <none>
- cordon. No new pod will be scheduled on this node.
- Eviction. Through the eviction API, so the PDB is respected (4 evictions allowed here, because the HPA had scaled to 6 pods).
- DaemonSets. kindnet and kube-proxy stay:
--ignore-daemonsets. - State. The node is empty and unschedulable: ready for maintenance.
- Recreated. The evicted pods are recreated on lab-worker by the ReplicaSet.
$ kubectl uncordon lab-worker2 node/lab-worker2 uncordoned1 $ kubectl get nodes NAME STATUS ROLES AGE VERSION lab-control-plane Ready control-plane 10m v1.37.1 lab-worker Ready <none> 10m v1.37.1 lab-worker2 Ready <none> 10m v1.37.1
- uncordon. The node accepts pods again (existing pods do not move back on their own).
ChallengeWatch a PDB block a drain
Set the kubelab PDB to minAvailable: 3 while the Deployment has 3 replicas, then drain a worker that runs one of them. What does kubectl do? How do you get out of it cleanly?
Show the solution
$ kubectl -n kubelab patch pdb kubelab -p '{"spec":{"minAvailable":3}}' $ kubectl -n kubelab get pdb kubelab $ kubectl drain lab-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=60s $ kubectl -n kubelab patch pdb kubelab -p '{"spec":{"minAvailable":2}}' $ kubectl uncordon lab-worker2
ALLOWED DISRUPTIONS shows 0, so kubectl keeps retrying the eviction ("Cannot evict pod as it would violate the pod's disruption budget") until the timeout. The fix is never --disable-eviction in production: restore a sane PDB (or add replicas), then drain again. And do not forget to uncordon.
Back up etcd
On kubeadm, etcd runs as a static pod and its certificates live in /etc/kubernetes/pki/etcd/. Check the members, the endpoint status, then take a snapshot and verify it:
$ kubectl -n kube-system exec etcd-lab-control-plane -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key member list -w table ┌──────────────────┬─────────┬───────────────────┬─────────────────────────┬─────────────────────────┬────────────┐ │ ID │ STATUS │ NAME │ PEER ADDRS │ CLIENT ADDRS │ IS LEARNER │ ├──────────────────┼─────────┼───────────────────┼─────────────────────────┼─────────────────────────┼────────────┤ │ 23da9c3f2594532a │ started │ lab-control-plane │ https://172.18.0.3:2380 │ https://172.18.0.3:2379 │ false │1 └──────────────────┴─────────┴───────────────────┴─────────────────────────┴─────────────────────────┴────────────┘ $ kubectl -n kube-system exec etcd-lab-control-plane -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key endpoint status -w table ┌────────────────────────┬──────────────────┬─────────┬─────────────────┬─────────┬────────┬───────────────────────┬────────┬───────────┬────────────┬───────────┬────────────┬────────────────────┬────────┬──────────────────────────┬───────────────────┐ │ ENDPOINT │ ID │ VERSION │ STORAGE VERSION │ DB SIZE │ IN USE │ PERCENTAGE NOT IN USE │ QUOTA │ IS LEADER │ IS LEARNER │ RAFT TERM │ RAFT INDEX │ RAFT APPLIED INDEX │ ERRORS │ DOWNGRADE TARGET VERSION │ DOWNGRADE ENABLED │2 ├────────────────────────┼──────────────────┼─────────┼─────────────────┼─────────┼────────┼───────────────────────┼────────┼───────────┼────────────┼───────────┼────────────┼────────────────────┼────────┼──────────────────────────┼───────────────────┤ │ https://127.0.0.1:2379 │ 23da9c3f2594532a │ 3.7.0 │ 3.7.0 │ 13 MB │ 12 MB │ 7% │ 2.1 GB │ true │ false │ 2 │ 3586 │ 3586 │ │ │ false │ └────────────────────────┴──────────────────┴─────────┴─────────────────┴─────────┴────────┴───────────────────────┴────────┴───────────┴────────────┴───────────┴────────────┴────────────────────┴────────┴──────────────────────────┴───────────────────┘ $ kubectl -n kube-system exec etcd-lab-control-plane -- etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key snapshot save /var/lib/etcd/snapshot-2026-10-19.db Snapshot saved at /var/lib/etcd/snapshot-2026-10-19.db3 Server version 3.7.0 {"level":"info","ts":"2026-10-19T15:42:44.934515Z","caller":"snapshot/v3_snapshot.go:83","msg":"created temporary db file","path":"/var/lib/etcd/snapshot-2026-10-19.db.part"} {"level":"info","ts":"2026-10-19T15:42:44.950032Z","logger":"client","caller":"v3@v3.7.0/maintenance.go:236","msg":"opened snapshot stream; downloading"} {"level":"info","ts":"2026-10-19T15:42:44.953240Z","caller":"snapshot/v3_snapshot.go:96","msg":"fetching snapshot","endpoint":"https://127.0.0.1:2379"} {"level":"info","ts":"2026-10-19T15:42:45.065011Z","logger":"client","caller":"v3@v3.7.0/maintenance.go:302","msg":"completed snapshot read; closing"} {"level":"info","ts":"2026-10-19T15:42:45.084731Z","caller":"snapshot/v3_snapshot.go:111","msg":"fetched snapshot","endpoint":"https://127.0.0.1:2379","size":"13 MB","took":"149.890332ms","etcd-version":"3.7.0"} {"level":"info","ts":"2026-10-19T15:42:45.084839Z","caller":"snapshot/v3_snapshot.go:121","msg":"saved","path":"/var/lib/etcd/snapshot-2026-10-19.db"} $ kubectl -n kube-system exec etcd-lab-control-plane -- etcdutl snapshot status /var/lib/etcd/snapshot-2026-10-19.db -w table ┌──────────┬──────────┬────────────┬────────────┬─────────┐ │ HASH │ REVISION │ TOTAL KEYS │ TOTAL SIZE │ VERSION │4 ├──────────┼──────────┼────────────┼────────────┼─────────┤ │ c9a1ed62 │ 3311 │ 866 │ 13 MB │ 3.7.0 │ └──────────┴──────────┴────────────┴────────────┴─────────┘
- Members. A single member here; 3 or 5 in production.
- Endpoint status. Version, database size, Raft leader.
- Snapshot. A consistent copy of the whole database. Copy it off the node.
- Check.
etcdutl snapshot status: the backup is readable.
Restoring is the delicate part, and it takes the whole cluster back in time. On the control-plane node:
- Restore the snapshot into a new directory.
etcdutl snapshot restore /backup/etcd.db --data-dir /var/lib/etcd-restore - Point etcd at it. Edit
/etc/kubernetes/manifests/etcd.yamlso thehostPathof the data volume is/var/lib/etcd-restore. The kubelet restarts the static pod. - Wait and verify. The API server reconnects; check nodes, system pods and a few known objects. Practise this on a test cluster, never for the first time during an incident.
Cluster certificates
$ docker exec lab-control-plane kubeadm certs check-expiration [check-expiration] Reading configuration from the "kubeadm-config" ConfigMap in namespace "kube-system"... [check-expiration] Use 'kubeadm init phase upload-config kubeadm --config your-config-file' to re-upload it. CERTIFICATE EXPIRES RESIDUAL TIME CERTIFICATE AUTHORITY EXTERNALLY MANAGED admin.conf Oct 19, 2027 15:32 UTC 364d ca no apiserver Oct 19, 2027 15:32 UTC 364d ca no 1 apiserver-etcd-client Oct 19, 2027 15:32 UTC 364d etcd-ca no apiserver-kubelet-client Oct 19, 2027 15:32 UTC 364d ca no controller-manager.conf Oct 19, 2027 15:32 UTC 364d ca no etcd-healthcheck-client Oct 19, 2027 15:32 UTC 364d etcd-ca no etcd-peer Oct 19, 2027 15:32 UTC 364d etcd-ca no etcd-server Oct 19, 2027 15:32 UTC 364d etcd-ca no front-proxy-client Oct 19, 2027 15:32 UTC 364d front-proxy-ca no scheduler.conf Oct 19, 2027 15:32 UTC 364d ca no super-admin.conf Oct 19, 2027 15:32 UTC 364d ca no CERTIFICATE AUTHORITY EXPIRES RESIDUAL TIME EXTERNALLY MANAGED ca Oct 16, 2036 15:32 UTC 9y no 2 etcd-ca Oct 16, 2036 15:32 UTC 9y no front-proxy-ca Oct 16, 2036 15:32 UTC 9y no
- 1 year. Component certificates expire after one year.
- CA: 10 years. Certificate authorities last ten years.
Upgrading Kubernetes
$ docker exec lab-control-plane kubeadm upgrade plan [preflight] Running pre-flight checks. [upgrade/config] Reading configuration from the "kubeadm-config" ConfigMap in namespace "kube-system"... [upgrade/config] Use 'kubeadm init phase upload-config kubeadm --config your-config-file' to re-upload it. [upgrade] Running cluster health checks1 [upgrade] Fetching available versions to upgrade to [upgrade/versions] Cluster version: 1.37.1 [upgrade/versions] kubeadm version: v1.37.1 [upgrade/versions] Target version: v1.37.1 [upgrade/versions] Latest version in the v1.37 series: v1.37.12 W1019 15:56:05.022755 2043 version.go:108] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable.txt": Get "https://dl.k8s.io/release/stable.txt": dial tcp: lookup dl.k8s.io on 172.18.0.1:53: no such host3 W1019 15:56:05.022803 2043 version.go:109] falling back to the local client version: v1.37.1 W1019 15:56:05.036592 2043 version.go:108] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.37.txt": Get "https://dl.k8s.io/release/stable-1.37.txt": dial tcp: lookup dl.k8s.io on 172.18.0.1:53: no such host W1019 15:56:05.036614 2043 version.go:109] falling back to the local client version: v1.37.1
- Checks. kubeadm checks the cluster's health (it runs a test Job).
- Plan. Already up to date: nothing to apply.
- Offline. The node had no Internet access in the capture sandbox, so kubeadm falls back to its own version.
- Prepare. Read the release notes, list deprecated APIs still in use (
kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis, or tools such as pluto and kubent), and check add-on compatibility (CNI, gateway or ingress controller, cert-manager, operators). - First control plane. Back up etcd. Upgrade the
kubeadmpackage, runkubeadm upgrade plan, thenkubeadm upgrade apply v1.x.y. - Every other node. On secondary control planes and on workers, upgrade kubeadm, then run
kubeadm upgrade node. - Kubelets, one node at a time.
kubectl drain→ upgrade kubelet and kubectl →systemctl daemon-reload && systemctl restart kubelet→kubectl uncordon. - Verify, then repeat. One minor version at a time: nodes Ready, system pods healthy, application smoke tests green.
Kubelet and node health
$ docker exec lab-worker systemctl status kubelet --no-pager | head -12 ● kubelet.service - kubelet: The Kubernetes Node Agent Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; preset: enabled) Drop-In: /etc/systemd/system/kubelet.service.d └─10-kubeadm.conf, 11-kind.conf1 Active: active (running) since Mon 2026-10-19 15:32:31 UTC; 11min ago2 Docs: http://kubernetes.io/docs/ Process: 296 ExecStartPre=/bin/sh -euc if [ -f /sys/fs/cgroup/cgroup.controllers ]; then /kind/bin/create-kubelet-cgroup-v2.sh; fi (code=exited, status=0/SUCCESS) Process: 298 ExecStartPre=/bin/sh -euc if [ ! -f /sys/fs/cgroup/cgroup.controllers ] && [ ! -d /sys/fs/cgroup/systemd/kubelet ]; then mkdir -p /sys/fs/cgroup/systemd/kubelet; fi (code=exited, status=0/SUCCESS) Main PID: 299 (kubelet) Tasks: 13 (limit: 9612) Memory: 43.7M () CGroup: /kubelet.slice/kubelet.service $ docker exec lab-worker crictl ps | head -8 CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE3 fccc3c407c33e 5a7252c143818 About a minute ago Running app 2 ff2a00c1575f0 oom-66c5d4b56b-bfdvw debug e1ce2e5abdba6 5a7252c143818 About a minute ago Running app 0 1a2a110981629 web-5b97c5d84d-k974s debug af264cd065170 5a7252c143818 About a minute ago Running kubelab 0 976227241e7ef demo-kubelab-5b77db878d-z26jx kubelab-helm d604c0a9d953b 1dd431a517ec3 About a minute ago Running metrics-server 0 b9f42dd5eacc4 metrics-server-84c99cb944-rgzff kube-system b6f868efd345c 5a7252c143818 2 minutes ago Running app 0 83cf833a708f2 web-5b97c5d84d-5n7ls debug 7438a3188f810 5a7252c143818 2 minutes ago Running app 0 34067b7af8e37 not-ready-ff679bbcb-fgtbt debug f79b0c7e2609c 5a7252c143818 2 minutes ago Running kubelab 0 a28a484336258 demo-kubelab-5b77db878d-d98xk kubelab-helm
- Configuration. systemd drop-ins: the arguments added by kubeadm.
- State. The first thing to check when a node is NotReady.
- crictl ps. The node's containers as the runtime sees them.
$ kubectl top nodes NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) lab-control-plane 134m 6% 1008Mi 12% 1 lab-worker 86m 4% 366Mi 4% lab-worker2 59m 2% 243Mi 3% $ kubectl top pods -A --sort-by=memory | head -10 NAMESPACE NAME CPU(cores) MEMORY(bytes) kube-system kube-apiserver-lab-control-plane 40m 372Mi 2 kube-system etcd-lab-control-plane 26m 106Mi kube-system kube-controller-manager-lab-control-plane 14m 73Mi kube-system kube-proxy-x5zbp 1m 46Mi kube-system kube-proxy-dx7f2 1m 46Mi kube-system kindnet-sf4p2 3m 42Mi kube-system kindnet-lhdzr 4m 41Mi kube-system kube-scheduler-lab-control-plane 8m 30Mi kube-system metrics-server-84c99cb944-rgzff 3m 16Mi $ kubectl describe node lab-worker | sed -n '/Conditions:/,/Addresses:/p' Conditions: Type Status LastHeartbeatTime LastTransitionTime Reason Message ---- ------ ----------------- ------------------ ------ ------- MemoryPressure False Mon, 19 Oct 2026 17:39:42 +0200 Mon, 19 Oct 2026 17:32:32 +0200 KubeletHasSufficientMemory kubelet has sufficient memory available3 DiskPressure False Mon, 19 Oct 2026 17:39:42 +0200 Mon, 19 Oct 2026 17:32:32 +0200 KubeletHasNoDiskPressure kubelet has no disk pressure PIDPressure False Mon, 19 Oct 2026 17:39:42 +0200 Mon, 19 Oct 2026 17:32:32 +0200 KubeletHasSufficientPID kubelet has sufficient PID available Ready True Mon, 19 Oct 2026 17:39:42 +0200 Mon, 19 Oct 2026 17:32:54 +0200 KubeletReady kubelet is posting ready status Addresses:
- top nodes. The control plane uses the most (API server, etcd).
- top pods. Sorted by memory.
- Conditions. Memory, disk and PID pressure: when one turns True, the kubelet starts evicting pods.
kubeadmThe official tool to install and evolve a cluster: init, join, upgrade, certs, reset.
- Why it exists
- Build a conformant cluster on your own machines (on premises, VMs).
- In real companies
- On-premises clusters and labs; managed services (EKS, AKS, GKE) do this work for you. kind uses kubeadm inside its nodes.
- How it works
kubeadm init --pod-network-cidr=...→ install the CNI →kubeadm joinon the workers (token + CA hash). The control plane runs as static pods (/etc/kubernetes/manifests).- Limits and pitfalls
- The join token expires after 24 h (
kubeadm token create --print-join-command). HA needs a stable control-plane endpoint (load balancer) from the very first init. - Exam tip
- Installing and upgrading with kubeadm is the core of the Cluster Architecture domain (25%).
cordon, drain, uncordoncordon: no new pod on the node. drain: cordon + evict the pods (respecting PDBs). uncordon: back in service.
- Why it exists
- Node maintenance: OS patching, kubelet upgrade, machine replacement.
- In real companies
- The standard procedure before touching a node; automated by managed node-pool upgrades.
- How it works
kubectl drain n1 --ignore-daemonsets --delete-emptydir-data;kubectl uncordon n1.- Limits and pitfalls
- A PDB that is too strict blocks the drain (it retries forever). Pods without a controller are lost (
--force). - Exam tip
- Upgrading a node: upgrade the kubeadm package →
kubeadm upgrade node→ drain → upgrade kubelet and kubectl →systemctl daemon-reload && systemctl restart kubelet→ uncordon.
Upgrading the clusterMoving to the next minor version of Kubernetes: control plane first, then the nodes, one minor version at a time.
- Why it exists
- Stay supported: each minor version is only maintained for about 14 months.
- In real companies
- One to three upgrades a year; test in staging first, check deprecated APIs (kubent, pluto) and add-on compatibility.
- How it works
kubeadm upgrade plan→kubeadm upgrade apply v1.x.y(first control plane) →kubeadm upgrade nodeon every other node (secondary control planes and workers) → kubelet/kubectl on each node after a drain. On clouds: a button or Terraform, then the node pools.- Limits and pitfalls
- You never skip a minor version. The kubelet must not be newer than the API server.
- Exam tip
- Likely in the exam.
Backing up and restoring etcdA consistent snapshot of the etcd database, restorable after a disaster.
- Why it exists
- Return to a known cluster state after corruption, a massive human error or the loss of the control plane.
- In real companies
- A scheduled snapshot (CronJob or systemd timer on the control plane) copied off the cluster and encrypted; restores tested regularly. On managed services the provider backs up etcd: you back up objects and volumes with Velero instead.
- How it works
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=... snapshot save /backup/etcd.db;etcdutl snapshot status; restore:etcdutl snapshot restore --data-dir /var/lib/etcd-restore, then point the etcd manifest at that directory.- Limits and pitfalls
- A backup you never restored is not a backup. A restore takes the whole cluster back in time.
Cluster certificatesThe internal PKI: the cluster CA and the certificates of the API server, kubelets, etcd and admin clients.
- Why it exists
- Authenticate and encrypt every control-plane conversation.
- In real companies
- kubeadm certificates expire after one year: renewed automatically by every
kubeadm upgrade, otherwise watch them (alerting); kubelets rotate their own. - How it works
kubeadm certs check-expiration;kubeadm certs renew all, then restart the static pods. Files in/etc/kubernetes/pki.- Limits and pitfalls
- An expired certificate cuts off access to the cluster: a classic incident on forgotten clusters.
Users and authenticationKubernetes has no "user" object: identity comes from a client certificate, an OIDC token or a ServiceAccount token.
- Why it exists
- Plug in the company directory and remove access in one place.
- In real companies
- OIDC (Entra ID, Okta, Keycloak) with groups → RoleBindings; a traced break-glass admin access.
- How it works
- Certificate: a CertificateSigningRequest approved with
kubectl certificate approve, then a kubeconfig. OIDC: API server configuration (AuthenticationConfiguration) and kubelogin on the client side. - Limits and pitfalls
- A client certificate cannot be revoked before it expires: avoid them for humans.
Observability (metrics, logs, traces)The stack that gives you visibility: Prometheus + Alertmanager + Grafana, Loki or Elasticsearch for logs, OpenTelemetry for traces.
- Why it exists
- Detect, alert and diagnose before your users notice.
- In real companies
- kube-prometheus-stack (Helm) on every cluster: node, pod and API metrics; standard alerts (KubePodCrashLooping, KubeNodeNotReady...). The lab app exposes
/metrics. - How it works
- ServiceMonitor / PodMonitor (Prometheus Operator CRDs) declare what to scrape; Fluent Bit as a DaemonSet ships logs.
- Limits and pitfalls
- Keep metric label cardinality under control, or Prometheus explodes.
Backing up applications (Velero)A tool that backs up Kubernetes objects and volumes (snapshots or file copy) to object storage.
- Why it exists
- Restore a deleted namespace, move apps between clusters, recover from a disaster.
- In real companies
- Scheduled backups per namespace, tested by restoring into a staging cluster.
- How it works
velero install,velero schedule create,velero restore create --from-backup.- Limits and pitfalls
- Database data is better backed up with the database's own tools (pg_dump, the operator).
GitOps (Argo CD, Flux)A model where git is the source of truth: an agent inside the cluster keeps comparing the real state with git and fixes any drift.
- Why it exists
- Traceable deployments (one commit = one change), reviews in pull requests, rollback by revert, drift detection.
- In real companies
- The dominant model in companies: CI builds and pushes the image, updates the version in the config repository, Argo CD deploys.
- How it works
- Argo CD: an Application object (git or OCI chart source, destination cluster/namespace,
syncPolicyautomated + prune + selfHeal). - Limits and pitfalls
- Do not mix GitOps with manual
kubectl apply: the agent overwrites manual changes (that is the point).
Multi-tenancy and platformsSharing clusters safely between teams: namespaces, RBAC, quotas, NetworkPolicies, Pod Security, admission policies.
- Why it exists
- Offer teams guarded self-service instead of one cluster per team.
- In real companies
- A platform team that hands out ready-to-use namespaces through GitOps or a portal (Backstage), with automatic guardrails.
- How it works
- Pattern: one namespace per team/app/environment + a group RoleBinding + quota + LimitRange + default-deny + PSA restricted.
- Limits and pitfalls
- A namespace does not isolate the kernel: for untrusted tenants, use separate clusters or vClusters.
Managed Kubernetes (EKS, AKS, GKE)Services where the cloud provider runs the control plane (API server, etcd, upgrades); you run the nodes (or not, in Autopilot/Fargate modes) and the workloads.
- Why it exists
- Focus on applications instead of PKI, etcd and control-plane availability.
- In real companies
- The vast majority of company clusters. kubeadm skills stay useful to understand what happens and for on premises.
- How it works
- Created with Terraform/OpenTofu; pod identity through the cloud's federation; load balancers, storage and DNS integrated.
- Limits and pitfalls
- Versions follow the provider's support windows: plan your upgrade calendar.
Enterprise best practices
- review any application against the checklist companies use before production;
- spot the seven most common anti-patterns and say what to do instead.
This is the list a senior reviewer runs through before an application goes to production. The lab's manifests and chart follow it; tick it off against your own projects.
- Images. Minimal (distroless), non-root, immutable tag or digest, scanned and signed in CI, private registry,
imagePullPolicy: IfNotPresent. - Pods. Three suitable probes, requests on everything, a memory limit, a clean stop on SIGTERM, a restricted securityContext, a dedicated ServiceAccount without unnecessary token.
- Availability. At least 2 replicas, topology spread across nodes and zones, a PDB, rolling updates that never lose capacity, an HPA on a meaningful metric.
- Configuration. Nothing environment-specific in the image; secrets in a vault through External Secrets; a config checksum to roll pods.
- Namespaces. One per app and environment, with group RBAC, ResourceQuota, LimitRange, Pod Security restricted and a default-deny NetworkPolicy.
- Deployment. Everything in git; charts versioned in an OCI registry; Argo CD or Flux; no
kubectl editin production; changes reviewed in pull requests. - Observability. JSON logs on stdout, a
/metricsendpoint, OpenTelemetry traces,app.kubernetes.io/*labels to tie metrics, logs and costs together. - Operations. Planned cluster upgrades, etcd and volumes backed up and restored in tests, certificates monitored, a runbook for every alert.
- Policies. Kyverno or Gatekeeper: allowed registries, mandatory labels and limits, signed images, no
latest, no privileged pods.
| Common anti-pattern | Why it hurts | Do this instead |
|---|---|---|
image: app:latest | Nobody knows what runs; rollback is impossible | A version tag plus the digest |
| No requests | Blind scheduling, a useless HPA, BestEffort pods evicted first | Measured requests (the VPA in recommendation mode helps) |
| Liveness that checks the database | A database outage restarts the whole fleet | Minimal liveness, dependencies in readiness |
| Secrets in git or in values.yaml | A permanent leak (git history never forgets) | External Secrets, Sealed Secrets, SOPS |
cluster-admin for the CI | Compromising the CI compromises the cluster | A namespace-scoped ServiceAccount, or pull-based GitOps |
| One replica, no PDB | An outage at every drain or node upgrade | 2+ replicas, a PDB, topology spread |
kubectl edit in production | Invisible drift, overwritten at the next deployment | Change git, let the pipeline deploy |
Preparing CKA, CKAD and CKS
- choose the right certification for your role;
- map each exam domain to the stations of this course;
- use an exam strategy that protects your score.
The three Linux Foundation exams are entirely hands-on: two hours of command-line tasks on real clusters, no multiple choice. The official documentation is available during the exam. The CKS requires a valid CKA. Prices, the Kubernetes version used and the bundled practice sessions change over time: check the official pages before registering.
| Exam | Domains and weights | Where in this course |
|---|---|---|
| CKA administrator | Troubleshooting 30% · Cluster architecture, installation and configuration 25% (RBAC, kubeadm, HA, Helm and Kustomize, CNI/CSI/CRI, CRDs and operators) · Services and networking 20% (NetworkPolicy, Gateway API, Ingress, CoreDNS) · Workloads and scheduling 15% · Storage 10% | Debug, Run the cluster, Architecture, Networking, Storage |
| CKAD developer | Application design and build 20% · Application deployment 20% (rolling, blue/green, canary, Helm, Kustomize) · Observability and maintenance 15% (probes, logs, debugging, deprecations) · Environment, configuration and security 25% · Services and networking 20% | Workloads, Configuration, Lab 1, Lab 2 |
| CKS security | Cluster setup 15% · Cluster hardening 15% · System hardening 10% · Minimize microservice vulnerabilities 20% · Supply chain security 20% · Monitoring, logging and runtime security 20% | Security, plus the GitHub and GitLab courses for the supply chain |
Start with short, high-value tasks and never stay stuck more than 6 or 7 minutes. Begin every task with the ssh it names, and come back to the base machine when you are done. Generate YAML with --dry-run=client -o yaml. Verify every task (get, describe, auth can-i, a curl from a pod). Practise on the killer.sh simulator, and replay this lab's failure scenarios until each takes you less than three minutes.
Final exam and challenges
- prove to yourself that you can explain every part of the course;
- keep progressing with challenges that build real experience.
Twenty-five questions covering the whole line. Aim for 80% or more. Every explanation points you back to the right station if you miss one.
Your result
Answer every question above and your score appears here.
Challenges
Knowledge becomes skill through repetition. Tick these off over the coming weeks; your progress stays in this browser.
- Install the tools. Docker, kind 0.33+, kubectl 1.36+, Helm 4; check with
kind version,kubectl version --client,helm version. - Create the lab cluster. Clone the repository,
kind create cluster --name lab --config kind/cluster.yaml,kubectl get nodes. - Build and load the images. 1.0.0, and 1.1.0 for the update step.
- Lab 1.
kubectl apply -k manifests/, rollout status, the smoke-test Job,set imagethenrollout undo. - Prove the NetworkPolicy. probe-allowed succeeds, probe-denied fails after about a minute.
- StatefulSet, RBAC, Pod Security.
statefulset.yaml,rbac-developer.yaml(withauth can-i),psa-test.yaml(must be refused). - Lab 2.
helm lint,template,install --wait,test,upgrade --set,history,rollback; then render-f values-prod.yaml. - Write a template. The optional Ingress from lab 2, protected by the JSON schema.
- Ten failures, three minutes each. Diagnose without reading the files, fix, verify.
- Node maintenance. Drain lab-worker2, watch the PDB, uncordon. Then again with
minAvailable: 3to see it block. - Back up etcd. The etcdctl commands of the admin station, inside the etcd pod; check with
etcdutl snapshot status. - Make the CI work for you. Fork the repository, change
messageinvalues.yaml, push: the Actions tab replays the whole lab on kind. - Sit a mock exam. killer.sh, in real conditions: two hours, no notes but the official docs.
Concept index
Type a word (pvc, rbac, hook, drain, probe...) to filter. Click a concept to jump to its sheet, already open.
Glossary
Resources and next steps
The lab
- github.com/georgesmomo/k8s-helm-lab: the app, the manifests, the chart, the failure scenarios and the CI that replays everything on kind.
- Found a mistake or have an idea? Open an issue. Pull requests are welcome.
Official documentation
- Kubernetes documentation (also the documentation you may use during the exams)
- Helm documentation and the Helm 3 end-of-life announcement
- Gateway API and the ingress-nginx retirement notice
- kind
Certifications
The DevSecOps Learning Series
Kubernetes runs the images; the two other courses build, scan, sign and ship them.
- GitHub Actions DevSecOps: from your first workflow to signed images deployed on Kubernetes, with GH-200 preparation.
- GitLab CI/CD DevSecOps: the same supply chain built with GitLab CI/CD.