Kubernetes + Helm
DevSecOps Learning Series by Georges MOMO

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
Written by Georges MOMO, DevSecOps engineer. I built this lab to learn Kubernetes in depth, in public, and to help others reach a senior level with real practice instead of slides.

Welcome aboard

30 minutes, plus the time to install four tools
By the end of this station you can
  • 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

  1. Understand the model and the machine. Stations 1 to 10: the declarative model, the cluster's architecture, kubectl, and every family of objects.
  2. Lab 1, plain YAML. Deploy the kubelab API with eleven hand-written manifests and kubectl apply, update it, roll it back.
  3. Lab 2, Helm. Deploy the same app as a chart: values, templates, hooks, tests, upgrades, rollbacks, an OCI registry.
  4. Break it and fix it. Ten failure scenarios taken from real production incidents, with the real symptoms.
  5. Run the cluster. Drain nodes, back up etcd, check certificates, plan an upgrade.
  6. Prove it. Enterprise review checklist, CKA/CKAD/CKS strategy, and a final exam.
About the terminal outputs

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...
  1. Sandbox config. The captures used a copy of kind/sandbox-cluster.yaml. On your machine use kind/cluster.yaml: same topology, same results.
  2. kubeadm init. kind runs kubeadm inside the control-plane container.
  3. CNI. kindnet is installed. Without a CNI the nodes would stay NotReady.
  4. kubeadm join. The two workers join the cluster.
  5. Context. Your kubeconfig is updated automatically.
  6. kind load. Your local image is copied into the containerd of every node: no registry needed.
Why does the capture say sandbox/cluster.yaml?

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 --client and helm version all answer (Helm must print v4.x).
  • kubectl get nodes shows three nodes in Ready state.
  • 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

30 minutes, the one idea everything else rests on
By the end of this station you can
  • 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.
Think of a thermostat

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?"

Try it: be the chaos

This Deployment asks for 3 replicas. Click a pod to delete it, as kubectl delete pod would, and watch the ReplicaSet controller react. Change the desired count too.

Desired
3
Actual pods

Four words to own

Object
An entry in the API (Pod, Deployment, Service...) with apiVersion, kind, metadata, spec (what you want) and status (what is).
Controller
A program that watches one kind of object and acts until status matches spec. 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.
In real companies

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

1 hour 30, with eleven concept sheets
By the end of this station you can
  • 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.
Think of an airport

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.

Control plane: lab-control-plane kube-apiserver:6443, the only door etcd:2379, Raft, all state kube-schedulerpicks nodes controller-managerreconcile loops static pods from /etc/kubernetes/manifests/ kubelet, containerd, kube-proxy, CNIalso a node, with a NoSchedule taint lab-workerlab-worker2 kubelet kubelet containerd kube-proxy containerd kube-proxy pod pod pod kindnet (CNI) DaemonSet kindnet (CNI) DaemonSet pod network: one IP per pod,no NAT between pods watch
Every arrow starts at the API server. Kubelets and controllers watch the API; they never talk to each other directly.

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
  1. Wide columns. -o wide adds the internal IP, OS, kernel and container runtime (containerd 2.3). Every kubelet runs v1.37.1.
  2. Control plane. Runs the API server, etcd, the scheduler and the controller manager. It carries a NoSchedule taint, so your apps do not land there.
  3. 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
  1. CoreDNS. Two replicas (a Deployment) that answer DNS queries for Services.
  2. 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.
  3. kindnet (CNI). A DaemonSet: one pod per node, using the node's network (its IP is the node IP). It programs routes between nodes.
  4. kube-proxy. A DaemonSet that writes the Service rules on every node.
  5. metrics-server. Added for kubectl top and 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
  1. Static manifests. Editing one of these files restarts that component: this is how you change an API server flag on a kubeadm cluster.
  2. crictl. The container as the runtime sees it, even when the API is down. Your last-resort tool.
  3. Authorization. Node (each kubelet only touches its own pods) plus RBAC (role-based rules).
  4. etcd. The API server is the only client of etcd.
  5. Service range. ClusterIPs are taken from 10.96.0.0/16.
