Kubernetes Learning Path
In short: six milestones on a free local cluster. Each lists what to build, a command that proves it works, and a complete solution script.
Finish the Docker learning path
first. Create the practice cluster,
then for each milestone read the listed cheat sheet
sections and work in the practice namespace. Start each milestone clean:
kubectl delete namespace practice --ignore-not-found
kubectl create namespace practice
kubectl config set-context --current --namespace practice
The plan
| Milestone | You learn | You prove it with |
|---|---|---|
| 1 | Pods and Deployments | An app that heals itself and scales |
| 2 | Services and labels | Traffic reaching every replica by one name |
| 3 | ConfigMaps and Secrets | Settings changed without rebuilding the image |
| 4 | Probes, resources, rollouts | A bad release stopped and rolled back |
| 5 | Storage, Jobs, CronJobs | Data that outlives pods; a scheduled task |
| 6 | Operating a namespace | Broken pods diagnosed; least-privilege access; a staging overlay |
| Final | Everything | Your own app, deployed properly |
Milestone 1: Workloads
Learn: What Kubernetes is · kubectl basics · Pods · Deployments · YAML manifests
Tasks:
- Write a manifest
shop.yamlfor a Deploymentshoprunning 3 replicas ofnginx:1.27, and apply it. - Delete two of its pods at once and watch them come back.
- Scale it to 5 by editing the file (not with
kubectl scale) and applying again.
Check: kubectl get deployment shop shows 5/5 ready, and the pods you
deleted were replaced with new names.
Solution
cat > shop.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop
spec:
replicas: 3
selector:
matchLabels: {app: shop}
template:
metadata:
labels: {app: shop}
spec:
containers:
- name: web
image: nginx:1.27
EOF
kubectl apply -f shop.yaml
kubectl rollout status deployment/shop
kubectl delete $(kubectl get pods -l app=shop -o name | head -2) # delete two pods
kubectl rollout status deployment/shop # replaced automatically
sed -i.bak 's/replicas: 3/replicas: 5/' shop.yaml
kubectl apply -f shop.yaml
kubectl rollout status deployment/shop
kubectl get deployment shop -o jsonpath='{.status.readyReplicas}'; echo # 5
Check yourself:
- Which object actually recreated the deleted pods?
- Why is editing the file and re-applying better than
kubectl scalein a team?
Milestone 2: Services & labels
Learn: Services · Labels & selectors · Namespaces & contexts
Tasks:
- Deploy 3 replicas of
hashicorp/http-echo, each answering with its own pod name. - Put a Service
echoin front of them. - From a client pod, call the Service 20 times and count how many different pods answered.
Check: the count is more than 1 — one name, many pods behind it.
Solution
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
spec:
replicas: 3
selector:
matchLabels: {app: echo}
template:
metadata:
labels: {app: echo}
spec:
containers:
- name: echo
image: hashicorp/http-echo:1.0
args: ["-listen=:5678", "-text=$(POD_NAME)"]
env:
- name: POD_NAME
valueFrom:
fieldRef: {fieldPath: metadata.name}
---
apiVersion: v1
kind: Service
metadata:
name: echo
spec:
selector: {app: echo}
ports:
- port: 80
targetPort: 5678
EOF
kubectl rollout status deployment/echo
kubectl run client --image=busybox:1.36 --restart=Never -- \
sh -c 'for i in $(seq 20); do wget -qO- http://echo; done'
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/client --timeout=60s
kubectl logs client | sort -u | wc -l # 2 or 3 different pods answered
kubectl apply -f - reads the manifest from the command itself (the part
between <<'EOF' and EOF), which keeps the solution in one script.
Check yourself:
- What does the Service use to decide which pods get traffic?
- What happens to traffic if you change one pod's
applabel?
Milestone 3: Configuration
Learn: ConfigMaps & Secrets
Tasks:
- Create a ConfigMap
shop-configwithTHEME=light, and a Secretshop-secretwithAPI_KEY=abc123. - Run a Deployment whose container prints both values every few seconds.
- Change the theme to
darkand make the running app pick it up.
Check: the latest log line says theme=dark, and the API key never
appears in the Deployment's YAML (kubectl get deploy -o yaml).
Solution
kubectl create configmap shop-config --from-literal=THEME=light
kubectl create secret generic shop-secret --from-literal=API_KEY=abc123
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: printer
spec:
replicas: 1
selector:
matchLabels: {app: printer}
template:
metadata:
labels: {app: printer}
spec:
terminationGracePeriodSeconds: 2 # this shell loop ignores SIGTERM; don't wait the default 30 s
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "while true; do echo theme=$THEME key=$API_KEY; sleep 2; done"]
envFrom:
- configMapRef: {name: shop-config}
- secretRef: {name: shop-secret}
EOF
kubectl rollout status deployment/printer
sleep 3 && kubectl logs deployment/printer --tail=1 # theme=light key=abc123
kubectl create configmap shop-config --from-literal=THEME=dark --dry-run=client -o yaml | kubectl replace -f -
kubectl rollout restart deployment/printer # env vars are read only at start
kubectl rollout status deployment/printer
sleep 5 && kubectl logs deployment/printer --tail=1 # theme=dark key=abc123
kubectl get deployment printer -o yaml | grep abc123 || echo "not in the Deployment" # the value lives only in the Secret
Check yourself:
- Why did the app need a restart to see the new theme?
- Who in your team should be allowed to read
shop-secret?
Milestone 4: Health, resources & rollouts
Learn: Health checks · Requests & limits · Rolling updates & rollbacks
Tasks:
- Deploy
nginx:1.27with 3 replicas, a readiness probe, requests and limits, and a rolling-update strategy that never drops below 3 ready pods. - Release
nginx:1.27-alpineand watch it roll out. - Release a broken image tag. Show that the 3 old pods keep running, then roll back.
Check: after the rollback the image is nginx:1.27-alpine, 3/3 pods are
ready, and the QoS class is Burstable.
Solution
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
selector:
matchLabels: {app: web}
template:
metadata:
labels: {app: web}
spec:
containers:
- name: nginx
image: nginx:1.27
readinessProbe:
httpGet: {path: /, port: 80}
periodSeconds: 2
resources:
requests: {cpu: 50m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
EOF
kubectl rollout status deployment/web
kubectl set image deployment/web nginx=nginx:1.27-alpine
kubectl rollout status deployment/web
kubectl set image deployment/web nginx=nginx:no-such-tag
sleep 15
kubectl get deployment web -o jsonpath='{.status.readyReplicas}'; echo # 3 — old pods still serve
kubectl rollout undo deployment/web
kubectl rollout status deployment/web
kubectl get deployment web -o jsonpath='{.spec.template.spec.containers[0].image}'; echo # nginx:1.27-alpine
kubectl get pods -l app=web -o jsonpath='{.items[0].status.qosClass}'; echo # Burstable
Check yourself:
- Which setting guaranteed the broken release never reduced capacity?
- What would happen without a readiness probe?
Milestone 5: Storage & jobs
Learn: Storage · Jobs & CronJobs · StatefulSets
Tasks:
- Create a 1 GiB PersistentVolumeClaim
reports. - Run a Job that writes a report file into it.
- Run a second, separate Job that reads the file — proving the data outlived the first pod.
- Create a CronJob that would run the report every night at 02:00.
Check: the reader Job's log shows the report line, and
kubectl get cronjob nightly-report shows the schedule 0 2 * * *.
Solution
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reports
spec:
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 1Gi}
EOF
job() { # job <name> <shell command> — a Job with the reports volume at /reports
kubectl apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: $1
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: task
image: busybox:1.36
command: ["sh", "-c"]
args: ['$2']
volumeMounts: [{name: data, mountPath: /reports}]
volumes:
- name: data
persistentVolumeClaim: {claimName: reports}
EOF
kubectl wait --for=condition=complete "job/$1" --timeout=120s
}
job writer 'echo "sales report: 42 orders" > /reports/today.txt'
job reader 'cat /reports/today.txt'
kubectl logs job/reader # sales report: 42 orders
kubectl create cronjob nightly-report --image=busybox:1.36 --schedule='0 2 * * *' -- \
sh -c 'echo "report at $(date)"'
kubectl get cronjob nightly-report -o jsonpath='{.spec.schedule}'; echo # 0 2 * * *
Check yourself:
- What would the reader have printed without the PVC?
- How would you run the CronJob once, right now, to test it?
Milestone 6: Operate a namespace
Learn: Troubleshooting · Common mistakes · Access control (RBAC) · Kustomize & Helm
Tasks:
- Create three broken pods — one crashing, one with a bad image, one asking for more CPU than exists — and diagnose each using only
get,describe, andlogs. - Create a ServiceAccount
viewerthat can read pods and logs but cannot delete anything. - Make a Kustomize overlay that deploys the Milestone 1 app as
staging-shopwith 1 replica.
Check: you can name each broken pod's cause; kubectl auth can-i
answers yes for reading and no for deleting; kubectl get deploy shows
staging-shop with 1 replica.
Solution
kubectl run crashy --image=busybox:1.36 -- sh -c 'echo "missing DATABASE_URL"; exit 1'
kubectl run typo --image=ngnix:1.27
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: greedy
spec:
containers:
- name: app
image: nginx:1.27
resources:
requests: {cpu: "64"}
EOF
sleep 20
kubectl get pods crashy typo greedy # Error/CrashLoopBackOff · ImagePullBackOff · Pending
kubectl logs crashy # missing DATABASE_URL
kubectl describe pod typo | grep -m1 "Failed to pull" # …ngnix… — a typo in the image name
kubectl describe pod greedy | grep -m1 "Insufficient cpu" # 0/1 nodes are available: 1 Insufficient cpu
kubectl create serviceaccount viewer
kubectl create role pod-viewer --verb=get,list,watch --resource=pods,pods/log
kubectl create rolebinding viewer-binding --role=pod-viewer --serviceaccount=practice:viewer
kubectl auth can-i get pods/log --as=system:serviceaccount:practice:viewer # yes
kubectl auth can-i delete pods --as=system:serviceaccount:practice:viewer || true # no
mkdir -p base staging
cat > base/shop.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop
spec:
replicas: 3
selector:
matchLabels: {app: shop}
template:
metadata:
labels: {app: shop}
spec:
containers:
- name: web
image: nginx:1.27
EOF
printf 'resources:\n - shop.yaml\n' > base/kustomization.yaml
cat > staging/kustomization.yaml <<'EOF'
resources:
- ../base
namePrefix: staging-
replicas:
- name: shop
count: 1
EOF
kubectl apply -k staging
kubectl rollout status deployment/staging-shop
kubectl get deployment staging-shop -o jsonpath='{.spec.replicas}'; echo # 1
Check yourself:
- Which command told you why each pod was broken?
- Why give the viewer
pods/logseparately frompods?
Final project
In short: deploy an app of your own the way a production team would.
- Take the image from your Docker final project
and load it into kind (
kind load docker-image <name>:<tag> --name learn). - Write manifests for: a Deployment (2+ replicas, probes, requests and limits, rolling-update strategy), a Service, a ConfigMap, a Secret, and a database as a StatefulSet with a PVC.
- Add a Job that runs database migrations, and a CronJob for a nightly clean-up.
- Give the app its own ServiceAccount with no API access
(
automountServiceAccountToken: false). - Make
base/andstaging/Kustomize folders; staging uses 1 replica and a different ConfigMap value. - Release a new version, then a broken one, and roll back — without the Service ever losing all its ready pods.
You're done when: kubectl apply -k staging on a fresh cluster brings up
a working app, and you can explain every field in your manifests.
Next: read Troubleshooting and the interview questions in the complete guide.