In real companies

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 /livez and /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: Fail can block the whole cluster.
Exam tip
CKA: be able to read its static manifest /etc/kubernetes/manifests/kube-apiserver.yaml and 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-dir for 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 the FailedScheduling event.
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.nodeName set 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 LoadBalancer Service, 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-proxy ConfigMap 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 logs on the node to look underneath Kubernetes. The low-level runtime (runc) creates the namespaces and cgroups.
Limits and pitfalls
docker ps on a modern Kubernetes node shows nothing about pods: use crictl.
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.local to 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 coredns ConfigMap (Corefile). Test: kubectl run dns --rm -it --image=busybox:1.36 --restart=Never -- nslookup kubernetes.default.
Limits and pitfalls
ndots:5 in the pods' /etc/resolv.conf multiplies 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

1 hour, the tool you will use every day
By the end of this station you can
  • 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'.
  1. Current context. The star marks the active context: cluster kind-lab, user kind-lab, default namespace (empty).
  2. 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
  1. Short name. deploy, rs, sts, ds: use them everywhere (kubectl get deploy).
  2. Cluster-scoped objects. Node, PersistentVolume, Namespace, CRD... live at cluster level: -n has no effect on them.
  3. 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.
  1. maxSurge. How many extra pods may exist during the update. A percentage is rounded up.
  2. 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 toCommand
List with more detailkubectl get pods -o wide -n kubelab, -A for all namespaces
See everything of one appkubectl get all,cm,secret,pvc,ing -n kubelab -l app.kubernetes.io/name=kubelab
Understand a statekubectl describe pod <pod>, then read the Events section
Generate YAMLkubectl create deploy web --image=nginx --replicas=2 --dry-run=client -o yaml
Look before you applykubectl diff -f manifests/, then kubectl apply -f manifests/
Extract one valuekubectl get pod x -o jsonpath='{.status.podIP}'
Wait for a statekubectl wait --for=condition=Ready pod -l app=x --timeout=60s
Follow livekubectl get pods -w, kubectl events -w
Check a permissionkubectl auth can-i create deployments -n kubelab --as=jane
Exam productivity

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

  1. Which image does every pod of the cluster run? One line per pod, no other tool than kubectl.
  2. Which objects of the API are cluster-scoped?
  3. What does the field spec.template.spec.topologySpreadConstraints.whenUnsatisfiable accept?
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 · the KUBECONFIG variable 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>, -A for 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 -k for Kustomize). --server-side for Server-Side Apply (each field has an owner, the "field manager"). kubectl diff -f before applying.
Limits and pitfalls
create fails if the object exists, apply updates 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-by on 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=server validates 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 kustomize to 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

2 hours, the objects that run your code
By the end of this station you can
  • 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...ObjectTypical company example
Stateless, N interchangeable copiesDeploymentAPI, front end, queue worker
Stateful, each instance needs its own identity and diskStatefulSetPostgreSQL (through an operator), Kafka, Elasticsearch
Needed once on every nodeDaemonSetFluent Bit, node-exporter, the CNI, Falco
A task that must finishJobSchema migration, import, smoke test
A task on a scheduleCronJobNightly 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
Think of a restaurant

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, exec or grpc; 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. Status Init:0/1 in kubectl 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: Always becomes 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.

Rolling update simulator

Set the replicas and the two limits (a number, or a percentage like 25%), then step through the rollout exactly as the Deployment controller computes it: maxSurge percentages round up, maxUnavailable percentages round down.

Old (1.0.0)
New (1.1.0)

Grey outline: old pod. Dashed: new pod starting. Filled: new pod ready.

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) or Recreate.
Limits and pitfalls
Changing a mounted ConfigMap does not restart the pods: that is why the Helm chart has a checksum annotation. replicas in 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 progressDeadlineSeconds and 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: Never or OnFailure. 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 test runs it now.
Limits and pitfalls
Without timeZone, the time is the controller's (often UTC). Forbid prevents 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

45 minutes, keep configuration out of the image
By the end of this station you can
  • 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"
base64 is not encryption

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: true for 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. data is base64, stringData is 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_NAME and NODE_NAME: the API's answer tells you which pod replied.
How it works
env.valueFrom.fieldRef (metadata.name, spec.nodeName, status.podIP) or resourceFieldRef (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

2 hours, how pods find and protect each other
By the end of this station you can
  • 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 typeReachable fromTypical use
ClusterIPInside the cluster onlyEvery internal service (the default)
NodePortEach node's IP, port 30000-32767Labs, a manual external load balancer
LoadBalancerAn IP from the cloud (or MetalLB)Exposing the Gateway, or a TCP service
clusterIP: None (headless)DNS straight to the podsStatefulSets, clients that pick their pod
ExternalNameA 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 (Service port → pod targetPort, 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, nodePort optional. 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: LoadBalancer plus cloud annotations (internal, subnet...). EXTERNAL-IP appears in kubectl 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.local in the StatefulSet lab.
How it works
clusterIP: None + selector; referenced by the StatefulSet's serviceName.
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, kubelab is enough; from another one, kubelab.kubelab.
How it works
Test with nslookup kubelab.kubelab from 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
Good to know in 2026

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, backendRefs with weight). See manifests/10-httproute.yaml and 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, then curl 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

1 hour, keeping data when pods die
By the end of this station you can
  • 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.
Think of a hotel

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
  1. Order. kubelab-db-0 starts and becomes Ready before kubelab-db-1.
  2. Stable names. Always -0, -1: recreated with the same name and the same volume.
  3. volumeClaimTemplates. One PVC per pod, named <template>-<pod>.
  4. 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
  1. Per-pod DNS. <pod>.<headless service>.<namespace>.svc.cluster.local
  2. 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 allowVolumeExpansion on 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 with reclaimPolicy: Retain for critical data.
How it works
provisioner, parameters, reclaimPolicy (Delete or Retain), volumeBindingMode (WaitForFirstConsumer recommended), allowVolumeExpansion.
Limits and pitfalls
reclaimPolicy: Delete deletes 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
accessModes in the PVC; reclaimPolicy in 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
/tmp as an emptyDir for an app whose image is read-only.
How it works
volumes: - name: tmp, emptyDir: {sizeLimit: 100Mi} (or medium: 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

2 hours, where pods run and with how much
By the end of this station you can
  • 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.

QoS class calculator

Fill in the resources of a single-container pod (leave a field empty to omit it). Kubernetes computes the QoS class, which decides who is evicted first when a node runs out of memory.

In real companies

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, units m (millicores) and Mi/Gi. Compare with real usage with kubectl 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=true nodes; sensitive apps on a dedicated node pool.
How it works
nodeSelector: {disktype: ssd} (simple) or affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution with 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/hostname or topology.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 carries node-role.kubernetes.io/control-plane:NoSchedule; Kubernetes adds node.kubernetes.io/not-ready:NoExecute to 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: 1 on topology.kubernetes.io/zone and on kubernetes.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), then priorityClassName in the pod. system-cluster-critical and system-node-critical already 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
  1. Usage. About 300m per pod, for 50m of requests.
  2. Target exceeded. 595% of the requests on average, for a 70% target.
  3. Scaled out. The HPA took the Deployment to its maximum (maxReplicas 6).
  4. 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 hpa shows TARGETS.
Limits and pitfalls
Without CPU requests the HPA cannot compute a percentage (<unknown>). Do not also set replicas in 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: 2 out of 3, or maxUnavailable: 1.
How it works
policy/v1 PodDisruptionBudget with a selector. kubectl get pdb: ALLOWED DISRUPTIONS. kubectl drain respects 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 quota shows Used / Hard. With a quota on requests.cpu, every pod must declare requests (hence the LimitRange).
Limits and pitfalls
A pod refused by the quota never appears: the ReplicaSet carries the FailedCreate event (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: Container with default, defaultRequest, min, max.
Limits and pitfalls
Applied at creation only: it does not change existing pods.

Security: identities, permissions, pods, network, images

2 hours, defence in depth
By the end of this station you can
  • 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
  1. Role + RoleBinding. The team-kubelab group gets the developer Role in kubelab.
  2. Allowed. patch on deployments is what kubectl rollout restart needs.
  3. Denied. No access to Secrets, on purpose.
  4. 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
  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 ClusterRoles view, edit, admin already 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: false if it never calls the API. Workload Identity (GKE), IRSA / Pod Identity (EKS), Workload Identity (AKS) to reach the cloud without keys.
How it works
serviceAccountName in the pod. Projected tokens are short-lived and audience-bound. kubectl create token <sa> for a temporary token.
Limits and pitfalls
The default ServiceAccount 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
restricted in enforce mode for application namespaces, at least baseline elsewhere; privileged only for system namespaces (CNI, node monitoring).
How it works
Labels pod-security.kubernetes.io/enforce|audit|warn: restricted on the namespace (see manifests/00-namespace.yaml). Warnings are printed by kubectl 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: true on top is recommended by most audits, but restricted does not require it.
How it works
spec.securityContext (pod: runAsUser, fsGroup, seccomp) and containers[].securityContext. The lab and the chart apply all of it.
Limits and pitfalls
readOnlyRootFilesystem breaks 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: Fail blocks 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 (provider kms v2 preferably, or aescbc) 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 verifyImages rejects 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; imagePullSecrets for 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-file on kubeadm.
Limits and pitfalls
RequestResponse on everything produces huge volumes and can contain secrets.
Where the images come from

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

45 minutes, Kubernetes as a platform for platforms
By the end of this station you can
  • 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.
    
  1. New types. Each CRD adds one object type to the API.
  2. Scope. GatewayClass is cluster-wide; Gateway and HTTPRoute are namespaced.
  3. explain. CRDs that ship a schema are documented like built-in objects. weight is 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: Cluster for 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=orphan keeps 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

2 hours, hands on keyboard
By the end of this station you can
  • 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

FileObjectGood practice it shows
00-namespace.yamlNamespacePod Security restricted enforced
01-configmap.yamlConfigMapConfiguration as a variable and as a file
02-secret.yamlSecretTeaching example only: in real life, External Secrets
03-serviceaccount.yamlServiceAccountA dedicated identity, no token mounted
04-deployment.yamlDeploymentThree probes, requests and limits, a full securityContext, topology spread, a rollout that never loses capacity, the Downward API
05-service.yamlServicetargetPort by name
06-pdb.yaml, 07-hpa.yamlPDB, HPAAvailability during drains, elasticity
08-networkpolicy.yamlNetworkPolicyDefault deny, then a targeted opening
09-quota-limitrange.yamlResourceQuota, LimitRangeNamespace guardrails
10-httproute.yamlHTTPRouteGateway API exposure (needs a Gateway controller, so it is left out of the kustomization)
kustomization.yamlKustomizeOne 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
  1. Order. The namespace first, then everything that lives in it: Kustomize sorts by kind.
  2. Deployment. The controller now creates a ReplicaSet, which creates the pods.
  3. 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>
  1. Progress. rollout status waits until all 3 pods are available (probes passing).
  2. Success. Exit code 0: you can use this command as a gate in CI.
  3. 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
  1. ReplicaSet. Created by the Deployment; the suffix is a hash of the pod template.
  2. Service. A stable virtual IP (10.96.x.x) on port 80.
  3. EndpointSlice. The three IPs of the ready pods behind the Service.
  4. PDB. 3 pods, minimum 2: one voluntary eviction allowed at a time.
  5. HPA. <unknown> for the first seconds, until metrics-server reports measurements.
  6. 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
  1. Job finished. COMPLETIONS 1/1: the pod exited with code 0.
  2. CronJob. Scheduled at 3 a.m. Paris time (the timeZone field).
  3. 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
  1. Allowed. probe-allowed carries role: client, so the allow-http-to-kubelab rule lets it in.
  2. Denied. probe-denied ends in Failed: default-deny blocks the connection.
  3. Typical symptom. A packet dropped by a NetworkPolicy shows up as a timeout, not as an immediate refusal.
Where this output comes from

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>
  1. Trigger. Changing the pod template (here the image) creates a new revision.
  2. One at a time. maxSurge 1, maxUnavailable 0: one new pod, it becomes Ready, one old pod leaves.
  3. Old ReplicaSet. Kept at 0 replicas so you can roll back.
  4. New ReplicaSet. The three pods now run 1.1.0.
  5. History. CHANGE-CAUSE is empty: fill it with the kubernetes.io/change-cause annotation.
$ 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
  1. undo. The Deployment takes the pod template of the previous revision again.
  2. Warning. Your YAML file still says 1.0.0... but the next apply will overwrite the live state. In production, fix it in git.
  3. Switch. The old ReplicaSet goes back to 3 and the new one to 0: nothing is rebuilt.
  4. Check. The running image is 1.0.0 again.
What to remember

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/, then rollout status ends 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 with auth can-i) and workloads/psa-test.yaml (it must be refused).
  • set image to 1.1.0, watch the ReplicaSets, then rollout undo.

Helm: why, and how it works

1 hour 30, the package manager of Kubernetes
By the end of this station you can
  • 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).

Think of a recipe and a meal

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.

Criterionkubectl apply (plain YAML)KustomizeHelm
ParametersCopy and pasteOverlays and patchesValues and templates
Versioned, shareable packageNoNo (a git folder)Yes (chart, OCI)
Knows what is installedNoNoRelease and revisions
RollbackPer Deployment onlyBy git reverthelm rollback of the whole app
Hooks (migrations), testsNoNoYes
Third-party softwareRarely providedSometimesThe market standard
ReadabilityHighestGoodDepends 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 3Helm 4What it means for you
Client-side three-way merge--server-side=true by default on install; auto on upgradeThe 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-failureRename it in your pipelines
--force--force-replaceSame: rename it
Unsigned pluginsPlugins 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 (chart version, appVersion), values.yaml, values.schema.json, templates/ (with _helpers.tpl, NOTES.txt, tests/), charts/ (dependencies), crds/.
Limits and pitfalls
version (of the chart) and appVersion (of the app) are two different things: bump version on 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 named sh.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-upgrade blocks every new upgrade (helm rollback unblocks 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.yaml versioned in git; never secrets in them.
How it works
Priority: values.yaml < -f file1 < -f file2 < --set. helm get values <release> --all shows the result. values.schema.json validates types and required fields.
Limits and pitfalls
--set with lists or dots in keys is tricky (--set-string, --set-json). --reuse-values reuses 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 template and helm install --dry-run=server show the result.
Limits and pitfalls
YAML indentation is the first source of errors: nindent is 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 uninstall does 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-succeeded delete policy) if you want --logs to 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.enabled to switch one on.
How it works
dependencies: name, version, repository (https:// or oci://), condition. helm dependency update downloads them into charts/ and writes Chart.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 upgrade earlier, in the pull request.
How it works
--wait waits for resources to be ready (Helm 4 relies on kstatus). helm rollback x 3. helm history to 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. Then helm 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 list does 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=true by default; upgrade: --server-side=auto (keeps the release's method). --wait alone = watcher strategy (kstatus); without --wait, Helm only waits for hooks. --atomic becomes --rollback-on-failure, --force becomes --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

2 hours, install, test, upgrade, roll back, publish
By the end of this station you can
  • 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 fileRole
Chart.yamlName, chart version (0.1.0), appVersion (1.0.0, the default image tag), minimum kubeVersion
values.yamlSafe defaults: 2 replicas, securityContext, probes, PDB, migrations on
values-prod.yamlProduction: 3 replicas, HPA, external Secret, NetworkPolicy, HTTPRoute
values.schema.jsonRefuses invalid values before anything is rendered
templates/_helpers.tplNames, standard labels, image (digest wins over tag)
templates/deployment.yamlThe checksum/config annotation: a ConfigMap change rolls the pods
templates/migration-job.yamlpre-install / pre-upgrade hook
templates/tests/test-connection.yamlThe test run by helm test
templates/NOTES.txtThe 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
  1. Lint passes. Structure, templates and rendering are valid.
  2. Schema. values.schema.json rejects 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
  1. Full name. <release>-<chart>, built by kubelab.fullname.
  2. Standard labels. Generated by kubelab.labels.
  3. Stable selector. Only name and instance: never the version.
  4. 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
  1. Status. deployed: --wait waited until the resources were ready.
  2. Revision. First revision, stored in a Secret of the namespace.
  3. 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
  1. Result. The test pod finished successfully.
  2. 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: ""
  1. helm list. Release, revision, chart and app version.
  2. Resources. 2 replicas (from values.yaml) instead of 3.
  3. Storage. One Secret per revision: this is Helm's memory.
  4. 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
  1. 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
  1. Upgrade. New revision 2.
  2. History. Revision 1 becomes superseded.
  3. 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
  1. Rollback. Every resource goes back to revision 1.
  2. New revision. Revision 3 is a copy of revision 1: history is never rewritten.
  3. 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
  1. Package. A versioned archive of the chart (version 0.1.0).
  2. OCI push. The same kind of registry as for images. --plain-http only because this local registry has no TLS.
  3. Digest. You can install by digest for an immutable deployment.
In real companies

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

3 hours, ten real failures to break and fix
By the end of this station you can
  • 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 debug and crictl.

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.

Triage drill

Ten real symptoms from the lab cluster, in random order. For each one, pick the first command you would run, then the most likely cause. Aim for ten out of ten in under three minutes per case.

The map: from STATUS to the first command

What kubectl get pods showsFirst commandUsual causes
Pendingdescribe pod → EventsRequests too high, PVC Pending, taint without toleration, impossible affinity; a quota (look at the ReplicaSet) if the pod does not even exist
ImagePullBackOffdescribe pod → EventsTag that does not exist, private registry without imagePullSecrets, wrong image name, registry unreachable
CreateContainerConfigErrordescribe podMissing ConfigMap, missing Secret or key, runAsNonRoot on an image that runs as root
CrashLoopBackOfflogs --previousConfiguration error, missing variable, OOMKilled (137), liveness too strict, wrong command
Running but 0/1describe pod → probesWrong probe path or port, a dependency down, slow startup
Ready, but no trafficget endpointslicesSelector does not match labels, wrong targetPort, NetworkPolicy, DNS or namespace
Nothing obviousone floor downkubectl get events -A --sort-by=.lastTimestamp, describe node, kubectl top, kubectl debug, and on the node crictl, journalctl -u kubelet
Exit codeMeaning
0Normal end (a Job that succeeded, or an app that stops when it should not)
1, 2...An application error: read the logs
126 / 127Command not executable / not found (command, args, image)
137Killed by SIGKILL (128 + 9): OOMKilled if that is the Reason, or the end of the grace period
143SIGTERM 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
  1. Symptom. Kubernetes keeps retrying with a growing delay.
  2. 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 imagePullSecrets for a private registry.
  3. 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
  1. Symptom. RESTARTS keeps growing, with a longer wait between restarts.
  2. Cause. "DATABASE_URL variable missing": logs --previous shows the log of the container that crashed. Fix: add the variable (secretKeyRef).
  3. 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
  1. Killed. The request made the app allocate 100 MiB: the kernel killed the process.
  2. Restart. The pod stays, the container restarts.
  3. Cause. Fix: raise limits.memory or fix the leak. Measure with kubectl 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:
  1. Symptom. The pod is never placed.
  2. Cause. FailedScheduling explains node by node: 1 taint (the control plane) and 2 without enough CPU. Fix: realistic requests, or more nodes.
  3. Capacity. describe node shows 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
  1. Symptom. The container runs but is never ready, so the Service sends it no traffic.
  2. 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
  1. Symptom. Empty EndpointSlice: the Service has nowhere to send traffic.
  2. Pod labels. The pods carry app=web...
  3. Cause. ...but the selector looks for app=website.
  4. 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
  1. Symptom. The container cannot be created.
  2. Cause. Create the ConfigMap (or mark the reference optional: true if 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
  1. PVC Pending. The volume is never provisioned...
  2. Pod Pending. ...so the pod waits too.
  3. 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
  1. Symptom. A 403 from the API, naming the user, the verb, the resource and the namespace.
  2. Fix. A Role (get, list, watch on pods) and a RoleBinding to the ServiceAccount.
  3. 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)
  1. Symptom. The app is not broken: Kubernetes is killing it.
  2. Cause. The probe fails while the app is still starting...
  3. 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}}
The fix for scenario 10, in words

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
  1. Target. Shares the process namespace of the app container.
  2. Processes. From the debug container you can see the app process (uid 65532).
  3. Pod network. localhost:8080 answers: same network as the app.
  4. 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
  1. Scheduling. Pending: resources, PVC.
  2. Probes. Unhealthy: readiness or liveness.
  3. Storage. Volume provisioning.
  4. 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 of kubectl 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 pod for any pod that is not Running/Ready; describe node for 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 logs is 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
--previous is 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 --profile compatible 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, logs and events only, 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

3 hours, the work that keeps the platform alive
By the end of this station you can
  • 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>
  1. cordon. No new pod will be scheduled on this node.
  2. Eviction. Through the eviction API, so the PDB is respected (4 evictions allowed here, because the HPA had scaled to 6 pods).
  3. DaemonSets. kindnet and kube-proxy stay: --ignore-daemonsets.
  4. State. The node is empty and unschedulable: ready for maintenance.
  5. 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
  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 │
└──────────┴──────────┴────────────┴────────────┴─────────┘
  1. Members. A single member here; 3 or 5 in production.
  2. Endpoint status. Version, database size, Raft leader.
  3. Snapshot. A consistent copy of the whole database. Copy it off the node.
  4. 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:

  1. Restore the snapshot into a new directory. etcdutl snapshot restore /backup/etcd.db --data-dir /var/lib/etcd-restore
  2. Point etcd at it. Edit /etc/kubernetes/manifests/etcd.yaml so the hostPath of the data volume is /var/lib/etcd-restore. The kubelet restarts the static pod.
  3. 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. 1 year. Component certificates expire after one year.
  2. 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
  1. Checks. kubeadm checks the cluster's health (it runs a test Job).
  2. Plan. Already up to date: nothing to apply.
  3. Offline. The node had no Internet access in the capture sandbox, so kubeadm falls back to its own version.
  1. 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).
  2. First control plane. Back up etcd. Upgrade the kubeadm package, run kubeadm upgrade plan, then kubeadm upgrade apply v1.x.y.
  3. Every other node. On secondary control planes and on workers, upgrade kubeadm, then run kubeadm upgrade node.
  4. Kubelets, one node at a time. kubectl drain → upgrade kubelet and kubectl → systemctl daemon-reload && systemctl restart kubelet → kubectl uncordon.
  5. 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
  1. Configuration. systemd drop-ins: the arguments added by kubeadm.
  2. State. The first thing to check when a node is NotReady.
  3. 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:
  1. top nodes. The control plane uses the most (API server, etcd).
  2. top pods. Sorted by memory.
  3. 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 join on 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 node on 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, syncPolicy automated + 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

45 minutes, the checklist a senior reviewer applies
By the end of this station you can
  • 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 edit in production; changes reviewed in pull requests.
  • Observability. JSON logs on stdout, a /metrics endpoint, 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-patternWhy it hurtsDo this instead
image: app:latestNobody knows what runs; rollback is impossibleA version tag plus the digest
No requestsBlind scheduling, a useless HPA, BestEffort pods evicted firstMeasured requests (the VPA in recommendation mode helps)
Liveness that checks the databaseA database outage restarts the whole fleetMinimal liveness, dependencies in readiness
Secrets in git or in values.yamlA permanent leak (git history never forgets)External Secrets, Sealed Secrets, SOPS
cluster-admin for the CICompromising the CI compromises the clusterA namespace-scoped ServiceAccount, or pull-based GitOps
One replica, no PDBAn outage at every drain or node upgrade2+ replicas, a PDB, topology spread
kubectl edit in productionInvisible drift, overwritten at the next deploymentChange git, let the pipeline deploy

Preparing CKA, CKAD and CKS

30 minutes to plan, weeks to practise
By the end of this station you can
  • 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.

ExamDomains and weightsWhere 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
Exam strategy

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

45 minutes for the exam, weeks for the challenges
By the end of this station you can
  • 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 image then rollout undo.
  • Prove the NetworkPolicy. probe-allowed succeeds, probe-denied fails after about a minute.
  • StatefulSet, RBAC, Pod Security. statefulset.yaml, rbac-developer.yaml (with auth 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: 3 to 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 message in values.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.
Ix

Concept index

Every concept sheet of the course, searchable

Type a word (pvc, rbac, hook, drain, probe...) to filter. Click a concept to jump to its sheet, already open.

    Aa

    Glossary

    Every term used in the course, in one sentence
    →

    Resources and next steps

    Where to go deeper

    The lab

    Official documentation

    Certifications

    • CKA, CKAD, CKS (official pages, curricula and current prices)

    The DevSecOps Learning Series

    Kubernetes runs the images; the two other courses build, scan, sign and ship them.