Introduction
You know Docker. You’ve written Compose files. You’ve built images, connected containers, and mounted volumes. Now your app needs to survive the real world — scaling, self-healing, zero-downtime deploys, secrets management, and multi-service coordination at scale. That’s what this book teaches.
What You’ll Build
By the end of this book, you’ll have deployed KubeShop — a five-service microservices e-commerce app — to both a local Minikube cluster and a production-grade Azure Kubernetes Service (AKS) cluster, complete with Helm packaging, autoscaling, RBAC, and a full CI/CD pipeline.
graph LR
U([User]) --> W[web :3000\nAPI Gateway]
W --> C[catalogue :3001]
W --> CA[cart :3002]
W --> O[orders :3003]
W --> P[payment :5000]
C --> MDB1[(MongoDB)]
O --> MDB2[(MongoDB)]
CA --> R[(Redis)]
The stack: Node.js · Python · MongoDB · Redis · Docker · Kubernetes · Helm · GitHub Actions · ArgoCD · AKS
Prerequisites
You need these before starting. Run the check commands — if they pass, you’re good to go.
| Tool | Version | Check | Expected Output |
|---|---|---|---|
| Docker | 20+ | docker --version | Docker version 20.x... |
| kubectl | 1.28+ | kubectl version --client | Client Version: v1.28... |
| Minikube | 1.32+ | minikube version | minikube version: v1.3... |
| Helm | 3.x | helm version | version.BuildInfo{Version:"v3... |
| Git | any | git --version | git version 2... |
💡 Tip: On Ubuntu/Debian, the quickest setup is:
# kubectl curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl # minikube curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 sudo install minikube-linux-amd64 /usr/local/bin/minikube # helm curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
How to Use This Book
Each chapter is designed to take 30–60 minutes and ends with a hands-on lab. The pattern is always:
Concept (short) → Example (immediate) → Try it → Break it
- Read linearly if you’re new to K8s.
- Jump around if you have experience — every section is self-contained.
- Do the labs. Reading about K8s is like reading about swimming. You have to get in the water.
🔥 The “Break It!” philosophy: The fastest way to understand a system is to watch it fail. Every chapter has intentional breakage exercises. Don’t skip them.
Chapter Roadmap
| # | Chapter | Time | You’ll Be Able To |
|---|---|---|---|
| 1 | The Container Orchestration Problem | ~45 min | Explain why K8s exists and navigate a live cluster |
| 2 | kubectl — Your Swiss Army Knife | ~40 min | Query and control any K8s resource from the CLI |
| 3 | Pods — The Atomic Unit | ~50 min | Create, debug, and destroy pods with confidence |
| 4 | Workload Controllers | ~55 min | Deploy apps with zero-downtime rolling updates |
| 5 | Services | ~45 min | Connect pods across namespaces using DNS |
| 6 | Ingress — HTTP Routing | ~50 min | Route external traffic with NGINX and TLS |
| 7 | ConfigMaps and Secrets | ~40 min | Externalize config and manage sensitive data |
| 8 | Storage | ~45 min | Persist data across pod restarts |
| 9 | KubeShop Project | ~60 min | Containerize a real 5-service microservice app |
| 10 | Deploying KubeShop | ~60 min | Deploy a full microservices stack to Minikube |
| 11 | Health Checks & Self-Healing | ~45 min | Configure probes and run chaos experiments |
| 12 | Helm | ~55 min | Package and template K8s apps with Helm |
| 13 | Scheduling & Autoscaling | ~50 min | Autoscale under load and control pod placement |
| 14 | Security | ~55 min | Lock down a cluster with RBAC and Network Policies |
| 15 | Observability | ~60 min | Set up Prometheus, Grafana, and log aggregation |
| 16 | CI/CD with GitHub Actions | ~55 min | Build a full GitOps pipeline with ArgoCD |
| 17 | Azure Kubernetes Service | ~60 min | Deploy KubeShop to a production cloud cluster |
| 18 | K8s Internals | ~40 min | Explain what happens when you run kubectl apply |
| 19 | Troubleshooting Playbook | ~35 min | Debug any pod, network, or storage failure |
Ready? Let’s go to Chapter 1. →
Chapter 1: The Container Orchestration Problem
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Explain why Kubernetes exists, describe every component of a K8s cluster, and navigate a live Minikube cluster using
kubectl.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 1.1 | From Docker to Orchestration — Why Compose Isn’t Enough | ~8 min |
| 1.2 | Kubernetes Architecture — The 10,000ft View | ~8 min |
| 1.3 | Control Plane Deep Dive | ~6 min |
| 1.4 | Worker Nodes and the Kubelet | ~5 min |
| 1.5 | 🔬 Lab: Your First Cluster — Minikube Setup & Exploration | ~30 min |
Prerequisites
- Docker installed and working (
docker pssucceeds) kubectlinstalled (kubectl version --client)minikubeinstalled (minikube version)- No prior Kubernetes experience needed
1.1 From Docker to Orchestration — Why Compose Isn’t Enough
⏱️ ~8 min read
TL;DR: Docker Compose is a great local tool, but it breaks down the moment you need resilience, scaling, or multi-host deployments. Kubernetes solves all of that — systematically.
You Already Know How to Do This
Here’s a dead-simple Docker Compose file that runs a web app and its database:
# docker-compose.yml
services:
web:
image: myapp:latest
ports:
- "3000:3000"
environment:
- DB_HOST=db
depends_on:
- db
db:
image: mongo:7
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
Run docker compose up and it works. Beautifully simple.
Now your app gets traffic. Real traffic. And things start to go wrong.
The Five Walls You’ll Hit
Wall 1: No Self-Healing
Your web container crashes at 3am. Compose does nothing. It’s dead until someone manually restarts it.
🔗 Docker Parallel:
restart: alwaysin Compose helps, but only on the same host. If the host dies, so does everything on it.
Wall 2: Manual Scaling
You need 10 instances of web to handle load:
docker compose up --scale web=10
OK — but now all 10 are on the same machine, competing for the same CPU and RAM. You’ve created 10x the problem.
Wall 3: Rolling Updates Are Your Problem
Deploying a new version means downtime unless you write complex scripts yourself. Compose has no concept of “replace containers one at a time while keeping traffic flowing.”
Wall 4: Single Host
Compose is a single-machine tool. Your app is bound to one server. If that server goes down, everything goes down. Scale beyond one machine? You’re on your own.
Wall 5: No Load Balancing
Who distributes traffic across your 10 web containers? Compose doesn’t. You’d need to set up Nginx or HAProxy yourself, manually update configs when containers come and go.
What Kubernetes Gives You
Kubernetes is what happens when you solve all five walls systematically.
graph LR
subgraph "Docker Compose World"
DC[Your Laptop\nor Single VM]
DC --> C1[container 1]
DC --> C2[container 2]
end
subgraph "Kubernetes World"
CP[Control Plane\nK8s Brain]
N1[Node 1]
N2[Node 2]
N3[Node 3]
CP --> N1
CP --> N2
CP --> N3
N1 --> P1[Pod] & P2[Pod]
N2 --> P3[Pod] & P4[Pod]
N3 --> P5[Pod]
end
| Problem | Compose | Kubernetes |
|---|---|---|
| Container crashes | Stays dead (or restarts on same host only) | Automatically restarted anywhere in the cluster |
| Need more instances | Manual --scale, same machine | kubectl scale, spread across nodes |
| Deploy new version | Downtime or custom scripts | Built-in rolling updates |
| Multi-host | Not supported | Native — designed for it |
| Load balancing | DIY | Built in via Services |
| Secrets management | .env files | Encrypted Secrets objects |
| Health checks | Basic healthcheck: | Liveness, readiness, startup probes |
The Mental Shift: Desired State
The single most important concept in Kubernetes:
You declare what you want. Kubernetes makes it happen and keeps it that way.
In Docker Compose, you issue commands: “start this container”, “stop that container.” You’re in control.
In Kubernetes, you write a declaration: “I want 5 instances of this app running at all times.” Then you walk away. Kubernetes watches the cluster 24/7 and reconciles reality with your declaration.
graph LR
Y[You write YAML:\n'I want 5 replicas'] --> K[Kubernetes API]
K --> CM[Controller Manager\nconstantly watches]
CM -->|"Reality: 5 pods running\n✅ Nothing to do"| OK[All good]
CM -->|"Reality: 3 pods running\n❌ 2 missing"| FIX[Create 2 new pods]
This is called the reconciliation loop — or more precisely, the control loop. It runs constantly, forever, for every resource in your cluster.
🔗 Docker Parallel: There’s no equivalent in Compose. The closest thing is
restart: always, but that only handles crashes, not desired-count enforcement.
Try It
Before we write any Kubernetes configs, let’s see Compose’s limit firsthand:
# Kill a container and watch Compose do nothing (without restart policy)
docker run -d --name test-no-restart nginx
docker kill test-no-restart
docker ps -a # Status: Exited
Expected output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
abc123 nginx ... ... Exited (137) 5 seconds ago test-no-restart
Dead. Staying dead. Now you understand why you need Kubernetes.
# Cleanup
docker rm test-no-restart
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Compose is single-host | It cannot span multiple machines |
| 2 | No self-healing in Compose | Crashed containers stay crashed |
| 3 | Desired State | K8s continuously reconciles reality with your declaration |
| 4 | K8s is an orchestrator | It manages containers across a cluster of machines |
✅ Quick Check
Q1: You have 3 replicas of your app running. You manually delete one. What does Kubernetes do?
Answer
Kubernetes detects that the actual state (2 replicas) differs from the desired state (3 replicas) and immediately creates a new pod to restore the count. This happens automatically, typically within seconds.Q2: Your team uses docker-compose up --scale web=5. Why is this insufficient for production?
Answer
All 5 containers run on the same machine, sharing its CPU/RAM. There's no multi-host distribution, no load balancer, and if the machine fails, all 5 instances die simultaneously. It also doesn't provide rolling updates or automatic recovery.Q3: What happens in Kubernetes if you don’t explicitly tell it to stop something?
Answer
It keeps running indefinitely. The control loop ensures the desired state is maintained. If you want something gone, you delete the resource declaration — Kubernetes then reconciles by terminating the pods.1.2 Kubernetes Architecture — The 10,000ft View
⏱️ ~8 min read
TL;DR: A Kubernetes cluster has two kinds of machines: a Control Plane (the brain) and Worker Nodes (the muscle). Everything you do goes through the API Server.
The Two-Tier Architecture
Every Kubernetes cluster, whether it’s running on your laptop via Minikube or powering Netflix in production, follows the same fundamental architecture.
graph TB
subgraph "Control Plane (The Brain)"
API[🔵 API Server\nkube-apiserver]
ETCD[🗃️ etcd\nCluster Store]
SCHED[📅 Scheduler\nkube-scheduler]
CM[⚙️ Controller Manager\nkube-controller-manager]
API <-->|read/write| ETCD
API --> SCHED
API --> CM
end
subgraph "Worker Node 1"
KB1[Kubelet]
KP1[kube-proxy]
CR1[Container Runtime\ncontainerd / CRI-O]
P1[Pod A]
P2[Pod B]
KB1 --> CR1 --> P1 & P2
end
subgraph "Worker Node 2"
KB2[Kubelet]
KP2[kube-proxy]
CR2[Container Runtime]
P3[Pod C]
KB2 --> CR2 --> P3
end
YOU([👤 You]) -->|kubectl| API
API -->|instructions| KB1 & KB2
The Control Plane — The Brain
The control plane runs on dedicated machines (usually 1–3 for HA). It makes all the cluster-wide decisions. You never run application workloads on control plane nodes.
| Component | Role | Analogy |
|---|---|---|
| API Server | The single entry point for all cluster operations | Hotel reception desk — every request goes through here |
| etcd | Distributed key-value store — the source of truth for all cluster state | The hotel’s booking ledger |
| Scheduler | Decides which node a new pod runs on | Hotel concierge assigning rooms |
| Controller Manager | Runs control loops that watch state and reconcile | Floor supervisors who enforce the rules |
📝 Note: The control plane components communicate only through the API Server. Nothing talks to etcd directly except the API Server.
Worker Nodes — The Muscle
Worker nodes are where your application pods actually run. A cluster can have 1 to thousands of them.
| Component | Role |
|---|---|
| Kubelet | The K8s agent on each node — receives pod specs and ensures containers are running |
| kube-proxy | Maintains network rules so pods can communicate via Services |
| Container Runtime | Actually runs containers (containerd, CRI-O, Docker Engine via shim) |
🔗 Docker Parallel: The container runtime on a worker node is doing the same job as the Docker daemon on your machine — pulling images and running containers. The Kubelet is the layer above it telling it what to run.
The Flow: What Happens When You Run kubectl apply
Let’s trace a deployment request through the entire system:
sequenceDiagram
participant You
participant API as API Server
participant ETCD as etcd
participant SCHED as Scheduler
participant CM as Controller Manager
participant KL as Kubelet (Node)
You->>API: kubectl apply -f deployment.yaml
API->>ETCD: Store deployment spec
API-->>You: "deployment created"
CM->>API: Watch for new deployments
API-->>CM: "new Deployment found"
CM->>API: Create ReplicaSet → create Pods
API->>ETCD: Store pending pods
SCHED->>API: Watch for unscheduled pods
API-->>SCHED: "3 unscheduled pods"
SCHED->>API: Assign pods to nodes
API->>ETCD: Update pod → node assignment
KL->>API: Watch for pods assigned to my node
API-->>KL: "here are your pods"
KL->>KL: Pull image, start containers
KL->>API: Report pod status: Running
The key insight: everything is mediated by the API Server and persisted in etcd. Components don’t talk to each other directly — they all watch the API Server for changes.
Minikube: One Machine, Full Cluster
For local development, Minikube runs the entire cluster (control plane + one worker node) inside a single VM or Docker container on your machine.
Your Laptop
└── Minikube VM / Docker Container
├── Control Plane (kube-apiserver, etcd, scheduler, controller-manager)
└── Worker Node (kubelet, kube-proxy, containerd)
└── Your pods run here
This is why Minikube is perfect for learning — you get a real K8s cluster with no cloud bills.
⚠️ Warning: Minikube’s single-node setup means control plane and workloads share resources. Never use Minikube in production.
Try It
# Start minikube (if not already running)
minikube start
# See all cluster components
kubectl get pods -n kube-system
Expected output:
NAME READY STATUS RESTARTS AGE
coredns-5dd5756b68-xxxxx 1/1 Running 0 5m
etcd-minikube 1/1 Running 0 5m
kube-apiserver-minikube 1/1 Running 0 5m
kube-controller-manager-minikube 1/1 Running 0 5m
kube-proxy-xxxxx 1/1 Running 0 5m
kube-scheduler-minikube 1/1 Running 0 5m
storage-provisioner 1/1 Running 0 5m
You’re looking at the control plane components running as pods. Yes — K8s runs its own control plane inside itself (except etcd, which runs as a static pod directly managed by the kubelet).
# Inspect your single node
kubectl get nodes
Expected output:
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane 5m v1.30.x
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Control Plane | The brain — makes decisions, never runs your workloads |
| 2 | Worker Nodes | The muscle — where your pods actually run |
| 3 | API Server | Every operation goes through it; it’s the cluster’s front door |
| 4 | etcd | The single source of truth for all cluster state |
| 5 | Minikube | Packs the entire cluster into one machine for local dev |
✅ Quick Check
Q1: You submit a pod spec with kubectl apply. Which component decides which node the pod runs on?
Answer
The **Scheduler** (`kube-scheduler`). It watches for unscheduled pods and assigns them to nodes based on available resources, affinity rules, taints, and tolerations.Q2: etcd goes down in your production cluster. What happens?
Answer
The cluster becomes read-only effectively — existing workloads keep running (kubelets continue managing their pods locally), but no new changes can be made. New pods cannot be scheduled, deleted resources won't be removed, and `kubectl` commands will fail. This is why production clusters run etcd with 3 or 5 replicas.Q3: A worker node loses network connectivity to the control plane. What happens to the pods already running on it?
Answer
The pods keep running — the kubelet manages them locally and doesn't need constant control-plane connectivity to keep existing containers alive. However, after ~5 minutes (the node eviction timeout), the control plane marks the node as `NotReady` and starts scheduling replacement pods on other healthy nodes.1.3 Control Plane Deep Dive
⏱️ ~6 min read
TL;DR: Four components run the K8s control plane. Understanding what each one does — and what breaks when it’s gone — will save you hours of debugging.
The Four Components, Explained
🔵 API Server (kube-apiserver)
The API Server is the only component that talks to etcd. Everything else — the scheduler, controller manager, kubelet, kubectl — talks to the API Server, never directly to each other.
graph LR
kubectl --> API
Scheduler --> API
CM[Controller Manager] --> API
Kubelet --> API
API <--> etcd
What it does:
- Validates and processes all API requests (authentication, authorization, admission control)
- Reads/writes cluster state to etcd
- Exposes the Kubernetes REST API on port
6443
What breaks without it: Everything. You can’t do anything. Existing pods keep running (kubelet is independent), but no changes are possible.
🗃️ etcd
etcd is a distributed key-value store used as Kubernetes’ backing store for all cluster data. Every object you create — every Pod, Service, ConfigMap, Secret — is stored here as JSON.
# What etcd stores (conceptually):
/registry/pods/default/my-nginx-pod → { "kind": "Pod", "status": "Running", ... }
/registry/deployments/default/my-app → { "kind": "Deployment", "replicas": 3, ... }
/registry/services/default/my-svc → { "kind": "Service", "clusterIP": "10.96.0.1", ... }
Key facts:
- Uses the Raft consensus algorithm — needs a majority (quorum) of members to be healthy
- Always run 3 or 5 instances in production (never 2 or 4 — even numbers create split-brain risk)
- Back up etcd regularly — losing it means losing all cluster state
⚠️ Warning: etcd is not a general-purpose database. It’s purpose-built for small, critical configuration data. Don’t store application data in it.
📅 Scheduler (kube-scheduler)
The scheduler watches for Pending pods (pods with no node assigned) and decides where to run them.
graph TD
A[New Pod: Pending\nno node assigned] --> B{Scheduler}
B --> C[Filter: Which nodes\ncan run this pod?]
C --> D[Score: Which node\nis the best fit?]
D --> E[Bind pod to winning node]
E --> F[Pod: Scheduled\nKubelet takes over]
The scheduling decision considers:
- Resource requests (CPU/memory) — does the node have enough?
- Node selectors and affinity rules — does the pod want a specific node?
- Taints and tolerations — is the pod “allowed” on this node?
- Pod spread constraints — for even distribution
📝 Note: The scheduler doesn’t start containers — it just writes a node assignment to etcd. The kubelet on that node then picks it up and does the actual work.
Try It
Watch the scheduler in action:
# Create a pod and watch its transition through Pending → Running
kubectl run watcher-test --image=nginx --restart=Never
kubectl get pod watcher-test -w
Expected output:
NAME READY STATUS RESTARTS AGE
watcher-test 0/1 Pending 0 0s
watcher-test 0/1 ContainerCreating 0 1s
watcher-test 1/1 Running 0 3s
# Cleanup
kubectl delete pod watcher-test
⚙️ Controller Manager (kube-controller-manager)
This is a single binary that runs dozens of controllers — each one a control loop watching for a specific resource type.
graph LR
CM[Controller Manager]
CM --> RC[ReplicaSet Controller]
CM --> DC[Deployment Controller]
CM --> NC[Node Controller]
CM --> JC[Job Controller]
CM --> EC[Endpoints Controller]
CM --> SA[ServiceAccount Controller]
RC -->|"Desired: 3, Got: 2\n→ Create 1 pod"| API[API Server]
NC -->|"Node unreachable\n→ Evict pods"| API
A few important controllers:
| Controller | What it watches | What it does |
|---|---|---|
| ReplicaSet | ReplicaSets | Ensures desired pod count is maintained |
| Deployment | Deployments | Manages rolling updates via ReplicaSets |
| Node | Nodes | Marks nodes as unreachable, evicts pods |
| Job | Jobs | Ensures batch jobs run to completion |
| Endpoints | Services | Keeps endpoint lists updated as pods come/go |
💡 Tip: When you’re confused about why something happened automatically (a pod was deleted, a new one appeared), the answer is almost always “a controller did it.”
What Each Component’s Failure Looks Like
| Component Down | Immediate Impact | Pods Still Running? |
|---|---|---|
| API Server | No kubectl, no changes possible | ✅ Yes |
| etcd | API Server can’t read/write state | ✅ Yes (cached state) |
| Scheduler | New pods stay Pending forever | ✅ Yes (existing pods) |
| Controller Manager | No self-healing, no rollouts | ✅ Yes |
This table reveals something important: your applications keep running even if the control plane goes down. The kubelet on each worker node is self-sufficient enough to maintain existing pods. The control plane is only needed for changes.
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | API Server is the hub | Nothing talks to etcd except the API Server |
| 2 | etcd = cluster brain | Losing it loses all cluster state — back it up |
| 3 | Scheduler = placement | Picks which node, then hands off to kubelet |
| 4 | Controllers = reconcilers | Each runs a loop: compare desired vs actual, fix the diff |
✅ Quick Check
Q1: You scale a Deployment from 3 to 5 replicas. Which control plane component actually creates the 2 new pods?
Answer
The **ReplicaSet Controller** (inside the Controller Manager). The Deployment Controller updates the ReplicaSet spec, then the ReplicaSet Controller detects the count is wrong (3 actual vs 5 desired) and creates 2 new pod objects in etcd. The Scheduler then assigns those pods to nodes, and the Kubelet runs them.Q2: Why does Kubernetes run etcd with 3 instances instead of 2?
Answer
Raft consensus requires a majority (quorum) to agree on writes. With 3 nodes, you can lose 1 and still have a majority (2 of 3). With 2 nodes, losing 1 means you have no quorum and the cluster halts. 2-node clusters provide no fault tolerance.Q3: Your Scheduler pod crashes on Minikube. You had 3 running nginx pods. What happens to them?
Answer
Nothing immediately — the 3 existing pods keep running. The Scheduler is only needed for new pod placement. However, if one of those pods dies and a new one needs to be created, it will stay in `Pending` state indefinitely until the Scheduler recovers.1.4 Worker Nodes and the Kubelet
⏱️ ~5 min read
TL;DR: Worker nodes are where your pods actually live. Three components do the work: Kubelet (the agent), kube-proxy (the network rules), and a container runtime (the actual container runner).
The Three Node Components
graph TB
subgraph "Worker Node"
KB[Kubelet\n'The Node Agent']
KP[kube-proxy\n'Network Rules']
CR[Container Runtime\ncontainerd]
subgraph "Pod A"
C1[Container 1]
C2[Container 2]
end
subgraph "Pod B"
C3[Container 3]
end
KB -->|manages| C1 & C2 & C3
CR -->|runs| C1 & C2 & C3
KP -->|routes traffic to| C1 & C3
end
API[API Server] -->|pod specs| KB
KB -->|status updates| API
Kubelet — The Node Agent
The Kubelet is the most important component on each worker node. It’s the bridge between the control plane and the containers.
What the Kubelet does:
- Watches the API Server for pods assigned to its node
- Tells the container runtime to pull images and start containers
- Runs health checks (liveness/readiness probes) on containers
- Reports pod status back to the API Server continuously
# See kubelet logs on Minikube
minikube ssh "sudo journalctl -u kubelet -n 50 --no-pager"
📝 Note: The Kubelet is the only component that runs directly on the node as a systemd service — not as a pod. Everything else can be containerized, but not the thing that starts containers.
🔗 Docker Parallel: The Kubelet is like a smarter, cluster-aware
docker run— but instead of you telling it what to run, the API Server does.
kube-proxy — Network Rules Manager
kube-proxy runs on every node and maintains iptables/IPVS rules that implement Kubernetes Services.
What it does: When you create a Service with a virtual IP (ClusterIP), kube-proxy programs the node’s networking layer to forward traffic destined for that IP to the correct pods.
graph LR
Client[Client Pod] -->|"10.96.0.1:80\n(Service ClusterIP)"| KP[kube-proxy rules\non this node]
KP -->|forwards to| P1[Pod 10.244.1.5:8080]
KP -->|or forwards to| P2[Pod 10.244.2.3:8080]
KP -->|or forwards to| P3[Pod 10.244.3.7:8080]
📝 Note: In modern clusters (K8s 1.9+), kube-proxy defaults to IPVS mode, which is more efficient than iptables for large clusters with thousands of services.
Container Runtime — The Actual Container Runner
The container runtime is what actually pulls images and runs containers. Kubernetes uses the Container Runtime Interface (CRI) to talk to it, making the runtime swappable.
| Runtime | Used By | Notes |
|---|---|---|
| containerd | Most clusters (GKE, AKS, EKS) | Default runtime since K8s 1.24 |
| CRI-O | OpenShift | Lightweight, OCI-compliant |
| Docker Engine | Legacy | Removed as direct runtime in K8s 1.24 |
⚠️ Warning: Docker was removed as a direct Kubernetes runtime in v1.24. Minikube still uses Docker as the node driver (to create the VM), but containerd is the runtime inside the cluster. Your Docker images still work — the image format is standardized (OCI).
# Check the runtime on your minikube node
kubectl get node minikube -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}'
Expected output:
containerd://1.7.x
How a Pod Gets Running: The Full Chain
Here’s the complete journey from your kubectl apply to a running container:
sequenceDiagram
participant K as kubectl
participant A as API Server
participant S as Scheduler
participant KB as Kubelet
participant CR as Container Runtime
K->>A: Apply pod spec
A->>A: Validate, store in etcd
S->>A: Poll: any unscheduled pods?
A-->>S: Yes, pod XYZ
S->>A: Assign pod XYZ to node-1
KB->>A: Poll: any pods for me?
A-->>KB: Yes, pod XYZ
KB->>CR: Pull image nginx:latest
CR-->>KB: Image ready
KB->>CR: Start container
CR-->>KB: Container running (PID 1234)
KB->>A: Update pod status → Running
Try It
# Get detailed node info — see the kubelet version, container runtime, OS
kubectl describe node minikube | head -40
Look for:
Container Runtime Version— shows containerdKubelet Version— the K8s version running on this nodeAllocatable— how much CPU/memory is available for podsConditions— should beReady: True
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Kubelet is the node agent | Watches API Server and manages pods on its node |
| 2 | Kubelet is not a pod | Runs as a systemd service — it can’t manage itself |
| 3 | kube-proxy = Service networking | Programs iptables/IPVS rules for Service routing |
| 4 | Container runtime = OCI layer | Pulls images and runs containers via CRI |
✅ Quick Check
Q1: The Kubelet on node-2 crashes. What happens to pods on that node?
Answer
Existing containers keep running — the container runtime manages them independently. However, health checks stop running, so K8s won't restart failing containers. The API Server will eventually mark the node `NotReady` and start evicting pods to schedule on healthy nodes.Q2: Why can’t Kubernetes use the Docker daemon directly as its container runtime after v1.24?
Answer
Docker Engine doesn't implement the Container Runtime Interface (CRI) directly. The `dockershim` compatibility layer that K8s maintained was complex and buggy — the K8s team removed it in v1.24. Containerd (which Docker uses internally anyway) implements CRI natively and is simpler and faster.Q3: You create a Service with clusterIP: 10.96.5.50. Which component makes that IP actually work?
Answer
**kube-proxy** on every node. It programs iptables/IPVS rules so that traffic to `10.96.5.50` gets load-balanced to the backend pods. Without kube-proxy, the ClusterIP would be a phantom — the IP exists in etcd but no traffic would route to it.Lab: Your First Cluster — Minikube Setup & Exploration
⏱️ ~30 min hands-on
| Prerequisites | minikube, kubectl, and Docker installed |
| Difficulty | 🟢 Beginner |
| What you’ll do | Start a cluster, explore every component, poke the API Server directly |
Objectives
- Start a Minikube cluster and verify all control plane components are running
- Inspect a node and understand what the output means
- Navigate the cluster using
kubectl get,describe, andcluster-info - Access the Kubernetes Dashboard
- Call the API Server directly with
curl
Setup
# Verify your tools are installed
kubectl version --client
minikube version
docker version
# Start minikube with 2 CPUs and 2GB RAM
minikube start --cpus=2 --memory=2048
Expected output:
😄 minikube v1.33.x on Linux
✨ Using the docker driver based on existing profile
👍 Starting "minikube" primary control-plane node in "minikube" cluster
🚜 Pulling base image v0.0.44 ...
🔄 Restarting existing docker container for "minikube" ...
🐳 Preparing Kubernetes v1.30.x on Docker 26.x.x ...
🔎 Verifying Kubernetes components...
🌟 Enabled addons: storage-provisioner, default-storageclass
🏄 Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
Exercise 1: Verify the Cluster is Healthy
What we’re doing: Check that all control plane components are running and the node is ready.
kubectl cluster-info
Expected output:
Kubernetes control plane is running at https://192.168.49.2:8443
CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
💡 What just happened?
cluster-infoshows the API Server URL and any running cluster services. The IP192.168.49.2is the Minikube VM’s internal IP.
# Check node status
kubectl get nodes
Expected output:
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane 5m v1.30.x
# Get more detail
kubectl get nodes -o wide
Expected output:
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
minikube Ready control-plane 5m v1.30.x 192.168.49.2 <none> Ubuntu 22.04.4 LTS 5.15.x containerd://1.7.x
Note the CONTAINER-RUNTIME column — you can see containerd is what’s actually running your containers.
Exercise 2: Inspect the Control Plane Pods
What we’re doing: Kubernetes runs its own control plane as pods in the kube-system namespace.
kubectl get pods -n kube-system
Expected output:
NAME READY STATUS RESTARTS AGE
coredns-5dd5756b68-abcde 1/1 Running 0 10m
etcd-minikube 1/1 Running 0 10m
kube-apiserver-minikube 1/1 Running 0 10m
kube-controller-manager-minikube 1/1 Running 0 10m
kube-proxy-xxxxx 1/1 Running 0 10m
kube-scheduler-minikube 1/1 Running 0 10m
storage-provisioner 1/1 Running 0 10m
💡 What just happened? The control plane runs as pods! Each component (etcd, API server, scheduler, controller manager) is a pod managed by the kubelet. This is called a static pod — defined directly in
/etc/kubernetes/manifests/on the node, not via the API.
# Describe the API server pod
kubectl describe pod kube-apiserver-minikube -n kube-system | head -30
Look at the Image: line — you’ll see the exact version of the API server binary being used.
Exercise 3: Deeply Inspect the Node
kubectl describe node minikube
This is a long output. Here’s what to focus on:
# Pipe through grep to extract key sections
kubectl describe node minikube | grep -A5 "Conditions:"
kubectl describe node minikube | grep -A5 "Allocatable:"
kubectl describe node minikube | grep -A10 "Allocated resources:"
What to look for:
| Section | What it tells you |
|---|---|
Conditions | Is the node healthy? DiskPressure, MemoryPressure, Ready |
Allocatable | How much CPU/memory pods can use |
Allocated resources | How much is already used by running pods |
Events | Recent node-level events |
Exercise 4: Explore All Namespaces
What we’re doing: Everything in K8s lives in a namespace. Let’s see what’s already there.
kubectl get namespaces
Expected output:
NAME STATUS AGE
default Active 15m
kube-node-lease Active 15m
kube-public Active 15m
kube-system Active 15m
| Namespace | Purpose |
|---|---|
default | Where your workloads go unless you specify otherwise |
kube-system | K8s internal components |
kube-public | Readable by all; used for cluster info bootstrap |
kube-node-lease | Node heartbeat lease objects |
# See EVERYTHING in every namespace
kubectl get all --all-namespaces
Exercise 5: Access the Dashboard
minikube dashboard
💡 What just happened? Minikube opens a web browser with the Kubernetes Dashboard — a visual UI for the cluster. You can see pods, deployments, services, and more. It’s useful for exploration but not recommended for production management.
The dashboard opens automatically. Explore:
- Workloads → Pods → see the kube-system pods
- Cluster → Nodes → see node resource usage
Press Ctrl+C to stop when done.
Exercise 6: Talk to the API Server Directly
Every kubectl command is just an HTTP request. Let’s see the raw API.
# Start a proxy to the API Server (runs in background)
kubectl proxy --port=8001 &
# Now call the API directly with curl
curl http://localhost:8001/api/v1/namespaces
Expected output (truncated):
{
"kind": "NamespaceList",
"apiVersion": "v1",
"items": [
{ "metadata": { "name": "default" } },
{ "metadata": { "name": "kube-system" } }
]
}
# List pods in kube-system via raw API
curl http://localhost:8001/api/v1/namespaces/kube-system/pods | python3 -m json.tool | grep '"name"' | head -10
# Stop the proxy
kill %1
💡 What just happened? You bypassed
kubectlentirely and spoke directly to the Kubernetes API Server. This is exactly whatkubectldoes under the hood — it formats HTTP requests and pretty-prints the JSON responses.
🔥 Break It! Challenge
Understand what happens when a control plane pod “disappears.”
# On minikube, static pods are managed by the kubelet from manifests
minikube ssh "ls /etc/kubernetes/manifests/"
Expected output:
etcd.yaml kube-apiserver.yaml kube-controller-manager.yaml kube-scheduler.yaml
These files define the control plane pods. If you delete one, the kubelet immediately recreates it. Try it:
# Move the scheduler manifest (simulating a crash)
minikube ssh "sudo mv /etc/kubernetes/manifests/kube-scheduler.yaml /tmp/"
# Watch the scheduler disappear
kubectl get pods -n kube-system -w
You’ll see kube-scheduler-minikube go Terminating. Now create a new pod — it’ll stay Pending:
kubectl run test-pod --image=nginx
kubectl get pod test-pod # Status: Pending ← no scheduler!
Now restore it:
minikube ssh "sudo mv /tmp/kube-scheduler.yaml /etc/kubernetes/manifests/"
sleep 10
kubectl get pods -n kube-system # Scheduler is back
kubectl get pod test-pod # Now Running!
kubectl delete pod test-pod
What you learned: The Scheduler is needed for new pod placement. Existing pods are unaffected by its absence.
Cleanup
# Everything is clean — minikube cluster keeps running for future chapters
minikube status
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Start and verify a K8s cluster | kubectl get nodes shows Ready |
| 2 | Inspect control plane pods | kubectl get pods -n kube-system |
| 3 | Understand node resource allocation | kubectl describe node minikube |
| 4 | Navigate namespaces | kubectl get all --all-namespaces |
| 5 | Call the raw Kubernetes API | curl localhost:8001/api/v1/namespaces |
| 6 | Observe Scheduler failure impact | Pod stayed Pending without scheduler |
Chapter 2: kubectl — Your Swiss Army Knife
⏱️ Total chapter time: ~45 min (20 min reading + 25 min lab)
After this chapter, you will be able to: Query, inspect, create, and delete any Kubernetes resource from the command line — and do it efficiently with output formatting and filtering.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 2.1 | Anatomy of a kubectl Command | ~5 min |
| 2.2 | Imperative vs Declarative — Two Ways to Talk to K8s | ~5 min |
| 2.3 | Context, Namespaces, and kubeconfig | ~5 min |
| 2.4 | Output Formatting, Filtering, and JSONPath | ~6 min |
| 2.5 | 🔬 Lab: kubectl Power User Drills | ~25 min |
Prerequisites
- Completed Chapter 1 (Minikube cluster running)
minikube statusshowsRunning
2.1 Anatomy of a kubectl Command
⏱️ ~5 min read
TL;DR: Every kubectl command follows one pattern:
kubectl [verb] [resource] [name] [flags]. Master this pattern and you can figure out any command on the fly.
The Command Structure
kubectl <verb> <resource> <name> <flags>
kubectl get pods my-nginx --namespace=default
kubectl describe node minikube
kubectl delete deployment my-app --grace-period=0
kubectl logs pod/my-pod -c sidecar -f
That’s it. Once this pattern is in muscle memory, you don’t need to memorize hundreds of commands.
The Verbs
The verbs you’ll use 90% of the time:
| Verb | What it does | Example |
|---|---|---|
get | List one or more resources | kubectl get pods |
describe | Detailed info + events for a resource | kubectl describe pod my-pod |
create | Create a resource from a file | kubectl create -f pod.yaml |
apply | Create or update a resource from a file | kubectl apply -f deployment.yaml |
delete | Delete a resource | kubectl delete pod my-pod |
edit | Open a resource in your editor live | kubectl edit deployment my-app |
logs | Stream container logs | kubectl logs my-pod |
exec | Run a command inside a container | kubectl exec -it my-pod -- bash |
port-forward | Forward a local port to a pod | kubectl port-forward pod/my-pod 8080:80 |
scale | Change replica count | kubectl scale deployment my-app --replicas=5 |
rollout | Manage deployment rollouts | kubectl rollout status deployment/my-app |
The Resources
Resources are what K8s manages. You refer to them by their kind name (or shorthand):
| Full Name | Shorthand | What it is |
|---|---|---|
pods | po | The atomic unit |
deployments | deploy | Manages ReplicaSets |
services | svc | Network endpoint |
namespaces | ns | Isolation boundary |
nodes | no | Cluster machines |
configmaps | cm | Configuration data |
secrets | — | Sensitive data |
persistentvolumeclaims | pvc | Storage requests |
ingresses | ing | HTTP routing |
# These are all equivalent
kubectl get deployments
kubectl get deployment
kubectl get deploy
# See ALL resource types in your cluster
kubectl api-resources
Essential Flags
| Flag | Short | What it does |
|---|---|---|
--namespace | -n | Target a specific namespace |
--all-namespaces | -A | Target all namespaces |
--output | -o | Change output format (yaml, json, wide) |
--filename | -f | Specify a file |
--watch | -w | Stream changes live |
--selector | -l | Filter by label |
--dry-run=client | — | Preview changes without applying |
--force | — | Force delete (skip graceful termination) |
Getting Help — Built In
You never need to Google basic syntax. kubectl has it all:
# Get help on any verb
kubectl get --help
# Get help on any resource type
kubectl explain pod
kubectl explain pod.spec
kubectl explain pod.spec.containers
kubectl explain pod.spec.containers.resources.limits
kubectl explain is like having the K8s API reference built into your terminal. Use it constantly.
Expected output for kubectl explain pod.spec.containers.resources.limits:
KIND: Pod
VERSION: v1
FIELD: limits <map[string]Quantity>
DESCRIPTION:
Limits describes the maximum amount of compute resources allowed. More info:
https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
Try It
# Confirm your cluster is running
kubectl get nodes
# List all pods across all namespaces
kubectl get pods -A
# Get help for the 'exec' verb
kubectl exec --help
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | kubectl [verb] [resource] [name] | The universal pattern for all commands |
| 2 | Shorthand aliases | po, deploy, svc, cm, ns save keystrokes |
| 3 | kubectl explain | Built-in API reference — use it instead of Googling |
| 4 | -n / -A flags | Scope commands to a namespace or all namespaces |
✅ Quick Check
Q1: What does kubectl get deploy -A do?
Answer
Lists all Deployments across every namespace in the cluster. `-A` is short for `--all-namespaces`, and `deploy` is the shorthand for `deployments`.Q2: You want to see the detailed spec of a ConfigMap named app-config in the staging namespace. Which command?
Answer
`kubectl describe configmap app-config -n staging` (or `kubectl describe cm app-config -n staging`). For the raw YAML: `kubectl get cm app-config -n staging -o yaml`.Q3: You run kubectl delete pod my-pod but the pod comes back immediately. Why?
Answer
The pod is managed by a controller — most likely a Deployment or ReplicaSet. Deleting the pod triggers the controller to immediately create a replacement to maintain the desired count. To permanently remove it, delete the Deployment: `kubectl delete deployment my-app`.2.2 Imperative vs Declarative — Two Ways to Talk to K8s
⏱️ ~5 min read
TL;DR: Imperative = “do this now.” Declarative = “this is what I want.” Use imperative for quick experiments; always use declarative (
kubectl apply -f) in real workflows.
Imperative: Give Orders
Imperative commands tell Kubernetes exactly what action to take. Fast and convenient for one-off tasks.
# Imperative: "Create an nginx pod, right now"
kubectl run my-nginx --image=nginx --port=80
# Imperative: "Scale this deployment to 5"
kubectl scale deployment my-app --replicas=5
# Imperative: "Create a ConfigMap with this value"
kubectl create configmap app-config --from-literal=DB_HOST=localhost
🔗 Docker Parallel: This is like
docker run— you issue a command, something happens.
The problem: How do you track what was created? How do teammates reproduce it? How do you put it in Git?
Declarative: Describe Desired State
Declarative means writing a YAML file that describes what you want to exist, then applying it.
# nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-nginx
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
kubectl apply -f nginx-deployment.yaml
🔗 Docker Parallel: This is like
docker-compose.yml— you describe the desired state, and the tool figures out what actions to take.
apply is idempotent. Run it 10 times — it only makes changes when the file differs from the live state. This is safe to automate.
The Key Difference: create vs apply
kubectl create -f | kubectl apply -f | |
|---|---|---|
| Resource doesn’t exist | ✅ Creates it | ✅ Creates it |
| Resource already exists | ❌ Error | ✅ Updates it |
| Safe to run repeatedly | No | Yes |
| Tracks changes | No | Yes (via annotation) |
| Use in CI/CD | Never | Always |
# This will fail on second run:
kubectl create -f nginx-deployment.yaml
kubectl create -f nginx-deployment.yaml # ❌ Error: already exists
# This is always safe:
kubectl apply -f nginx-deployment.yaml
kubectl apply -f nginx-deployment.yaml # ✅ "unchanged"
Generating YAML from Imperative Commands
Here’s a power move: use imperative commands to generate YAML files, then commit those.
# Generate a Pod YAML without creating it
kubectl run my-pod --image=nginx --dry-run=client -o yaml
# Generate a Deployment YAML
kubectl create deployment my-app --image=nginx --replicas=3 \
--dry-run=client -o yaml > my-deployment.yaml
# Generate a Service YAML
kubectl expose deployment my-app --port=80 --type=ClusterIP \
--dry-run=client -o yaml > my-service.yaml
Expected output for the pod command:
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: null
labels:
run: my-pod
name: my-pod
spec:
containers:
- image: nginx
name: my-pod
resources: {}
dnsPolicy: ClusterFirst
restartPolicy: Always
status: {}
💡 Tip:
--dry-run=client -o yamlis one of the most useful kubectl tricks. It generates a valid YAML scaffold that you edit and commit — way faster than writing YAML from scratch.
When to Use Which
| Situation | Use |
|---|---|
| Quick debugging — “just run this pod” | Imperative (kubectl run) |
| Generating a starter YAML | Imperative + --dry-run=client -o yaml |
| Any real deployment | Declarative (kubectl apply -f) |
| CI/CD pipeline | Declarative only |
| Sharing with the team | Declarative (YAML in Git) |
| One-off admin tasks | Imperative OK |
🏭 In Production: Imperative commands have no audit trail. In production, every change should go through
kubectl applybacked by a Git commit. This is the foundation of GitOps.
Try It
# Create a deployment imperatively
kubectl create deployment demo --image=nginx --replicas=2
# Now export it as YAML to see what K8s actually stored
kubectl get deployment demo -o yaml
# Delete it
kubectl delete deployment demo
# Now do the same thing declaratively
kubectl create deployment demo --image=nginx --replicas=2 \
--dry-run=client -o yaml | kubectl apply -f -
# Verify
kubectl get deploy demo
kubectl delete deployment demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Imperative | Fast, not reproducible — use for experiments only |
| 2 | Declarative | apply -f is idempotent and Git-friendly |
| 3 | create vs apply | apply is always safer — use it by default |
| 4 | --dry-run=client -o yaml | Generate YAML scaffolds from imperative commands |
✅ Quick Check
Q1: You run kubectl apply -f app.yaml in your CI pipeline. The resource already exists with different settings. What happens?
Answer
Kubernetes computes the diff between the current state and the desired state in the YAML file, then applies only the necessary changes. The resource is updated in-place. This is why `apply` is idempotent and CI-safe.Q2: What does kubectl run test --image=busybox --dry-run=client -o yaml actually do?
Answer
It generates the YAML that *would* be sent to the API Server — but doesn't actually create anything. The `--dry-run=client` flag means the request is processed locally without contacting the cluster. It's perfect for generating YAML templates.Q3: A colleague used imperative commands to configure the production cluster. Why is this a problem?
Answer
There's no audit trail — you can't see what was changed, when, or by whom. It can't be peer-reviewed (no Git PR). It's not reproducible — if the cluster is destroyed, you can't rebuild it. And it's easy to make typos with no review gate. All production changes should go through declarative YAML in version control.2.3 Context, Namespaces, and kubeconfig
⏱️ ~5 min read
TL;DR:
kubeconfigstores your cluster credentials. A context is a named combo of cluster + user + namespace. Namespaces isolate resources within a cluster. Knowing how to switch between them is essential once you have more than one cluster.
The kubeconfig File
When you ran minikube start, it automatically configured ~/.kube/config. This file tells kubectl which cluster to talk to and how to authenticate.
cat ~/.kube/config
Structure (simplified):
apiVersion: v1
kind: Config
clusters:
- name: minikube
cluster:
server: https://192.168.49.2:8443 # API Server URL
certificate-authority: ~/.minikube/ca.crt # TLS cert to trust
users:
- name: minikube
user:
client-certificate: ~/.minikube/profiles/minikube/client.crt
client-key: ~/.minikube/profiles/minikube/client.key
contexts:
- name: minikube
context:
cluster: minikube # which cluster
user: minikube # which credentials
namespace: default # default namespace
current-context: minikube # ← which context is active RIGHT NOW
Three sections: clusters (where), users (who), contexts (which cluster + which user + which namespace).
Working with Contexts
A context is a named shortcut: “cluster X, authenticated as user Y, defaulting to namespace Z.”
# See all contexts
kubectl config get-contexts
# See the current context
kubectl config current-context
# Switch to a different context
kubectl config use-context my-other-cluster
# View the full kubeconfig
kubectl config view
Expected output for get-contexts:
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* minikube minikube minikube default
The * marks your active context. If you had an AKS cluster configured, it would appear here too.
⚠️ Warning: The classic ops horror story: running
kubectl deleteagainst production when you meant staging. Always verify your current context before destructive commands. Some teams aliaskubectlto print the context on every command.
# Quick safety check — add this to your .bashrc or .zshrc
alias kctx='kubectl config current-context'
Namespaces — Isolation Within a Cluster
A namespace is a virtual partition within a single cluster. Resources in different namespaces are isolated from each other (mostly — Nodes and PersistentVolumes are cluster-wide).
graph TB
subgraph "Kubernetes Cluster"
subgraph "namespace: default"
P1[web-pod]
S1[web-svc]
end
subgraph "namespace: staging"
P2[web-pod]
S2[web-svc]
end
subgraph "namespace: monitoring"
P3[prometheus-pod]
P4[grafana-pod]
end
N1[Node 1] & N2[Node 2]
end
The pods named web-pod in default and staging are completely separate objects — same name, different namespaces.
# List namespaces
kubectl get namespaces
# Create a namespace
kubectl create namespace staging
# Run a command in a specific namespace
kubectl get pods -n staging
# Set your default namespace (so you don't type -n every time)
kubectl config set-context --current --namespace=staging
# Reset to default
kubectl config set-context --current --namespace=default
Namespace DNS
Services in different namespaces can reach each other via DNS:
# Format: SERVICE-NAME.NAMESPACE.svc.cluster.local
# From any pod in the cluster:
curl http://web-svc.staging.svc.cluster.local
curl http://web-svc.default.svc.cluster.local
# Short form works within the same namespace:
curl http://web-svc
Managing Multiple Clusters
In the real world you’ll have multiple clusters (local minikube, staging, production). The workflow:
# Add a new cluster config (e.g., after AKS provisioning)
az aks get-credentials --resource-group my-rg --name my-aks-cluster
# Now you have two contexts
kubectl config get-contexts
# Switch between them
kubectl config use-context minikube
kubectl config use-context my-aks-cluster
💡 Tip: Install kubectx + kubens — tiny tools that make switching contexts and namespaces much faster:
kubectx minikube # switch cluster context kubens staging # switch namespaceThey also highlight the current context in your shell prompt.
Try It
# Create two namespaces
kubectl create namespace dev
kubectl create namespace staging
# Deploy an nginx pod to each
kubectl run nginx-dev --image=nginx -n dev
kubectl run nginx-staging --image=nginx -n staging
# See both — notice same name, different namespace
kubectl get pods -A | grep nginx
# Clean up
kubectl delete namespace dev staging
Expected output:
dev nginx-dev 1/1 Running 0 30s
staging nginx-staging 1/1 Running 0 28s
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | kubeconfig | Stores cluster URLs, credentials, and context definitions |
| 2 | Context | Named combo of cluster + user + namespace |
| 3 | Namespace | Virtual partition within a cluster; resources isolated by name |
| 4 | Cross-namespace DNS | <svc>.<ns>.svc.cluster.local reaches any service |
✅ Quick Check
Q1: You accidentally ran a command against production instead of staging. How could you prevent this in the future?
Answer
Multiple approaches: (1) Use `kubectx` and display the active context in your shell prompt so it's always visible. (2) Use RBAC to limit what your production context credentials can do. (3) Use a plugin like `kubectl-safe` that warns before destructive operations on production contexts. (4) Rename contexts to include `prod-READONLY` or similar warnings.Q2: A pod in namespace frontend needs to call a service named api in namespace backend. What URL should it use?
Answer
`http://api.backend.svc.cluster.local` — the full DNS name format is `SERVICE.NAMESPACE.svc.cluster.local`. The short form `http://api` only works within the same namespace.Q3: You set kubectl config set-context --current --namespace=staging. What changes in your kubeconfig?
Answer
The `namespace` field of the current context in `~/.kube/config` is updated to `staging`. From now on, all commands that don't specify `-n` will operate in `staging` instead of `default`. It does NOT affect other contexts.2.4 Output Formatting, Filtering, and JSONPath
⏱️ ~6 min read
TL;DR:
kubectl getis not just for listing things — with the right flags it’s a precision query tool.-o wide,-o yaml,-o jsonpath, and-l(label selectors) are how senior engineers extract exactly the information they need.
Output Formats
# Default tabular output
kubectl get pods
# More columns (node, IP)
kubectl get pods -o wide
# Full YAML — what's actually stored in etcd
kubectl get pod my-pod -o yaml
# JSON format
kubectl get pod my-pod -o json
# Just the names — great for piping
kubectl get pods -o name
The -o yaml trick is critical. When you want to understand what fields a resource has, or when you need to copy an existing resource and modify it:
# Export a live resource as editable YAML
kubectl get deployment my-app -o yaml > my-app-copy.yaml
# Edit the file, change the name, apply it
kubectl apply -f my-app-copy.yaml
Label Selectors — Filtering by Label
Labels are key-value pairs attached to resources. They’re how K8s knows which pods belong to which Service or Deployment.
# List pods with a specific label
kubectl get pods -l app=nginx
# Multiple labels (AND)
kubectl get pods -l app=nginx,env=production
# All resources with a label, across types
kubectl get all -l app=my-app
# Show labels in output
kubectl get pods --show-labels
graph LR
subgraph "All Pods"
P1["web-1\napp=nginx\nenv=prod"]
P2["web-2\napp=nginx\nenv=prod"]
P3["api-1\napp=api\nenv=prod"]
P4["web-dev\napp=nginx\nenv=dev"]
end
Q["kubectl get pods\n-l app=nginx,env=prod"] -->|matches| P1 & P2
🔗 Docker Parallel: Docker Compose has
labels:on services too, but they’re mostly cosmetic. In Kubernetes, labels are fundamental — Services and Deployments use label selectors to know which pods they own.
Field Selectors — Filtering by Status Fields
# Only Running pods
kubectl get pods --field-selector=status.phase=Running
# Pods on a specific node
kubectl get pods --field-selector=spec.nodeName=minikube
# Failed pods across all namespaces
kubectl get pods -A --field-selector=status.phase=Failed
JSONPath — Surgical Extraction
When you need one specific field from a resource, JSONPath is the tool.
# Get just the node's IP address
kubectl get node minikube -o jsonpath='{.status.addresses[0].address}'
# Get all pod names
kubectl get pods -o jsonpath='{.items[*].metadata.name}'
# Get image names for every container in every pod
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}'
# Get the ClusterIP of a service
kubectl get svc my-svc -o jsonpath='{.spec.clusterIP}'
Expected output for pod names:
my-nginx-abc12 my-api-def34 redis-xyz56
💡 Tip: Not sure of the JSONPath for what you want? Start with
-o jsonto see the full structure, then build your JSONPath from there.
Custom Columns — Table Output You Define
# Custom table: name, image, node
kubectl get pods -o custom-columns=\
'NAME:.metadata.name,IMAGE:.spec.containers[0].image,NODE:.spec.nodeName'
Expected output:
NAME IMAGE NODE
my-nginx-abc12 nginx:1.25 minikube
my-api-def34 node:20-slim minikube
Sorting Output
# Sort pods by creation time (newest last)
kubectl get pods --sort-by=.metadata.creationTimestamp
# Sort by restart count (most restarted first — useful for debugging)
kubectl get pods --sort-by='.status.containerStatuses[0].restartCount'
# Sort nodes by CPU capacity
kubectl get nodes --sort-by='.status.capacity.cpu'
Combining It All — Real-World Recipes
# "Which pods are running on which nodes, sorted by node?"
kubectl get pods -o wide --sort-by='.spec.nodeName'
# "What images are my deployments using?"
kubectl get deployments -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].image}{"\n"}{end}'
# "Find all pods NOT in Running state"
kubectl get pods -A --field-selector='status.phase!=Running'
# "Get the NodePort for a service"
kubectl get svc my-svc -o jsonpath='{.spec.ports[0].nodePort}'
Try It
# Create a test deployment
kubectl create deployment fmt-demo --image=nginx:1.25 --replicas=2
# Try each format
kubectl get pods -l app=fmt-demo -o wide
kubectl get pods -l app=fmt-demo -o yaml | grep -A3 "image:"
kubectl get pods -l app=fmt-demo -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
# Custom columns
kubectl get pods -l app=fmt-demo \
-o custom-columns='POD:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName'
# Cleanup
kubectl delete deployment fmt-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | -o wide | Extra columns: IP, node, nominated node |
| 2 | -o yaml | Full resource spec — use for copying/debugging |
| 3 | -l key=value | Filter by label — the primary K8s query mechanism |
| 4 | -o jsonpath='{...}' | Extract a single field from any resource |
| 5 | --sort-by | Sort output by any JSONPath field |
✅ Quick Check
Q1: You want a list of all pod names and their container images in a single line per pod. Which output format do you use?
Answer
JSONPath with a range loop: ```bash kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}' ``` Or custom columns: ```bash kubectl get pods -o custom-columns='NAME:.metadata.name,IMAGE:.spec.containers[0].image' ```Q2: A Service’s label selector is app=backend. You have pods labeled app=backend-v2. Does the Service route to them?
Answer
No. Label selectors are exact matches. `app=backend` does NOT match `app=backend-v2`. The selector must exactly match all the specified labels on the pod. You'd need to change either the pod labels or the Service selector.Q3: You need to find all pods that are NOT in the Running phase across all namespaces. What command?
Answer
```bash kubectl get pods -A --field-selector='status.phase!=Running' ``` This uses a field selector with a not-equal operator (`!=`) on the `status.phase` field.Lab: kubectl Power User Drills
⏱️ ~25 min hands-on
| Prerequisites | Minikube running, Chapter 2 sections 2.1–2.4 read |
| Difficulty | 🟢 Beginner |
| What you’ll do | Run a gauntlet of kubectl drills covering every technique from this chapter |
Objectives
- Use
kubectl explainto look up API fields without Googling - Generate YAML using
--dry-run=clientand edit it - Filter pods by label, field, and namespace
- Extract specific data with JSONPath and custom columns
- Switch and manage namespaces confidently
- Debug a misconfigured deployment
Setup
# Verify cluster is running
kubectl get nodes
# Create a dedicated namespace for this lab
kubectl create namespace drill-lab
# Switch to it
kubectl config set-context --current --namespace=drill-lab
# Verify
kubectl config view --minify | grep namespace
Expected output:
namespace: drill-lab
Exercise 1: Use kubectl explain as Your Dictionary
What we’re doing: Look up API field definitions without leaving the terminal.
# What fields does a Pod spec have?
kubectl explain pod.spec
# What are the container resource fields?
kubectl explain pod.spec.containers.resources
# What does restartPolicy accept?
kubectl explain pod.spec.restartPolicy
Challenge: Find out what pod.spec.containers.livenessProbe.httpGet fields are available — using only kubectl explain.
kubectl explain pod.spec.containers.livenessProbe.httpGet
Expected output (key fields):
FIELDS:
host <string>
httpHeaders <[]Object>
path <string> -required-
port <IntOrString> -required-
scheme <string>
💡 What just happened? You just looked up the Kubernetes API reference without opening a browser. Make this a habit.
Exercise 2: Generate YAML Without Writing It From Scratch
What we’re doing: Use imperative commands + --dry-run=client -o yaml to scaffold manifests.
# Generate a Deployment manifest
kubectl create deployment webserver \
--image=nginx:1.25 \
--replicas=3 \
--port=80 \
--dry-run=client -o yaml > /tmp/webserver-deployment.yaml
cat /tmp/webserver-deployment.yaml
Now edit it — add a label to the pod template:
# Open the file and add env=lab under the existing labels in spec.template.metadata.labels
# Use any editor: nano, vim, or:
sed -i '/app: webserver/a\ env: lab' /tmp/webserver-deployment.yaml
Verify your edit:
grep -A5 "labels:" /tmp/webserver-deployment.yaml | head -20
Apply it:
kubectl apply -f /tmp/webserver-deployment.yaml
kubectl get deployment webserver
kubectl get pods --show-labels
Expected output:
NAME READY STATUS RESTARTS AGE LABELS
webserver-xxxxx 1/1 Running 0 10s app=webserver,env=lab,pod-template-hash=xxxxx
webserver-xxxxx 1/1 Running 0 10s app=webserver,env=lab,pod-template-hash=xxxxx
webserver-xxxxx 1/1 Running 0 10s app=webserver,env=lab,pod-template-hash=xxxxx
Exercise 3: Deploy Multiple Apps with Different Labels
What we’re doing: Set up a multi-app environment to practice filtering.
# Deploy a second app (simulating an API service)
kubectl create deployment api-server \
--image=node:20-slim \
--replicas=2 \
-n drill-lab
# Label the api-server pods differently
kubectl label deployment api-server tier=backend env=lab
# Create a third deployment
kubectl create deployment redis-cache \
--image=redis:7 \
--replicas=1 \
-n drill-lab
kubectl label deployment redis-cache tier=cache env=lab
Now practice filtering:
# All pods
kubectl get pods
# Only webserver pods
kubectl get pods -l app=webserver
# All pods with env=lab
kubectl get pods -l env=lab
# All pods with env=lab AND tier=backend
kubectl get pods -l env=lab,tier=backend
# Show labels in output
kubectl get pods --show-labels
# Non-running pods
kubectl get pods --field-selector=status.phase!=Running
Exercise 4: JSONPath Drills
What we’re doing: Extract specific fields like a surgeon.
# Get all pod names, one per line
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
# Get pod name + image, tab-separated
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}'
# Get the IP of the first webserver pod
kubectl get pods -l app=webserver -o jsonpath='{.items[0].status.podIP}'
# Get restart counts for all pods
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].restartCount}{"\n"}{end}'
Custom columns version — same info, prettier output:
kubectl get pods \
-o custom-columns='NAME:.metadata.name,IMAGE:.spec.containers[0].image,IP:.status.podIP,RESTARTS:.status.containerStatuses[0].restartCount'
Expected output:
NAME IMAGE IP RESTARTS
webserver-abc-xxx nginx:1.25 10.244.0.5 0
webserver-abc-yyy nginx:1.25 10.244.0.6 0
api-server-def-xxx node:20-slim 10.244.0.7 0
redis-cache-ghi-xxx redis:7 10.244.0.8 0
Exercise 5: Namespace Operations
What we’re doing: Work across namespaces.
# Create another namespace with a pod
kubectl create namespace other-lab
kubectl run isolated-pod --image=nginx -n other-lab
# Prove the namespaces are isolated
kubectl get pods # drill-lab pods only
kubectl get pods -n other-lab # other-lab pods only
kubectl get pods -A # ALL pods, all namespaces
# Cross-namespace view with labels
kubectl get pods -A --show-labels
# Clean up other-lab
kubectl delete namespace other-lab
Exercise 6: Watch Mode + Sorting
# In one terminal, watch pods:
kubectl get pods -w &
# In the same terminal, scale up/down and watch the changes:
kubectl scale deployment webserver --replicas=5
sleep 3
kubectl scale deployment webserver --replicas=2
# Stop the watch
kill %1
# Sort all pods by restart count
kubectl get pods --sort-by='.status.containerStatuses[0].restartCount'
# Sort by creation time
kubectl get pods --sort-by=.metadata.creationTimestamp
Exercise 7: Debug a Broken Deployment
What we’re doing: Apply a broken deployment and diagnose it using only kubectl.
# Apply this broken deployment (bad image name)
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: broken-app
namespace: drill-lab
spec:
replicas: 2
selector:
matchLabels:
app: broken-app
template:
metadata:
labels:
app: broken-app
spec:
containers:
- name: app
image: nginx:doesnotexist999
ports:
- containerPort: 80
EOF
Now diagnose:
# 1. See the deployment status
kubectl get deployment broken-app
# 2. See the pods
kubectl get pods -l app=broken-app
# 3. Describe a failing pod — look at Events at the bottom
kubectl describe pod -l app=broken-app
# 4. Check the specific error
kubectl get pods -l app=broken-app -o jsonpath='{range .items[*]}{.status.containerStatuses[0].state.waiting.reason}{"\n"}{end}'
Expected output from describe (Events section):
Events:
Type Reason Message
---- ------ -------
Warning Failed Failed to pull image "nginx:doesnotexist999": ... not found
Warning Failed Error: ErrImagePull
Warning BackOff Back-off pulling image "nginx:doesnotexist999"
Fix it:
kubectl set image deployment/broken-app app=nginx:1.25
kubectl rollout status deployment/broken-app
💡 What just happened?
kubectl set imageis an imperative way to update a container image. The deployment controller immediately starts a rolling update. In production, you’d update the YAML andkubectl apply.
🔥 Break It! Challenge
What happens if you delete a namespace that has running workloads?
# Watch what happens to all resources in drill-lab if we delete it
# (Don't do this yet — just understand the outcome)
# First: list everything in the namespace
kubectl get all -n drill-lab
# The answer: deleting a namespace cascades to ALL resources in it
# kubectl delete namespace drill-lab ← this would kill everything
# Instead, try deleting just the api-server deployment and watch
# the pods disappear but the other deployments stay:
kubectl delete deployment api-server
kubectl get pods -w # watch pods go away, then Ctrl+C
The lesson: Namespace deletion is nuclear — it kills everything inside it with no confirmation. Never delete namespaces in production without knowing exactly what’s inside.
Cleanup
# Switch back to default namespace first
kubectl config set-context --current --namespace=default
# Delete the lab namespace and everything in it
kubectl delete namespace drill-lab
# Verify cleanup
kubectl get all -n drill-lab 2>&1 # Should say "No resources found"
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Use kubectl explain as API reference | Found livenessProbe.httpGet fields without Googling |
| 2 | Generate YAML with --dry-run | Created an editable deployment manifest |
| 3 | Filter by labels and fields | Used -l, --field-selector, --show-labels |
| 4 | Extract data with JSONPath | Got pod names, images, IPs in custom format |
| 5 | Work across namespaces | Used -n and -A to scope commands |
| 6 | Debug a failing deployment | Used describe to find ImagePullBackOff root cause |
Chapter 3: Pods — The Atomic Unit
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Create pods declaratively, understand every phase of the pod lifecycle, build multi-container pods, set resource limits, and debug failures like
CrashLoopBackOffandOOMKilled.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 3.1 | What Is a Pod, Really? | ~5 min |
| 3.2 | Pod Lifecycle and Phases | ~6 min |
| 3.3 | Multi-Container Pods: Sidecars, Init, and Ambassadors | ~7 min |
| 3.4 | Resource Requests and Limits | ~6 min |
| 3.5 | 🔬 Lab: Run, Inspect, Break, and Debug Pods | ~30 min |
Prerequisites
- Completed Chapter 2 (kubectl confident)
minikube statusshowsRunning
3.1 What Is a Pod, Really?
⏱️ ~5 min read
TL;DR: A Pod is NOT a container. It’s a wrapper around one or more containers that share the same network namespace and can share storage volumes. It’s the smallest deployable unit in Kubernetes.
The Key Insight: Pods Are Not Containers
🔗 Docker Parallel: In Docker, you deploy containers. In Kubernetes, you deploy Pods. A Pod can contain one container (usually) or multiple tightly-coupled containers that need to share a network stack.
Here’s the minimal pod definition:
# my-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: my-nginx
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
kubectl apply -f my-pod.yaml
kubectl get pod my-nginx
What Containers in a Pod Share
This is what makes pods special. Every container in a pod shares:
graph TB
subgraph "Pod"
NET[Shared Network Namespace\nSame IP address\nSame localhost]
subgraph "Container A\nnginx"
PA[:80]
end
subgraph "Container B\nlog-shipper"
PB[:9000]
end
VOL[Shared Volume\n/var/log]
PA --- NET
PB --- NET
PA --- VOL
PB --- VOL
end
| Resource | Shared? | Meaning |
|---|---|---|
| Network namespace | ✅ Yes | Same IP, can talk via localhost |
| Ports | ✅ Yes | Port conflicts between containers ARE possible |
| Volumes | Optional | Volumes must be explicitly mounted by each container |
| Filesystem | ❌ No | Each container has its own isolated filesystem |
| Process namespace | Optional | Can share via shareProcessNamespace: true |
⚠️ Warning: Because containers in a pod share a network namespace, two containers cannot both listen on port 80. Plan your container ports carefully in multi-container pods.
Why Not Just Run Containers Directly?
Valid question. The answer is: pods add a layer of abstraction that gives Kubernetes useful guarantees.
- Atomic scheduling — all containers in a pod land on the same node together
- Shared lifecycle — pod-level restart policies apply to all containers
- Co-location guarantee — sidecar and main container are always on the same machine, eliminating network latency between them
📝 Note: In practice, 90% of pods contain exactly one container. Multi-container pods are for specific patterns (sidecar, init) covered in section 3.3.
The Pod YAML Anatomy
apiVersion: v1 # API group version — always v1 for Pods
kind: Pod # Resource type
metadata:
name: my-app # Unique name within a namespace
namespace: default # Namespace (defaults to "default")
labels: # Key-value pairs for selecting/grouping
app: my-app
version: "1.0"
annotations: # Non-identifying metadata (not used for selection)
description: "Demo pod"
spec:
containers: # List of containers (at least one required)
- name: app # Container name (unique within the pod)
image: nginx:1.25 # Image:tag — always pin the tag!
ports:
- containerPort: 80 # Informational only — doesn't actually publish the port
env: # Environment variables
- name: ENV
value: production
resources: # CPU/memory limits (covered in 3.4)
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
restartPolicy: Always # Always | OnFailure | Never
⚠️ Warning:
containerPortin a pod spec is purely informational — it doesn’t actually open a firewall port or publish the container. Networking is handled by Services (Chapter 5).
Try It
# Apply the pod
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: my-nginx
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
EOF
# Watch it start
kubectl get pod my-nginx -w
# Once Running — get full details
kubectl describe pod my-nginx
# Access it directly (no Service needed for testing)
kubectl port-forward pod/my-nginx 8080:80 &
curl http://localhost:8080
kill %1
# Cleanup
kubectl delete pod my-nginx
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Pod ≠ Container | A pod wraps 1+ containers that share network and optionally storage |
| 2 | Shared network | Containers in a pod use localhost to talk to each other |
| 3 | containerPort is cosmetic | It doesn’t publish anything — Services do the actual networking |
| 4 | Always pin image tags | nginx:latest in production is a reliability disaster |
✅ Quick Check
Q1: You have two containers in a pod. Container A listens on port 8080. Can Container B also listen on port 8080?
Answer
No. They share a network namespace, which means they share the same port space. If both try to bind port 8080, the second one will fail to start. Treat the port space of a pod as if it were a single machine.Q2: Container A in a pod writes a file to /tmp/data. Can Container B read it?
Answer
No — not without explicitly sharing a volume. Containers have isolated filesystems. To share files between containers in a pod, you must define a `volume` in the pod spec and mount it in both containers.Q3: You delete a pod. Does Kubernetes immediately create a new one?
Answer
Only if the pod is managed by a controller (Deployment, ReplicaSet, etc.). A bare pod — one you created directly with `kubectl apply -f pod.yaml` — is gone when deleted. This is why you almost never create bare pods in production; you always use a Deployment.3.2 Pod Lifecycle and Phases
⏱️ ~6 min read
TL;DR: A pod moves through phases:
Pending → Running → Succeeded/Failed. Each container inside has its own state too. Understanding both levels is how you debug 80% of pod problems.
Pod Phases
stateDiagram-v2
[*] --> Pending : Pod accepted by API Server
Pending --> Running : All containers started
Running --> Succeeded : All containers exited 0
Running --> Failed : At least one container exited non-zero
Pending --> Failed : Image pull error / unschedulable
Running --> Unknown : Node lost contact with control plane
| Phase | Meaning |
|---|---|
Pending | Pod accepted, but not all containers are running yet. Could be scheduling, image pulling, or init container running. |
Running | Pod bound to a node; at least one container is running (or starting/restarting) |
Succeeded | All containers exited with code 0. Terminal state. |
Failed | All containers have exited; at least one exited non-zero. Terminal state. |
Unknown | Pod state can’t be determined — usually a node communication problem |
# Check phase
kubectl get pod my-pod -o jsonpath='{.status.phase}'
Container States
Inside a running pod, each container has its own state — independent of the pod phase:
| Container State | Meaning |
|---|---|
Waiting | Not running yet. Check reason — usually ContainerCreating, ImagePullBackOff, or CrashLoopBackOff |
Running | Container started and process is alive |
Terminated | Container process exited. Check exitCode and reason |
# Get container state details
kubectl get pod my-pod -o jsonpath='{.status.containerStatuses[0].state}'
Pod Conditions
Conditions give more granular status than phase. They’re how kubectl determines what to show in kubectl describe:
| Condition | Meaning |
|---|---|
PodScheduled | Pod has been assigned to a node |
Initialized | All init containers completed successfully |
ContainersReady | All containers are ready |
Ready | Pod is ready to serve traffic (used by Services) |
kubectl get pod my-pod -o jsonpath='{.status.conditions}' | python3 -m json.tool
The Critical Failure Modes
CrashLoopBackOff
The container keeps crashing. K8s restarts it, but adds an exponential back-off delay (10s, 20s, 40s… up to 5 min).
# Cause: check the logs from the PREVIOUS run
kubectl logs my-pod --previous
# Or describe to see restart count and last exit code
kubectl describe pod my-pod | grep -A5 "Last State:"
ImagePullBackOff / ErrImagePull
Kubernetes can’t pull the container image. Causes:
- Wrong image name / tag
- Private registry without credentials
- No internet connectivity on the node
# See the exact error
kubectl describe pod my-pod | grep -A5 "Events:"
OOMKilled
The container exceeded its memory limit and was killed by the kernel.
# Check exit code — OOMKilled shows exitCode: 137
kubectl get pod my-pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'
Pending (stuck)
Pod can’t be scheduled. Causes:
- Not enough CPU/memory on any node
- Node selector or affinity rules don’t match any node
- Taint not tolerated
# See why it's stuck
kubectl describe pod my-pod | grep -A10 "Events:"
Restart Policies
restartPolicy controls what happens when a container exits:
| Policy | Behavior | Use Case |
|---|---|---|
Always | Always restart, regardless of exit code | Long-running services (default) |
OnFailure | Restart only if exit code != 0 | Batch jobs that should succeed |
Never | Never restart | One-shot diagnostic containers |
spec:
restartPolicy: OnFailure # for Jobs
containers:
- name: worker
image: my-batch-job
🔗 Docker Parallel: This is equivalent to
restart: unless-stopped(Always),restart: on-failure(OnFailure), and no restart policy (Never) in Docker Compose.
Try It
# Create a pod that immediately crashes
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: crasher
spec:
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo 'Starting...' && exit 1"]
restartPolicy: Always
EOF
# Watch it crash and enter CrashLoopBackOff
kubectl get pod crasher -w
# See the logs from the crashed run
kubectl logs crasher
kubectl logs crasher --previous # After it's restarted at least once
# See restart count and exit code
kubectl describe pod crasher | grep -A8 "Containers:"
# Cleanup
kubectl delete pod crasher
Expected progression:
NAME READY STATUS RESTARTS AGE
crasher 0/1 ContainerCreating 0 2s
crasher 0/1 Error 0 3s
crasher 0/1 CrashLoopBackOff 1 8s
crasher 0/1 Error 2 28s
crasher 0/1 CrashLoopBackOff 3 42s
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | 5 pod phases | Pending → Running → Succeeded/Failed/Unknown |
| 2 | Container states | Waiting / Running / Terminated — separate from pod phase |
| 3 | CrashLoopBackOff | Container keeps crashing — check logs --previous |
| 4 | OOMKilled = exitCode 137 | Container exceeded memory limit |
| 5 | restartPolicy | Always (default), OnFailure (jobs), Never (one-shots) |
✅ Quick Check
Q1: A pod is in Running phase but 0/1 READY. What does this mean?
Answer
The container is running (it started successfully), but its **readiness probe** is failing — meaning K8s doesn't consider the pod ready to serve traffic yet. The pod won't receive traffic from a Service until it becomes Ready. Common causes: the app is still initializing, or the readiness probe endpoint returns a non-200 status.Q2: Your pod has RESTARTS: 47 and status CrashLoopBackOff. What’s your first debugging step?
Answer
Run `kubectl logs my-pod --previous` to see the logs from the last crashed container. The `--previous` flag is critical here — without it, you'd see logs from the current (possibly empty) container start. Look for the error message or stack trace that caused the exit.Q3: What’s the difference between a pod in Failed phase vs CrashLoopBackOff status?
Answer
`Failed` is a terminal pod phase — the pod has stopped and will not be restarted (typically when `restartPolicy: Never` or `OnFailure` with a zero-exit scenario). `CrashLoopBackOff` is a container **status** — the pod is still alive and K8s is still trying to restart the container, but applying exponential back-off. The pod phase would be `Running` in CrashLoopBackOff.3.3 Multi-Container Pods: Sidecars, Init, and Ambassadors
⏱️ ~7 min read
TL;DR: Multi-container pods solve specific co-location problems. The three patterns are: sidecar (augments the main container), init container (runs setup before the main container), and ambassador (proxies external traffic). Show the YAML first, understand the pattern second.
Pattern 1: Sidecar
A sidecar runs alongside the main container, sharing its network and storage. The most common use case: log shipping.
# sidecar-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-with-logger
spec:
volumes:
- name: shared-logs # Shared volume between containers
emptyDir: {}
containers:
# Main application
- name: app
image: nginx:1.25
volumeMounts:
- name: shared-logs
mountPath: /var/log/nginx
# Sidecar: ships logs from shared volume
- name: log-shipper
image: busybox
command: ["sh", "-c", "tail -f /logs/access.log"]
volumeMounts:
- name: shared-logs
mountPath: /logs # Same volume, different mount path
graph LR
subgraph "Pod: app-with-logger"
APP[nginx\nwrites to\n/var/log/nginx]
VOL[(emptyDir\nshared-logs)]
LOG[log-shipper\nreads from\n/logs]
APP -->|writes| VOL
VOL -->|reads| LOG
LOG -->|ships to| EXT[External\nLog System]
end
Key facts about emptyDir:
- Created when the pod starts, deleted when the pod dies
- Lives on the node’s disk (or RAM if
medium: Memory) - Perfect for sharing data between containers in a pod
🔗 Docker Parallel: No direct equivalent in Compose. You’d need to set up a log driver or bind mount. The sidecar pattern keeps the main app container simple and logging as a separate concern.
Pattern 2: Init Containers
Init containers run sequentially before any regular containers start. They must complete successfully. If they fail, the pod restarts.
# init-container-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-with-init
spec:
initContainers:
# Runs FIRST — waits for a dependency to be ready
- name: wait-for-db
image: busybox
command:
- sh
- -c
- |
until nc -z postgres-svc 5432; do
echo "Waiting for postgres..."
sleep 2
done
echo "Postgres is up!"
# Runs SECOND — database migration
- name: run-migrations
image: myapp:latest
command: ["python", "manage.py", "migrate"]
env:
- name: DATABASE_URL
value: "postgresql://postgres-svc:5432/mydb"
# Only starts AFTER all init containers succeed
containers:
- name: app
image: myapp:latest
ports:
- containerPort: 8000
sequenceDiagram
participant K as Kubernetes
participant I1 as init: wait-for-db
participant I2 as init: run-migrations
participant A as app container
K->>I1: Start
I1-->>K: ✅ Exit 0
K->>I2: Start
I2-->>K: ✅ Exit 0
K->>A: Start (finally!)
A-->>K: Running
Why use init containers instead of a startup script in the main container?
- Separation of concerns — the app image stays clean
- Different image per init step (wait with busybox, migrate with your app)
- Init containers can have different security contexts
- Failures are clearly visible:
kubectl get podshowsInit:0/2thenInit:1/2etc.
# See init container progress
kubectl get pod app-with-init
# Output: Init:1/2 ← first init done, waiting on second
Pattern 3: Ambassador
An ambassador container proxies connections between the main container and external services — useful for handling TLS, service discovery, or protocol translation locally.
# ambassador-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: app-with-ambassador
spec:
containers:
# Main app talks to localhost:6379 (simple, no auth)
- name: app
image: myapp:latest
env:
- name: REDIS_HOST
value: localhost # ← talks to ambassador, not Redis directly
- name: REDIS_PORT
value: "6379"
# Ambassador: handles auth and TLS to real Redis
- name: redis-proxy
image: envoyproxy/envoy:v1.29
# Envoy configured to proxy localhost:6379 → redis-cloud.example.com:6380 (TLS+auth)
📝 Note: The ambassador pattern is less common today — service meshes (Istio, Linkerd) have largely replaced it for production TLS/auth proxying. But it’s still useful for local development and specific edge cases.
Checking Multi-Container Pods
With multiple containers, you must specify which one you’re targeting:
# Logs from a specific container
kubectl logs app-with-logger -c log-shipper
kubectl logs app-with-logger -c app
# Exec into a specific container
kubectl exec -it app-with-logger -c app -- bash
kubectl exec -it app-with-logger -c log-shipper -- sh
# Describe shows all containers
kubectl describe pod app-with-logger
Try It
# Deploy the sidecar pod
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: sidecar-demo
spec:
volumes:
- name: shared-data
emptyDir: {}
containers:
- name: writer
image: busybox
command: ["sh", "-c", "while true; do date >> /data/log.txt; sleep 2; done"]
volumeMounts:
- name: shared-data
mountPath: /data
- name: reader
image: busybox
command: ["sh", "-c", "tail -f /shared/log.txt"]
volumeMounts:
- name: shared-data
mountPath: /shared
EOF
# Watch both containers start
kubectl get pod sidecar-demo -w
# See the writer's output via reader's logs
kubectl logs sidecar-demo -c reader -f &
# Let it run for 10 seconds, then stop
sleep 10 && kill %1
# Exec into the writer and check the file directly
kubectl exec -it sidecar-demo -c writer -- cat /data/log.txt
# Cleanup
kubectl delete pod sidecar-demo
Expected log output (reader):
Mon Jul 14 10:00:01 UTC 2026
Mon Jul 14 10:00:03 UTC 2026
Mon Jul 14 10:00:05 UTC 2026
Key Takeaways
| # | Pattern | When to Use |
|---|---|---|
| 1 | Sidecar | Augment main container: log shipping, metrics, proxy |
| 2 | Init Container | Pre-flight checks, DB migrations, dependency waiting |
| 3 | Ambassador | Protocol translation, local proxy to external service |
| 4 | emptyDir | Ephemeral shared storage within a pod |
✅ Quick Check
Q1: Your app container takes 60 seconds to initialize and fails readiness checks during that time. Should you use an init container or a startup probe?
Answer
A **startup probe** (covered in Chapter 11). Init containers are for pre-flight tasks that run *before* the main container starts. If your app is initializing but already running, a startup probe tells K8s to give it more time before declaring it failed. Use init containers for things like DB migrations or waiting for dependencies.Q2: An init container in a pod exits with code 1. What happens?
Answer
The main containers never start. Kubernetes restarts the init container according to the pod's `restartPolicy`. If `restartPolicy: Always`, it keeps retrying with exponential back-off until the init container succeeds. The pod stays in `Init:0/N` status. This is by design — it prevents your app from starting against an unready dependency.Q3: You have a sidecar container that ships logs to an external service. The main app container crashes and restarts. What happens to the sidecar?
Answer
The sidecar keeps running. Container restarts in a pod are per-container — if only the main container crashes, only that container is restarted. The sidecar stays alive and continues running. (Exception: if the pod itself is killed and rescheduled, all containers restart together.)3.4 Resource Requests and Limits
⏱️ ~6 min read
TL;DR:
requests= “I need at least this much” (used for scheduling).limits= “I cannot exceed this much” (enforced at runtime). Getting these right is the difference between a stable cluster and one that OOMKills pods in the middle of the night.
The Two Numbers: Requests vs Limits
resources:
requests:
memory: "128Mi" # Scheduler guarantee: only place me on a node with 128Mi free
cpu: "250m" # Scheduler guarantee: only place me on a node with 250m CPU free
limits:
memory: "256Mi" # Hard limit: if I exceed this, I get OOMKilled
cpu: "500m" # Soft limit: if I exceed this, I get throttled (not killed)
The difference in behavior between CPU and memory limits is critical:
| Resource | Over Limit Behavior |
|---|---|
| CPU | Throttled — the container is slowed down but keeps running |
| Memory | OOMKilled — the container is immediately terminated (exit code 137) |
graph LR
subgraph "CPU Exceeded"
A[Container wants 600m CPU] --> B[Limit: 500m]
B --> C[Throttled\nSlowed down\nStill running ✅]
end
subgraph "Memory Exceeded"
D[Container uses 300Mi RAM] --> E[Limit: 256Mi]
E --> F[OOMKilled\nTerminated immediately ❌\nExit code 137]
end
CPU Units: Millicores
CPU is measured in millicores (m). 1000m = 1 CPU core.
250m = ¼ of a CPU core
500m = ½ of a CPU core
1000m = 1 full CPU core (same as writing "1")
2000m = 2 CPU cores (same as writing "2")
# These are equivalent:
cpu: "1"
cpu: "1000m"
💡 Tip: For most web services, start with
requests: 100mandlimits: 500m. Adjust based on profiling. Node.js and Python are rarely CPU-bound; JVM apps need more.
Memory Units
Memory uses standard binary suffixes:
64Mi = 64 mebibytes (~67 MB)
128Mi = 128 mebibytes (~134 MB)
256Mi = 256 mebibytes
1Gi = 1 gibibyte (~1.07 GB)
⚠️ Warning:
M(megabytes) andMi(mebibytes) are different. UseMito avoid confusion. K8s accepts both but they differ by ~5%.
QoS Classes: What Kubernetes Does Under Pressure
Based on how you set requests/limits, Kubernetes assigns each pod a Quality of Service class. This determines which pods get evicted first when a node runs low on memory.
| QoS Class | Condition | Eviction Priority |
|---|---|---|
| Guaranteed | requests == limits for all containers | Last to be evicted |
| Burstable | requests < limits, or only one set | Middle priority |
| BestEffort | No requests or limits set | First to be evicted |
# Guaranteed QoS — for critical workloads
resources:
requests:
memory: "256Mi"
cpu: "500m"
limits:
memory: "256Mi" # ← same as requests
cpu: "500m" # ← same as requests
# BestEffort QoS — avoid in production
# (no resources block at all)
🏭 In Production: Always set resource requests for every container. Without them, the scheduler makes uninformed decisions and nodes can get overloaded. BestEffort pods are the first to die when the node gets memory pressure.
LimitRange: Cluster-Wide Defaults
Admins can set default limits per namespace so developers don’t have to:
# limitrange.yaml
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: default
spec:
limits:
- type: Container
default: # Applied if no limits set
memory: "256Mi"
cpu: "500m"
defaultRequest: # Applied if no requests set
memory: "128Mi"
cpu: "100m"
max: # Nobody in this namespace can set higher than this
memory: "2Gi"
cpu: "2"
Try It
# Deploy a pod with resource limits
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: resource-demo
spec:
containers:
- name: app
image: nginx:1.25
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
EOF
# See its QoS class
kubectl get pod resource-demo -o jsonpath='{.status.qosClass}'
# See resource requests on the node
kubectl describe node minikube | grep -A6 "Allocated resources:"
# Cleanup
kubectl delete pod resource-demo
Expected QoS output:
Burstable
(Because requests < limits. Set them equal for Guaranteed.)
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | requests = scheduling | Minimum guaranteed resources; used by the Scheduler |
| 2 | limits = enforcement | Hard cap: CPU throttles, memory kills |
| 3 | OOMKilled = exit 137 | Container exceeded memory limit — raise the limit or fix the leak |
| 4 | Always set requests | Without them, the Scheduler flies blind |
| 5 | QoS classes | Guaranteed > Burstable > BestEffort for eviction order |
✅ Quick Check
Q1: A container with limits.cpu: 500m suddenly needs 800m CPU to handle a traffic spike. What happens?
Answer
The container is **CPU-throttled** — it gets slowed down but keeps running. The kernel's CFS (Completely Fair Scheduler) enforces the limit by reducing how much CPU time the container gets. Response times increase, but the container doesn't crash. This is why CPU limits are "soft" limits.Q2: You set requests.memory: 128Mi and limits.memory: 128Mi. What QoS class does this pod get?
Answer
**Guaranteed** — because requests equal limits for all resources. This pod is the last to be evicted when the node is under memory pressure. Use this for critical production workloads.Q3: Your node has 4Gi of RAM. You have 40 pods each with requests.memory: 128Mi and no limits set. A burst of traffic causes one pod to use 2Gi. What happens?
Answer
Without limits, the pod can consume up to 2Gi (or more). This creates **memory pressure** on the node. K8s starts evicting **BestEffort** pods first (those with no requests or limits), then **Burstable**, then **Guaranteed**. The overcommitted pod itself might eventually be killed when the node's OOM killer fires. This is why setting both requests AND limits is essential.Lab: Run, Inspect, Break, and Debug Pods
⏱️ ~30 min hands-on
| Prerequisites | Chapters 3.1–3.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Create pods imperatively and declaratively, exec into them, read logs, trigger an OOMKill, debug a CrashLoopBackOff, and use init containers |
Objectives
- Create and inspect pods using both methods
- Use
kubectl execto run commands inside a container - Read logs including from previous (crashed) containers
- Trigger and diagnose an OOMKilled container
- Debug a pod stuck in CrashLoopBackOff
- Use an init container to gate a pod’s startup
Setup
# Create a dedicated namespace
kubectl create namespace pod-lab
kubectl config set-context --current --namespace=pod-lab
Exercise 1: Create and Inspect a Pod
What we’re doing: Create a pod both ways and compare the experience.
# Method A: Imperative
kubectl run nginx-imperative --image=nginx:1.25
# Method B: Declarative
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: nginx-declarative
namespace: pod-lab
labels:
method: declarative
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
EOF
# Watch both come up
kubectl get pods -w
Expected output:
NAME READY STATUS RESTARTS AGE
nginx-declarative 0/1 ContainerCreating 0 2s
nginx-imperative 0/1 ContainerCreating 0 5s
nginx-declarative 1/1 Running 0 4s
nginx-imperative 1/1 Running 0 7s
Now deep-inspect:
# Full YAML of what's actually stored in K8s
kubectl get pod nginx-declarative -o yaml
# Human-readable summary with events
kubectl describe pod nginx-declarative
Look for these in describe output:
QoS Class:— should beBurstableNode:— which minikube nodeIP:— the pod’s internal IPEvents:— the pull/start sequence
Exercise 2: Exec and Port-Forward
What we’re doing: Get inside a running container and access it from your laptop.
# Get a shell inside the nginx container
kubectl exec -it nginx-declarative -- bash
# Inside the container — explore
hostname # Pod name
cat /etc/hostname # Same
env | grep KUBERNETES # K8s injects these env vars
curl localhost # Access nginx from inside
exit
# Port-forward to access from your host
kubectl port-forward pod/nginx-declarative 8080:80 &
curl http://localhost:8080
echo "Response received!"
kill %1
Expected curl output:
<!DOCTYPE html>
<html>
<head><title>Welcome to nginx!</title></head>
...
💡 What just happened?
kubectl execopens a shell inside the running container’s filesystem and process space.port-forwardtunnels traffic from your laptop’s port 8080 through the kubectl proxy to the pod’s port 80.
Exercise 3: Logs and Log Tailing
# View nginx access logs
kubectl logs nginx-declarative
# Follow logs in real time
kubectl logs nginx-declarative -f &
# In another command, generate some traffic
kubectl port-forward pod/nginx-declarative 8080:80 &
for i in {1..5}; do curl -s http://localhost:8080 > /dev/null; done
# You should see log entries appear in the -f stream
kill %1 %2
# Logs since a timestamp
kubectl logs nginx-declarative --since=5m
# Last N lines
kubectl logs nginx-declarative --tail=20
Exercise 4: Trigger and Diagnose CrashLoopBackOff
What we’re doing: Intentionally create a crashing pod and walk through the full debugging flow.
# Pod that exits immediately with an error message
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: crashloop-demo
namespace: pod-lab
spec:
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo 'ERROR: Database connection refused at postgres:5432'; exit 1"]
restartPolicy: Always
EOF
Watch the CrashLoopBackOff progression:
kubectl get pod crashloop-demo -w
Expected output:
NAME READY STATUS RESTARTS AGE
crashloop-demo 0/1 ContainerCreating 0 1s
crashloop-demo 0/1 Error 0 3s
crashloop-demo 0/1 CrashLoopBackOff 1 8s
crashloop-demo 0/1 Error 2 28s
crashloop-demo 0/1 CrashLoopBackOff 3 52s
Now debug:
# Step 1: Get the error from logs (current run — might be empty after restart)
kubectl logs crashloop-demo
# Step 2: Get logs from the PREVIOUS crash
kubectl logs crashloop-demo --previous
# Step 3: See restart count and last exit code
kubectl describe pod crashloop-demo | grep -A15 "Containers:"
# Step 4: Check via JSONPath
kubectl get pod crashloop-demo -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'
Expected output from --previous logs:
ERROR: Database connection refused at postgres:5432
Key fields from describe:
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: Error
Exit Code: 1
Started: Mon, 14 Jul 2026 10:00:05 +0000
Finished: Mon, 14 Jul 2026 10:00:05 +0000
Ready: False
Restart Count: 4
# Cleanup
kubectl delete pod crashloop-demo
Exercise 5: Trigger an OOMKill
What we’re doing: Set a very low memory limit and watch the container get killed by the kernel.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: oomkill-demo
namespace: pod-lab
spec:
containers:
- name: memory-hog
image: polinux/stress
command: ["stress"]
args: ["--vm", "1", "--vm-bytes", "150M", "--vm-hang", "1"]
resources:
requests:
memory: "50Mi"
limits:
memory: "100Mi" # Container will try to use 150M but limit is 100M
restartPolicy: Never
EOF
Watch it get OOMKilled:
kubectl get pod oomkill-demo -w
Expected output:
NAME READY STATUS RESTARTS AGE
oomkill-demo 0/1 Pending 0 1s
oomkill-demo 1/1 Running 0 3s
oomkill-demo 0/1 OOMKilled 0 5s
oomkill-demo 0/1 Completed 0 5s
Diagnose it:
# Confirm OOMKill via exit code (137 = killed by signal 9)
kubectl get pod oomkill-demo -o jsonpath='{.status.containerStatuses[0].state.terminated.reason}'
# Output: OOMKilled
kubectl get pod oomkill-demo -o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}'
# Output: 137
# Full terminated state
kubectl describe pod oomkill-demo | grep -A10 "Last State:"
💡 What just happened? The container tried to allocate 150Mi of RAM, but the limit was 100Mi. The kernel’s OOM killer terminated the process. Exit code 137 = killed by SIGKILL (128 + 9). The fix: either raise the memory limit or fix the memory leak in your app.
kubectl delete pod oomkill-demo
Exercise 6: Init Container Gate
What we’re doing: Use an init container to simulate a dependency check that gates the main app.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: init-demo
namespace: pod-lab
spec:
initContainers:
- name: delay-start
image: busybox
command: ["sh", "-c", "echo 'Init: running preflight checks...'; sleep 10; echo 'Init: all checks passed!'"]
containers:
- name: app
image: nginx:1.25
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
EOF
Watch the init container run first:
kubectl get pod init-demo -w
Expected progression:
NAME READY STATUS RESTARTS AGE
init-demo 0/1 Init:0/1 0 2s
init-demo 0/1 Init:0/1 0 5s
init-demo 0/1 PodInitializing 0 11s
init-demo 1/1 Running 0 12s
See the init container logs:
kubectl logs init-demo -c delay-start
Expected output:
Init: running preflight checks...
Init: all checks passed!
kubectl delete pod init-demo
🔥 Break It! Challenge
What happens when you try to schedule a pod that’s too big for the node?
# Minikube has ~2GB RAM. Request more than exists.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: toobig-pod
namespace: pod-lab
spec:
containers:
- name: app
image: nginx:1.25
resources:
requests:
memory: "100Gi" # 100GB on a 2GB node
cpu: "64" # 64 cores on a 2-core node
EOF
# See it stuck in Pending
kubectl get pod toobig-pod
# Find out WHY it's Pending
kubectl describe pod toobig-pod | grep -A10 "Events:"
Expected event:
Warning FailedScheduling 0/1 nodes are available: 1 Insufficient memory, 1 Insufficient cpu.
The Scheduler found zero nodes that could satisfy the request. The pod stays Pending indefinitely.
kubectl delete pod toobig-pod
Cleanup
# Delete all pods in the lab namespace
kubectl delete pod --all -n pod-lab
# Switch back to default namespace
kubectl config set-context --current --namespace=default
# Delete the lab namespace
kubectl delete namespace pod-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Create pods imperatively and declaratively | Both nginx pods ran successfully |
| 2 | Exec into a container | Ran hostname, curl localhost inside the pod |
| 3 | Read and tail logs | Captured nginx access logs with -f |
| 4 | Debug CrashLoopBackOff | Used logs --previous to find the error message |
| 5 | Diagnose OOMKilled | Confirmed exit code 137 and OOMKilled reason |
| 6 | Use init containers | Watched Init:0/1 gate the main container |
| 7 | Understand scheduling failures | Saw Insufficient memory error for oversized pod |
Chapter 4: Workload Controllers
⏱️ Total chapter time: ~60 min (30 min reading + 30 min lab)
After this chapter, you will be able to: Deploy applications using the right controller for the job, perform zero-downtime rolling updates, rollback a bad deployment, and understand when to use each controller type.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 4.1 | ReplicaSets — Desired State and Self-Healing | ~5 min |
| 4.2 | Deployments — Rolling Updates, Rollbacks, and Strategy | ~8 min |
| 4.3 | DaemonSets — One Per Node | ~5 min |
| 4.4 | StatefulSets — When Identity Matters | ~6 min |
| 4.5 | Jobs and CronJobs — Run-to-Completion Workloads | ~5 min |
| 4.6 | 🔬 Lab: Deploy, Scale, Update, and Rollback | ~30 min |
Prerequisites
- Completed Chapter 3 (understand pods)
minikube statusshowsRunning
4.1 ReplicaSets — Desired State and Self-Healing
⏱️ ~5 min read
TL;DR: A ReplicaSet ensures N copies of a pod are always running. If one dies, it creates a replacement. You almost never create ReplicaSets directly — Deployments manage them for you. But understanding them explains how Deployments work.
What a ReplicaSet Does
# replicaset.yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
spec:
replicas: 3 # I want 3 pods running at all times
selector:
matchLabels:
app: nginx # I own pods with this label
template: # Pod template — used to create new pods
metadata:
labels:
app: nginx # Must match selector above
spec:
containers:
- name: nginx
image: nginx:1.25
The ReplicaSet Controller runs a continuous loop:
graph LR
RS[ReplicaSet Controller]
RS -->|Count pods with\nlabel app=nginx| C{Actual vs Desired}
C -->|"Actual: 3 = Desired: 3"| OK[✅ Nothing to do]
C -->|"Actual: 2 < Desired: 3"| CREATE[Create 1 pod from template]
C -->|"Actual: 4 > Desired: 3"| DELETE[Delete 1 pod]
CREATE --> RS
DELETE --> RS
The Label Selector — The Critical Link
The ReplicaSet knows which pods it “owns” via a label selector. This has an important implication:
⚠️ Warning: If you manually create a pod with a label that matches a ReplicaSet’s selector, the ReplicaSet will immediately delete it — it’s trying to maintain exactly N pods.
# A ReplicaSet with replicas: 3 and selector app=nginx
# If you manually add a 4th pod with the same label:
kubectl run extra --image=nginx --labels=app=nginx
# The ReplicaSet controller sees 4 pods when it wants 3
# → It immediately deletes one of them (randomly, often the one you just made)
Why Not Create ReplicaSets Directly?
One word: updates.
If you have a ReplicaSet running nginx:1.24 and you want to update to nginx:1.25, you’d need to delete the ReplicaSet and create a new one — causing downtime. There’s no rolling update.
Deployments solve this by managing two ReplicaSets during a rollout: the old one scales down as the new one scales up. The ReplicaSet is the mechanism; the Deployment is the orchestrator.
graph TB
D[Deployment] --> RS1[ReplicaSet v1\nnginx:1.24\nreplicas: 0]
D --> RS2[ReplicaSet v2\nnginx:1.25\nreplicas: 3]
The old RS stays (with 0 replicas) to allow rollbacks.
Try It
# Create a ReplicaSet directly (educational — not how you'd do it in practice)
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
spec:
replicas: 3
selector:
matchLabels:
app: nginx-rs-demo
template:
metadata:
labels:
app: nginx-rs-demo
spec:
containers:
- name: nginx
image: nginx:1.25
EOF
kubectl get pods -l app=nginx-rs-demo
# Delete one pod — watch it get recreated
POD=$(kubectl get pods -l app=nginx-rs-demo -o name | head -1)
kubectl delete $POD
# Immediately check — RS creates a replacement
kubectl get pods -l app=nginx-rs-demo
# Cleanup
kubectl delete replicaset nginx-rs
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | ReplicaSet = desired count enforcer | Continuously reconciles actual pod count to replicas |
| 2 | Label selector = ownership | RS owns any pod whose labels match its selector |
| 3 | Don’t use ReplicaSets directly | Use Deployments — they add rolling updates and rollback |
| 4 | Old RS kept at 0 replicas | Enables instant rollback without re-pulling images |
✅ Quick Check
Q1: You have a ReplicaSet with replicas: 5. You manually kubectl delete pod one of them. How many pods will exist 10 seconds later?
Answer
Still 5. The ReplicaSet controller detects the count dropped to 4 and immediately creates a new pod from its template. This is the self-healing behavior — the desired state (5 replicas) is continuously enforced.Q2: Can one ReplicaSet manage pods running different container images?
Answer
No. A ReplicaSet has a single pod template. All pods it creates are identical. If you need pods with different specs (e.g., different images), you need separate ReplicaSets or a higher-level controller like a Deployment (which manages RS generations).Q3: Why does a Deployment keep the old ReplicaSet around at 0 replicas after a rollout?
Answer
For instant rollback. When you run `kubectl rollout undo`, Kubernetes simply scales the old ReplicaSet back up and scales the new one down. Because the old RS already exists and the pods' image layers are cached on the node, this is very fast — no re-pulling required.4.2 Deployments — Rolling Updates, Rollbacks, and Strategy
⏱️ ~8 min read
TL;DR: Deployments are how you deploy everything in Kubernetes. They manage ReplicaSets to give you zero-downtime rolling updates, instant rollbacks, and declarative version history. This is the controller you’ll use 90% of the time.
The Full Deployment YAML
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
labels:
app: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
strategy:
type: RollingUpdate # Default — zero downtime
rollingUpdate:
maxSurge: 1 # Allow 1 extra pod above desired during rollout
maxUnavailable: 0 # Never go below desired count (zero downtime)
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: app
image: nginx:1.24 # ← version we'll upgrade
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
readinessProbe: # Deployment waits for this before continuing rollout
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 3
Rolling Update — What Actually Happens
With maxSurge: 1 and maxUnavailable: 0 on a 3-replica deployment:
sequenceDiagram
participant D as Deployment
participant RS1 as Old RS (v1)
participant RS2 as New RS (v2)
Note over D: kubectl apply (image: nginx:1.25)
D->>RS2: Create RS v2
RS2->>RS2: Create pod v2-1 (surge: now 4 pods)
RS2-->>D: pod v2-1 Ready ✅
D->>RS1: Scale down: delete pod v1-1 (back to 3)
RS2->>RS2: Create pod v2-2 (4 pods again)
RS2-->>D: pod v2-2 Ready ✅
D->>RS1: Scale down: delete pod v1-2
RS2->>RS2: Create pod v2-3
RS2-->>D: pod v2-3 Ready ✅
D->>RS1: Scale down: delete pod v1-3
Note over D: Rollout complete. RS1 stays at 0.
The key: a pod must pass its readiness probe before the old one is deleted. This guarantees at least 3 healthy pods throughout.
Update Strategy Options
RollingUpdate (default)
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # or "25%" — extra pods above desired
maxUnavailable: 0 # or "25%" — pods below desired allowed to be unavailable
| maxSurge | maxUnavailable | Behavior |
|---|---|---|
| 1 | 0 | Zero-downtime; slower (one at a time) |
| 25% | 25% | Balanced; default K8s behavior |
| 100% | 0 | Blue-green style (doubles pod count briefly) |
| 0 | 1 | Takes one down, brings one up (canary-like) |
Recreate
strategy:
type: Recreate # Kill ALL old pods, then create new ones
Causes downtime. Use only when old and new versions cannot run simultaneously (e.g., DB schema migrations with breaking changes).
Performing a Rolling Update
# Method 1: Edit the deployment file and kubectl apply
# (change nginx:1.24 → nginx:1.25 in deployment.yaml)
kubectl apply -f deployment.yaml
# Method 2: Set image imperatively
kubectl set image deployment/my-app app=nginx:1.25
# Method 3: Edit live
kubectl edit deployment my-app # opens in $EDITOR
# Watch the rollout happen
kubectl rollout status deployment/my-app
Expected rollout output:
Waiting for deployment "my-app" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "my-app" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "my-app" rollout to finish: 1 old replicas are pending termination...
deployment "my-app" successfully rolled out
Rollback
# See rollout history
kubectl rollout history deployment/my-app
# Rollout history output:
# REVISION CHANGE-CAUSE
# 1 <none>
# 2 <none>
# Add change-cause annotation for meaningful history
kubectl annotate deployment/my-app kubernetes.io/change-cause="Update to nginx 1.25"
# Rollback to previous version (RS v1 scales back up)
kubectl rollout undo deployment/my-app
# Rollback to a specific revision
kubectl rollout undo deployment/my-app --to-revision=1
# Watch the rollback
kubectl rollout status deployment/my-app
🏭 In Production: Always annotate deployments with
--record(deprecated) orkubernetes.io/change-causesorollout historyis meaningful. “Deployed by CI build #1234 - ticket PROJ-567” is infinitely more useful than<none>.
Pausing and Resuming Rollouts
# Make multiple changes without triggering a rollout each time
kubectl rollout pause deployment/my-app
kubectl set image deployment/my-app app=nginx:1.25
kubectl set resources deployment/my-app -c app --limits=memory=256Mi,cpu=400m
# Apply all changes at once
kubectl rollout resume deployment/my-app
Scaling
# Scale manually
kubectl scale deployment my-app --replicas=10
# Or edit the YAML and kubectl apply
# In production: use HPA (Chapter 13) to autoscale
Try It
# Create a deployment with nginx:1.24
kubectl create deployment update-demo --image=nginx:1.24 --replicas=3
# Verify
kubectl get deploy update-demo
kubectl get pods -l app=update-demo
# Perform a rolling update to nginx:1.25
kubectl set image deployment/update-demo update-demo=nginx:1.25
# Watch it roll out
kubectl rollout status deployment/update-demo
# Confirm new image
kubectl get pods -l app=update-demo -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
# Check history
kubectl rollout history deployment/update-demo
# Rollback
kubectl rollout undo deployment/update-demo
kubectl rollout status deployment/update-demo
# Confirm original image restored
kubectl get pods -l app=update-demo -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
# Cleanup
kubectl delete deployment update-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Deployment manages ReplicaSets | Creates a new RS per rollout; keeps old ones for rollback |
| 2 | maxSurge + maxUnavailable | Control speed vs safety tradeoff in rolling updates |
| 3 | Readiness probe gates rollout | New pod must be Ready before old one is deleted |
| 4 | rollout undo = instant rollback | Old RS scales back up — no re-download needed |
| 5 | Recreate = downtime strategy | Use only when old/new versions can’t coexist |
✅ Quick Check
Q1: Your deployment has maxSurge: 0 and maxUnavailable: 1. During a rolling update of 5 replicas, how many pods are running at any given time?
Answer
At minimum 4. With `maxUnavailable: 1`, one pod can be unavailable at any time. With `maxSurge: 0`, no extra pods can be created. So the pattern is: terminate one old pod → create one new pod → wait for it to become Ready → repeat. You'll always have 4–5 pods, never 6.Q2: A rollout is in progress. You update the deployment image again. What happens?
Answer
Kubernetes aborts the current rollout and starts a new one for the latest change. The in-progress RS is scaled back, and a new RS for the new image is created. You can see this with `kubectl rollout history` — each change creates a new revision.Q3: kubectl rollout undo fails with “no previous revision.” Why?
Answer
The deployment has only one revision in history — it was never updated. `rollout undo` requires at least two revisions to have a "previous" one to roll back to. You can check with `kubectl rollout history deployment/my-app`.4.3 DaemonSets — One Per Node
⏱️ ~5 min read
TL;DR: A DaemonSet ensures exactly one pod runs on every node in the cluster. When new nodes are added, a pod is automatically scheduled on them. When nodes are removed, the pods are garbage collected.
When You Need Exactly One Pod Per Node
Some workloads are inherently node-level — they need to run on every machine:
| Use Case | Example |
|---|---|
| Log collection | Fluentd, Filebeat — collect logs from the node’s filesystem |
| Metrics collection | Prometheus Node Exporter — scrape node-level metrics |
| Network plugins | Calico, Flannel — implement pod networking (CNI) |
| Storage agents | Ceph, Longhorn — manage node-local storage |
| Security agents | Falco — monitor syscalls on each node |
📝 Note: Look at your
kube-systemnamespace —kube-proxyis itself a DaemonSet. It runs on every node to maintain iptables rules.
DaemonSet YAML
# daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-collector
namespace: kube-system
spec:
selector:
matchLabels:
app: log-collector
template:
metadata:
labels:
app: log-collector
spec:
tolerations:
# Allow scheduling on control plane nodes too
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: fluentd
image: fluent/fluentd:v1.16
resources:
limits:
memory: "200Mi"
cpu: "100m"
volumeMounts:
- name: varlog
mountPath: /var/log
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
terminationGracePeriodSeconds: 30
volumes:
- name: varlog
hostPath: # Mount the NODE's /var/log — not the pod's
path: /var/log
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
The Key Difference: hostPath Volumes
DaemonSets typically mount host directories — they need to read files from the actual node filesystem. This is a hostPath volume, which is different from emptyDir or PersistentVolumes:
graph LR
subgraph "Node A"
HF["/var/log/ on Node"]
subgraph "DaemonSet Pod"
DS[log-collector\nmounts /var/log]
end
HF --> DS
end
subgraph "Node B"
HF2["/var/log/ on Node"]
subgraph "DaemonSet Pod"
DS2[log-collector\nmounts /var/log]
end
HF2 --> DS2
end
⚠️ Warning:
hostPathvolumes are powerful and dangerous. A pod with ahostPathmount to/can read the entire node filesystem. In production, restrict DaemonSet permissions with Pod Security Standards (Chapter 14).
DaemonSets vs Deployments
| Deployment | DaemonSet | |
|---|---|---|
| Pod count control | You set replicas | One per node (automatic) |
| Scheduling | Scheduler picks nodes | Runs on ALL nodes (or subset via nodeSelector) |
| Scaling | Manual or HPA | Automatic as nodes are added/removed |
| Use case | Stateless apps | Node-level infrastructure |
Targeting Specific Nodes
You can restrict a DaemonSet to a subset of nodes using nodeSelector or nodeAffinity:
spec:
template:
spec:
nodeSelector:
disk: ssd # Only run on nodes labeled disk=ssd
# Label a node
kubectl label node minikube disk=ssd
# DaemonSet will now only schedule on labeled nodes
Try It
# On minikube (single node), a DaemonSet creates exactly 1 pod
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: ds-demo
spec:
selector:
matchLabels:
app: ds-demo
template:
metadata:
labels:
app: ds-demo
spec:
containers:
- name: agent
image: busybox
command: ["sh", "-c", "while true; do echo 'Monitoring node...'; sleep 10; done"]
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
# Exactly 1 pod on our 1-node cluster
kubectl get pods -l app=ds-demo -o wide
# See which node it's on
kubectl get pod -l app=ds-demo -o jsonpath='{.items[0].spec.nodeName}'
# Cleanup
kubectl delete daemonset ds-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | One pod per node | DaemonSet guarantees node-level coverage |
| 2 | Auto-scales with cluster | New node → new pod; removed node → pod cleaned up |
| 3 | Uses hostPath | Typically mounts the node’s own filesystem |
| 4 | kube-proxy is a DaemonSet | Node networking implemented this way in every cluster |
✅ Quick Check
Q1: Your 10-node cluster runs a log-collector DaemonSet. You add 3 more nodes. What happens?
Answer
Kubernetes automatically schedules one log-collector pod on each of the 3 new nodes. You now have 13 pods total with zero manual intervention. This is the entire point of DaemonSets.Q2: You want a DaemonSet to run on all nodes EXCEPT the control plane. How?
Answer
By default, DaemonSets don't schedule on control-plane nodes because control-plane nodes have a `NoSchedule` taint. Simply don't add the `tolerations` block for `node-role.kubernetes.io/control-plane` — the DaemonSet will skip control-plane nodes automatically.Q3: Can you kubectl scale a DaemonSet to 0?
Answer
No — DaemonSets don't have a `replicas` field. The pod count is determined by the number of matching nodes, not a user-specified number. To "disable" a DaemonSet, you'd either delete it or use a `nodeSelector` that matches no nodes.4.4 StatefulSets — When Identity Matters
⏱️ ~6 min read
TL;DR: StatefulSets are for applications that need stable network identity and stable storage — databases, message queues, consensus systems. Each pod gets a persistent name (
pod-0,pod-1) and its own PersistentVolumeClaim that follows it across restarts.
The Problem with Deployments for Stateful Apps
Deployments give each pod a random suffix (my-app-7f4b9c-x8p2q). If a pod is replaced, it gets a new random name, a new IP, and starts with an empty disk.
That’s fine for stateless services. But try running a database replica set where:
- Replica 1 needs to always be
db-0so others can find it db-0needs its data to survive restarts- Replicas must start in order (primary before secondaries)
Deployments can’t do this. StatefulSets can.
StatefulSet YAML
# statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mongo
spec:
serviceName: mongo # Must match a Headless Service (see below)
replicas: 3
selector:
matchLabels:
app: mongo
template:
metadata:
labels:
app: mongo
spec:
containers:
- name: mongo
image: mongo:7
ports:
- containerPort: 27017
volumeMounts:
- name: data
mountPath: /data/db # Each pod mounts ITS OWN volume here
volumeClaimTemplates: # ← This is the StatefulSet superpower
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
The volumeClaimTemplates section automatically creates a separate PVC for each pod:
data-mongo-0→mongo-0data-mongo-1→mongo-1data-mongo-2→mongo-2
The Four Guarantees of StatefulSets
graph TB
subgraph "StatefulSet Guarantees"
G1["1. Stable Pod Names\nmongo-0, mongo-1, mongo-2\n(not random hashes)"]
G2["2. Stable Network Identity\nmongo-0.mongo.default.svc.cluster.local\n(predictable DNS)"]
G3["3. Stable Storage\nEach pod keeps its own PVC\nacross restarts and reschedules"]
G4["4. Ordered Operations\nStart: 0 → 1 → 2\nTerminate: 2 → 1 → 0"]
end
| Feature | Deployment | StatefulSet |
|---|---|---|
| Pod names | Random (abc-7f4-x8p) | Ordinal (db-0, db-1) |
| Pod DNS | Random | pod-name.svc-name.ns.svc.cluster.local |
| Storage | Shared or none | Each pod gets its own PVC |
| Start order | Parallel | Sequential (0, 1, 2…) |
| Delete order | Parallel | Reverse sequential (2, 1, 0…) |
The Required Headless Service
StatefulSets require a Headless Service (clusterIP: None) to provide stable DNS for individual pods:
# headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: mongo # Must match StatefulSet's serviceName
spec:
clusterIP: None # ← Headless: no virtual IP, DNS returns pod IPs directly
selector:
app: mongo
ports:
- port: 27017
With this, each pod gets a stable DNS record:
mongo-0.mongo.default.svc.cluster.local → 10.244.0.5
mongo-1.mongo.default.svc.cluster.local → 10.244.0.6
mongo-2.mongo.default.svc.cluster.local → 10.244.0.7
If mongo-1 is rescheduled to a different node, its DNS name stays mongo-1.mongo... — it just resolves to a new IP. Other services can always reach it by name.
When to Use StatefulSets
Use StatefulSets for:
- Databases (MongoDB, PostgreSQL, MySQL clusters)
- Message queues (Kafka, RabbitMQ)
- Consensus systems (Zookeeper, etcd)
- Any app that needs persistent, pod-specific identity
Don’t use StatefulSets for:
- Stateless web services (use Deployment)
- Batch jobs (use Jobs)
- Any app that doesn’t need stable identity or its own storage
🏭 In Production: Most teams use Helm charts (Chapter 12) for databases, which package StatefulSets with the right configuration. Don’t roll your own database StatefulSet for production without understanding your specific database’s clustering requirements.
Try It
# Create the headless service
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: nginx-headless
spec:
clusterIP: None
selector:
app: nginx-sts
ports:
- port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nginx-sts
spec:
serviceName: nginx-headless
replicas: 3
selector:
matchLabels:
app: nginx-sts
template:
metadata:
labels:
app: nginx-sts
spec:
containers:
- name: nginx
image: nginx:1.25
resources:
limits:
memory: "64Mi"
cpu: "100m"
EOF
# Watch them start IN ORDER (0, then 1, then 2)
kubectl get pods -l app=nginx-sts -w
# See stable pod names
kubectl get pods -l app=nginx-sts
# Delete the middle pod — it comes back with the SAME name
kubectl delete pod nginx-sts-1
kubectl get pods -l app=nginx-sts -w # nginx-sts-1 returns with same name
# Cleanup
kubectl delete statefulset nginx-sts
kubectl delete service nginx-headless
Expected pod names:
NAME READY STATUS RESTARTS AGE
nginx-sts-0 1/1 Running 0 30s
nginx-sts-1 1/1 Running 0 25s
nginx-sts-2 1/1 Running 0 20s
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Stable pod names | pod-0, pod-1 — never random hashes |
| 2 | volumeClaimTemplates | Each pod gets its own persistent disk that follows it |
| 3 | Headless Service required | Provides per-pod DNS for stable network identity |
| 4 | Ordered start/stop | Pods start 0→N, stop N→0 — critical for cluster consensus |
✅ Quick Check
Q1: A StatefulSet pod kafka-2 gets rescheduled to a new node. Does it retain its data?
Answer
Yes. The PVC `data-kafka-2` is bound to a PersistentVolume that exists independently of the pod. When `kafka-2` restarts on the new node, the StatefulSet controller ensures the same PVC is mounted. The data survives node changes.Q2: You scale a StatefulSet from 3 to 5 replicas. In what order do pods 3 and 4 start?
Answer
Pod `3` starts first and must become `Running` and `Ready` before pod `4` is created. StatefulSets always maintain sequential ordering during scale-up. This is critical for databases where nodes join the cluster in a specific sequence.Q3: Can you use a regular (non-headless) Service with a StatefulSet?
Answer
Yes — and it's common to have both. A regular ClusterIP Service provides a load-balanced entry point for clients that don't care which replica they hit. The headless Service provides per-pod DNS for applications that need to target specific replicas (like a primary node). StatefulSets often have both types.4.5 Jobs and CronJobs — Run-to-Completion Workloads
⏱️ ~5 min read
TL;DR: Jobs run a task to completion (exit 0) and then stop. CronJobs run Jobs on a schedule. Use them for database backups, data processing, report generation — anything that has a start and end.
Jobs
A Job creates one or more pods, runs them to completion, and tracks success/failure.
# job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: db-backup
spec:
completions: 1 # How many successful pod completions we need
parallelism: 1 # How many pods to run in parallel
backoffLimit: 3 # Retry up to 3 times before marking Job failed
activeDeadlineSeconds: 300 # Kill the job if it runs longer than 5 min
template:
spec:
restartPolicy: OnFailure # Required — Never or OnFailure (not Always)
containers:
- name: backup
image: postgres:16
command:
- sh
- -c
- |
pg_dump -h $DB_HOST -U $DB_USER mydb | gzip > /backup/backup-$(date +%Y%m%d).sql.gz
echo "Backup complete!"
env:
- name: DB_HOST
value: postgres-svc
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
⚠️ Warning: Jobs require
restartPolicy: OnFailureorNever. The defaultAlwaysis not allowed — a Job that restarts forever never “completes.”
Parallel Jobs
Run multiple pods simultaneously for parallelizable work:
spec:
completions: 10 # Need 10 successful completions total
parallelism: 3 # Run 3 pods at a time
backoffLimit: 5
graph LR
J[Job\ncompletions:10\nparallelism:3]
J --> P1[Pod 1 ✅]
J --> P2[Pod 2 ✅]
J --> P3[Pod 3 ✅]
P1 --> P4[Pod 4 ✅]
P2 --> P5[Pod 5 ✅]
P3 --> P6[Pod 6 ✅]
P4 & P5 & P6 --> DONE[Job Complete\n6 of 10... continues]
CronJobs
A CronJob creates a new Job on a schedule, using standard cron syntax.
# cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-backup
spec:
schedule: "0 2 * * *" # Every day at 2:00 AM
concurrencyPolicy: Forbid # Don't start a new Job if the last one is still running
successfulJobsHistoryLimit: 3 # Keep last 3 successful job records
failedJobsHistoryLimit: 1 # Keep last 1 failed job record
startingDeadlineSeconds: 60 # If missed schedule by 60s, skip this run
jobTemplate: # The Job spec to run
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: postgres:16
command: ["sh", "-c", "echo 'Running backup...' && sleep 5 && echo 'Done!'"]
CronJob Schedule Syntax
┌─── minute (0–59)
│ ┌─── hour (0–23)
│ │ ┌─── day of month (1–31)
│ │ │ ┌─── month (1–12)
│ │ │ │ ┌─── day of week (0–7, 0=Sunday)
│ │ │ │ │
* * * * *
"0 2 * * *" → Every day at 2:00 AM
"*/15 * * * *" → Every 15 minutes
"0 9 * * 1" → Every Monday at 9:00 AM
"0 0 1 * *" → First day of every month at midnight
💡 Tip: Use crontab.guru to validate cron expressions before applying them.
ConcurrencyPolicy Options
| Policy | Behavior |
|---|---|
Allow (default) | Multiple Jobs can run concurrently |
Forbid | Skip new run if previous is still running |
Replace | Kill the running Job and start a fresh one |
Use Forbid for anything that shouldn’t overlap (database backups, report generation). Use Allow for independent tasks.
Try It
# Create a simple job
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: hello-job
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: hello
image: busybox
command: ["sh", "-c", "echo 'Job started!'; sleep 3; echo 'Job done!'; exit 0"]
EOF
# Watch it run to completion
kubectl get pods -l job-name=hello-job -w
# See Job status
kubectl get job hello-job
# Read the output
kubectl logs -l job-name=hello-job
# Create a CronJob (runs every minute for demo)
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: minutely-hello
spec:
schedule: "* * * * *"
successfulJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: hello
image: busybox
command: ["sh", "-c", "date; echo 'Cron tick!'"]
EOF
# Wait ~70 seconds, then see the created Jobs
sleep 70 && kubectl get jobs
# Cleanup
kubectl delete job hello-job
kubectl delete cronjob minutely-hello
Expected Job output:
NAME STATUS COMPLETIONS DURATION AGE
hello-job Complete 1/1 4s 30s
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Job = run-to-completion | Pod exits 0 = success; retried up to backoffLimit on failure |
| 2 | restartPolicy: OnFailure | Required for Jobs — Always is forbidden |
| 3 | parallelism + completions | Control parallel batch processing workloads |
| 4 | CronJob = scheduled Job | Standard cron syntax; creates a new Job each trigger |
| 5 | concurrencyPolicy: Forbid | Prevents overlapping runs for non-reentrant tasks |
✅ Quick Check
Q1: A Job with backoffLimit: 3 fails 4 times. What happens?
Answer
After the 4th failure (exceeding the backoff limit of 3), the Job is marked as **Failed** and no more pods are created. The failed pods remain for log inspection. You'd need to delete and recreate the Job to try again.Q2: A CronJob with concurrencyPolicy: Forbid is scheduled every 5 minutes but takes 8 minutes to run. What happens?
Answer
The second scheduled run is **skipped** because the first is still running. The third run (at 10 minutes) also gets skipped. The first run completes at ~8 minutes, and the next scheduled run after that proceeds normally. You never have two copies running simultaneously.Q3: You need to process 1000 independent work items, running 10 workers at a time. Which Job fields do you set?
Answer
Set `completions: 1000` and `parallelism: 10`. Kubernetes runs 10 pods simultaneously, and as each completes, a new one starts until 1000 successful completions are reached. Each pod should process one work item and exit 0 on success.Lab: Deploy, Scale, Update, and Rollback
⏱️ ~30 min hands-on
| Prerequisites | Chapter 4 sections 4.1–4.5 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Deploy nginx with a Deployment, scale it, perform a rolling update, observe self-healing, trigger a bad rollout and rollback, and run a CronJob |
Objectives
- Deploy an application with 3 replicas using a Deployment
- Scale the Deployment up and down
- Perform a zero-downtime rolling update
- Observe ReplicaSet self-healing after pod deletion
- Trigger a bad rollout (wrong image) and roll back
- Create a CronJob and verify scheduled execution
Setup
kubectl create namespace workloads-lab
kubectl config set-context --current --namespace=workloads-lab
Exercise 1: Deploy and Inspect
What we’re doing: Deploy nginx and understand what gets created.
# Create a deployment declaratively
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: webserver
namespace: workloads-lab
annotations:
kubernetes.io/change-cause: "Initial deployment — nginx 1.24"
spec:
replicas: 3
selector:
matchLabels:
app: webserver
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: webserver
version: "1.24"
spec:
containers:
- name: nginx
image: nginx:1.24
ports:
- containerPort: 80
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
EOF
# Wait for rollout
kubectl rollout status deployment/webserver
Now inspect what was created:
# Deployment
kubectl get deployment webserver
# ReplicaSet created by the Deployment
kubectl get replicaset
# Pods created by the ReplicaSet
kubectl get pods -l app=webserver -o wide
# Full ownership chain
kubectl describe deployment webserver | grep -A3 "NewReplicaSet"
Expected output (deployment):
NAME READY UP-TO-DATE AVAILABLE AGE
webserver 3/3 3 3 30s
💡 What just happened? Creating the Deployment caused it to create a ReplicaSet, which created 3 pods. The chain is: Deployment → ReplicaSet → Pods. You only manage the Deployment.
Exercise 2: Scale Up and Down
# Scale to 5
kubectl scale deployment webserver --replicas=5
kubectl rollout status deployment/webserver
kubectl get pods -l app=webserver
# Scale back to 2
kubectl scale deployment webserver --replicas=2
kubectl get pods -l app=webserver -w # Watch 3 pods terminate
# Scale back to 3 for the rest of the lab
kubectl scale deployment webserver --replicas=3
kubectl rollout status deployment/webserver
Exercise 3: Self-Healing — Delete a Pod
What we’re doing: Prove the ReplicaSet recreates killed pods.
# Get pod names
kubectl get pods -l app=webserver
# Delete one pod (replace with your actual pod name)
POD=$(kubectl get pods -l app=webserver -o name | head -1)
echo "Deleting: $POD"
kubectl delete $POD
# Immediately watch — new pod appears within seconds
kubectl get pods -l app=webserver -w
Expected output:
NAME READY STATUS RESTARTS AGE
webserver-abc-x1 1/1 Running 0 5m
webserver-abc-x2 1/1 Running 0 5m
webserver-abc-x3 0/1 Terminating 0 5m ← deleted
webserver-abc-x4 0/1 ContainerCreating 0 1s ← NEW pod
webserver-abc-x4 1/1 Running 0 4s
The ReplicaSet detected the count dropped to 2 and immediately created a replacement.
Exercise 4: Rolling Update
What we’re doing: Update from nginx:1.24 to nginx:1.25 with zero downtime.
# In a separate watch (run this first)
kubectl get pods -l app=webserver -w &
# Perform the rolling update
kubectl set image deployment/webserver nginx=nginx:1.25
kubectl annotate deployment/webserver kubernetes.io/change-cause="Update to nginx 1.25" --overwrite
# Watch the rollout status
kubectl rollout status deployment/webserver
Expected watch output (one-at-a-time with maxSurge:1 maxUnavailable:0):
webserver-old-x1 1/1 Running 0 5m
webserver-old-x2 1/1 Running 0 5m
webserver-old-x3 1/1 Running 0 5m
webserver-new-y1 0/1 ContainerCreating 0 2s ← new RS pod
webserver-new-y1 1/1 Running 0 5s ← ready!
webserver-old-x1 1/1 Terminating 0 5m ← old removed
webserver-new-y2 0/1 ContainerCreating 0 1s
...
# Kill the background watch
kill %1
# Confirm new image
kubectl get pods -l app=webserver -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}'
# See rollout history
kubectl rollout history deployment/webserver
Expected history:
REVISION CHANGE-CAUSE
1 Initial deployment — nginx 1.24
2 Update to nginx 1.25
Exercise 5: Bad Rollout + Rollback
What we’re doing: Deploy a broken image and roll back before it causes full outage.
# Deploy a non-existent image tag (simulating a bad push)
kubectl set image deployment/webserver nginx=nginx:this-tag-does-not-exist
kubectl annotate deployment/webserver kubernetes.io/change-cause="BROKEN — bad tag" --overwrite
# Watch it fail — new pods get ImagePullBackOff
kubectl get pods -l app=webserver -w &
sleep 20 && kill %1
# Check deployment status
kubectl rollout status deployment/webserver --timeout=30s
Expected output:
Waiting for deployment "webserver" rollout to finish: 1 out of 3 new replicas have been updated...
error: timed out waiting for the condition
# See the bad pods
kubectl get pods -l app=webserver
# One pod is ImagePullBackOff — but maxUnavailable:0 means the old pods stay!
# Current state: 3 old pods still running + 1 new (broken) pod trying
💡 Key insight: Because we set
maxUnavailable: 0, the old pods are NOT removed until the new ones are Ready. The broken new pod can’t become Ready → the old pods keep serving traffic → no outage!
# Rollback immediately
kubectl rollout undo deployment/webserver
kubectl rollout status deployment/webserver
# Verify we're back on nginx:1.25
kubectl get pods -l app=webserver -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
# History now shows 4 revisions
kubectl rollout history deployment/webserver
Exercise 6: DaemonSet Demo
# Create a simple DaemonSet (1 pod on our 1-node cluster)
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-monitor
namespace: workloads-lab
spec:
selector:
matchLabels:
app: node-monitor
template:
metadata:
labels:
app: node-monitor
spec:
containers:
- name: monitor
image: busybox
command: ["sh", "-c", "while true; do echo \"Node: $(hostname), Time: $(date)\"; sleep 5; done"]
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
kubectl get pods -l app=node-monitor -o wide
kubectl logs -l app=node-monitor
# Can't scale a DaemonSet
kubectl scale daemonset node-monitor --replicas=0 # ← Error
Exercise 7: CronJob
# Run a job every minute
cat <<'EOF' | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: ticker
namespace: workloads-lab
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: tick
image: busybox
command: ["sh", "-c", "echo \"Tick at: $(date)\""]
EOF
# Watch for the job to fire (wait ~70 seconds)
echo "Waiting 70 seconds for first CronJob run..."
sleep 70
kubectl get jobs
kubectl get pods -l job-name
# Get the output
kubectl logs -l job-name --tail=5
Expected jobs output (after 2+ minutes):
NAME STATUS COMPLETIONS DURATION AGE
ticker-28000001 Complete 1/1 4s 2m
ticker-28000002 Complete 1/1 4s 1m
🔥 Break It! Challenge
What happens when a Deployment’s
maxUnavailablecauses an outage during a bad rollout?
# Create a "risky" deployment with maxUnavailable:1
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: risky-deploy
namespace: workloads-lab
spec:
replicas: 3
selector:
matchLabels:
app: risky
strategy:
rollingUpdate:
maxSurge: 0
maxUnavailable: 1 # ← allows 1 pod to be missing
template:
metadata:
labels:
app: risky
spec:
containers:
- name: app
image: nginx:1.25
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
kubectl rollout status deployment/risky-deploy
# Now push a broken image — this time 1 old pod WILL be removed first
kubectl set image deployment/risky-deploy app=nginx:broken-tag
kubectl get pods -l app=risky -w &
sleep 15 && kill %1
# State: 2 running old pods + 1 failed new pod = only 2 serving!
kubectl get pods -l app=risky
The lesson: maxUnavailable: 0 is a safety net. If you use maxUnavailable: 1 (or the 25% default), a bad rollout CAN reduce capacity. Always use maxUnavailable: 0 with a readinessProbe for zero-risk deployments.
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace workloads-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Deploy with Deployment | 3-replica nginx running from YAML |
| 2 | Scale up/down | kubectl scale changed pod count correctly |
| 3 | ReplicaSet self-heals | Deleted pod was recreated within seconds |
| 4 | Rolling update | Updated from nginx:1.24 → 1.25 with zero downtime |
| 5 | Safe rollback | Bad image rolled back with rollout undo |
| 6 | DaemonSet | One pod per node; can’t be scaled manually |
| 7 | CronJob | Scheduled execution with job history |
| 8 | maxUnavailable risk | Saw how wrong strategy can reduce capacity |
Chapter 5: Services — Exposing Your Applications
⏱️ Total chapter time: ~50 min (25 min reading + 25 min lab)
After this chapter, you will be able to: Connect pods using ClusterIP, expose applications externally with NodePort and LoadBalancer, use DNS to discover services, and debug connectivity failures.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 5.1 | ClusterIP — Internal Communication | ~6 min |
| 5.2 | NodePort — Exposing to the Outside | ~5 min |
| 5.3 | LoadBalancer — Cloud-Native Exposure | ~5 min |
| 5.4 | Headless Services and DNS | ~5 min |
| 5.5 | 🔬 Lab: Service Discovery and Connectivity Debugging | ~25 min |
Prerequisites
- Completed Chapter 4 (comfortable with Deployments)
minikube statusshowsRunning
5.1 ClusterIP — Internal Communication
⏱️ ~6 min read
TL;DR: ClusterIP is the default Service type. It gives a group of pods a stable virtual IP that other pods can reach by name. Pod IPs change constantly; Service IPs never do.
The Problem Services Solve
Pods are ephemeral. Every time a pod restarts, it gets a new IP address. If Service A hardcodes Service B’s pod IP, it breaks the moment B’s pod is replaced.
graph LR
subgraph "Without a Service"
A[frontend\n10.244.0.5] -->|"hardcoded: 10.244.1.8"| B1[backend\n10.244.1.8]
B1 -->|crashes!| DEAD[💀]
B2[backend\n10.244.1.9] -.->|"new pod, new IP\nfrontend doesn't know"| Q[❓]
end
graph LR
subgraph "With a ClusterIP Service"
A2[frontend] -->|"always: backend-svc\n10.96.45.123"| SVC[Service\nClusterIP\n10.96.45.123]
SVC --> P1[backend pod\n10.244.1.9]
SVC --> P2[backend pod\n10.244.1.10]
SVC --> P3[backend pod\n10.244.1.11]
end
The Service IP never changes. The pods behind it come and go — the Service automatically routes to healthy ones.
ClusterIP Service YAML
# service-clusterip.yaml
apiVersion: v1
kind: Service
metadata:
name: backend-svc
spec:
type: ClusterIP # Default — omitting type also gives ClusterIP
selector:
app: backend # Route traffic to pods with this label
ports:
- name: http
port: 80 # Port the Service listens on
targetPort: 8080 # Port on the pods to forward to
protocol: TCP
kubectl apply -f service-clusterip.yaml
kubectl get svc backend-svc
Expected output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
backend-svc ClusterIP 10.96.45.123 <none> 80/TCP 10s
The CLUSTER-IP is the stable virtual IP. EXTERNAL-IP: <none> means it’s not reachable from outside the cluster — by design.
How It Works Under the Hood
When a Service is created, the Endpoints controller builds a list of pod IPs matching the selector:
# See which pod IPs are behind a Service
kubectl get endpoints backend-svc
Expected output:
NAME ENDPOINTS AGE
backend-svc 10.244.0.4:8080,10.244.0.5:8080,10.244.0.6:8080 1m
kube-proxy on every node watches these Endpoints and programs iptables rules to load-balance traffic to those IPs.
graph TB
Client[Client Pod] -->|curl backend-svc:80| KP[kube-proxy\niptables rules]
KP -->|"random selection\n(round-robin)"| P1[Pod 10.244.0.4:8080]
KP --> P2[Pod 10.244.0.5:8080]
KP --> P3[Pod 10.244.0.6:8080]
Service DNS — The Real Power
Every Service gets an automatic DNS entry maintained by CoreDNS:
# Full DNS name format:
SERVICE-NAME.NAMESPACE.svc.cluster.local
# Examples:
backend-svc.default.svc.cluster.local
postgres.database.svc.cluster.local
redis.cache.svc.cluster.local
# Short form (same namespace only):
backend-svc
From any pod in the cluster:
# These all reach the same Service:
curl http://backend-svc # same namespace only
curl http://backend-svc.default # same cluster
curl http://backend-svc.default.svc.cluster.local # fully qualified
🔗 Docker Parallel: In Docker Compose, containers reach each other by service name (
http://backend). Kubernetes Services work the same way — but across multiple nodes and with automatic load balancing.
Port Mapping: port vs targetPort
ports:
- port: 80 # What OTHER pods use to reach this Service
targetPort: 8080 # What port YOUR pods actually listen on
This decouples the internal implementation from the interface. Your app can change from port 8080 to 9000 — just update targetPort, no changes needed by consumers.
# Named targetPort (better — references container's port by name)
spec:
containers:
- name: app
ports:
- name: http
containerPort: 8080
# Service can reference by name instead of number
spec:
ports:
- port: 80
targetPort: http # ← references the named port
Try It
# Deploy a backend
kubectl create deployment backend --image=nginx:1.25 --replicas=3
# Create a ClusterIP Service
kubectl expose deployment backend --port=80 --target-port=80 --name=backend-svc
# Verify
kubectl get svc backend-svc
kubectl get endpoints backend-svc
# Access from another pod (using DNS)
kubectl run curl-test --image=curlimages/curl --rm -it --restart=Never -- \
curl -s http://backend-svc
# Cleanup
kubectl delete deployment backend
kubectl delete svc backend-svc
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | ClusterIP = stable virtual IP | Pod IPs change; Service IP never does |
| 2 | Label selector = routing | Service routes to pods matching its selector |
| 3 | Endpoints object | Maintained automatically; lists live pod IPs |
| 4 | DNS auto-registered | Every Service gets a DNS name via CoreDNS |
| 5 | port vs targetPort | Service port vs pod port — can be different |
✅ Quick Check
Q1: A ClusterIP Service has 3 healthy pods and 1 crashed pod (all matching the selector). Does the Service route to the crashed pod?
Answer
No. The Endpoints controller only includes pods that are **Running and Ready**. If a pod's readiness probe fails or it crashes, it's removed from the Endpoints list. Traffic is only sent to healthy pods.Q2: You change a Deployment’s label from app: backend to app: backend-v2. The Service selector is app: backend. What happens?
Answer
The Service stops routing to the new pods. The Endpoints list becomes empty (no pods match `app: backend` anymore). Traffic to the Service will fail with connection refused. You'd need to update either the Service selector or the pod labels to restore connectivity.Q3: Two Services in different namespaces are both named api. Can they coexist? How does a pod tell them apart?
Answer
Yes — Services are scoped to a namespace, so two Services named `api` in different namespaces are completely separate objects. Pods distinguish them via the full DNS name: `api.namespace-a.svc.cluster.local` vs `api.namespace-b.svc.cluster.local`. Using the short form `api` only reaches the Service in the same namespace.5.2 NodePort — Exposing to the Outside
⏱️ ~5 min read
TL;DR: NodePort opens a port (30000–32767) on every node in the cluster and forwards traffic to your Service. It’s the simplest way to expose an app externally — but rarely used in production (Ingress is preferred).
How NodePort Works
# service-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
name: web-nodeport
spec:
type: NodePort
selector:
app: web
ports:
- port: 80 # ClusterIP port (internal)
targetPort: 80 # Pod port
nodePort: 30080 # External port on every node (30000–32767)
# Omit to let K8s auto-assign
graph LR
Browser["Your Browser\nlocalhost:30080"] -->|NodePort| N1["Node\n:30080"]
N1 --> SVC[Service ClusterIP]
SVC --> P1[Pod :80]
SVC --> P2[Pod :80]
SVC --> P3[Pod :80]
Traffic flow:
- External client hits any node on port
30080 - Node forwards to the Service’s ClusterIP
- Service load-balances to a pod
The NodePort Range
By default, nodePort values must be in 30000–32767. This range is intentionally high to avoid conflicts with system ports.
# Omit nodePort — K8s picks one for you
kubectl expose deployment web --type=NodePort --port=80
# See the assigned port
kubectl get svc web
# PORTS column: 80:31245/TCP ← 31245 is the auto-assigned NodePort
Accessing on Minikube
On Minikube, the “node” is a VM or container with its own IP, not localhost:
# Get the Minikube IP
minikube ip
# Output: 192.168.49.2
# Access the NodePort
curl http://$(minikube ip):30080
# Or use the shortcut — minikube opens it in a browser
minikube service web-nodeport
# Or get the URL directly
minikube service web-nodeport --url
NodePort vs ClusterIP
| Feature | ClusterIP | NodePort |
|---|---|---|
| Accessible from | Inside cluster only | Inside cluster + external |
| Includes ClusterIP | — | ✅ Yes (NodePort extends it) |
| Port range | Any | 30000–32767 |
| Production use | ✅ Standard | ⚠️ Rarely — use Ingress instead |
🏭 In Production: NodePort has two problems: (1) clients must know the node IP, which changes when nodes are replaced, and (2) you can’t use standard ports 80/443. In production, use an Ingress (Chapter 6) or LoadBalancer Service instead.
Try It
# Deploy a simple web app
kubectl create deployment web --image=nginx:1.25 --replicas=2
# Expose with NodePort — let K8s pick the port
kubectl expose deployment web --type=NodePort --port=80
# See the assigned NodePort
kubectl get svc web
# Access it on Minikube
minikube service web --url
# Or manually:
NODE_PORT=$(kubectl get svc web -o jsonpath='{.spec.ports[0].nodePort}')
MINIKUBE_IP=$(minikube ip)
curl http://$MINIKUBE_IP:$NODE_PORT
# Cleanup
kubectl delete deployment web
kubectl delete svc web
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | NodePort opens on every node | Traffic to ANY node on that port reaches your Service |
| 2 | Range: 30000–32767 | Avoids conflicts with system/app ports |
| 3 | Extends ClusterIP | NodePort Services also have a ClusterIP for internal use |
| 4 | Minikube needs minikube ip | The node IP is the VM/container IP, not localhost |
✅ Quick Check
Q1: You have a 5-node cluster with a NodePort Service on port 31000. Client sends traffic to node 3. Node 3 has no matching pods — they all run on nodes 1 and 2. Does the request succeed?
Answer
Yes. kube-proxy on node 3 has iptables rules to forward the traffic to the Service's ClusterIP, which then routes to pods on nodes 1 and 2. NodePort traffic is forwarded cluster-wide, not limited to pods on the receiving node.Q2: Can you set nodePort: 80 to use a standard HTTP port?
Answer
No by default. The allowed range is 30000–32767. You can change this range in the kube-apiserver config (`--service-node-port-range`), but using standard ports for NodePort in production is a security and operational anti-pattern. Use Ingress with a proper LoadBalancer instead.Q3: You delete the NodePort Service and recreate it. Does it get the same NodePort number?
Answer
Only if you explicitly set `nodePort: 31000` in the spec. If you omit it and let K8s auto-assign, you get a random available port in the range — potentially a different one. This is another reason NodePort is awkward in production: clients must be updated if the port changes.5.3 LoadBalancer — Cloud-Native Exposure
⏱️ ~5 min read
TL;DR: A LoadBalancer Service provisions a real cloud load balancer (AWS ELB, Azure ALB, GCP CLB) with a public IP automatically. On Minikube, use
minikube tunnelto simulate this. In production, this is the standard way to expose services to the internet.
How LoadBalancer Works
# service-loadbalancer.yaml
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80
graph LR
Internet -->|"Public IP\n203.0.113.42:80"| LB["Cloud Load Balancer\n(AWS ELB / Azure ALB / GCP CLB)"]
LB --> N1[Node 1\nNodePort]
LB --> N2[Node 2\nNodePort]
N1 & N2 --> SVC[ClusterIP Service]
SVC --> P1[Pod] & P2[Pod] & P3[Pod]
A LoadBalancer Service is really three services stacked:
- A ClusterIP (internal routing)
- A NodePort (on each node)
- A cloud load balancer pointed at those NodePorts
When you kubectl apply a LoadBalancer Service in a cloud cluster, the cloud controller manager calls the cloud API to provision a real load balancer. The external IP appears in the EXTERNAL-IP column after ~30–60 seconds.
# Cloud cluster — shows a real external IP
kubectl get svc web-lb
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# web-lb LoadBalancer 10.96.10.5 203.0.113.42 80:31456/TCP 45s
On Minikube: minikube tunnel
Minikube doesn’t have a cloud controller, so LoadBalancer Services stay in pending state without help:
kubectl get svc web-lb
# EXTERNAL-IP: <pending> ← stuck without tunnel
Fix this with minikube tunnel — it creates a network route on your machine that acts as the load balancer:
# Run in a separate terminal (requires sudo on macOS/Linux)
minikube tunnel
# Now in your original terminal:
kubectl get svc web-lb
# EXTERNAL-IP: 127.0.0.1 ← now accessible on localhost!
curl http://127.0.0.1:80
⚠️ Warning:
minikube tunnelrequires elevated privileges and must stay running. If you close the terminal, the tunnel closes andEXTERNAL-IPgoes back topending.
When to Use LoadBalancer vs Ingress
| Scenario | Use |
|---|---|
| Single service to expose | LoadBalancer — simple, one command |
| Multiple services on same domain | Ingress — HTTP routing, one LB shared |
| TCP/UDP (non-HTTP) | LoadBalancer — Ingress is HTTP-only |
| TLS termination at L7 | Ingress — with TLS certs |
| Cost-conscious (cloud bills per LB) | Ingress — one LB for many services |
💡 Tip: In production, most teams use one LoadBalancer that fronts an Ingress Controller (like NGINX), then use Ingress resources for path/host routing. This avoids paying for a separate cloud load balancer per service.
Try It
# Deploy app
kubectl create deployment web --image=nginx:1.25 --replicas=2
# Create LoadBalancer Service
kubectl expose deployment web --type=LoadBalancer --port=80
# Watch EXTERNAL-IP (starts as <pending>)
kubectl get svc web -w &
# In a new terminal, start the tunnel
# minikube tunnel ← run this in another terminal
# After tunnel starts, EXTERNAL-IP becomes 127.0.0.1
# Kill the watch
kill %1
# Access it (with tunnel running)
curl http://127.0.0.1
# Cleanup
kubectl delete deployment web
kubectl delete svc web
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | LoadBalancer = cloud LB + NodePort + ClusterIP | Three layers stacked |
| 2 | Cloud controller provisions the LB | Calls AWS/Azure/GCP API automatically |
| 3 | minikube tunnel simulates it locally | Routes 127.0.0.1 to the Service |
| 4 | Prefer Ingress for HTTP in production | One LB shared across many services = cost savings |
✅ Quick Check
Q1: You apply a LoadBalancer Service on a bare-metal cluster with no cloud provider. What is EXTERNAL-IP?
Answer
`pending` — indefinitely. Without a cloud controller manager to provision an external load balancer, the IP is never assigned. Solutions for bare-metal include **MetalLB** (assigns IPs from a configured pool) or exposing via NodePort + an external load balancer configured manually.Q2: You have 20 microservices. Should each get its own LoadBalancer Service?
Answer
No — that's 20 cloud load balancers, each costing money (~$20–30/month each on major clouds). The standard pattern is one LoadBalancer in front of an NGINX Ingress Controller, then one Ingress resource per service for HTTP routing. Chapter 6 covers this in detail.Q3: A LoadBalancer Service has port: 443. Does this mean TLS is terminated at the load balancer?
Answer
Not necessarily — by default, the cloud LB passes TCP traffic through (SSL passthrough), and your pods handle TLS. To terminate TLS at the LB level, you need cloud-specific annotations (e.g., `service.beta.kubernetes.io/aws-load-balancer-ssl-cert` on EKS). For standard TLS termination in Kubernetes, use Ingress with TLS configuration (Chapter 6).5.4 Headless Services and DNS
⏱️ ~5 min read
TL;DR: A Headless Service (
clusterIP: None) skips the virtual IP entirely. DNS returns the actual pod IPs directly. This is essential for StatefulSets and any app that needs to know which specific instance it’s talking to.
Regular vs Headless DNS
With a regular ClusterIP Service, DNS returns one stable virtual IP:
# Regular Service DNS lookup:
nslookup backend-svc.default.svc.cluster.local
→ Address: 10.96.45.123 (one ClusterIP)
With a Headless Service, DNS returns all matching pod IPs directly:
# Headless Service DNS lookup:
nslookup mongo.default.svc.cluster.local
→ Address: 10.244.0.5 (mongo-0's IP)
→ Address: 10.244.0.6 (mongo-1's IP)
→ Address: 10.244.0.7 (mongo-2's IP)
The client picks which IP to use — enabling client-side load balancing and direct pod addressing.
Headless Service YAML
# headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: mongo
spec:
clusterIP: None # ← This is what makes it headless
selector:
app: mongo
ports:
- port: 27017
That’s the only difference from a regular Service — clusterIP: None.
Per-Pod DNS (StatefulSets)
When a Headless Service is used with a StatefulSet (serviceName: mongo), each pod gets its own stable DNS record:
graph TB
subgraph "Headless Service: mongo"
DNS1["mongo-0.mongo.default.svc.cluster.local\n→ 10.244.0.5"]
DNS2["mongo-1.mongo.default.svc.cluster.local\n→ 10.244.0.6"]
DNS3["mongo-2.mongo.default.svc.cluster.local\n→ 10.244.0.7"]
end
Client[MongoDB\nClient Library] -->|"Primary:\nmongo-0.mongo..."| P0[mongo-0]
Client -->|"Secondary:\nmongo-1.mongo..."| P1[mongo-1]
Client -->|"Secondary:\nmongo-2.mongo..."| P2[mongo-2]
The MongoDB client can discover and address each replica independently — something impossible with a ClusterIP Service that hides individual pod identities.
DNS Record Types
| DNS Query | Regular Service | Headless Service |
|---|---|---|
A record for service | Returns ClusterIP | Returns all pod IPs |
SRV record | Returns service ClusterIP + port | Returns pod IPs + ports |
A record for pod (pod-0.svc.ns...) | Not possible | ✅ Each StatefulSet pod has one |
# Inside a pod — check the DNS for a headless Service
kubectl run dns-test --image=busybox --rm -it --restart=Never -- \
nslookup mongo.default.svc.cluster.local
# For a regular Service — one IP
# For a headless Service — multiple IPs (one per pod)
Headless Without a Selector
You can also create a headless Service with no selector — then manually manage the Endpoints. Useful for bridging to external databases:
# External database bridge
apiVersion: v1
kind: Service
metadata:
name: external-db
spec:
clusterIP: None
ports:
- port: 5432
---
apiVersion: v1
kind: Endpoints
metadata:
name: external-db # Must match Service name
subsets:
- addresses:
- ip: 192.168.1.100 # Your external DB server IP
ports:
- port: 5432
Now pods can reach your external DB via external-db.default.svc.cluster.local:5432 — a fully internal DNS name pointing to an external resource.
Try It
# Create a headless Service backed by a Deployment
kubectl create deployment headless-demo --image=nginx:1.25 --replicas=3
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: headless-demo
spec:
clusterIP: None
selector:
app: headless-demo
ports:
- port: 80
EOF
# Regular Service for comparison
kubectl expose deployment headless-demo --name=regular-svc --port=80
# Now compare DNS responses from inside a pod
kubectl run dns-test --image=busybox --rm -it --restart=Never -- sh -c "
echo '--- Headless (multiple IPs):';
nslookup headless-demo.default.svc.cluster.local;
echo '--- Regular (one ClusterIP):';
nslookup regular-svc.default.svc.cluster.local
"
# Cleanup
kubectl delete deployment headless-demo
kubectl delete svc headless-demo regular-svc
Expected output:
--- Headless (multiple IPs):
Server: 10.96.0.10
Address 1: 10.244.0.4
Address 2: 10.244.0.5
Address 3: 10.244.0.6
--- Regular (one ClusterIP):
Server: 10.96.0.10
Address 1: 10.96.45.200
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | clusterIP: None = headless | No virtual IP; DNS returns real pod IPs |
| 2 | StatefulSet per-pod DNS | pod-N.svc.ns.svc.cluster.local per pod |
| 3 | Client-side load balancing | The client app chooses which pod IP to use |
| 4 | Selector-less headless | Bridge to external services via manual Endpoints |
✅ Quick Check
Q1: A StatefulSet pod kafka-1 is rescheduled to a different node with a new IP. Does kafka-1.kafka.default.svc.cluster.local still work?
Answer
Yes. The DNS record for `kafka-1.kafka.default.svc.cluster.local` is automatically updated to point to kafka-1's new IP. The DNS name is stable — only the IP it resolves to changes. This is the stable network identity guarantee of StatefulSets.Q2: A regular ClusterIP Service has 5 pods. You do an nslookup for the Service — how many IPs are returned?
Answer
One — the ClusterIP (virtual IP). Individual pod IPs are hidden behind the virtual IP. Load balancing happens via kube-proxy's iptables rules, not at the DNS level.Q3: When would you use a headless Service WITHOUT a StatefulSet?
Answer
When your client application implements its own load balancing or service discovery (e.g., gRPC clients, Cassandra drivers, Kafka clients). These apps want to discover all pod IPs and manage connections themselves. Using a headless Service lets them get the full pod IP list from DNS without going through kube-proxy.Lab: Service Discovery and Connectivity Debugging
⏱️ ~25 min hands-on
| Prerequisites | Chapter 5 sections 5.1–5.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Wire up a two-service app using ClusterIP, expose it with NodePort, debug DNS, and break the label selector to observe the failure |
Objectives
- Deploy two services and connect them via ClusterIP DNS
- Expose an app externally with NodePort
- Debug DNS resolution from inside a pod
- Observe and fix a broken label selector
- Inspect Endpoints to understand what a Service is routing to
- Use
minikube tunnelwith a LoadBalancer Service
Setup
kubectl create namespace svc-lab
kubectl config set-context --current --namespace=svc-lab
Exercise 1: ClusterIP — Wiring Two Services
What we’re doing: Deploy a frontend and backend, connect them using ClusterIP DNS.
# Deploy backend (simple nginx responding as our "API")
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: svc-lab
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: api
image: nginx:1.25
ports:
- containerPort: 80
resources:
limits:
memory: "64Mi"
cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
name: backend-svc
namespace: svc-lab
spec:
type: ClusterIP
selector:
app: backend
ports:
- port: 8080 # Service port clients use
targetPort: 80 # Pod's actual port
EOF
# Verify
kubectl get deploy backend
kubectl get svc backend-svc
kubectl get endpoints backend-svc
Expected endpoints output:
NAME ENDPOINTS AGE
backend-svc 10.244.0.4:80,10.244.0.5:80,10.244.0.6:80 30s
Now test connectivity from a “frontend” pod:
# Run a curl pod as a frontend substitute
kubectl run frontend-test \
--image=curlimages/curl \
--rm -it --restart=Never \
-- sh -c "
echo 'Testing short DNS name:';
curl -s -o /dev/null -w '%{http_code}' http://backend-svc:8080;
echo '';
echo 'Testing full DNS name:';
curl -s -o /dev/null -w '%{http_code}' http://backend-svc.svc-lab.svc.cluster.local:8080;
echo ''
"
Expected output:
Testing short DNS name:
200
Testing full DNS name:
200
💡 What just happened? The pod resolved
backend-svcvia CoreDNS to the ClusterIP, which kube-proxy forwarded to one of the 3 backend pods. The8080→80port translation happened transparently.
Exercise 2: Inspect the DNS Infrastructure
What we’re doing: Look at CoreDNS and understand how Service DNS works.
# See CoreDNS pods (the cluster's DNS server)
kubectl get pods -n kube-system -l k8s-app=kube-dns
# Run a debug pod and perform DNS lookups
kubectl run dns-debug \
--image=busybox \
--rm -it --restart=Never \
-- sh -c "
echo '=== DNS server configured for this pod ===';
cat /etc/resolv.conf;
echo '';
echo '=== Lookup backend-svc (ClusterIP) ===';
nslookup backend-svc.svc-lab.svc.cluster.local;
echo '';
echo '=== Lookup kube-dns (system service) ===';
nslookup kube-dns.kube-system.svc.cluster.local
"
Expected resolv.conf:
nameserver 10.96.0.10 ← CoreDNS ClusterIP
search svc-lab.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
The search domains are why short names like backend-svc work — the OS appends the search suffix automatically.
Exercise 3: Expose with NodePort
What we’re doing: Expose the backend to the outside world using NodePort.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: backend-nodeport
namespace: svc-lab
spec:
type: NodePort
selector:
app: backend
ports:
- port: 80
targetPort: 80
nodePort: 30500
EOF
# Verify
kubectl get svc backend-nodeport
# Access via Minikube
MINIKUBE_IP=$(minikube ip)
echo "Accessing: http://$MINIKUBE_IP:30500"
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://$MINIKUBE_IP:30500
# Or use minikube shortcut
minikube service backend-nodeport -n svc-lab --url
Expected:
HTTP Status: 200
Exercise 4: Watch the Endpoints Live
What we’re doing: Watch how the Endpoints list updates as pods are added/removed.
# Start watching Endpoints
kubectl get endpoints backend-svc -w &
# Scale the backend up — watch new IPs appear
kubectl scale deployment backend --replicas=5
sleep 5
# Scale down — watch IPs disappear
kubectl scale deployment backend --replicas=1
sleep 5
# Kill the watch
kill %1
Expected watch output:
NAME ENDPOINTS AGE
backend-svc 10.244.0.4:80,10.244.0.5:80,10.244.0.6:80 5m
backend-svc 10.244.0.4:80,...+2 more... 5m30s ← scaled to 5
backend-svc 10.244.0.4:80 6m ← scaled to 1
Scale back to 3 for the next exercise:
kubectl scale deployment backend --replicas=3
Exercise 5: Break It — Wrong Label Selector
What we’re doing: Misconfigure the Service selector and debug the resulting failure.
# Create a Service with a WRONG selector
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: broken-svc
namespace: svc-lab
spec:
type: ClusterIP
selector:
app: typo-backend # ← WRONG — pods are labeled app=backend
ports:
- port: 80
targetPort: 80
EOF
# Test connectivity — it fails
kubectl run curl-test \
--image=curlimages/curl \
--rm -it --restart=Never \
-- curl --connect-timeout 3 http://broken-svc || echo "Connection failed!"
Expected:
curl: (28) Connection timed out after 3001 milliseconds
Connection failed!
Now diagnose:
# Step 1: Check Endpoints — is anything there?
kubectl get endpoints broken-svc
# Expected: broken-svc <none> ← empty! No pods match the selector
# Step 2: Describe the Service
kubectl describe svc broken-svc | grep -A5 "Selector:"
# Selector: app=typo-backend ← wrong!
# Step 3: Fix it
kubectl patch svc broken-svc -p '{"spec":{"selector":{"app":"backend"}}}'
# Step 4: Verify Endpoints now populated
kubectl get endpoints broken-svc
# Step 5: Test connectivity — now succeeds
kubectl run curl-test \
--image=curlimages/curl \
--rm -it --restart=Never \
-- curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://broken-svc
💡 The debugging rule: If a Service isn’t routing, check Endpoints first. Empty Endpoints = selector mismatch. That’s the #1 Service failure cause.
Exercise 6: LoadBalancer with Minikube Tunnel
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
name: backend-lb
namespace: svc-lab
spec:
type: LoadBalancer
selector:
app: backend
ports:
- port: 80
targetPort: 80
EOF
kubectl get svc backend-lb
# EXTERNAL-IP: <pending> initially
# Open a NEW terminal and run:
# minikube tunnel
# (keep it running while you test)
# Back in this terminal, wait a moment then:
kubectl get svc backend-lb
# EXTERNAL-IP: 127.0.0.1
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://127.0.0.1
🔥 Break It! Challenge
What happens when you send traffic to a ClusterIP from OUTSIDE the cluster?
# Get the ClusterIP of the backend-svc
CLUSTER_IP=$(kubectl get svc backend-svc -o jsonpath='{.spec.clusterIP}')
echo "ClusterIP: $CLUSTER_IP"
# Try to curl it directly from your host machine
curl --connect-timeout 3 http://$CLUSTER_IP:8080 || echo "Cannot reach ClusterIP from outside!"
# Try from inside the cluster (works fine)
kubectl run curl-inside \
--image=curlimages/curl \
--rm -it --restart=Never \
-- curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://$CLUSTER_IP:8080
The lesson: ClusterIPs are only routable inside the cluster. They exist in a virtual IP space managed by kube-proxy’s iptables rules on cluster nodes. Your host machine has no routes to that IP space (unless you use kubectl proxy or minikube tunnel).
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace svc-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | ClusterIP DNS routing | backend-svc:8080 resolved and returned HTTP 200 |
| 2 | DNS mechanics | Read /etc/resolv.conf and ran nslookup from inside a pod |
| 3 | Live Endpoints | Watched IPs appear/disappear as pods scaled |
| 4 | NodePort external access | Reached app on minikube-ip:30500 from host |
| 5 | Broken selector debugging | Found empty Endpoints → fixed selector → connectivity restored |
| 6 | LoadBalancer + tunnel | Used minikube tunnel to get a real external IP |
| 7 | ClusterIP is cluster-only | Confirmed unreachable from host machine |
Chapter 6: Ingress — HTTP Routing
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Set up NGINX Ingress on Minikube, route HTTP traffic to multiple services using path and host rules, and terminate TLS with a self-signed certificate.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 6.1 | Ingress Controllers and Resources | ~5 min |
| 6.2 | Setting Up NGINX Ingress on Minikube | ~5 min |
| 6.3 | Path-Based and Host-Based Routing | ~7 min |
| 6.4 | TLS Termination | ~6 min |
| 6.5 | 🔬 Lab: Multi-Service Ingress with TLS | ~30 min |
Prerequisites
- Completed Chapter 5 (Services)
minikube statusshowsRunning
6.1 Ingress Controllers and Resources
⏱️ ~5 min read
TL;DR: Ingress is a two-part system: an Ingress Controller (a running pod that does the actual routing) and Ingress Resources (YAML rules that tell it where to send traffic). Without an Ingress Controller, Ingress Resources do nothing.
The Problem Ingress Solves
You have 5 microservices. Without Ingress:
- 5 LoadBalancer Services = 5 cloud load balancers = 5 external IPs = 5 monthly bills
- Clients need to know 5 different IPs/ports
- TLS certificates per service = complexity multiplied by 5
With Ingress:
- 1 LoadBalancer Service for the Ingress Controller
- 1 external IP for all services
- All routing rules defined in simple YAML
- TLS terminated once, centrally
graph LR
Internet -->|"203.0.113.1:443"| LB[Cloud LB\n1 IP total]
LB --> IC[Ingress Controller\nnginx pod]
IC -->|"/api/*"| API[api-svc\n:8080]
IC -->|"/auth/*"| AUTH[auth-svc\n:3000]
IC -->|"/"| WEB[web-svc\n:80]
IC -->|"admin.example.com"| ADMIN[admin-svc\n:9000]
The Two Parts
1. Ingress Controller
A running pod (or set of pods) that watches Ingress Resources and configures itself to route traffic. It’s NOT built into Kubernetes — you install it separately.
Popular controllers:
| Controller | Use Case |
|---|---|
| NGINX Ingress | Most widely used; great for Minikube and most production setups |
| Traefik | Auto-discovers services; popular with Docker/K8s |
| HAProxy Ingress | High-performance TCP/HTTP |
| Kong | API gateway features built in |
| AWS ALB Ingress | Native AWS Application Load Balancer |
| GKE Ingress | Native Google Cloud |
2. Ingress Resource
A Kubernetes object (kind: Ingress) that declares routing rules. The controller reads these and updates its config:
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx # Which controller handles this
rules:
- host: myapp.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
The Ingress Resource Anatomy
spec:
ingressClassName: nginx # Required in modern K8s (1.18+)
tls: # Optional TLS config
- hosts:
- myapp.example.com
secretName: myapp-tls
rules:
- host: myapp.example.com # Match by hostname (optional — omit for any host)
http:
paths:
- path: /api # URL path to match
pathType: Prefix # Prefix | Exact | ImplementationSpecific
backend:
service:
name: api-svc # Route to this Service
port:
number: 8080
pathType options:
| PathType | Behavior |
|---|---|
Prefix | Matches path and all sub-paths: /api matches /api, /api/users, /api/v2/... |
Exact | Exact match only: /api matches ONLY /api, not /api/users |
ImplementationSpecific | Controller-defined behavior (rarely used) |
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Controller ≠ Resource | The controller does the routing; the Resource declares the rules |
| 2 | NGINX Ingress is the default | Most common controller; what Minikube’s addon uses |
| 3 | ingressClassName is required | Tells which controller owns this Ingress resource |
| 4 | One IP, many services | The whole point — cost savings and simplicity |
✅ Quick Check
Q1: You apply an Ingress Resource but no traffic is routed. What’s the most likely cause?
Answer
No Ingress Controller is installed. The Ingress Resource is just a configuration object — it does nothing without a controller watching for it. Check with `kubectl get pods -n ingress-nginx` (for NGINX) to see if the controller pod exists and is running.Q2: You have two Ingress Controllers installed (NGINX and Traefik). You apply an Ingress without ingressClassName. Which controller handles it?
Answer
This is ambiguous — behavior depends on the cluster config. Historically, a cluster-wide default IngressClass could be set. Without it, both controllers may try to handle it (causing conflicts) or neither does. Always specify `ingressClassName` explicitly to avoid ambiguity.Q3: pathType: Prefix with path /app. Does this match /application?
Answer
No. `Prefix` matches the exact path element, not substring. `/app` as a prefix matches `/app`, `/app/`, `/app/settings`, but NOT `/application`. The match is against path segments separated by `/`, not arbitrary string prefixes.6.2 Setting Up NGINX Ingress on Minikube
⏱️ ~5 min read
TL;DR: One command enables the NGINX Ingress Controller on Minikube. After that, you need a way to reach it — either via
minikube tunnelor by using Minikube’s built-in IP with the NodePort the controller exposes.
Enable the Minikube Ingress Addon
minikube addons enable ingress
Expected output:
💡 ingress is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: https://github.com/kubernetes/minikube/blob/master/OWNERS
▪ Using image registry.k8s.io/ingress-nginx/controller:v1.10.x
▪ Using image registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.x
▪ Using image registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.x
🔎 Verifying ingress addon...
🌟 The 'ingress' addon is enabled
Verify It’s Running
# Check the controller pod (takes ~60 seconds to start)
kubectl get pods -n ingress-nginx
# Wait for it to be Ready
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
Expected pod output:
NAME READY STATUS RESTARTS AGE
ingress-nginx-controller-xxxxxxxxx-yyyyy 1/1 Running 0 90s
# See what Services the controller exposes
kubectl get svc -n ingress-nginx
Expected:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller NodePort 10.96.37.205 <none> 80:31xxx/TCP,443:32xxx/TCP
ingress-nginx-controller-admission ClusterIP 10.96.88.14 <none> 443/TCP
The controller is exposed as a NodePort on Minikube, listening on ports 80 and 443.
The IngressClass
When the addon installs, it creates an IngressClass named nginx:
kubectl get ingressclass
Expected:
NAME CONTROLLER PARAMETERS AGE
nginx k8s.io/ingress-nginx <none> 2m
Reference this in your Ingress Resources with ingressClassName: nginx.
Accessing Ingress on Minikube
Ingress on Minikube works best by adding hosts to /etc/hosts pointing to the Minikube IP:
# Get Minikube's IP
minikube ip
# Output: 192.168.49.2
# Add to /etc/hosts (requires sudo)
echo "$(minikube ip) myapp.local api.local" | sudo tee -a /etc/hosts
# Now these hostnames work:
curl http://myapp.local
curl http://api.local
Alternatively — use --header with curl to fake the Host header:
MINIKUBE_IP=$(minikube ip)
# Fake the host header — useful for testing without /etc/hosts
curl -H "Host: myapp.local" http://$MINIKUBE_IP
Architecture Overview
graph LR
Browser -->|"myapp.local:80"| HOST["/etc/hosts:\nmyapp.local → 192.168.49.2"]
HOST -->|"192.168.49.2:80"| NP["Minikube Node\nNodePort :31xxx"]
NP --> IC["ingress-nginx-controller\npod"]
IC -->|"Host: myapp.local\nPath: /api"| API[api-svc]
IC -->|"Host: myapp.local\nPath: /"| WEB[web-svc]
Try It — Quick Smoke Test
# Verify the addon is enabled
minikube addons list | grep ingress
# Deploy a test app
kubectl create deployment smoke-test --image=nginx:1.25
kubectl expose deployment smoke-test --port=80
# Create an Ingress rule
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smoke-test-ingress
spec:
ingressClassName: nginx
rules:
- host: smoke.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: smoke-test
port:
number: 80
EOF
# Test with curl (fake host header)
curl -H "Host: smoke.local" http://$(minikube ip)
# Cleanup
kubectl delete deployment smoke-test
kubectl delete svc smoke-test
kubectl delete ingress smoke-test-ingress
Expected: nginx welcome page HTML.
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | minikube addons enable ingress | One command installs NGINX Ingress Controller |
| 2 | Controller runs in ingress-nginx namespace | Check pod health there, not default |
| 3 | IngressClass nginx is auto-created | Use ingressClassName: nginx in Ingress Resources |
| 4 | Use /etc/hosts or Host: header | Route custom hostnames to Minikube IP for testing |
✅ Quick Check
Q1: The NGINX Ingress controller pod is in CrashLoopBackOff. What happens to existing Ingress Resources?
Answer
Existing traffic routing breaks — no new requests are processed by the Ingress controller. The underlying Services still exist, so direct Service-level access (ClusterIP, NodePort) still works. But all Ingress-based routing fails until the controller recovers.Q2: Why does Minikube use NodePort for the Ingress controller instead of LoadBalancer?
Answer
Minikube doesn't have a cloud controller by default, so LoadBalancer Services stay in pending state (as we saw in Chapter 5). NodePort lets the controller be accessible on a specific high port on the Minikube node IP. `minikube tunnel` would also work to give it a proper external IP.Q3: You apply an Ingress with ingressClassName: traefik but only have NGINX Ingress installed. What happens?
Answer
The Ingress Resource is created in Kubernetes but never acted on. No controller claims it, so no routing rules are configured. Traffic to the matching hosts/paths goes nowhere. The Ingress will remain in `ADDRESS` empty state indefinitely.6.3 Path-Based and Host-Based Routing
⏱️ ~7 min read
TL;DR: Ingress supports two routing dimensions: path-based (same domain, different URL paths → different services) and host-based (different domains → different services). You can combine both in one Ingress resource.
Path-Based Routing
All traffic to the same domain, split by URL path:
# path-based-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: path-routing
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2 # Strip the path prefix
spec:
ingressClassName: nginx
rules:
- host: myapp.local
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: api-svc
port:
number: 8080
- path: /auth(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: auth-svc
port:
number: 3000
- path: /()(.*)
pathType: ImplementationSpecific
backend:
service:
name: web-svc
port:
number: 80
graph LR
C[Client] -->|"myapp.local/api/users"| IC[NGINX Ingress]
C -->|"myapp.local/auth/login"| IC
C -->|"myapp.local/"| IC
IC -->|strips /api prefix → /users| API[api-svc :8080]
IC -->|strips /auth prefix → /login| AUTH[auth-svc :3000]
IC -->|passes /| WEB[web-svc :80]
The Rewrite Problem
Without the rewrite-target annotation, requests to /api/users would hit the backend service as /api/users. But your backend probably expects just /users — the /api prefix is just for Ingress routing.
# Without rewrite:
Client → /api/users → api-svc receives: /api/users ← often breaks
# With rewrite-target: /$2
Client → /api/users → api-svc receives: /users ← correct
The regex (/|$)(.*) captures the path after the prefix into group $2, and rewrite-target: /$2 sends just that part to the backend.
💡 Tip: For simpler cases where your backend CAN handle the full path (e.g., serving at
/api/...), omit the rewrite annotation entirely and usepathType: Prefix.
Simple Path Routing (No Rewrite)
If your services handle the full path, keep it simple:
# simple-path-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: simple-routing
spec:
ingressClassName: nginx
rules:
- host: myapp.local
http:
paths:
- path: /api # /api and all sub-paths → api-svc
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8080
- path: / # Everything else → web-svc
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
⚠️ Warning: Order matters with
Prefixpaths. Always put more specific paths first (e.g.,/apibefore/). NGINX Ingress picks the longest matching path, but listing specific paths first avoids ambiguity.
Host-Based Routing
Route different domains to different services — all on the same IP:
# host-based-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: host-routing
spec:
ingressClassName: nginx
rules:
# Public-facing app
- host: myapp.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
# Admin panel — separate domain
- host: admin.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-svc
port:
number: 9000
# API subdomain
- host: api.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8080
The NGINX controller reads the HTTP Host header to determine which rule applies.
Combining Path + Host
You can mix both dimensions in one Ingress:
spec:
rules:
- host: myapp.local # Specific host
http:
paths:
- path: /api # AND specific path
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
- host: api.myapp.local # API subdomain — all paths to api-svc
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 8080
Default Backend
When no rule matches, you can specify a default backend:
spec:
defaultBackend: # Catch-all for unmatched requests
service:
name: default-404-svc
port:
number: 80
rules:
- ...
Without a defaultBackend, NGINX returns its own 404 page for unmatched requests.
Try It
# Deploy two services
kubectl create deployment app-a --image=nginx:1.25
kubectl create deployment app-b --image=nginx:1.25
kubectl expose deployment app-a --port=80 --name=svc-a
kubectl expose deployment app-b --port=80 --name=svc-b
# Customize their responses so we can tell them apart
kubectl exec -it deploy/app-a -- sh -c "echo 'Service A' > /usr/share/nginx/html/index.html"
kubectl exec -it deploy/app-b -- sh -c "echo 'Service B' > /usr/share/nginx/html/index.html"
# Create a path-based Ingress
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: path-demo
spec:
ingressClassName: nginx
rules:
- host: demo.local
http:
paths:
- path: /a
pathType: Prefix
backend:
service:
name: svc-a
port:
number: 80
- path: /b
pathType: Prefix
backend:
service:
name: svc-b
port:
number: 80
EOF
MINIKUBE_IP=$(minikube ip)
# Test path routing
echo "=== /a should go to Service A ==="
curl -s -H "Host: demo.local" http://$MINIKUBE_IP/a
echo "=== /b should go to Service B ==="
curl -s -H "Host: demo.local" http://$MINIKUBE_IP/b
# Cleanup
kubectl delete deployment app-a app-b
kubectl delete svc svc-a svc-b
kubectl delete ingress path-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Path routing | Same host, different URL paths → different Services |
| 2 | Host routing | Different Host headers → different Services |
| 3 | rewrite-target | Strip routing prefix before forwarding to backend |
| 4 | Specificity order | More specific paths should come before catch-alls |
| 5 | defaultBackend | Catch-all for unmatched requests |
✅ Quick Check
Q1: You have two Ingress rules: path /api and path /. A request comes in for /apikeys. Which rule matches?
Answer
With `pathType: Prefix`, `/api` does NOT match `/apikeys` — prefix matching is by path segments, not string prefix. So `/apikeys` matches only the `/` rule. If you used `pathType: ImplementationSpecific` with regex, the behavior would depend on the regex used.Q2: A client sends a request with Host: unknown.local — no Ingress rule matches this host. What does the user see?
Answer
The NGINX Ingress Controller returns a 404 or its default backend response (if one is configured). The request doesn't reach any of your application services. If you have a `defaultBackend` defined on the Ingress, that service handles it instead.Q3: You want /api/v1/users and /api/v2/users to go to the same backend service. What’s the simplest Ingress rule?
Answer
A single rule with `path: /api` and `pathType: Prefix`. This matches `/api`, `/api/v1/users`, `/api/v2/users`, and all other `/api/*` paths — routing them all to the same backend service.6.4 TLS Termination
⏱️ ~6 min read
TL;DR: The Ingress Controller handles TLS (HTTPS) so your backend services don’t have to. You create a TLS Secret containing a certificate and key, reference it in your Ingress, and the controller handles encryption/decryption transparently.
How TLS Termination Works
sequenceDiagram
participant C as Browser (HTTPS)
participant IC as Ingress Controller (TLS terminates here)
participant S as Backend Service (plain HTTP)
C->>IC: HTTPS request (encrypted)\nHost: myapp.local
IC->>IC: Decrypt using TLS cert
IC->>S: HTTP request (plain)\nforwarded to backend-svc:80
S-->>IC: HTTP response
IC-->>C: HTTPS response (encrypted)
Your backend pods only ever see plain HTTP. The Ingress Controller handles all the TLS complexity. This is called TLS termination at the edge.
Step 1: Create the TLS Secret
You need a certificate and private key. For local dev, generate a self-signed cert:
# Generate a self-signed certificate (valid for 365 days)
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout tls.key \
-out tls.crt \
-subj "/CN=myapp.local/O=myapp" \
-addext "subjectAltName=DNS:myapp.local,DNS:api.local"
# Create a TLS Secret in Kubernetes
kubectl create secret tls myapp-tls \
--cert=tls.crt \
--key=tls.key
# Verify
kubectl get secret myapp-tls
kubectl describe secret myapp-tls
Expected secret output:
Name: myapp-tls
Namespace: default
Type: kubernetes.io/tls
Data
====
tls.crt: 1234 bytes
tls.key: 1679 bytes
⚠️ Warning: Self-signed certificates cause browser “Not Secure” warnings. For production, use cert-manager with Let’s Encrypt to get free, auto-renewing trusted certificates.
Step 2: Reference the Secret in Ingress
# tls-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tls-ingress
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true" # Redirect HTTP → HTTPS
spec:
ingressClassName: nginx
tls:
- hosts:
- myapp.local # Must match the certificate's CN/SAN
secretName: myapp-tls # The TLS Secret created above
rules:
- host: myapp.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
The tls block tells the controller which certificate to use for which hostname. The rules block still uses http — it’s the routing rules, not the protocol. The controller handles the HTTPS→HTTP translation.
HTTP to HTTPS Redirect
The annotation nginx.ingress.kubernetes.io/ssl-redirect: "true" makes NGINX automatically redirect any HTTP request to HTTPS:
Client: GET http://myapp.local/
Ingress: 301 Redirect → https://myapp.local/
Client: GET https://myapp.local/ (now HTTPS)
Ingress: 200 OK (TLS terminated, forwarded to backend as HTTP)
Without this annotation, both HTTP and HTTPS work simultaneously.
Multiple Domains, Multiple Certs
You can have multiple TLS entries for different domains:
spec:
tls:
- hosts:
- myapp.local
secretName: myapp-tls # cert for myapp.local
- hosts:
- admin.local
secretName: admin-tls # separate cert for admin.local
rules:
- host: myapp.local
http:
paths: [...]
- host: admin.local
http:
paths: [...]
In Production: cert-manager + Let’s Encrypt
For production, never use self-signed certs. The standard setup:
# With cert-manager installed, just add this annotation:
metadata:
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- myapp.example.com
secretName: myapp-tls # cert-manager creates and rotates this automatically
cert-manager watches Ingress Resources with this annotation, requests certificates from Let’s Encrypt, stores them as Secrets, and automatically rotates them before expiry. Zero manual cert management.
Try It
# Generate a self-signed cert
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout /tmp/tls.key \
-out /tmp/tls.crt \
-subj "/CN=tls-demo.local/O=demo" \
2>/dev/null
# Create the Secret
kubectl create secret tls demo-tls \
--cert=/tmp/tls.crt \
--key=/tmp/tls.key
# Deploy a test app
kubectl create deployment tls-app --image=nginx:1.25
kubectl expose deployment tls-app --port=80 --name=tls-svc
# Create TLS Ingress
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tls-demo
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- tls-demo.local
secretName: demo-tls
rules:
- host: tls-demo.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tls-svc
port:
number: 80
EOF
MINIKUBE_IP=$(minikube ip)
# Test HTTP → HTTPS redirect
curl -v -H "Host: tls-demo.local" http://$MINIKUBE_IP 2>&1 | grep -E "< HTTP|Location"
# Test HTTPS directly (skip cert verification since self-signed)
curl -k -s -H "Host: tls-demo.local" https://$MINIKUBE_IP
# Cleanup
kubectl delete deployment tls-app
kubectl delete svc tls-svc
kubectl delete ingress tls-demo
kubectl delete secret demo-tls
Expected redirect output:
< HTTP/1.1 308 Permanent Redirect
Location: https://tls-demo.local/
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | TLS termination at the controller | Backends only see plain HTTP |
| 2 | kubernetes.io/tls Secret | Holds tls.crt + tls.key |
| 3 | ssl-redirect annotation | Automatically redirects HTTP → HTTPS |
| 4 | cert-manager for production | Auto-provisions and rotates Let’s Encrypt certs |
✅ Quick Check
Q1: Your TLS certificate is for myapp.example.com but your Ingress host is app.example.com. What happens?
Answer
NGINX serves the certificate anyway, but browsers will show a certificate error — the cert's Common Name and Subject Alternative Names don't match the hostname. Users see "Your connection is not private." Always ensure the certificate's CN/SAN matches the Ingress host exactly.Q2: You enable ssl-redirect: true. A client sends a plain HTTP request. What response do they get?
Answer
A `308 Permanent Redirect` to the HTTPS version of the same URL. The browser (or curl) then follows the redirect to the HTTPS endpoint. 308 is used instead of 301 because 308 preserves the HTTP method (important for POST requests).Q3: Your TLS Secret’s certificate expires. What happens to traffic?
Answer
HTTPS traffic continues to work but browsers show certificate expiry warnings and may block the connection. The Ingress controller keeps serving the expired cert — Kubernetes doesn't automatically rotate TLS Secrets. This is exactly why cert-manager with Let's Encrypt is essential in production: it auto-rotates certs before they expire.Lab: Multi-Service Ingress with TLS
⏱️ ~30 min hands-on
| Prerequisites | NGINX Ingress enabled (minikube addons enable ingress), Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Deploy three services, wire them up with path and host routing, add TLS, and debug a misconfigured Ingress |
Objectives
- Enable and verify the NGINX Ingress Controller
- Deploy three services with distinct responses
- Configure path-based routing for two services under one domain
- Add host-based routing for a third service on a different domain
- Add TLS with a self-signed certificate
- Debug a broken Ingress rule
Setup
kubectl create namespace ingress-lab
kubectl config set-context --current --namespace=ingress-lab
# Ensure NGINX Ingress is enabled and ready
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
echo "Minikube IP: $(minikube ip)"
Exercise 1: Deploy Three Services
What we’re doing: Create three distinct services that we’ll route to via Ingress.
# Service 1: Web frontend
kubectl create deployment web --image=nginx:1.25 -n ingress-lab
kubectl expose deployment web --port=80 --name=web-svc -n ingress-lab
kubectl exec -n ingress-lab deploy/web -- \
sh -c "echo '<h1>Welcome to Web Frontend</h1>' > /usr/share/nginx/html/index.html"
# Service 2: API
kubectl create deployment api --image=nginx:1.25 -n ingress-lab
kubectl expose deployment api --port=80 --name=api-svc -n ingress-lab
kubectl exec -n ingress-lab deploy/api -- \
sh -c 'echo '"'"'{"service":"api","status":"ok"}'"'"' > /usr/share/nginx/html/index.html'
# Service 3: Admin panel (different domain)
kubectl create deployment admin --image=nginx:1.25 -n ingress-lab
kubectl expose deployment admin --port=80 --name=admin-svc -n ingress-lab
kubectl exec -n ingress-lab deploy/admin -- \
sh -c "echo '<h1>Admin Panel</h1>' > /usr/share/nginx/html/index.html"
# Verify all running
kubectl get deploy,svc -n ingress-lab
Exercise 2: Path-Based Routing
What we’re doing: Route / to the web frontend and /api to the API service — both under myapp.local.
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: path-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: myapp.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
EOF
# Check Ingress status (ADDRESS should show Minikube IP)
kubectl get ingress path-ingress -n ingress-lab
# Test (using Host header to fake DNS)
MINIKUBE_IP=$(minikube ip)
echo "=== Testing / → web-svc ==="
curl -s -H "Host: myapp.local" http://$MINIKUBE_IP/
echo ""
echo "=== Testing /api → api-svc ==="
curl -s -H "Host: myapp.local" http://$MINIKUBE_IP/api
Expected output:
=== Testing / → web-svc ===
<h1>Welcome to Web Frontend</h1>
=== Testing /api → api-svc ===
{"service":"api","status":"ok"}
💡 What just happened? NGINX reads the
Hostheader and the URL path, then selects the matching backend. The two services share a single IP but respond to different paths.
Exercise 3: Add Host-Based Routing
What we’re doing: Route traffic to the admin service using a separate hostname.
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: host-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: admin.local # Separate hostname → admin service
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-svc
port:
number: 80
EOF
# Test host-based routing
MINIKUBE_IP=$(minikube ip)
echo "=== myapp.local → web-svc ==="
curl -s -H "Host: myapp.local" http://$MINIKUBE_IP/
echo ""
echo "=== admin.local → admin-svc ==="
curl -s -H "Host: admin.local" http://$MINIKUBE_IP/
# Optional: Add to /etc/hosts for browser testing
echo "# Add these for browser access:"
echo "echo '$(minikube ip) myapp.local admin.local' | sudo tee -a /etc/hosts"
Expected:
=== myapp.local → web-svc ===
<h1>Welcome to Web Frontend</h1>
=== admin.local → admin-svc ===
<h1>Admin Panel</h1>
Exercise 4: Add TLS
What we’re doing: Secure myapp.local with a self-signed certificate.
# Generate certificate covering both hosts
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout /tmp/lab.key \
-out /tmp/lab.crt \
-subj "/CN=myapp.local/O=ingress-lab" \
-addext "subjectAltName=DNS:myapp.local,DNS:admin.local" \
2>/dev/null
echo "Certificate generated."
# Create TLS Secret in ingress-lab namespace
kubectl create secret tls lab-tls \
--cert=/tmp/lab.crt \
--key=/tmp/lab.key \
-n ingress-lab
kubectl get secret lab-tls -n ingress-lab
Update the path-based Ingress to use TLS:
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: path-ingress
namespace: ingress-lab
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- myapp.local
secretName: lab-tls
rules:
- host: myapp.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web-svc
port:
number: 80
EOF
MINIKUBE_IP=$(minikube ip)
# Test HTTP → HTTPS redirect (expect 308)
echo "=== HTTP redirect check ==="
curl -v -H "Host: myapp.local" http://$MINIKUBE_IP/ 2>&1 | grep -E "< HTTP|Location"
# Test HTTPS directly (-k skips cert verification)
echo ""
echo "=== HTTPS response ==="
curl -k -s -H "Host: myapp.local" https://$MINIKUBE_IP/
# Inspect the certificate being served
echo ""
echo "=== Certificate info ==="
echo | openssl s_client -connect $MINIKUBE_IP:443 \
-servername myapp.local 2>/dev/null | openssl x509 -noout -subject -dates
Expected:
=== HTTP redirect check ===
< HTTP/1.1 308 Permanent Redirect
Location: https://myapp.local/
=== HTTPS response ===
<h1>Welcome to Web Frontend</h1>
=== Certificate info ===
subject=CN=myapp.local, O=ingress-lab
notBefore=Jul 15 05:00:00 2026 GMT
notAfter=Jul 15 05:00:00 2027 GMT
Exercise 5: Debug a Broken Ingress
What we’re doing: Introduce a common Ingress mistake and diagnose it.
# Create a broken Ingress (references a Service that doesn't exist)
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: broken-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: broken.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nonexistent-svc # ← This service doesn't exist
port:
number: 80
EOF
# Test — gets 503 Service Unavailable
MINIKUBE_IP=$(minikube ip)
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" \
-H "Host: broken.local" http://$MINIKUBE_IP/
Expected:
HTTP Status: 503
Now diagnose:
# Step 1: Check Ingress resource
kubectl describe ingress broken-ingress -n ingress-lab
# Step 2: Verify the backend Service exists
kubectl get svc -n ingress-lab | grep nonexistent # Nothing found
# Step 3: Check NGINX Ingress controller logs
kubectl logs -n ingress-nginx \
-l app.kubernetes.io/component=controller \
--tail=20 | grep -i "error\|broken"
# Step 4: Fix it by pointing to a real service
kubectl patch ingress broken-ingress -n ingress-lab \
--type='json' \
-p='[{"op":"replace","path":"/spec/rules/0/http/paths/0/backend/service/name","value":"web-svc"}]'
# Verify the fix
curl -s -H "Host: broken.local" http://$MINIKUBE_IP/
🔥 Break It! Challenge
What happens when two Ingress resources claim the same host/path?
# Create a second Ingress that claims myapp.local/
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: duplicate-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: myapp.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-svc
port:
number: 80
EOF
# Which one wins?
for i in {1..5}; do
curl -s -H "Host: myapp.local" http://$MINIKUBE_IP/ 2>/dev/null
echo ""
done
Observation: NGINX uses the Ingress that was created first (by creation timestamp). The second one for the same host/path is effectively ignored (or causes inconsistent behavior). Check NGINX controller logs to see the conflict warning.
The lesson: Conflicting Ingress rules are a common production gotcha. Use namespaced Ingress resources carefully, and always check for conflicts after applying new Ingress rules.
kubectl delete ingress duplicate-ingress -n ingress-lab
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace ingress-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | NGINX Ingress setup | Controller pod Running in ingress-nginx namespace |
| 2 | Path-based routing | / → web-svc, /api → api-svc with correct responses |
| 3 | Host-based routing | admin.local → admin-svc independently |
| 4 | TLS termination | HTTPS working with self-signed cert, HTTP redirects to HTTPS |
| 5 | Debug 503 errors | Traced to missing backend service via describe + controller logs |
| 6 | Duplicate rules | Observed first-wins behavior for conflicting Ingress rules |
Chapter 7: ConfigMaps and Secrets
⏱️ Total chapter time: ~45 min (20 min reading + 25 min lab)
After this chapter, you will be able to: Externalize application configuration using ConfigMaps, manage sensitive data with Secrets, mount both as environment variables or files, and understand the security implications.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 7.1 | ConfigMaps — Externalizing Configuration | ~5 min |
| 7.2 | Secrets — Handling Sensitive Data | ~6 min |
| 7.3 | Environment Variables vs Volume Mounts | ~5 min |
| 7.4 | Immutable ConfigMaps and Secret Rotation | ~4 min |
| 7.5 | 🔬 Lab: Configure a 12-Factor App | ~25 min |
Prerequisites
- Completed Chapter 3 (Pods)
minikube statusshowsRunning
7.1 ConfigMaps — Externalizing Configuration
⏱️ ~5 min read
TL;DR: A ConfigMap stores non-sensitive configuration as key-value pairs. It decouples your container image from its configuration — the same image can run in dev, staging, and prod with different ConfigMaps injected at runtime.
The Problem: Hardcoded Config
# Bad: config baked into the image
ENV DB_HOST=prod-db.internal
ENV MAX_CONNECTIONS=100
ENV LOG_LEVEL=info
Now you can’t reuse the same image in dev (different DB), staging (different log level), or if the DB hostname changes.
🔗 Docker Parallel: In Compose, you use
.envfiles orenvironment:keys. In Kubernetes, ConfigMaps serve the same purpose — but they’re cluster objects that multiple pods can reference.
Creating ConfigMaps
Three ways to create a ConfigMap:
# Method 1: From literal values
kubectl create configmap app-config \
--from-literal=DB_HOST=postgres-svc \
--from-literal=DB_PORT=5432 \
--from-literal=LOG_LEVEL=info \
--from-literal=MAX_CONNECTIONS=100
# Method 2: From a .env file
# Create app.env:
# DB_HOST=postgres-svc
# DB_PORT=5432
# LOG_LEVEL=info
kubectl create configmap app-config --from-env-file=app.env
# Method 3: From a file (key = filename, value = file content)
kubectl create configmap nginx-config --from-file=nginx.conf
# Method 4: Declarative YAML (preferred for GitOps)
Declarative YAML (preferred):
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: default
data:
# Simple key-value pairs
DB_HOST: "postgres-svc"
DB_PORT: "5432"
LOG_LEVEL: "info"
MAX_CONNECTIONS: "100"
# Multi-line config file content
app.properties: |
server.port=8080
server.timeout=30
feature.dark-mode=true
nginx.conf: |
server {
listen 80;
location / {
proxy_pass http://backend:8080;
}
}
kubectl apply -f configmap.yaml
kubectl get configmap app-config
kubectl describe configmap app-config
What ConfigMaps Store
ConfigMaps hold strings only. For binary data, use the binaryData field (base64-encoded), but this is uncommon — Secrets handle sensitive binary data better.
data:
key: "value" # Simple string
port: "5432" # Numbers stored as strings — always quote them
config.yaml: | # Multi-line file content
key: value
nested:
key: value
⚠️ Warning: ConfigMaps have a size limit of 1 MiB. They’re for configuration, not bulk data. If you need larger config files, use an init container to fetch them from an external source.
Try It
# Create a ConfigMap declaratively
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-config
data:
APP_ENV: "production"
MAX_RETRIES: "3"
config.json: |
{
"debug": false,
"timeout": 30,
"retries": 3
}
EOF
# View it
kubectl get configmap demo-config -o yaml
# See the data fields
kubectl get configmap demo-config \
-o jsonpath='{range .data}{@}{"\n"}{end}'
# Cleanup
kubectl delete configmap demo-config
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | ConfigMap = configuration container | Stores strings: env vars, config file contents |
| 2 | Decouples image from config | Same image, different ConfigMaps per environment |
| 3 | 1 MiB limit | For config only — not bulk data |
| 4 | YAML preferred | Declarative ConfigMaps belong in Git |
✅ Quick Check
Q1: You update a ConfigMap while pods are running. Do the pods see the new values immediately?
Answer
It depends on how the ConfigMap is consumed. Pods that read ConfigMap values as **environment variables** do NOT see updates — env vars are set at container startup and don't change while the container is running. Pods that consume ConfigMaps as **volume-mounted files** WILL see updates within ~60 seconds (kubelet polling interval). For env vars, you need to restart (rolling-update) the pods.Q2: Should you store a database password in a ConfigMap?
Answer
No. ConfigMaps store data in plain text — it's visible to anyone with `kubectl get configmap` access. For sensitive data (passwords, API keys, tokens, certs), use Secrets (section 7.2). Secrets have access control mechanisms that ConfigMaps don't.Q3: You have three environments (dev, staging, prod) each with different DB hostnames. What’s the recommended approach?
Answer
Create a ConfigMap named `app-config` in each namespace (or cluster), each with the appropriate `DB_HOST` value. Your pod spec references `app-config` without environment-specific logic — Kubernetes injects the right values based on which namespace/cluster the pod runs in. Same YAML, different ConfigMaps per environment.7.2 Secrets — Handling Sensitive Data
⏱️ ~6 min read
TL;DR: Secrets store sensitive data (passwords, tokens, keys). They look like ConfigMaps but are base64-encoded and have stricter access controls. Important: base64 is NOT encryption. Secrets are only as secure as your RBAC and etcd encryption configuration.
Creating Secrets
# From literals (most common)
kubectl create secret generic db-credentials \
--from-literal=username=admin \
--from-literal=password=s3cr3t!
# From files (e.g., SSH keys, TLS certs)
kubectl create secret generic ssh-key \
--from-file=id_rsa=/path/to/private.key \
--from-file=id_rsa.pub=/path/to/public.key
# TLS Secret (special type for Ingress)
kubectl create secret tls myapp-tls \
--cert=tls.crt \
--key=tls.key
# Docker registry credentials (for pulling private images)
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=myuser \
--docker-password=mypassword
Declarative YAML:
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque # Generic secret type
data:
username: YWRtaW4= # base64("admin")
password: czNjcjN0IQ== # base64("s3cr3t!")
# Encode values yourself:
echo -n "admin" | base64 # YWRtaW4=
echo -n "s3cr3t!" | base64 # czNjcjN0IQ==
# Decode to verify:
echo "YWRtaW4=" | base64 --decode # admin
⚠️ Warning: If you store Secrets in Git as YAML, the base64-encoded values are trivially decodable by anyone with repo access. Use tools like Sealed Secrets, External Secrets Operator, or HashiCorp Vault to store secrets safely in Git.
Secret Types
| Type | Use Case | Created By |
|---|---|---|
Opaque | Generic key-value pairs | You |
kubernetes.io/tls | TLS certificates | kubectl create secret tls |
kubernetes.io/dockerconfigjson | Docker registry auth | kubectl create secret docker-registry |
kubernetes.io/service-account-token | Service account tokens | Kubernetes automatically |
kubernetes.io/basic-auth | HTTP basic auth | You |
kubernetes.io/ssh-auth | SSH private keys | You |
The base64 Reality Check
# Look at a Secret — the data appears base64-encoded
kubectl get secret db-credentials -o yaml
data:
password: czNjcjN0IQ== # "encrypted"? No. Just base64.
username: YWRtaW4=
# Anyone with kubectl access can decode it in one command:
kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 --decode
# Output: s3cr3t!
Base64 is an encoding, not encryption. It’s there to handle binary data safely in YAML, not to protect the value.
What actually protects Secrets:
- RBAC — restricts who can
getorlistSecrets - etcd encryption at rest — encrypts Secrets in the etcd database (must be enabled)
- Namespace isolation — Secrets in namespace A can’t be read by pods in namespace B
- Audit logging — who accessed which Secret and when
🏭 In Production: Enable etcd encryption at rest, restrict Secret access with RBAC, and never log Secret values. Consider an external secrets manager (AWS Secrets Manager, HashiCorp Vault) for critical secrets.
Using stringData (Easier YAML Authoring)
Instead of base64-encoding values yourself, use stringData:
# Kubernetes auto-encodes these to base64 on apply
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
stringData: # Plain text — K8s encodes automatically
username: admin
password: s3cr3t!
connection-string: "postgresql://admin:s3cr3t!@postgres-svc:5432/mydb"
kubectl apply -f secret.yaml
# Kubernetes stores it base64-encoded internally
kubectl get secret db-credentials -o yaml | grep -A3 "^data:"
💡 Tip: Use
stringDatain YAML files — it’s easier to read and less error-prone than manually base64-encoding values. Kubernetes handles the encoding internally.
Try It
# Create a Secret with stringData
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DB_PASSWORD: "mySuperSecretPassword123"
API_KEY: "sk-abcdef1234567890"
JWT_SECRET: "my-jwt-signing-key-never-share-this"
EOF
# See it stored as base64
kubectl get secret app-secrets -o yaml
# Decode individual values
kubectl get secret app-secrets \
-o jsonpath='{.data.DB_PASSWORD}' | base64 --decode
echo ""
# Cleanup
kubectl delete secret app-secrets
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Secrets = base64-encoded ConfigMaps | Same structure; different intent and access controls |
| 2 | base64 ≠ encryption | Anyone with kubectl get secret can decode it |
| 3 | RBAC protects Secrets | Restrict get/list on Secrets to only pods that need them |
| 4 | stringData for authoring | K8s auto-encodes; use for readable YAML |
| 5 | etcd encryption at rest | The real protection — encrypt the database, not just encode |
✅ Quick Check
Q1: A developer can kubectl get pods but you want to prevent them from reading Secrets. How?
Answer
Use RBAC. Create a Role that grants `get,list,watch` on `pods` but NOT on `secrets`. Bind this Role to the developer's ServiceAccount or user. Without explicit permission on the `secrets` resource, they cannot read Secret values.Q2: You store a database password in a Secret. A bug in your app logs all environment variables to stdout. Is the password exposed?
Answer
Yes — if the Secret was mounted as an environment variable and the app logs all env vars, the plaintext password appears in the container logs. This is a real vulnerability. Best practices: (1) mount Secrets as files instead of env vars, (2) never log env vars, (3) use a secrets manager with runtime injection instead of env vars.Q3: What happens to a pod that references a Secret that doesn’t exist yet?
Answer
The pod stays in `Pending` state with the reason `CreateContainerConfigError`. It can't start because Kubernetes tries to inject the Secret before running the container and fails when the Secret is not found. Once you create the Secret, the pod starts automatically.7.3 Environment Variables vs Volume Mounts
⏱️ ~5 min read
TL;DR: You can inject ConfigMap/Secret data into pods as environment variables (simple, but static) or as file mounts (more powerful, support live updates). Choose based on whether your app reads config from env vars or files.
Method 1: Environment Variables
Inject specific keys
spec:
containers:
- name: app
image: myapp:latest
env:
# From a ConfigMap key
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: app-config # ConfigMap name
key: DB_HOST # Key inside ConfigMap
# From a Secret key
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials # Secret name
key: password # Key inside Secret
# Plain static value (for reference)
- name: APP_VERSION
value: "1.2.3"
Inject ALL keys from a ConfigMap/Secret
spec:
containers:
- name: app
image: myapp:latest
envFrom:
- configMapRef:
name: app-config # All keys become env vars
- secretRef:
name: db-credentials # All keys become env vars
# Inside the pod — all ConfigMap keys are env vars
kubectl exec -it my-pod -- env | grep -E "DB_HOST|LOG_LEVEL|MAX_CONN"
Env var limitations:
- Static at container start — updates to ConfigMap/Secret require pod restart
- If a key doesn’t exist in the ConfigMap, the pod fails to start
- All keys from
envFrombecome env vars — potential for name collisions
Method 2: Volume Mounts
Mount ConfigMaps and Secrets as files in the container’s filesystem:
spec:
volumes:
# ConfigMap as a volume
- name: config-volume
configMap:
name: app-config # Each key becomes a file
# Secret as a volume
- name: secret-volume
secret:
secretName: db-credentials
defaultMode: 0400 # File permissions (owner read-only)
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: config-volume
mountPath: /etc/config # ConfigMap keys appear as files here
- name: secret-volume
mountPath: /etc/secrets # Secret keys appear as files here
readOnly: true
Inside the container:
/etc/config/
DB_HOST ← contains "postgres-svc"
LOG_LEVEL ← contains "info"
app.properties ← contains the multi-line properties content
/etc/secrets/
username ← contains "admin"
password ← contains "s3cr3t!"
Mounting Specific Keys as Files
Mount only specific keys, with custom filenames:
volumes:
- name: config-volume
configMap:
name: app-config
items:
- key: app.properties # ConfigMap key
path: application.properties # File name inside the mount
- key: nginx.conf
path: nginx/nginx.conf # Nested path
The Critical Difference: Live Updates
graph LR
CM[ConfigMap\nupdated] -->|"Kubelet detects change\n~60 second delay"| VM[Volume Mount\n/etc/config/KEY\nupdated automatically ✅]
CM -->|"Container was started\nwith env var value"| EV[Environment Variable\nNOT updated ❌\nrequires pod restart]
| Env Vars | Volume Mounts | |
|---|---|---|
| Setup complexity | Low | Medium |
| Live config updates | ❌ No — pod restart needed | ✅ Yes — ~60s propagation |
| Access pattern | os.getenv("KEY") | Read file at /etc/config/KEY |
| Secret security | ⚠️ Appears in env output | ✅ File, not env — less exposure |
| Binary data support | ❌ No | ✅ Yes (via binaryData) |
💡 Tip: For secrets especially, prefer volume mounts over env vars. A compromised process can dump all env vars trivially (
os.environin Python,process.envin Node.js). Reading a file requires explicit code. It’s a small but meaningful security improvement.
Try It
# Create both a ConfigMap and a Secret
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-config
data:
APP_ENV: production
LOG_LEVEL: info
config.json: |
{"timeout": 30, "retries": 3}
---
apiVersion: v1
kind: Secret
metadata:
name: demo-secret
type: Opaque
stringData:
DB_PASSWORD: "supersecret"
API_KEY: "key-12345"
EOF
# Pod using BOTH env vars and volume mounts
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: config-demo
spec:
volumes:
- name: config-vol
configMap:
name: demo-config
- name: secret-vol
secret:
secretName: demo-secret
defaultMode: 0400
containers:
- name: app
image: busybox
command: ["sh", "-c", "sleep 3600"]
env:
- name: APP_ENV
valueFrom:
configMapKeyRef:
name: demo-config
key: APP_ENV
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: demo-secret
key: DB_PASSWORD
volumeMounts:
- name: config-vol
mountPath: /etc/config
- name: secret-vol
mountPath: /etc/secrets
readOnly: true
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
kubectl wait pod config-demo --for=condition=Ready --timeout=30s
# Check env vars
kubectl exec config-demo -- env | grep -E "APP_ENV|DB_PASSWORD"
# Check volume-mounted files
kubectl exec config-demo -- ls /etc/config/
kubectl exec config-demo -- cat /etc/config/config.json
kubectl exec config-demo -- ls /etc/secrets/
kubectl exec config-demo -- cat /etc/secrets/API_KEY
# Cleanup
kubectl delete pod config-demo
kubectl delete configmap demo-config
kubectl delete secret demo-secret
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Env vars from ConfigMap | configMapKeyRef for specific keys, configMapRef for all |
| 2 | Volume mounts = files | Each ConfigMap/Secret key becomes a file in the mount path |
| 3 | Live updates | Only volume mounts update without pod restart |
| 4 | Secrets as files | Prefer over env vars for sensitive data |
| 5 | defaultMode: 0400 | Set file permissions on Secret mounts |
✅ Quick Check
Q1: Your app reads config from environment variables. You update a ConfigMap. What must you do to apply the new config?
Answer
Trigger a rolling restart of the Deployment: `kubectl rollout restart deployment/my-app`. This creates new pods that start with the updated environment variables from the ConfigMap. The old pods are terminated in a rolling fashion.Q2: You mount a ConfigMap as a volume. The ConfigMap has 5 keys. How many files appear in the mount directory?
Answer
5 files — one per key. The key name becomes the filename, and the key's value becomes the file content. If you only want specific keys mounted, use the `items` field to select which keys to include and optionally rename them.Q3: A pod has envFrom: - secretRef: name: my-secret. The Secret has a key MY_SECRET_VALUE. Another env var in the same pod is also named MY_SECRET_VALUE. Which one wins?
Answer
An explicitly defined `env` entry wins over `envFrom`. The order of precedence is: explicit `env` > `envFrom`. This lets you override specific values from a bulk-imported ConfigMap/Secret without modifying the source object.7.4 Immutable ConfigMaps and Secret Rotation
⏱️ ~4 min read
TL;DR: Mark ConfigMaps and Secrets as
immutable: truefor performance gains and accidental-change protection. For Secret rotation, update the Secret object and trigger a pod restart — or use volume mounts for zero-restart rotation.
Immutable ConfigMaps and Secrets
apiVersion: v1
kind: ConfigMap
metadata:
name: stable-config
immutable: true # Cannot be edited after creation
data:
APP_VERSION: "2.1.0"
FEATURE_FLAGS: "dark-mode,beta-dashboard"
apiVersion: v1
kind: Secret
metadata:
name: stable-creds
immutable: true
stringData:
API_KEY: "key-never-changes"
Benefits of immutable: true:
- Performance — kubelet stops watching the ConfigMap/Secret for changes. At scale (thousands of ConfigMaps), this reduces API server load significantly
- Safety — protects against accidental
kubectl editchanges that would silently affect running pods - Auditability — forces you to create a new version, making changes explicit
Limitation: You cannot update an immutable ConfigMap or Secret. To change values:
# Must delete and recreate
kubectl delete configmap stable-config
kubectl apply -f new-stable-config.yaml
# Or create a new versioned name:
# stable-config-v2, then update pod specs to reference it
Secret Rotation
Rotating a secret (e.g., after a password change) without downtime:
With Volume Mounts (Zero-Downtime)
# 1. Update the Secret with the new value
kubectl patch secret db-credentials \
--type='json' \
-p='[{"op":"replace","path":"/data/password","value":"'$(echo -n "newpassword" | base64)'"}]'
# Or with stringData:
kubectl create secret generic db-credentials \
--from-literal=password=newpassword \
--dry-run=client -o yaml | kubectl apply -f -
# 2. Wait for kubelet to propagate the update to volume-mounted files (~60s)
# Your app must re-read the file on each use (not cache it at startup!)
If your app re-reads the mounted file on each request (or periodically), rotation is zero-downtime.
With Env Vars (Requires Rolling Restart)
# 1. Update the Secret
kubectl create secret generic db-credentials \
--from-literal=password=newpassword \
--dry-run=client -o yaml | kubectl apply -f -
# 2. Trigger a rolling restart so pods pick up new env vars
kubectl rollout restart deployment/my-app
kubectl rollout status deployment/my-app
Versioned ConfigMaps Pattern
A practical pattern for config changes that need explicit pod rollouts:
# Create a new versioned ConfigMap
kubectl create configmap app-config-v2 \
--from-literal=LOG_LEVEL=debug \
--from-literal=MAX_CONNECTIONS=200
# Update the Deployment to reference the new ConfigMap
# (edit deployment.yaml and change configMapKeyRef.name to app-config-v2)
kubectl apply -f deployment.yaml
# The rolling update ensures pods only use one config version at a time
# Old pods: app-config-v1 | New pods: app-config-v2
# After confirming the rollout is healthy, delete the old ConfigMap
kubectl delete configmap app-config-v1
This pattern ensures:
- Rollback is possible (old ConfigMap still exists during rollout)
- Exact config state is versioned and auditable
- No ambiguity about which pods use which config
Try It
# Create an immutable ConfigMap
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: fixed-config
immutable: true
data:
REGION: "us-east-1"
CLUSTER_NAME: "prod-cluster"
EOF
# Try to edit it — should fail
kubectl patch configmap fixed-config \
--type='json' \
-p='[{"op":"replace","path":"/data/REGION","value":"eu-west-1"}]'
# Error: configmap "fixed-config" is immutable
# Must delete and recreate to change
kubectl delete configmap fixed-config
# Cleanup
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | immutable: true | Prevents accidental changes; improves kubelet performance |
| 2 | Volume mount rotation | Update Secret → kubelet propagates within ~60s (zero restart) |
| 3 | Env var rotation | Requires pod restart to pick up new values |
| 4 | Versioned ConfigMaps | Explicit rotation pattern with rollback capability |
✅ Quick Check
Q1: You have 10,000 ConfigMaps in a large cluster. How does immutable: true help?
Answer
Kubelet stops watching immutable ConfigMaps for changes. Normally, kubelet subscribes to change events for every ConfigMap used by pods it manages. At 10,000 ConfigMaps, this creates significant API server and network overhead. Marking them immutable eliminates this watch overhead entirely.Q2: Your app caches the database password in memory at startup (reads env var once). You rotate the Secret and restart pods. Is there a window where old password is used?
Answer
Yes, but minimally — only during the rolling restart. With `maxUnavailable: 0`, old pods (using old password) stay running until new pods (using new password) are Ready. Since the database accepts both passwords during a rotation window (typically possible), the transition is seamless. Always coordinate with your DB rotation window.Q3: Can you make a Secret immutable after it was created as mutable?
Answer
Yes — you can patch an existing Secret to add `immutable: true`. But once set, you cannot remove or change `immutable: true` — you'd need to delete and recreate the Secret. It's a one-way door.Lab: Configure a 12-Factor App
⏱️ ~25 min hands-on
| Prerequisites | Chapter 7 sections 7.1–7.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Deploy a realistic multi-tier app with config/secrets injected via env vars and volume mounts, observe live config updates, and simulate secret rotation |
Objectives
- Create ConfigMaps for app config and an nginx config file
- Create Secrets for database credentials and an API key
- Deploy an app that reads from both env vars and mounted files
- Update a ConfigMap and observe live propagation via volume mount
- Rotate a Secret and trigger a rolling restart
- Debug a missing ConfigMap reference
Setup
kubectl create namespace config-lab
kubectl config set-context --current --namespace=config-lab
Exercise 1: Create ConfigMaps
What we’re doing: Create two ConfigMaps — one for simple key-value config, one for a config file.
# ConfigMap 1: App settings
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: config-lab
data:
APP_ENV: "production"
LOG_LEVEL: "info"
MAX_CONNECTIONS: "50"
CACHE_TTL: "300"
ALLOWED_HOSTS: "myapp.local,localhost"
EOF
# ConfigMap 2: NGINX config file (to be mounted as a file)
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: config-lab
data:
default.conf: |
server {
listen 80;
server_name _;
add_header X-Config-Source "ConfigMap" always;
add_header X-App-Env "$APP_ENV" always;
location /health {
return 200 "OK\n";
add_header Content-Type text/plain;
}
location / {
root /usr/share/nginx/html;
index index.html;
}
}
EOF
kubectl get configmap -n config-lab
Exercise 2: Create Secrets
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: config-lab
type: Opaque
stringData:
DB_HOST: "postgres-svc.config-lab.svc.cluster.local"
DB_USER: "appuser"
DB_PASSWORD: "InitialPassword123!"
DB_NAME: "appdb"
---
apiVersion: v1
kind: Secret
metadata:
name: api-keys
namespace: config-lab
type: Opaque
stringData:
STRIPE_SECRET_KEY: "sk_test_abcdefghijklmnopqrstuvwx"
SENDGRID_API_KEY: "SG.xxxxxxxxxxxxxxxx"
JWT_SECRET: "my-jwt-signing-secret-minimum-32-chars-long"
EOF
# Verify Secrets exist (values are hidden)
kubectl get secrets -n config-lab
# Decode to verify (this is what an attacker would do too)
kubectl get secret db-credentials -n config-lab \
-o jsonpath='{.data.DB_PASSWORD}' | base64 --decode
echo ""
Exercise 3: Deploy the App
What we’re doing: Deploy nginx with config injected via both env vars and volume mounts.
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
namespace: config-lab
spec:
replicas: 2
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
volumes:
# ConfigMap as a file (nginx config)
- name: nginx-conf-vol
configMap:
name: nginx-config
# Secret as files (credentials)
- name: secret-vol
secret:
secretName: db-credentials
defaultMode: 0400
containers:
- name: nginx
image: nginx:1.25
ports:
- containerPort: 80
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
# Env vars from ConfigMap (simple values)
envFrom:
- configMapRef:
name: app-config
# Individual Secret values as env vars
env:
- name: STRIPE_KEY
valueFrom:
secretKeyRef:
name: api-keys
key: STRIPE_SECRET_KEY
volumeMounts:
# Mount nginx config file
- name: nginx-conf-vol
mountPath: /etc/nginx/conf.d
readOnly: true
# Mount credentials as files
- name: secret-vol
mountPath: /etc/secrets
readOnly: true
EOF
kubectl rollout status deployment/webapp -n config-lab
Verify configuration was injected:
POD=$(kubectl get pods -n config-lab -l app=webapp -o name | head -1)
echo "=== Environment Variables from ConfigMap ==="
kubectl exec -n config-lab $POD -- env | grep -E "APP_ENV|LOG_LEVEL|MAX_CONN|CACHE_TTL"
echo ""
echo "=== Secret value as env var ==="
kubectl exec -n config-lab $POD -- sh -c 'echo "STRIPE_KEY starts with: ${STRIPE_KEY:0:10}..."'
echo ""
echo "=== Volume-mounted nginx config ==="
kubectl exec -n config-lab $POD -- cat /etc/nginx/conf.d/default.conf
echo ""
echo "=== Volume-mounted secret files ==="
kubectl exec -n config-lab $POD -- ls -la /etc/secrets/
kubectl exec -n config-lab $POD -- cat /etc/secrets/DB_USER
echo ""
Expected output (partial):
=== Environment Variables from ConfigMap ===
APP_ENV=production
LOG_LEVEL=info
MAX_CONNECTIONS=50
CACHE_TTL=300
=== Volume-mounted nginx config ===
server {
listen 80;
...
add_header X-Config-Source "ConfigMap" always;
...
=== Volume-mounted secret files ===
-r-------- 1 root root 11 DB_HOST
-r-------- 1 root root 7 DB_USER
-r-------- 1 root root 19 DB_PASSWORD
-r-------- 1 root root 5 DB_NAME
Exercise 4: Live Config Update via Volume Mount
What we’re doing: Update the nginx ConfigMap and watch the file change inside the container automatically.
# Record the current config file's last-modified time inside the pod
POD=$(kubectl get pods -n config-lab -l app=webapp -o name | head -1)
kubectl exec -n config-lab $POD -- stat /etc/nginx/conf.d/default.conf | grep Modify
# Update the ConfigMap — change a header value
kubectl patch configmap nginx-config -n config-lab \
--type='merge' \
-p='{"data":{"default.conf":"server {\n listen 80;\n server_name _;\n\n add_header X-Config-Source \"ConfigMap-v2\" always;\n\n location /health {\n return 200 \"OK-v2\\n\";\n add_header Content-Type text/plain;\n }\n\n location / {\n root /usr/share/nginx/html;\n index index.html;\n }\n}\n"}}'
echo "ConfigMap updated. Waiting ~60 seconds for kubelet to propagate..."
sleep 65
# Check if the file changed (kubelet propagated the update)
kubectl exec -n config-lab $POD -- stat /etc/nginx/conf.d/default.conf | grep Modify
kubectl exec -n config-lab $POD -- grep "Config-Source" /etc/nginx/conf.d/default.conf
Expected:
# Before:
Modify: 2026-07-15 05:30:00.000000000 +0000
# After ~60 seconds:
Modify: 2026-07-15 05:31:05.000000000 +0000 ← changed!
add_header X-Config-Source "ConfigMap-v2" always; ← new value!
💡 Note: The file updated automatically without any pod restart. However, nginx doesn’t reload its config automatically — you’d need to send SIGHUP to nginx or use an init container/sidecar that watches for file changes and reloads.
Exercise 5: Secret Rotation
What we’re doing: Simulate a database password rotation.
# Current password
kubectl exec -n config-lab $POD -- cat /etc/secrets/DB_PASSWORD
echo ""
# Rotate the password
kubectl create secret generic db-credentials \
-n config-lab \
--from-literal=DB_HOST="postgres-svc.config-lab.svc.cluster.local" \
--from-literal=DB_USER="appuser" \
--from-literal=DB_PASSWORD="RotatedPassword456!" \
--from-literal=DB_NAME="appdb" \
--dry-run=client -o yaml | kubectl apply -f -
echo "Secret updated. Waiting ~60 seconds for kubelet propagation..."
sleep 65
# New password is live in the file — no pod restart needed
kubectl exec -n config-lab $POD -- cat /etc/secrets/DB_PASSWORD
echo ""
# However — the env var DB_PASSWORD is NOT updated (if it were set via env)
# Env vars are static: compare with STRIPE_KEY (set via env, not file):
kubectl exec -n config-lab $POD -- env | grep STRIPE_KEY
Expected:
# Before rotation:
InitialPassword123!
# After ~60 seconds:
RotatedPassword456! ← updated via volume mount, no restart needed
Exercise 6: Debug a Missing ConfigMap
What we’re doing: Create a pod that references a non-existent ConfigMap and diagnose the failure.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: missing-config-pod
namespace: config-lab
spec:
containers:
- name: app
image: nginx:1.25
envFrom:
- configMapRef:
name: this-configmap-does-not-exist # ← doesn't exist
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
# Pod stays in Pending / CreateContainerConfigError
kubectl get pod missing-config-pod -n config-lab
# Describe to see the error
kubectl describe pod missing-config-pod -n config-lab | tail -15
Expected describe output (Events):
Warning Failed Error: configmap "this-configmap-does-not-exist" not found
Fix it:
# Create the missing ConfigMap
kubectl create configmap this-configmap-does-not-exist \
-n config-lab \
--from-literal=PLACEHOLDER=true
# Pod should start now (K8s retries)
kubectl get pod missing-config-pod -n config-lab -w
🔥 Break It! Challenge
What happens when a ConfigMap is deleted while pods are running?
# Create a pod with a volume-mounted ConfigMap
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: mounted-cm-pod
namespace: config-lab
spec:
volumes:
- name: cfg
configMap:
name: app-config
containers:
- name: app
image: busybox
command: ["sh", "-c", "while true; do cat /cfg/LOG_LEVEL; sleep 5; done"]
volumeMounts:
- name: cfg
mountPath: /cfg
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod mounted-cm-pod -n config-lab --for=condition=Ready --timeout=30s
kubectl logs mounted-cm-pod -n config-lab
# Delete the ConfigMap while the pod is running
kubectl delete configmap app-config -n config-lab
# Check what happens to the pod and the files
sleep 5
kubectl get pod mounted-cm-pod -n config-lab # Pod still running!
kubectl exec -n config-lab mounted-cm-pod -- cat /cfg/LOG_LEVEL # Still readable!
# Key insight: deleting a ConfigMap doesn't kill running pods
# The mounted files remain accessible until the pod is restarted
The lesson: Deleting a ConfigMap doesn’t immediately affect running pods that have already mounted it. The files remain cached by kubelet. However, if the pod restarts, it will fail to start — it can’t mount a ConfigMap that no longer exists.
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace config-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Create ConfigMaps and Secrets | Both deployed successfully with kubectl |
| 2 | Inject as env vars | env and envFrom blocks populated correctly |
| 3 | Mount as files | Nginx config and credentials visible as files |
| 4 | Live config update | ConfigMap file updated in ~60s without pod restart |
| 5 | Secret rotation | Password changed via volume mount, no restart needed |
| 6 | Missing ConfigMap debugging | Found configmap not found error via describe |
| 7 | Delete while running | Running pods keep working; restart would fail |
Chapter 8: Persistent Storage — Volumes, PVs, and PVCs
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Mount persistent storage to pods, understand the PV/PVC/StorageClass model, use dynamic provisioning on Minikube, and design stateful workloads that survive pod restarts.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 8.1 | The Storage Problem and Volume Types | ~5 min |
| 8.2 | PersistentVolumes and PersistentVolumeClaims | ~7 min |
| 8.3 | StorageClasses and Dynamic Provisioning | ~6 min |
| 8.4 | Access Modes and Reclaim Policies | ~5 min |
| 8.5 | 🔬 Lab: Stateful App with Persistent Storage | ~30 min |
Prerequisites
- Completed Chapters 3 and 4 (Pods and Workloads)
minikube statusshowsRunning
8.1 The Storage Problem and Volume Types
⏱️ ~5 min read
TL;DR: Container filesystems are ephemeral — they vanish when the container restarts. Kubernetes Volumes solve this by attaching external storage to pods. There are many volume types; knowing which to use when is the skill.
The Ephemeral Filesystem Problem
graph LR
P1["Pod (nginx)\nWrites /data/uploads/\n10,000 images"] -->|Pod crashes| DEAD[💀]
NEW["New Pod (nginx)\nFresh container\nFilesystem empty"] -->|"Where's\n/data/uploads?"| Q[❓ Gone forever]
Any data written inside a container lives only as long as the container lives. This is by design — it makes containers stateless and reproducible. But databases, file storage, and caches need persistence.
🔗 Docker Parallel: In Docker/Compose, you use bind mounts (
./data:/data) or named volumes (myvolume:/data). Kubernetes has equivalents —hostPath(like bind mount) and PersistentVolumes (like named volumes, but cluster-managed).
Volume Types — The Menu
Kubernetes supports many volume types. Here are the ones you’ll actually use:
| Volume Type | What It Is | Use Case |
|---|---|---|
emptyDir | Temporary dir, lives with the pod | Sharing data between containers in a pod (sidecar, init) |
hostPath | Mounts a directory from the node | DaemonSets, single-node dev — avoid in production |
configMap / secret | K8s objects as files | Inject config and credentials (Chapter 7) |
persistentVolumeClaim | Dynamically provisioned storage | Databases, file stores — the standard production approach |
nfs | Network File System | Shared read-write storage across nodes |
csi | Container Storage Interface plugin | Cloud block storage (AWS EBS, GCP PD, Azure Disk) |
emptyDir — Ephemeral Shared Storage
You’ve already seen this in Chapter 3 (sidecar pattern). Quick recap:
spec:
volumes:
- name: shared-data
emptyDir: {} # Created when pod starts, deleted when pod dies
# emptyDir:
# medium: Memory # Store in RAM instead of disk (faster, uses node RAM)
# sizeLimit: 500Mi # Limit how much storage it can use
containers:
- name: writer
volumeMounts:
- name: shared-data
mountPath: /data
- name: reader
volumeMounts:
- name: shared-data
mountPath: /shared
emptyDir survives container restarts within the same pod — but is deleted if the pod is rescheduled to another node.
hostPath — Node Filesystem Mount
spec:
volumes:
- name: node-logs
hostPath:
path: /var/log # Actual directory on the node
type: Directory # Must exist | DirectoryOrCreate | File | Socket
containers:
- name: log-reader
volumeMounts:
- name: node-logs
mountPath: /logs
⚠️ Warning:
hostPathvolumes are tied to a specific node. If the pod is rescheduled to a different node, it sees a different (empty or different-content) directory. Only use for DaemonSets (always on the same node) or single-node Minikube development.
The Standard Path: PersistentVolumeClaims
For anything beyond ephemeral sharing or DaemonSets, use PersistentVolumeClaims (PVCs). The PVC abstracts the actual storage backend — whether it’s an EBS volume, NFS server, or Minikube’s local storage:
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: my-pvc # Reference a PVC by name
containers:
- name: database
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
The PVC is covered in detail in the next section.
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Container filesystem is ephemeral | Data gone when container restarts |
| 2 | emptyDir = pod-lifetime storage | Survives container restart, not pod rescheduling |
| 3 | hostPath = node directory | Use only for DaemonSets or single-node dev |
| 4 | PVC = the production answer | Abstracts cloud/NFS/local storage behind a claim |
✅ Quick Check
Q1: An nginx pod writes uploaded files to /tmp/uploads. The pod crashes and is restarted by the ReplicaSet controller. Are the uploaded files still there?
Answer
No. `/tmp/uploads` is part of the container's writable layer — a plain directory in the container's ephemeral filesystem. When the container restarts, it gets a fresh filesystem from the image. All uploaded files are gone. To persist them, mount a PersistentVolumeClaim at `/tmp/uploads`.Q2: Two containers in the same pod need to share files. Which volume type is appropriate?
Answer
`emptyDir`. It's created when the pod starts and shared between all containers that mount it. It's the canonical way to share files between sidecar containers. Use `medium: Memory` if you need very fast shared storage (at the cost of node RAM).Q3: You have a DaemonSet that collects logs from /var/log on each node. Which volume type do you use?
Answer
`hostPath`. DaemonSets run exactly one pod per node, so there's no risk of the pod being rescheduled to a different node. `hostPath` is the correct and standard choice for DaemonSets that need access to the node's local filesystem (logs, container runtime sockets, etc.).8.2 PersistentVolumes and PersistentVolumeClaims
⏱️ ~7 min read
TL;DR: A PersistentVolume (PV) is a piece of actual storage provisioned by an admin. A PersistentVolumeClaim (PVC) is a pod’s request for storage. Kubernetes binds PVCs to PVs automatically. In practice, StorageClasses (next section) eliminate the need to create PVs manually.
The Three-Layer Model
graph TB
DEV["Developer\nPod + PVC\n(requests 5Gi)"]
OPS["Admin\nPersistentVolume\n(provisions 10Gi EBS volume)"]
K8S["Kubernetes\nBinding\n(PVC → PV)"]
DEV -->|creates| PVC[PersistentVolumeClaim\nsize: 5Gi\naccessMode: ReadWriteOnce]
OPS -->|creates| PV[PersistentVolume\ncapacity: 10Gi\naccessMode: ReadWriteOnce\nAWS EBS vol-xxxxx]
K8S -->|binds| BIND[PVC bound to PV\nPod gets the storage]
PVC --> BIND
PV --> BIND
- PV = the actual storage resource (an EBS volume, NFS export, etc.)
- PVC = a request for storage, written by the developer
- Binding = Kubernetes matches a PVC to a compatible PV
🔗 Docker Parallel: A PV is like a named Docker volume that exists on the host. A PVC is like a container’s
volumes:request for that named volume. The difference: Kubernetes manages the matching automatically.
PersistentVolume (Manual Provisioning)
Admins create PVs to represent physical storage:
# pv.yaml — Admin-managed
apiVersion: v1
kind: PersistentVolume
metadata:
name: postgres-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce # One node can mount read-write
persistentVolumeReclaimPolicy: Retain # Keep data after PVC deleted
storageClassName: manual # Label for matching with PVCs
# The actual storage backend:
hostPath: # Minikube dev only
path: /mnt/postgres-data
# In production, this would be:
# awsElasticBlockStore:
# volumeID: vol-0abcd1234
# fsType: ext4
PersistentVolumeClaim (Developer-Facing)
Pods don’t reference PVs directly — they reference PVCs:
# pvc.yaml — Developer-managed
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual # Must match a PV's storageClassName
resources:
requests:
storage: 5Gi # Request 5Gi from the pool of PVs
kubectl apply -f pv.yaml
kubectl apply -f pvc.yaml
# Kubernetes binds them automatically
kubectl get pv,pvc
Expected output:
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
persistentvolume/postgres-pv 10Gi RWO Retain Bound default/postgres-pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES
persistentvolumeclaim/postgres-pvc Bound postgres-pv 10Gi RWO
The PVC requested 5Gi but was bound to a 10Gi PV — PVs can be larger than the claim, but not smaller.
Using the PVC in a Pod
# pod-with-pvc.yaml
apiVersion: v1
kind: Pod
metadata:
name: postgres
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-pvc # Reference the PVC by name
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_PASSWORD
value: "password"
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
PVC Lifecycle States
stateDiagram-v2
[*] --> Pending : PVC created\nno matching PV yet
Pending --> Bound : K8s found a matching PV
Bound --> Released : Pod deleted PVC deleted\nPV still has data (Retain policy)
Released --> Available : Admin manually cleans data
Available --> Bound : New PVC can bind
Bound --> Lost : PV backend becomes unavailable
# Check PVC status
kubectl get pvc postgres-pvc
# STATUS column:
# Pending → waiting for a PV
# Bound → matched and ready to use
# Lost → the underlying PV disappeared
When PVC is Pending
The most common issue: a PVC stays Pending forever.
# Find out why
kubectl describe pvc my-pvc | grep -A10 "Events:"
Typical causes:
- No PV with matching
storageClassNameexists - No PV with sufficient capacity (PVC requests 10Gi, only 5Gi PVs available)
- No PV with matching
accessModes - StorageClass doesn’t exist or its provisioner isn’t running
Try It
# Create a PV backed by hostPath (Minikube only)
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: demo-pv
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Delete
storageClassName: manual
hostPath:
path: /tmp/demo-pv-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: demo-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual
resources:
requests:
storage: 500Mi
EOF
# Check binding
kubectl get pv,pvc
# Use it in a pod
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: pvc-writer
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: demo-pvc
containers:
- name: writer
image: busybox
command: ["sh", "-c", "echo 'Hello from PVC!' > /data/test.txt && sleep 3600"]
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod pvc-writer --for=condition=Ready --timeout=30s
kubectl exec pvc-writer -- cat /data/test.txt
# Delete the pod — data survives!
kubectl delete pod pvc-writer
# Recreate — data still there
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: pvc-reader
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: demo-pvc
containers:
- name: reader
image: busybox
command: ["sh", "-c", "cat /data/test.txt && sleep 60"]
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod pvc-reader --for=condition=Ready --timeout=30s
kubectl logs pvc-reader # "Hello from PVC!" — data survived pod deletion!
# Cleanup
kubectl delete pod pvc-reader
kubectl delete pvc demo-pvc
kubectl delete pv demo-pv
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | PV = physical storage | Admin-provisioned; represents real disk |
| 2 | PVC = storage request | Developer-written; K8s binds it to a PV |
| 3 | Data survives pod deletion | The PV/PVC live independently of pods |
| 4 | PVC Pending = no matching PV | Check storageClass, capacity, and accessMode |
✅ Quick Check
Q1: A PVC requests 500Mi but the only available PV has 1Gi. Is the binding successful?
Answer
Yes — a PV can satisfy a PVC as long as the PV's capacity is **equal or greater** than what the PVC requests, and the other constraints (storageClassName, accessModes) also match. The PVC gets bound to the 1Gi PV, and the extra 500Mi is "wasted" (not usable by other PVCs since the PV is now bound).Q2: You delete a pod that uses a PVC. Is the PVC deleted?
Answer
No. PVCs and pods have independent lifecycles. Deleting a pod doesn't delete its PVC, and the underlying PV data is preserved. You must explicitly delete the PVC with `kubectl delete pvc my-pvc`. This prevents accidental data loss.Q3: Two pods in the same namespace try to use the same PVC with accessMode: ReadWriteOnce. What happens?
Answer
Only one pod can use a `ReadWriteOnce` PVC at a time — from a single node. If both pods are scheduled to the same node, both can mount it. If they're on different nodes, the second pod will fail to mount it. For multi-node multi-pod access, you need `ReadWriteMany` (requires NFS or compatible storage backend).8.3 StorageClasses and Dynamic Provisioning
⏱️ ~6 min read
TL;DR: Instead of admins creating PVs by hand, a StorageClass tells Kubernetes how to automatically provision storage on demand. When a PVC is created, the StorageClass’s provisioner creates the backing volume automatically. This is how every major cloud Kubernetes offering works.
The Problem with Manual PVs
Manual provisioning doesn’t scale:
- Admin creates 10 PVs → developer creates 10 PVCs → bindings happen
- If a PVC needs 7Gi but only 5Gi PVs exist → stuck
- Admin must predict future storage needs
- Every cloud environment needs different PV specs
StorageClasses solve this with on-demand provisioning.
StorageClass YAML
# storageclass.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: kubernetes.io/aws-ebs # Which plugin creates the volumes
parameters:
type: gp3 # Storage type (provider-specific)
fsType: ext4
encrypted: "true"
reclaimPolicy: Delete # Delete volume when PVC is deleted
allowVolumeExpansion: true # Allow PVCs to grow (not shrink)
volumeBindingMode: WaitForFirstConsumer # Create volume only when pod is scheduled
Popular provisioners:
| Cloud | Provisioner | Storage Type |
|---|---|---|
| AWS EKS | ebs.csi.aws.com | gp3/io2 EBS volumes |
| GKE | pd.csi.storage.gke.io | Standard/SSD Persistent Disks |
| AKS | disk.csi.azure.com | Azure Managed Disks |
| Minikube | k8s.io/minikube-hostpath | Local hostPath (dev only) |
| On-premise | rancher.io/local-path | Node-local storage |
How Dynamic Provisioning Works
sequenceDiagram
participant D as Developer
participant K as Kubernetes
participant P as Cloud Provisioner
participant S as Cloud Storage
D->>K: Create PVC (5Gi, storageClass: fast-ssd)
K->>P: "Someone needs 5Gi of fast-ssd"
P->>S: Create EBS volume (5Gi, gp3)
S-->>P: Volume ID: vol-abc123
P->>K: Create PV (vol-abc123, 5Gi)
K->>K: Bind PVC → PV
K-->>D: PVC is Bound ✅
The developer writes a 5-line PVC YAML. Everything else is automatic.
Default StorageClass
Most clusters have a default StorageClass. If a PVC omits storageClassName, it uses the default:
# See StorageClasses in your cluster
kubectl get storageclass
# On Minikube:
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
# standard (default) k8s.io/minikube-hostpath Delete Immediate
The (default) annotation means PVCs without a storageClassName use this class automatically.
# See which StorageClass is marked default
kubectl get storageclass -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}'
Dynamic Provisioning in Practice
With dynamic provisioning, you only write the PVC — no PV needed:
# dynamic-pvc.yaml — No PV required, StorageClass handles it
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: database-storage
spec:
accessModes:
- ReadWriteOnce
storageClassName: standard # Minikube default (or fast-ssd in cloud)
resources:
requests:
storage: 5Gi
kubectl apply -f dynamic-pvc.yaml
# A PV is automatically created and bound
kubectl get pv,pvc
volumeBindingMode: WaitForFirstConsumer
A critical setting for cloud clusters:
volumeBindingMode: Immediate # Create volume NOW when PVC is created
volumeBindingMode: WaitForFirstConsumer # Create volume only when a pod using the PVC is scheduled
Why WaitForFirstConsumer matters:
Cloud block storage (EBS, Azure Disk) is zone-specific. If the volume is created in us-east-1a but your pod gets scheduled to us-east-1b, the pod can’t attach the volume.
With WaitForFirstConsumer, Kubernetes waits until it knows which node (and zone) the pod will run on, then creates the volume in that same zone.
🏭 In Production: Always use
WaitForFirstConsumerfor cloud block storage StorageClasses.Immediatecan cause unschedulable pods in multi-zone clusters.
Try It
# See Minikube's default StorageClass
kubectl get storageclass
# Create a PVC without specifying storageClassName (uses default)
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dynamic-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 256Mi
EOF
# A PV was created automatically!
kubectl get pv,pvc
# See the auto-created PV details
kubectl describe pvc dynamic-pvc | grep -E "StorageClass|Volume:|Capacity"
# Use it
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: dynamic-test
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: dynamic-pvc
containers:
- name: app
image: busybox
command: ["sh", "-c", "echo 'Dynamic provisioning works!' > /data/test.txt && sleep 60"]
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod dynamic-test --for=condition=Ready --timeout=30s
kubectl exec dynamic-test -- cat /data/test.txt
# Cleanup
kubectl delete pod dynamic-test
kubectl delete pvc dynamic-pvc
# PV is deleted automatically (reclaimPolicy: Delete)
kubectl get pv # Should be gone
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | StorageClass = provisioner config | Tells K8s HOW to create volumes on demand |
| 2 | Dynamic provisioning | PVC created → storage provisioned automatically |
| 3 | Default StorageClass | PVCs without storageClassName use it |
| 4 | WaitForFirstConsumer | Prevents zone-mismatch issues in multi-zone clouds |
✅ Quick Check
Q1: You delete a PVC backed by a StorageClass with reclaimPolicy: Delete. What happens to the data?
Answer
The underlying PersistentVolume and the actual storage (e.g., EBS volume) are deleted automatically. The data is gone. This is why `Delete` is the default for cloud storage — it prevents orphaned (and paid-for) volumes. Use `reclaimPolicy: Retain` if you want to preserve data after a PVC is deleted.Q2: A PVC in Pending state has storageClassName: fast-ssd. No StorageClass named fast-ssd exists. Will Kubernetes use the default StorageClass?
Answer
No. When you explicitly specify `storageClassName`, Kubernetes looks only for that specific class. If it doesn't exist, the PVC stays in `Pending` indefinitely. The default StorageClass is only used when `storageClassName` is omitted entirely (or set to `""`).Q3: Can you resize a PVC from 5Gi to 10Gi after it’s bound?
Answer
Only if the StorageClass has `allowVolumeExpansion: true`. Edit the PVC's `resources.requests.storage` to the new size — Kubernetes will expand the underlying volume. You may need to restart the pod for the filesystem resize to take effect inside the container. Note: shrinking PVCs is not supported.8.4 Access Modes and Reclaim Policies
⏱️ ~5 min read
TL;DR: Access modes define how many nodes can mount a volume simultaneously. Reclaim policies define what happens to the underlying storage when a PVC is deleted. Getting these right prevents both data loss and runaway cloud costs.
Access Modes
| Mode | Short | Meaning | Storage Type |
|---|---|---|---|
ReadWriteOnce | RWO | One node can mount read-write | Cloud block storage (EBS, Azure Disk) |
ReadOnlyMany | ROX | Multiple nodes can mount read-only | S3-backed, shared config |
ReadWriteMany | RWX | Multiple nodes can mount read-write | NFS, CephFS, Azure Files |
ReadWriteOncePod | RWOP | One pod (not just one node) read-write | High-security single-writer scenarios |
graph TB
subgraph "ReadWriteOnce (RWO)"
N1[Node 1\nMounts RW] --> V1[(Volume)]
N2[Node 2\n❌ Cannot mount] -.->|blocked| V1
end
subgraph "ReadWriteMany (RWX)"
N3[Node 1\nMounts RW] --> V2[(NFS Volume)]
N4[Node 2\nMounts RW] --> V2
N5[Node 3\nMounts RW] --> V2
end
The practical reality:
ReadWriteOnce— works for almost all databases and single-replica appsReadWriteMany— needed only for shared file storage across multiple pods on different nodes; requires NFS or cloud file storage- Most cloud block storage (EBS, Azure Disk, GCP PD) is RWO only
spec:
accessModes:
- ReadWriteOnce # Most common — put in a list even for a single mode
What Happens with Multiple Pods on the Same Node
ReadWriteOnce is per-node, not per-pod. Multiple pods on the same node can all mount the same RWO volume:
Node 1:
pod-a → /data (mounted RWO) ✅
pod-b → /data (same volume, same node) ✅
Node 2:
pod-c → /data (different node) ❌ blocked
This is why StatefulSets use volumeClaimTemplates — each pod gets its OWN PVC. They don’t share a single RWO volume across nodes.
Reclaim Policies
When a PVC is deleted, what happens to the PV and its data?
| Policy | Behavior | Use Case |
|---|---|---|
Delete | PV and underlying storage deleted | Default for dynamically provisioned; avoids orphaned cloud volumes |
Retain | PV stays (in Released state); data preserved | Production databases; manual cleanup required |
Recycle | Deprecated — basic scrub then make available again | Don’t use |
# In PV spec (for manually created PVs):
spec:
persistentVolumeReclaimPolicy: Retain
# In StorageClass (for dynamically provisioned):
reclaimPolicy: Delete
Choosing the Right Policy
graph TD
Q{Is this data\ncritical / irreplaceable?}
Q -->|Yes| R[Retain\n+ automated backup]
Q -->|No or easily recreated| D[Delete]
R --> RA[Production databases\nUser uploads\nTransaction logs]
D --> DA[Caches\nTemp processing\nTest data]
⚠️ Warning:
reclaimPolicy: Deletewith a production database is a common disaster scenario. A developer runskubectl delete pvc prod-db-pvcthinking it’s a dev environment — the EBS volume and all its data are gone instantly, with no recycle bin. Always useRetainfor production databases.
The Retain Workflow (After PVC Deletion)
With Retain, the PV enters Released state. To reuse it:
# 1. PVC was deleted — PV is now Released
kubectl get pv
# STATUS: Released
# 2. Recover data (copy off the volume before doing anything)
# 3. To allow a new PVC to bind this PV:
kubectl patch pv my-pv -p '{"spec":{"claimRef":null}}'
# Now the PV is Available again
# 4. Create a new PVC that matches this PV's storageClass and capacity
In practice, most teams use Retain + a separate backup strategy (Velero, pg_dump, etc.) rather than trying to rebind old PVs.
Checking PV Status
# Full PV status overview
kubectl get pv
# Detailed info on a specific PV
kubectl describe pv my-pv
# Check which PVC is bound to a PV
kubectl get pv -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\t"}{.spec.claimRef.name}{"\n"}{end}'
PV Status phases:
| Status | Meaning |
|---|---|
Available | Ready to be claimed |
Bound | Claimed by a PVC |
Released | PVC deleted; data still exists (Retain policy) |
Failed | Automatic reclamation failed |
Try It
# Create a PV with Retain policy
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: retain-demo-pv
spec:
capacity:
storage: 100Mi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath:
path: /tmp/retain-demo
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: retain-demo-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: manual
resources:
requests:
storage: 100Mi
EOF
kubectl get pv,pvc
# Write data
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: retain-writer
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: retain-demo-pvc
containers:
- name: w
image: busybox
command: ["sh", "-c", "echo 'Important data!' > /data/important.txt && sleep 10"]
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod retain-writer --for=condition=Ready --timeout=30s
kubectl exec retain-writer -- cat /data/important.txt
kubectl delete pod retain-writer
# Delete the PVC
kubectl delete pvc retain-demo-pvc
# PV is now Released — NOT deleted
kubectl get pv retain-demo-pv
# STATUS: Released ← data preserved on disk
# The actual data is still on the node
minikube ssh "cat /tmp/retain-demo/important.txt"
# Cleanup
kubectl delete pv retain-demo-pv
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | RWO = one node at a time | Most cloud block storage; fine for single-replica apps |
| 2 | RWX = multi-node shared | Requires NFS/CephFS; for multi-replica shared storage |
| 3 | Delete policy = risky | Default; auto-deletes data when PVC deleted |
| 4 | Retain for production | Data survives PVC deletion; manual cleanup required |
✅ Quick Check
Q1: A Deployment with 3 replicas uses a single PVC with accessMode: ReadWriteOnce. All 3 pods are on the same node. Does this work?
Answer
Yes — RWO is per-node, not per-pod. Multiple pods on the same node can all mount the same RWO volume simultaneously. However, if the Deployment ever scales or reschedules pods to other nodes, those pods will fail to mount the volume. For multi-node deployments that need shared storage, use RWX.Q2: You have reclaimPolicy: Delete on your production PostgreSQL PVC. A junior developer runs kubectl delete pvc postgres-data. What’s the damage?
Answer
Total data loss. With `reclaimPolicy: Delete`, the EBS volume (or other backing storage) is permanently deleted the moment the PVC is deleted. There is no undo. This is one of the most common and catastrophic mistakes in Kubernetes operations. Solution: use `Retain` policy and enable RBAC to prevent unauthorized PVC deletion.Q3: Can you change a PV’s reclaimPolicy after it’s created?
Answer
Yes — patch the PV: `kubectl patch pv my-pv -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'`. This is useful when you realize a production PV was accidentally created with `Delete` policy — fix it before any PVC deletions happen.Lab: Stateful App with Persistent Storage
⏱️ ~30 min hands-on
| Prerequisites | Chapter 8 sections 8.1–8.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Deploy PostgreSQL with a PVC, prove data persists across pod restarts, use a StatefulSet with volumeClaimTemplates, and observe what happens when storage is deleted |
Objectives
- Create a PVC using dynamic provisioning (Minikube default StorageClass)
- Deploy PostgreSQL using that PVC
- Write data to the database and restart the pod — prove data survives
- Deploy a StatefulSet with
volumeClaimTemplates(one PVC per pod) - Observe PVC lifecycle: what happens when the StatefulSet is deleted
- Debug a pod stuck due to a missing PVC
Setup
kubectl create namespace storage-lab
kubectl config set-context --current --namespace=storage-lab
Exercise 1: Dynamic PVC for PostgreSQL
What we’re doing: Create storage dynamically and deploy PostgreSQL against it.
# Step 1: Create a PVC (StorageClass not specified → uses default)
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: storage-lab
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 256Mi
EOF
# Check the PVC — starts Pending, then Bound
kubectl get pvc postgres-data -n storage-lab -w
Expected progression:
NAME STATUS VOLUME CAPACITY ACCESS MODES
postgres-data Pending ← before pod
postgres-data Bound pvc-xxx 256Mi RWO ← after pod scheduled
📝 Note: On Minikube with
Immediatebinding mode, the PVC may bind immediately. On cloud clusters withWaitForFirstConsumer, it staysPendinguntil a pod is scheduled.
# Step 2: Deploy PostgreSQL
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: storage-lab
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: postgres-data
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: "appdb"
- name: POSTGRES_USER
value: "appuser"
- name: POSTGRES_PASSWORD
value: "labpassword"
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
namespace: storage-lab
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
EOF
kubectl rollout status deployment/postgres -n storage-lab
Exercise 2: Write Data, Restart Pod, Verify Persistence
What we’re doing: Prove that data in a PVC-backed PostgreSQL survives pod restarts.
# Step 1: Write data to PostgreSQL
POD=$(kubectl get pods -n storage-lab -l app=postgres -o name | head -1)
kubectl exec -n storage-lab $POD -- \
psql -U appuser -d appdb -c "
CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT, created_at TIMESTAMPTZ DEFAULT NOW());
INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Carol');
SELECT * FROM users;
"
Expected output:
CREATE TABLE
INSERT 0 3
id | name | created_at
----+-------+-------------------------------
1 | Alice | 2026-07-15 05:45:00.123456+00
2 | Bob | 2026-07-15 05:45:00.123456+00
3 | Carol | 2026-07-15 05:45:00.123456+00
(3 rows)
# Step 2: Delete the pod (Deployment will recreate it)
kubectl delete pod -n storage-lab -l app=postgres
# Wait for new pod
kubectl rollout status deployment/postgres -n storage-lab
# Step 3: Query the NEW pod — data must still be there
NEW_POD=$(kubectl get pods -n storage-lab -l app=postgres -o name | head -1)
kubectl exec -n storage-lab $NEW_POD -- \
psql -U appuser -d appdb -c "SELECT * FROM users;"
Expected (same data in new pod):
id | name | created_at
----+-------+-------------------------------
1 | Alice | 2026-07-15 05:45:00.123456+00
2 | Bob | 2026-07-15 05:45:00.123456+00
3 | Carol | 2026-07-15 05:45:00.123456+00
(3 rows)
💡 What just happened? The new pod mounted the same PVC, which points to the same PersistentVolume. PostgreSQL found its data files at
/var/lib/postgresql/data/pgdataexactly where it left them. Pod identity is irrelevant — the data lives in the PV.
Exercise 3: StatefulSet with volumeClaimTemplates
What we’re doing: Deploy a StatefulSet where each pod gets its OWN PVC automatically.
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: data-store
namespace: storage-lab
spec:
serviceName: data-store
replicas: 3
selector:
matchLabels:
app: data-store
template:
metadata:
labels:
app: data-store
spec:
containers:
- name: store
image: busybox
command: ["sh", "-c", "echo \"Pod: $HOSTNAME\" > /data/identity.txt && sleep 3600"]
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "16Mi"
cpu: "50m"
volumeClaimTemplates: # ← Creates one PVC per pod
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 64Mi
EOF
kubectl rollout status statefulset/data-store -n storage-lab
Watch three separate PVCs get created:
kubectl get pvc -n storage-lab
Expected:
NAME STATUS VOLUME CAPACITY ACCESS MODES
data-data-store-0 Bound pvc-aaa 64Mi RWO ← for data-store-0
data-data-store-1 Bound pvc-bbb 64Mi RWO ← for data-store-1
data-data-store-2 Bound pvc-ccc 64Mi RWO ← for data-store-2
postgres-data Bound pvc-ddd 256Mi RWO
Verify each pod has its own isolated storage:
for i in 0 1 2; do
echo "=== data-store-$i ==="
kubectl exec -n storage-lab data-store-$i -- cat /data/identity.txt
done
Expected:
=== data-store-0 ===
Pod: data-store-0
=== data-store-1 ===
Pod: data-store-1
=== data-store-2 ===
Pod: data-store-2
Now delete and re-create data-store-1:
kubectl delete pod data-store-1 -n storage-lab
kubectl wait pod data-store-1 -n storage-lab --for=condition=Ready --timeout=60s
# It came back with the same PVC — identity preserved
kubectl exec -n storage-lab data-store-1 -- cat /data/identity.txt
# Pod: data-store-1 ← same content from the persistent volume
Exercise 4: StatefulSet Deletion and PVC Lifecycle
What we’re doing: Observe what happens to PVCs when a StatefulSet is deleted.
# Delete the StatefulSet (NOT the PVCs)
kubectl delete statefulset data-store -n storage-lab
# Check PVCs — they still exist!
kubectl get pvc -n storage-lab | grep data-store
Expected:
data-data-store-0 Bound ... ← Still there!
data-data-store-1 Bound ... ← Still there!
data-data-store-2 Bound ... ← Still there!
💡 Key insight: Deleting a StatefulSet does NOT delete its PVCs. This is intentional — it prevents accidental data loss. You must manually delete PVCs with
kubectl delete pvc. This is a common source of surprise (and saved data) in production.
# Clean up PVCs explicitly
kubectl delete pvc -n storage-lab data-data-store-0 data-data-store-1 data-data-store-2
Exercise 5: Inspect Storage Details
# See all PVs in the cluster
kubectl get pv
# Which PVC is bound to which PV?
kubectl get pv -o custom-columns=\
'NAME:.metadata.name,CAPACITY:.spec.capacity.storage,STATUS:.status.phase,CLAIM:.spec.claimRef.name,POLICY:.spec.persistentVolumeReclaimPolicy'
# See StorageClass details
kubectl describe storageclass standard # Minikube's default
# See full PVC details
kubectl describe pvc postgres-data -n storage-lab
🔥 Break It! Challenge
What happens when a pod references a PVC that doesn’t exist?
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: missing-pvc-pod
namespace: storage-lab
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: this-pvc-does-not-exist
containers:
- name: app
image: nginx:1.25
volumeMounts:
- name: data
mountPath: /data
resources:
limits:
memory: "32Mi"
cpu: "50m"
EOF
# Pod stays in Pending
kubectl get pod missing-pvc-pod -n storage-lab
# See the exact error
kubectl describe pod missing-pvc-pod -n storage-lab | tail -10
Expected Events:
Warning FailedMount Unable to attach or mount volumes: ... persistentvolumeclaim "this-pvc-does-not-exist" not found
Fix it by creating the PVC:
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: this-pvc-does-not-exist
namespace: storage-lab
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 64Mi
EOF
# Pod should start now
kubectl get pod missing-pvc-pod -n storage-lab -w
Cleanup
# Delete everything in the lab namespace
kubectl delete deployment postgres -n storage-lab
kubectl delete pvc postgres-data -n storage-lab
kubectl delete pvc this-pvc-does-not-exist -n storage-lab
kubectl delete pod missing-pvc-pod -n storage-lab
kubectl delete svc postgres-svc -n storage-lab
kubectl config set-context --current --namespace=default
kubectl delete namespace storage-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Dynamic PVC provisioning | PVC created without a pre-existing PV |
| 2 | PostgreSQL with PVC | Data written, pod deleted, data survived in new pod |
| 3 | StatefulSet PVC-per-pod | Three pods, three separate PVCs, isolated data |
| 4 | Pod identity preserved | data-store-1 got same PVC after deletion |
| 5 | PVCs outlive StatefulSet | PVCs remain after kubectl delete statefulset |
| 6 | Missing PVC debugging | Found error via describe, fixed by creating PVC |
Chapter 9: Namespaces, RBAC, and Multi-Tenancy
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Organize workloads into namespaces, control access using Roles and RoleBindings, create service accounts for pods, and design a basic multi-tenant cluster layout.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 9.1 | Namespaces — Cluster Partitioning | ~5 min |
| 9.2 | RBAC — Roles, ClusterRoles, and Bindings | ~8 min |
| 9.3 | ServiceAccounts — Pod Identities | ~6 min |
| 9.4 | Multi-Tenancy Patterns | ~5 min |
| 9.5 | 🔬 Lab: Lock Down a Namespace | ~30 min |
Prerequisites
- Comfortable with kubectl (Chapter 2)
minikube statusshowsRunning
9.1 Namespaces — Cluster Partitioning
⏱️ ~5 min read
TL;DR: A namespace is a virtual cluster inside a physical cluster. Resources in different namespaces are isolated by name but share the same underlying nodes and networking. Use namespaces to separate teams, environments, or applications.
What Namespaces Do (and Don’t) Isolate
graph TB
subgraph "Kubernetes Cluster"
subgraph "namespace: team-a"
PA1[web-deploy]
PA2[web-svc]
PA3[db-pvc]
end
subgraph "namespace: team-b"
PB1[web-deploy]
PB2[web-svc]
PB3[db-pvc]
end
subgraph "namespace: monitoring"
PM1[prometheus]
PM2[grafana]
end
N1[Node 1] & N2[Node 2]
end
Namespaces isolate:
- Resource names (two
web-svcServices can coexist in different namespaces) - RBAC policies (permissions are namespace-scoped)
- Resource quotas and limits
- Network policies (with a CNI that supports them)
Namespaces do NOT isolate:
- Nodes — all pods share the same node pool
- Node-level resources (CPU, RAM) — workloads from all namespaces compete
- Network traffic by default — cross-namespace pod communication is allowed
System Namespaces
kubectl get namespaces
Default namespaces every cluster starts with:
| Namespace | Purpose |
|---|---|
default | Where resources go if you don’t specify a namespace |
kube-system | Kubernetes system components (CoreDNS, kube-proxy, etc.) |
kube-public | Publicly readable (ConfigMap with cluster info) |
kube-node-lease | Node heartbeat objects |
⚠️ Warning: Never deploy your own applications into
kube-system. If you break something there, the entire cluster’s control plane can be affected.
Namespace-Scoped vs Cluster-Scoped Resources
Not everything lives in a namespace:
| Namespace-Scoped | Cluster-Scoped |
|---|---|
| Pods, Deployments, Services | Nodes |
| ConfigMaps, Secrets | PersistentVolumes |
| PVCs | StorageClasses |
| Roles, RoleBindings | ClusterRoles, ClusterRoleBindings |
| Ingress | IngressClass |
# Check if a resource type is namespace-scoped
kubectl api-resources --namespaced=true # Namespace-scoped
kubectl api-resources --namespaced=false # Cluster-scoped
Creating and Using Namespaces
# Create
kubectl create namespace team-a
# Declarative (preferred)
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
name: team-b
labels:
team: b
environment: production
EOF
# Switch default namespace in your context
kubectl config set-context --current --namespace=team-a
# Or use -n per command
kubectl get pods -n kube-system
# See all resources in all namespaces
kubectl get pods -A # Short for --all-namespaces
kubectl get deploy -A
Namespace Resource Quotas
Prevent one team’s namespace from consuming all cluster resources:
# resource-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-quota
namespace: team-a
spec:
hard:
requests.cpu: "4" # Total CPU requests in namespace
requests.memory: 4Gi # Total memory requests
limits.cpu: "8" # Total CPU limits
limits.memory: 8Gi # Total memory limits
pods: "20" # Max number of pods
services: "10" # Max number of Services
persistentvolumeclaims: "5" # Max PVCs
kubectl apply -f resource-quota.yaml
# See current usage vs quota
kubectl describe resourcequota team-quota -n team-a
Try It
# Create two namespaces
kubectl create namespace team-a
kubectl create namespace team-b
# Create same-named resources in each — no conflict
kubectl run nginx --image=nginx:1.25 -n team-a
kubectl run nginx --image=nginx:1.25 -n team-b
# Both exist independently
kubectl get pods -n team-a
kubectl get pods -n team-b
# Cross-namespace DNS still works (Service required for DNS)
# From a pod in team-a:
# curl http://my-svc.team-b.svc.cluster.local
# Cleanup
kubectl delete namespace team-a team-b
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Namespace = virtual cluster | Name isolation; RBAC scope; quota boundaries |
| 2 | Network is NOT isolated by default | Cross-namespace pod traffic is allowed without NetworkPolicy |
| 3 | Nodes and PVs are cluster-scoped | They exist outside any namespace |
| 4 | ResourceQuota limits namespace consumption | Prevents noisy-neighbor resource exhaustion |
✅ Quick Check
Q1: Team A’s web-svc Service is in namespace team-a. Team B’s pod needs to reach it. What DNS name do they use?
Answer
`web-svc.team-a.svc.cluster.local` — the full DNS name includes the namespace. The short form `web-svc` would only resolve to a Service named `web-svc` in team-b's own namespace.Q2: You apply a ResourceQuota to namespace dev with pods: "5". A developer tries to create a 6th pod. What happens?
Answer
The API server rejects the request with an error: `"pods" exceeded quota`. The 6th pod is never created. The developer must delete an existing pod or request a quota increase from the cluster admin.Q3: Can you move a running pod from one namespace to another?
Answer
No. Namespace is immutable — you cannot change a pod's namespace after creation. To "move" a pod to another namespace, you delete it in the old namespace and create a new one in the target namespace. The same applies to all namespace-scoped resources.9.2 RBAC — Roles, ClusterRoles, and Bindings
⏱️ ~8 min read
TL;DR: RBAC (Role-Based Access Control) controls who can do what on which resources. A Role defines permissions. A RoleBinding says “this user/group/serviceaccount gets those permissions.” It’s the primary security mechanism for controlling kubectl access.
The RBAC Model
graph LR
SUBJECT["Subject\n(User / Group / ServiceAccount)"] -->|bound by| RB[RoleBinding]
RB --> ROLE["Role\n(set of permissions)"]
ROLE --> RESOURCES["Resources\n(pods, secrets, deployments...)"]
ROLE --> VERBS["Verbs\n(get, list, create, delete...)"]
Four object types:
| Object | Scope | Purpose |
|---|---|---|
Role | Namespace | Permissions within one namespace |
ClusterRole | Cluster | Permissions cluster-wide (or reusable across namespaces) |
RoleBinding | Namespace | Grants a Role/ClusterRole to a subject in one namespace |
ClusterRoleBinding | Cluster | Grants a ClusterRole to a subject cluster-wide |
Role — Define Permissions
# role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: team-a # Only applies within team-a namespace
rules:
- apiGroups: [""] # "" = core API group (pods, services, configmaps)
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"] # apps API group (deployments, replicasets)
resources: ["deployments"]
verbs: ["get", "list"]
Available verbs:
| Verb | HTTP Method | Meaning |
|---|---|---|
get | GET | Read a single resource |
list | GET (collection) | List all resources of a type |
watch | GET (streaming) | Stream updates to resources |
create | POST | Create a new resource |
update | PUT | Replace an existing resource |
patch | PATCH | Partially update a resource |
delete | DELETE | Delete a resource |
deletecollection | DELETE (collection) | Delete multiple resources |
apiGroups reference:
# Find the API group for a resource
kubectl api-resources | grep deployment
# NAME SHORTNAMES APIVERSION NAMESPACED KIND
# deployments deploy apps/v1 true Deployment
# ^^^^
# apiGroup = "apps"
kubectl api-resources | grep pod
# pods po v1 true Pod
# ^
# v1 = core group → apiGroups: [""]
ClusterRole — Cluster-Wide or Reusable
# clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: node-reader # No namespace — cluster-scoped
rules:
- apiGroups: [""]
resources: ["nodes"] # Nodes are cluster-scoped
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
ClusterRoles can also be bound per-namespace via a RoleBinding (not ClusterRoleBinding) — useful for defining common permission sets once and reusing them:
# Bind the ClusterRole "view" to a user, but only in namespace "team-a"
kind: RoleBinding # ← RoleBinding, not ClusterRoleBinding
subjects:
- kind: User
name: alice
roleRef:
kind: ClusterRole # ← References a ClusterRole
name: view # Built-in ClusterRole
RoleBinding — Grant Permissions
# rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: alice-pod-reader
namespace: team-a # Only applies in team-a
subjects:
- kind: User # User | Group | ServiceAccount
name: alice # The username
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role # Role | ClusterRole
name: pod-reader # Must exist in same namespace (for Role)
apiGroup: rbac.authorization.k8s.io
Subject kinds:
# User (human)
- kind: User
name: alice
apiGroup: rbac.authorization.k8s.io
# Group (all users in a group)
- kind: Group
name: team-a-developers
apiGroup: rbac.authorization.k8s.io
# ServiceAccount (for pods — covered in section 9.3)
- kind: ServiceAccount
name: my-service-account
namespace: team-a # ServiceAccounts are namespaced
Built-in ClusterRoles
Kubernetes ships with useful ClusterRoles you can reuse:
| ClusterRole | What It Allows |
|---|---|
view | Read-only access to most resources |
edit | Read-write access to most resources (not Roles/RoleBindings) |
admin | Full access within a namespace, including Roles |
cluster-admin | Full access to everything — equivalent to root |
# Grant a user read-only access to a specific namespace
kubectl create rolebinding alice-viewer \
--clusterrole=view \
--user=alice \
--namespace=team-a
# Grant cluster-admin to a user (use with extreme caution)
kubectl create clusterrolebinding alice-admin \
--clusterrole=cluster-admin \
--user=alice
Checking Permissions
# Can I do this?
kubectl auth can-i get pods --namespace=team-a
# yes
# Can another user do this?
kubectl auth can-i get secrets --namespace=team-a --as=alice
# no
# What can alice do?
kubectl auth can-i --list --as=alice --namespace=team-a
# Audit: see all RoleBindings in a namespace
kubectl get rolebindings -n team-a -o wide
kubectl get clusterrolebindings -o wide | grep alice
Try It
kubectl create namespace rbac-demo
# Create a Role that allows reading pods only
cat <<'EOF' | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: rbac-demo
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: test-user-pod-reader
namespace: rbac-demo
subjects:
- kind: User
name: test-user
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
EOF
# Check what test-user can do
kubectl auth can-i get pods --namespace=rbac-demo --as=test-user # yes
kubectl auth can-i delete pods --namespace=rbac-demo --as=test-user # no
kubectl auth can-i get secrets --namespace=rbac-demo --as=test-user # no
kubectl auth can-i get pods --namespace=default --as=test-user # no (only in rbac-demo)
# Cleanup
kubectl delete namespace rbac-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Role = permission set | Defines what verbs on what resources |
| 2 | RoleBinding = assignment | Gives a Role to a subject in a namespace |
| 3 | ClusterRole = reusable | Same as Role but cluster-scoped or namespace-reusable |
| 4 | Built-in roles | view/edit/admin/cluster-admin cover most needs |
| 5 | kubectl auth can-i | Test permissions before and after applying RBAC |
✅ Quick Check
Q1: You want a pod to be able to list ConfigMaps in its own namespace. What subject type do you use in the RoleBinding?
Answer
`ServiceAccount` — pods have ServiceAccount identities, not User identities. Create a ServiceAccount, create a Role with `configmaps: ["get","list"]`, bind them with a RoleBinding, and set the pod's `serviceAccountName` to the ServiceAccount. Covered in detail in section 9.3.Q2: You grant a user the view ClusterRole via a ClusterRoleBinding. What can they see?
Answer
Everything in every namespace — all pods, services, deployments, configmaps, etc. (read-only). ClusterRoleBinding + ClusterRole = cluster-wide permissions. If you want to limit them to one namespace, use a **RoleBinding** (not ClusterRoleBinding) that references the same `view` ClusterRole.Q3: A developer accidentally deletes a production Deployment. You add delete to their Role’s allowed verbs list after the fact. What should you actually do?
Answer
Remove `delete` from their Role (or use the `view` ClusterRole which has no write permissions). Apply the principle of least privilege — developers shouldn't have `delete` on production resources. Use separate clusters or namespaces for production with stricter RBAC, and implement admission webhooks or OPA policies to add a confirmation layer for destructive operations.9.3 ServiceAccounts — Pod Identities
⏱️ ~6 min read
TL;DR: A ServiceAccount is a non-human identity for pods. When a pod needs to call the Kubernetes API (e.g., a CI runner listing pods, or an operator watching CRDs), it authenticates using its ServiceAccount’s token. By default, every pod gets the
defaultServiceAccount, which has almost no permissions.
Why Pods Need Identities
Some workloads need to interact with the Kubernetes API:
| Workload | API Operations |
|---|---|
| CI/CD runner (e.g., ArgoCD) | List/update Deployments |
| Autoscaler (HPA controller) | Read metrics, scale Deployments |
| Secrets manager (External Secrets) | Read/write Secrets |
| Monitoring (Prometheus) | List pods, read metrics endpoints |
| Custom operator | Watch/create/update custom resources |
Without a ServiceAccount with the right Role, these tools fail with 403 Forbidden from the API server.
The Default ServiceAccount
Every namespace has a default ServiceAccount. All pods use it automatically unless you specify otherwise:
# See the default ServiceAccount
kubectl get serviceaccount default -n default
# Every pod has a token mounted automatically
kubectl run tmp --image=nginx --rm -it --restart=Never -- \
cat /var/run/secrets/kubernetes.io/serviceaccount/token
The default SA has minimal permissions — by design. Don’t add permissions to it (every pod in the namespace inherits them).
Creating and Using a ServiceAccount
# serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-lister
namespace: monitoring
# role.yaml — what the SA is allowed to do
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-list-role
namespace: default
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
# rolebinding.yaml — connect SA to Role
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-lister-binding
namespace: default
subjects:
- kind: ServiceAccount
name: pod-lister
namespace: monitoring # SA is in 'monitoring' namespace
roleRef:
kind: Role
name: pod-list-role
apiGroup: rbac.authorization.k8s.io
# pod using the ServiceAccount
spec:
serviceAccountName: pod-lister # Must be in same namespace as pod
automountServiceAccountToken: true # Default is true
The Mounted Token
When a pod uses a ServiceAccount, Kubernetes mounts a short-lived JWT token as a projected volume:
/var/run/secrets/kubernetes.io/serviceaccount/
├── token ← JWT token to authenticate with the API server
├── ca.crt ← CA cert to verify the API server's TLS
└── namespace ← Current namespace name
# Inside a pod — call the K8s API using the mounted token
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
curl -s \
--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods | \
python3 -m json.tool | grep '"name"'
Disabling Token Auto-Mount
For pods that don’t need API access, disable the token mount to reduce attack surface:
spec:
automountServiceAccountToken: false # No token mounted
containers:
- name: app
image: nginx:1.25
Or disable it on the ServiceAccount itself (applies to all pods using it):
apiVersion: v1
kind: ServiceAccount
metadata:
name: no-api-access
automountServiceAccountToken: false
Cross-Namespace Access
A pod can only use ServiceAccounts in its own namespace. For cross-namespace API access, bind the SA to Roles in other namespaces:
# Pod in 'monitoring' namespace has SA 'prometheus'
# Bind it to the 'view' ClusterRole in EVERY namespace:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus-cluster-view
subjects:
- kind: ServiceAccount
name: prometheus
namespace: monitoring # SA lives in monitoring
roleRef:
kind: ClusterRole
name: view # Can view resources cluster-wide
apiGroup: rbac.authorization.k8s.io
Try It
kubectl create namespace sa-demo
# Create ServiceAccount, Role, and RoleBinding
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
name: api-reader
namespace: sa-demo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: sa-demo
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-reader-binding
namespace: sa-demo
subjects:
- kind: ServiceAccount
name: api-reader
namespace: sa-demo
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
EOF
# Test: can the SA get pods?
kubectl auth can-i get pods \
--namespace=sa-demo \
--as=system:serviceaccount:sa-demo:api-reader # yes
kubectl auth can-i delete pods \
--namespace=sa-demo \
--as=system:serviceaccount:sa-demo:api-reader # no
kubectl auth can-i get secrets \
--namespace=sa-demo \
--as=system:serviceaccount:sa-demo:api-reader # no
# Cleanup
kubectl delete namespace sa-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | ServiceAccount = pod identity | Allows pods to authenticate with the K8s API |
| 2 | Default SA has minimal permissions | Don’t add permissions to it |
| 3 | Token auto-mounted at /var/run/... | Short-lived JWT used for API calls |
| 4 | automountServiceAccountToken: false | Disable for pods that don’t need API access |
| 5 | SA subject format | system:serviceaccount:NAMESPACE:NAME |
✅ Quick Check
Q1: An operator pod needs to watch and update Deployments across all namespaces. What RBAC objects do you create?
Answer
Create a `ServiceAccount`, a `ClusterRole` with `deployments` get/list/watch/update verbs (using `apiGroups: ["apps"]`), and a `ClusterRoleBinding` that binds the ClusterRole to the ServiceAccount. Then set `serviceAccountName` in the operator pod spec.Q2: A pod uses the default ServiceAccount. An attacker compromises the pod. What can they do with the mounted token?
Answer
With the default SA (no extra permissions), they can call the K8s API but most operations return 403. They can read their own namespace name and verify the cluster CA. In a well-hardened cluster, the blast radius is minimal. This is why `automountServiceAccountToken: false` is recommended for pods that don't need API access — the token isn't there to steal.Q3: You create a ServiceAccount named deployer in namespace staging. Can a pod in namespace production use it?
Answer
No. `serviceAccountName` in a pod spec must refer to a ServiceAccount in the same namespace as the pod. A pod in `production` cannot use a ServiceAccount from `staging`. If you need similar permissions in both namespaces, create identically-configured ServiceAccounts in each.9.4 Multi-Tenancy Patterns
⏱️ ~5 min read
TL;DR: Multi-tenancy means multiple teams or customers share one cluster safely. The standard Kubernetes approach uses namespaces + RBAC + ResourceQuotas + NetworkPolicy. For stronger isolation, use separate clusters.
Isolation Levels
graph LR
subgraph "Soft Tenancy (Namespace-per-Team)"
N1[namespace: team-a\nRBAC + Quota]
N2[namespace: team-b\nRBAC + Quota]
N1 & N2 --> CLUSTER1[Shared Cluster\nShared Nodes]
end
subgraph "Hard Tenancy (Cluster-per-Team)"
CLUSTER2[Cluster A\nteam-a only]
CLUSTER3[Cluster B\nteam-b only]
end
| Isolation Level | Mechanism | Security | Cost | Use When |
|---|---|---|---|---|
| Soft | Namespace + RBAC + Quota | Medium | Low | Internal teams; trusted users |
| Hard | Separate clusters | High | High | Different customers; regulatory boundaries |
| vCluster | Virtual cluster per tenant | High | Medium | SaaS platforms needing k8s API isolation |
The Namespace-Per-Team Pattern
The most common production approach:
# Per-team namespace with labels
apiVersion: v1
kind: Namespace
metadata:
name: team-payments
labels:
team: payments
environment: production
cost-center: "123"
For each namespace, apply the standard set:
namespace/team-payments
├── ResourceQuota ← CPU/memory/pod limits
├── LimitRange ← Default requests/limits for pods without them
├── NetworkPolicy ← Block cross-team traffic
├── Role + RoleBinding ← Team-specific permissions
└── ServiceAccount ← Per-team app identity
LimitRange — Default Resource Bounds
ResourceQuota limits the total for a namespace. LimitRange sets defaults for individual pods that forget to set resources:
# limitrange.yaml
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: team-payments
spec:
limits:
- type: Container
default: # Applied if container has no limits
cpu: "500m"
memory: "256Mi"
defaultRequest: # Applied if container has no requests
cpu: "100m"
memory: "128Mi"
max: # No container in this namespace can exceed
cpu: "2"
memory: "2Gi"
min: # No container can go below
cpu: "50m"
memory: "32Mi"
Without LimitRange, pods without resource specs become BestEffort QoS class — first to be evicted.
NetworkPolicy — Traffic Isolation
By default, all pods in a cluster can talk to all other pods. NetworkPolicy restricts this:
# deny-all-ingress.yaml — Block all incoming traffic by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-ingress
namespace: team-payments
spec:
podSelector: {} # Applies to all pods in this namespace
policyTypes:
- Ingress
# No ingress rules = deny all incoming traffic
---
# allow-same-namespace.yaml — Allow traffic within the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: team-payments
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {} # Any pod in the same namespace
⚠️ Warning: NetworkPolicy requires a CNI plugin that supports it (Calico, Cilium, Weave). Minikube’s default CNI does NOT enforce NetworkPolicy. Apply them anyway — they’ll be enforced when you deploy to a production cluster with the right CNI.
Namespace Template (Standard Starter)
Here’s a complete namespace setup for a team:
# Script to create a fully configured team namespace
TEAM=payments
TEAM_NS=team-$TEAM
kubectl create namespace $TEAM_NS
# Resource limits
kubectl apply -f - <<EOF
apiVersion: v1
kind: ResourceQuota
metadata:
name: ${TEAM}-quota
namespace: ${TEAM_NS}
spec:
hard:
requests.cpu: "4"
requests.memory: 4Gi
limits.cpu: "8"
limits.memory: 8Gi
pods: "20"
services: "10"
---
apiVersion: v1
kind: LimitRange
metadata:
name: ${TEAM}-limits
namespace: ${TEAM_NS}
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
EOF
# Team ServiceAccount
kubectl create serviceaccount ${TEAM}-sa -n ${TEAM_NS}
# Bind 'edit' ClusterRole to team SA in their namespace
kubectl create rolebinding ${TEAM}-edit \
--clusterrole=edit \
--serviceaccount=${TEAM_NS}:${TEAM}-sa \
-n ${TEAM_NS}
Key Takeaways
| # | Pattern | One-liner |
|---|---|---|
| 1 | Namespace + RBAC + Quota | Soft multi-tenancy; sufficient for internal teams |
| 2 | Separate clusters | Hard multi-tenancy; for different customers or compliance |
| 3 | LimitRange | Sets defaults so no pod is BestEffort by accident |
| 4 | NetworkPolicy | Isolates traffic; requires a compatible CNI plugin |
| 5 | Namespace template | Apply ResourceQuota + LimitRange + RBAC consistently |
✅ Quick Check
Q1: Team A and Team B share a cluster with namespace-per-team. Team A’s pod sends a request directly to Team B’s pod IP. Is this blocked?
Answer
Not by default. Pod-to-pod networking is open by default in Kubernetes — namespace boundaries don't block network traffic. You need a NetworkPolicy to enforce traffic isolation. And even then, you need a CNI plugin (Calico, Cilium) that enforces NetworkPolicy. Without it, the policy objects exist but have no effect.Q2: A developer in namespace team-a runs kubectl get pods -n team-b. They have the edit role in team-a. Can they see team-b’s pods?
Answer
No. The `edit` RoleBinding in `team-a` only grants permissions within `team-a`. There's no permission for `team-b`. The kubectl command returns `Error from server (Forbidden)`.Q3: A pod has no resource requests defined. The namespace has a LimitRange with defaultRequest.cpu: 100m. What CPU request does the pod actually have?
Answer
`100m` — the LimitRange admission controller injects the default values at pod creation time. The pod's spec is mutated to include `requests.cpu: 100m` before it's stored in etcd. This prevents `BestEffort` pods from being created inadvertently.Lab: Lock Down a Namespace
⏱️ ~30 min hands-on
| Prerequisites | Chapter 9 sections 9.1–9.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Create a namespace with ResourceQuota and LimitRange, set up RBAC for a developer persona, create a ServiceAccount for an app, and verify all access controls work as expected |
Objectives
- Create a team namespace with ResourceQuota and LimitRange
- Create a
dev-userRole that allows read-write but not delete - Verify the user can’t access other namespaces
- Create a ServiceAccount for an app pod to call the K8s API
- Test that ResourceQuota blocks over-limit pod creation
- Observe the LimitRange injecting default resources
Setup
# Create the team namespace
kubectl create namespace team-alpha
# Verify it exists
kubectl get namespace team-alpha
Exercise 1: Apply ResourceQuota and LimitRange
What we’re doing: Set namespace-level resource boundaries.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ResourceQuota
metadata:
name: alpha-quota
namespace: team-alpha
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
pods: "5"
services: "3"
persistentvolumeclaims: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
name: alpha-limits
namespace: team-alpha
spec:
limits:
- type: Container
default:
cpu: "200m"
memory: "128Mi"
defaultRequest:
cpu: "100m"
memory: "64Mi"
max:
cpu: "1"
memory: "512Mi"
EOF
# See the quota
kubectl describe resourcequota alpha-quota -n team-alpha
kubectl describe limitrange alpha-limits -n team-alpha
Expected quota output:
Name: alpha-quota
Namespace: team-alpha
Resource Used Hard
-------- ---- ----
limits.cpu 0 4
limits.memory 0 4Gi
pods 0 5
requests.cpu 0 2
requests.memory 0 2Gi
...
Exercise 2: RBAC for a Developer
What we’re doing: Create a Role that lets a developer manage deployments and view pods/logs, but NOT delete anything or touch secrets.
cat <<'EOF' | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: developer
namespace: team-alpha
rules:
# Pods: read and exec, but not delete
- apiGroups: [""]
resources: ["pods", "pods/log", "pods/exec"]
verbs: ["get", "list", "watch"]
# Deployments: full management (but not delete)
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Services: create and view
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# ConfigMaps: full access (not Secrets)
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Events: view for debugging
- apiGroups: [""]
resources: ["events"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dev-alice
namespace: team-alpha
subjects:
- kind: User
name: alice
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: developer
apiGroup: rbac.authorization.k8s.io
EOF
echo "RBAC applied. Testing permissions..."
Test the permissions systematically:
# What alice CAN do
echo "=== Alice's allowed operations ==="
kubectl auth can-i get pods -n team-alpha --as=alice # yes
kubectl auth can-i list deployments -n team-alpha --as=alice # yes
kubectl auth can-i create deployments -n team-alpha --as=alice # yes
kubectl auth can-i update deployments -n team-alpha --as=alice # yes
kubectl auth can-i get configmaps -n team-alpha --as=alice # yes
echo ""
echo "=== What alice CANNOT do ==="
kubectl auth can-i delete pods -n team-alpha --as=alice # no
kubectl auth can-i delete deployments -n team-alpha --as=alice # no
kubectl auth can-i get secrets -n team-alpha --as=alice # no
kubectl auth can-i get pods -n default --as=alice # no (wrong namespace)
kubectl auth can-i get pods -n kube-system --as=alice # no
Expected output:
=== Alice's allowed operations ===
yes
yes
yes
yes
yes
=== What alice CANNOT do ===
no
no
no
no
no
Exercise 3: Namespace Isolation Verification
# Create resources in other namespaces
kubectl run nginx-default --image=nginx:1.25 -n default
# Alice cannot see pods in other namespaces
kubectl auth can-i list pods -n default --as=alice # no
kubectl auth can-i list pods -n kube-system --as=alice # no
kubectl auth can-i list pods --all-namespaces --as=alice # no
# But cluster-level admin can
kubectl auth can-i list pods -n default # yes (you're admin)
kubectl auth can-i list pods -n team-alpha --as=alice # yes (alice's namespace)
# Cleanup
kubectl delete pod nginx-default -n default
Exercise 4: ServiceAccount for App API Access
What we’re doing: Create an app that reads ConfigMaps from its own namespace via the K8s API.
# Create the ServiceAccount
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
name: config-reader
namespace: team-alpha
automountServiceAccountToken: true
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: configmap-reader
namespace: team-alpha
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: config-reader-binding
namespace: team-alpha
subjects:
- kind: ServiceAccount
name: config-reader
namespace: team-alpha
roleRef:
kind: Role
name: configmap-reader
apiGroup: rbac.authorization.k8s.io
EOF
# Verify the SA can list configmaps
kubectl auth can-i list configmaps -n team-alpha \
--as=system:serviceaccount:team-alpha:config-reader # yes
kubectl auth can-i list secrets -n team-alpha \
--as=system:serviceaccount:team-alpha:config-reader # no
# Deploy a pod using this ServiceAccount
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: app-settings
namespace: team-alpha
data:
REGION: "us-east-1"
TIER: "premium"
---
apiVersion: v1
kind: Pod
metadata:
name: api-caller
namespace: team-alpha
spec:
serviceAccountName: config-reader
containers:
- name: app
image: curlimages/curl:latest
command: ["sh", "-c", "sleep 3600"]
resources:
limits:
memory: "32Mi"
cpu: "100m"
EOF
kubectl wait pod api-caller -n team-alpha --for=condition=Ready --timeout=60s
# Call the K8s API from inside the pod using the mounted SA token
kubectl exec -n team-alpha api-caller -- sh -c '
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
echo "=== Listing ConfigMaps (allowed) ==="
curl -s \
--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $TOKEN" \
"https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/configmaps" \
| grep -o "\"name\":\"[^\"]*\"" | head -5
echo ""
echo "=== Listing Secrets (forbidden) ==="
curl -s \
--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $TOKEN" \
"https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/secrets" \
| grep -o "\"reason\":\"[^\"]*\""
'
Expected output:
=== Listing ConfigMaps (allowed) ===
"name":"app-settings"
"name":"kube-root-ca.crt"
=== Listing Secrets (forbidden) ===
"reason":"Forbidden"
Exercise 5: ResourceQuota in Action
What we’re doing: Prove the quota blocks pod creation over the limit.
# Create pods up to the limit (quota allows 5 pods)
for i in 1 2 3 4; do
kubectl run pod-$i --image=nginx:1.25 -n team-alpha
done
# Check quota usage
kubectl describe resourcequota alpha-quota -n team-alpha | grep -E "pods|cpu|memory"
Expected (partial):
pods 4 5 ← 4 used, limit 5
# Create one more (up to the limit)
kubectl run pod-5 --image=nginx:1.25 -n team-alpha
# Try to exceed — this should fail
kubectl run pod-6 --image=nginx:1.25 -n team-alpha
Expected error:
Error from server (Forbidden): pods "pod-6" is forbidden: exceeded quota: alpha-quota,
requested: pods=1, used: pods=5, limited: pods=5
Exercise 6: LimitRange Injects Defaults
What we’re doing: Create a pod without resource specs — watch LimitRange inject them.
# Pod with NO resource requests or limits
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: no-resources-pod
namespace: team-alpha
spec:
containers:
- name: app
image: busybox
command: ["sleep", "60"]
EOF
# Check what resources it actually got
kubectl get pod no-resources-pod -n team-alpha \
-o jsonpath='{.spec.containers[0].resources}' | python3 -m json.tool
Expected (LimitRange injected these):
{
"limits": {
"cpu": "200m",
"memory": "128Mi"
},
"requests": {
"cpu": "100m",
"memory": "64Mi"
}
}
The pod was created without specifying any resources, but LimitRange automatically injected the namespace defaults.
🔥 Break It! Challenge
What happens when you try to create a pod that EXCEEDS the LimitRange’s max?
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: too-many-resources
namespace: team-alpha
spec:
containers:
- name: app
image: nginx:1.25
resources:
requests:
memory: "1Gi" # LimitRange max is 512Mi
cpu: "2" # LimitRange max is 1 CPU
limits:
memory: "2Gi" # Way over max
cpu: "4" # Way over max
EOF
Expected error:
Error from server (Forbidden): error when creating "STDIN": pods "too-many-resources" is forbidden:
[maximum memory usage per Container is 512Mi, but limit is 2Gi.,
maximum cpu usage per Container is 1, but limit is 4.]
The admission controller rejects the pod at creation time — it never reaches the scheduler.
Cleanup
kubectl delete namespace team-alpha
kubectl config set-context --current --namespace=default
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | ResourceQuota per namespace | Created with CPU/memory/pod limits |
| 2 | LimitRange defaults | Pod without resources got 100m/64Mi automatically |
| 3 | Role for developer persona | alice has create/update but not delete/secrets |
| 4 | Namespace isolation | alice can’t see other namespaces |
| 5 | ServiceAccount API access | Pod called K8s API, got ConfigMaps, denied Secrets |
| 6 | Quota enforcement | 6th pod rejected with exceeded quota error |
| 7 | LimitRange max enforcement | Pod with 2Gi memory rejected at admission |
Chapter 10: Health Checks — Probes and Graceful Shutdown
⏱️ Total chapter time: ~50 min (22 min reading + 28 min lab)
After this chapter, you will be able to: Configure liveness, readiness, and startup probes so Kubernetes knows when your app is healthy, and implement graceful shutdown so pods drain cleanly without dropping requests.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 10.1 | Why Probes Exist — The Problem They Solve | ~4 min |
| 10.2 | Liveness Probes — Restart the Stuck | ~6 min |
| 10.3 | Readiness Probes — Control Traffic | ~5 min |
| 10.4 | Startup Probes — Slow-Starting Apps | ~4 min |
| 10.5 | Graceful Shutdown and preStop Hooks | ~5 min |
| 10.6 | 🔬 Lab: Probes and Shutdown Drills | ~28 min |
Prerequisites
- Completed Chapter 3 (Pods) and Chapter 4 (Deployments)
minikube statusshowsRunning
10.1 Why Probes Exist — The Problem They Solve
⏱️ ~4 min read
TL;DR: Kubernetes can’t read your app’s source code. Without probes, it only knows whether the container process is running — not whether your app is actually healthy. Probes are how you teach Kubernetes what “healthy” means for your specific application.
The Two Failure Modes Probes Solve
1. The Zombie Process
graph LR
APP[App process\nRunning ✅\nBut deadlocked 🧟] --> K8S[kubectl get pods\nStatus: Running\nREADY: 1/1]
K8S --> TRAFFIC[Traffic still\nrouted here ❌\nRequests timing out]
The container is running, but the app is stuck in a deadlock, holding all worker threads, or has a corrupted internal state. Kubernetes doesn’t know — it just sees a live process.
Liveness probes fix this: they detect unhealthy-but-running apps and trigger a container restart.
2. The Not-Ready-Yet App
graph LR
DEPLOY[Deployment\nrolling update] --> NEW[New pod\nProcess started\nbut warming up]
NEW -->|"K8s sends traffic immediately\nbefore app is ready"| FAIL[User gets\n500 errors ❌\nduring warmup]
A new pod starts, the container process launches, and Kubernetes immediately routes traffic to it. But your app needs 30 seconds to load its cache, connect to the database, and finish initializing.
Readiness probes fix this: the pod only receives traffic when the probe passes.
The Three Probe Types
| Probe | Question | Action on Failure |
|---|---|---|
| Liveness | “Is this container alive?” | Restart the container |
| Readiness | “Is this container ready for traffic?” | Remove from Service endpoints |
| Startup | “Has this slow app finished starting?” | Block liveness/readiness checks |
Probe Mechanisms
All three probe types support the same three check mechanisms:
# HTTP GET — checks an HTTP endpoint
httpGet:
path: /health
port: 8080
scheme: HTTP # HTTP or HTTPS
# TCP Socket — checks if a port accepts connections
tcpSocket:
port: 5432 # Good for databases
# Exec — runs a command inside the container
exec:
command:
- sh
- -c
- "redis-cli ping | grep PONG"
# gRPC — checks a gRPC health service (K8s 1.24+)
grpc:
port: 50051
service: "" # gRPC service name (optional)
Universal Probe Parameters
# These parameters apply to all probe types:
initialDelaySeconds: 10 # Wait this long after container starts before first check
periodSeconds: 10 # How often to run the check
timeoutSeconds: 5 # Max time to wait for a response (failure if exceeded)
successThreshold: 1 # How many successes to consider healthy (usually 1)
failureThreshold: 3 # How many consecutive failures before action is taken
💡 Rule of thumb: Set
initialDelaySecondsto your app’s average startup time × 2. Too short = false failures during startup. Too long = slow detection of real crashes.
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | K8s can’t read your code | Without probes, it only checks process existence |
| 2 | Liveness = restart trigger | For stuck/deadlocked apps that need a restart |
| 3 | Readiness = traffic gate | For apps that need time to warm up |
| 4 | Startup = slow-start protection | Prevents liveness from killing a slow-starting app |
✅ Quick Check
Q1: Your app is running but its connection pool to the database is exhausted. It can’t serve requests. Which probe should detect and fix this?
Answer
**Readiness probe** — if it's a temporary condition (pool will recover), remove the pod from the load balancer until it recovers. **Liveness probe** — if it's an unrecoverable condition (the app needs a restart to clear the pool). In practice, you might use both: readiness to stop traffic quickly, liveness to restart after a longer failure window.Q2: Kubernetes says your pod is READY: 1/1 and Running. A user reports the app is returning 500 errors. What’s likely wrong?
Answer
The liveness probe is passing (process is alive) but the readiness probe is either not configured or checking the wrong thing. The app may have hit an error state that doesn't crash the process but breaks request handling. This is the zombie process problem — you need a more thorough readiness check that validates the app can actually serve requests.Q3: You set failureThreshold: 3 on your liveness probe with periodSeconds: 10. How long until the container is restarted after it starts failing?
Answer
At least 30 seconds (3 failures × 10-second intervals). The liveness probe must fail `failureThreshold` consecutive times before Kubernetes restarts the container. This window prevents restarting due to temporary blips.10.2 Liveness Probes — Restart the Stuck
⏱️ ~6 min read
TL;DR: A liveness probe answers “Is this container still functional?” If it fails
failureThresholdtimes in a row, Kubernetes kills and restarts the container. Use it for apps that can get stuck without crashing.
Liveness Probe in Action
spec:
containers:
- name: app
image: myapp:latest
livenessProbe:
httpGet:
path: /health/live # Your liveness endpoint
port: 8080
initialDelaySeconds: 15 # Wait 15s after start (app startup time)
periodSeconds: 20 # Check every 20s
timeoutSeconds: 5 # Fail if no response in 5s
failureThreshold: 3 # Restart after 3 consecutive failures (60s window)
successThreshold: 1 # Once is enough to be "live"
Timeline with failure:
t=0s Container starts
t=15s First liveness check → 200 OK ✅
t=35s App deadlocks internally
t=55s Liveness check → timeout ❌ (1/3)
t=75s Liveness check → timeout ❌ (2/3)
t=95s Liveness check → timeout ❌ (3/3)
t=95s Kubernetes restarts the container
t=95s Container starts fresh — deadlock cleared
What Your Liveness Endpoint Should Check
The /health/live endpoint should verify the app’s core functionality — not dependencies:
# ✅ Good liveness check — verifies internal state
@app.get("/health/live")
def liveness():
# Check internal state only
if worker_thread_pool.is_hung():
raise HTTPException(503, "Thread pool hung")
if memory_usage() > 95:
raise HTTPException(503, "Out of memory")
return {"status": "ok"}
# ❌ Bad liveness check — checks external database
@app.get("/health/live")
def liveness():
db.execute("SELECT 1") # If DB is slow, liveness fails → restart loop!
return {"status": "ok"}
⚠️ Warning: Never check external dependencies (database, cache, third-party APIs) in a liveness probe. If the database goes down, ALL pods restart in a loop, causing total application failure. Check only internal app state.
Liveness with exec
For apps without an HTTP server (batch jobs, workers):
livenessProbe:
exec:
command:
- sh
- -c
- "test -f /tmp/healthy && test $(( $(date +%s) - $(stat -c %Y /tmp/healthy) )) -lt 60"
# App touches /tmp/healthy every 30s to signal it's alive
# If the file is more than 60s old, something's wrong
periodSeconds: 30
failureThreshold: 2
# App code touches the file regularly
import os, time
def main_loop():
while True:
process_message()
# Touch the health file — I'm still alive
open('/tmp/healthy', 'w').close()
time.sleep(10)
Liveness with TCP
For protocols that don’t speak HTTP (Redis, Postgres, etc.):
livenessProbe:
tcpSocket:
port: 6379 # Just checks if Redis accepts a TCP connection
initialDelaySeconds: 10
periodSeconds: 15
TCP is a shallow check — it verifies the port is open but not that the service is responding correctly.
Common Liveness Mistakes
| Mistake | Problem | Fix |
|---|---|---|
No initialDelaySeconds | App restarts before it finishes starting | Set to 2x startup time |
| Checking DB in liveness | DB slowness triggers restart loop | Only check internal state |
failureThreshold: 1 | One blip restarts the container | Use 3+ to avoid noise |
timeoutSeconds too short | Slow but healthy response = failure | Test actual response times |
| No liveness probe at all | Stuck apps stay stuck forever | Always add one |
Try It
# Deploy an app with liveness probe
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: liveness-demo
spec:
containers:
- name: app
image: nginx:1.25
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
resources:
limits:
memory: "64Mi"
cpu: "100m"
EOF
kubectl wait pod liveness-demo --for=condition=Ready --timeout=30s
# Watch the probe succeed
kubectl describe pod liveness-demo | grep -A5 "Liveness:"
# Now simulate a failure by blocking nginx
# We'll delete the index.html so nginx returns 403 (which liveness will detect as... still 403 is OK)
# More dramatically: stop nginx inside the container
kubectl exec liveness-demo -- nginx -s stop
# Watch the container get restarted
kubectl get pod liveness-demo -w
Expected output after nginx stops:
NAME READY STATUS RESTARTS AGE
liveness-demo 1/1 Running 0 30s
liveness-demo 0/1 Running 1 60s ← restarted!
liveness-demo 1/1 Running 1 63s ← healthy again
kubectl delete pod liveness-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Liveness = restart trigger | Container killed and restarted after failureThreshold failures |
| 2 | Check internal state only | Never check databases or external services |
| 3 | initialDelaySeconds critical | Prevents restart loops during normal startup |
| 4 | 3+ failure threshold | Tolerates transient blips |
✅ Quick Check
Q1: Your liveness probe has failureThreshold: 3 and periodSeconds: 10. The app is slow for 25 seconds then recovers. Is the container restarted?
Answer
No. Three consecutive failures takes 30 seconds (3 × 10s). If the app recovers within 25 seconds, only 2 checks fail before the 3rd check passes. Since failures must be **consecutive**, a passing check resets the failure counter to 0. The container is not restarted.Q2: A liveness probe check times out after timeoutSeconds: 5. Is that counted as a failure?
Answer
Yes. A timeout (no response within `timeoutSeconds`) is counted the same as an HTTP non-2xx response or a non-zero exec exit code. It increments the failure counter toward `failureThreshold`.Q3: You have a stateful app where restarting corrupts in-flight transactions. Should you use a liveness probe?
Answer
Use it carefully. Consider using only a readiness probe (removes from load balancer) rather than a liveness probe (restarts). For truly stateful apps where restart = data loss or corruption, a liveness probe can do more harm than good. Use an `exec` probe that checks a very specific recoverable condition, and set a high `failureThreshold` to avoid spurious restarts.10.3 Readiness Probes — Control Traffic
⏱️ ~5 min read
TL;DR: A readiness probe answers “Is this container ready to receive traffic?” Failure removes the pod from the Service’s Endpoints — requests stop going to it. Unlike liveness, failure does NOT restart the container.
Readiness vs Liveness — The Critical Difference
graph LR
subgraph "Liveness Failure"
LP[Probe fails] --> KILL[Container\nkilled] --> RESTART[Container\nrestarted]
end
subgraph "Readiness Failure"
RP[Probe fails] --> REMOVE[Pod removed\nfrom Endpoints] --> WAIT[Pod stays\nrunning\nNo traffic]
WAIT --> RECOVER[Probe passes\nagain] --> READD[Pod re-added\nto Endpoints]
end
Use readiness when the app can recover on its own (DB reconnect, cache warmup). Use liveness when the app needs a restart to recover.
Readiness Probe YAML
spec:
containers:
- name: app
image: myapp:latest
readinessProbe:
httpGet:
path: /health/ready # Different endpoint from liveness!
port: 8080
initialDelaySeconds: 5 # Start checking quickly
periodSeconds: 5 # Check frequently for fast recovery
timeoutSeconds: 3
failureThreshold: 3 # 3 failures = not ready (15s window)
successThreshold: 2 # Need 2 passes to be considered ready again
Note successThreshold: 2 — after a readiness failure, require 2 consecutive successes before re-adding to the load balancer. This prevents flapping.
What Your Readiness Endpoint Should Check
Unlike liveness, readiness CAN check external dependencies — because the consequence is just traffic removal, not restart:
# ✅ Readiness checks dependencies — this is OK
@app.get("/health/ready")
def readiness():
# Check database connectivity
try:
db.execute("SELECT 1")
except Exception:
raise HTTPException(503, "Database not reachable")
# Check cache is populated
if not cache.is_warmed_up():
raise HTTPException(503, "Cache not ready")
# Check connection pool
if db_pool.available_connections() < 2:
raise HTTPException(503, "Connection pool exhausted")
return {"status": "ready"}
Check | Liveness | Readiness
-------------------|----------|----------
Internal state | ✅ Yes | ✅ Yes
Database up? | ❌ No | ✅ Yes
Cache warmed? | ❌ No | ✅ Yes
External API ok? | ❌ No | ✅ Yes
Zero-Downtime Rolling Updates with Readiness
This is where readiness probes really shine. In Chapter 4, we saw that Deployments wait for pods to be Ready before removing old pods:
sequenceDiagram
participant D as Deployment Controller
participant NEW as New Pod
participant OLD as Old Pod
participant SVC as Service Endpoints
D->>NEW: Create new pod
NEW->>NEW: Start, connect to DB, warm cache
NEW-->>D: Readiness probe fails (30s)
Note over SVC: Old pod still in Endpoints
NEW-->>D: Readiness probe passes ✅
D->>SVC: Add new pod to Endpoints
D->>OLD: Remove old pod from Endpoints
D->>OLD: Terminate old pod
Without a readiness probe, the new pod gets traffic immediately after the container starts — before your app has connected to the database. Users see errors.
The READY: 0/1 State
When readiness fails, the pod shows 0/1 in READY but remains Running:
kubectl get pods
# NAME READY STATUS RESTARTS AGE
# my-app-xxx 0/1 Running 0 2m ← not ready, no traffic
# my-app-yyy 1/1 Running 0 5m ← ready, serving traffic
# Check why a pod is not ready
kubectl describe pod my-app-xxx | grep -A5 "Readiness:"
kubectl describe pod my-app-xxx | tail -20 # Events section
Try It
# Simulate an app that starts "not ready" for 20 seconds
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: readiness-demo
labels:
app: readiness-demo
spec:
containers:
- name: app
image: nginx:1.25
readinessProbe:
exec:
command:
- sh
- -c
- "test -f /tmp/ready"
initialDelaySeconds: 2
periodSeconds: 3
resources:
limits:
memory: "64Mi"
cpu: "100m"
EOF
# Pod is Running but NOT ready (file doesn't exist yet)
kubectl get pod readiness-demo -w &
sleep 5
# Mark the pod as ready
kubectl exec readiness-demo -- touch /tmp/ready
sleep 5
kill %1
# Verify the pod became ready
kubectl get pod readiness-demo
Expected progression:
NAME READY STATUS RESTARTS AGE
readiness-demo 0/1 Running 0 5s ← not ready
readiness-demo 1/1 Running 0 12s ← ready after touch!
kubectl delete pod readiness-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Readiness = traffic gate | Failure removes from Endpoints; no restart |
| 2 | Can check dependencies | Unlike liveness — DB/cache checks are fine here |
| 3 | Critical for rolling updates | Prevents traffic to not-yet-ready new pods |
| 4 | successThreshold: 2 | Prevents flapping when recovering |
✅ Quick Check
Q1: A pod’s readiness probe fails. The pod has replicas: 3. How many pods serve traffic?
Answer
Only 2. The failing pod is removed from the Service Endpoints — requests only go to the 2 healthy pods. The failing pod stays running (it's not restarted), and will be re-added to Endpoints automatically once the probe passes again.Q2: During a rolling update, should the Deployment use readiness or liveness to gate old pod removal?
Answer
**Readiness**. The Deployment controller checks if the new pod is in `Ready` state (readiness probe passing) before removing old pods. Liveness has nothing to do with the rollout gating — it only triggers restarts. A working readiness probe is the primary mechanism for zero-downtime rolling updates.Q3: You want the pod to receive traffic only after it has loaded a 500MB ML model into memory (takes ~2 minutes). How do you configure this?
Answer
Use a readiness probe with `initialDelaySeconds: 90` and a `path: /health/ready` endpoint that returns 200 only after the model is loaded. Also consider a startup probe (section 10.4) to be more precise. The readiness probe ensures zero traffic until the model is ready, and if loading fails, the pod stays unready indefinitely (but doesn't restart — use liveness for failure recovery).10.4 Startup Probes — Slow-Starting Apps
⏱️ ~4 min read
TL;DR: A startup probe disables liveness and readiness checks until it passes. This solves the “my app takes 3 minutes to start and liveness keeps killing it” problem — without setting a huge
initialDelaySecondson every probe.
The Problem: Slow-Starting Apps vs Liveness
Without startup probes, slow-starting apps face a dilemma:
App startup time: 120 seconds
Option A: initialDelaySeconds: 120 on liveness
→ Works for startup, but if the app deadlocks at runtime,
it takes 120 + (failures × period) seconds to detect
Option B: initialDelaySeconds: 10 on liveness
→ App is still starting at 10s → liveness fails → container restarts
→ Restart loop! App never finishes starting!
Startup probe solution:
Startup probe runs until it passes (max: failureThreshold × periodSeconds)
→ Only then do liveness and readiness probes activate
→ Liveness can have short initialDelaySeconds because startup handles the slow part
Startup Probe YAML
spec:
containers:
- name: app
image: legacy-jvm-app:latest
# Startup probe: keep checking until success, give up to 5 min
startupProbe:
httpGet:
path: /health/started
port: 8080
periodSeconds: 10
failureThreshold: 30 # 30 × 10s = 5 minutes max wait
successThreshold: 1
# These only activate AFTER startup probe passes
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 20
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
The Startup Probe Timeline
sequenceDiagram
participant K as Kubernetes
participant S as Startup Probe
participant L as Liveness Probe
participant R as Readiness Probe
K->>S: Container starts
loop Every 10 seconds (up to 30 failures = 5 min)
S->>K: Check /health/started
K-->>S: 503 (still starting...)
end
K->>S: Check /health/started → 200 ✅
Note over S: Startup probe PASSED
Note over L,R: Liveness and Readiness probes ACTIVATED
K->>L: Begin liveness checks (every 20s)
K->>R: Begin readiness checks (every 5s)
When to Use a Startup Probe
Use startup probes when:
- App startup time is variable or long (JVM apps, model loading, DB migrations on startup)
- Startup time is longer than your ideal liveness
initialDelaySeconds - You want accurate liveness detection (small
initialDelaySeconds) without startup kill
# Java app with startup probe
startupProbe:
exec:
command:
- sh
- -c
- "curl -f http://localhost:8080/actuator/health/startup || exit 1"
failureThreshold: 60 # 60 × 10s = 10 minutes max startup window
periodSeconds: 10
💡 Tip:
failureThreshold × periodSeconds= maximum startup window. Set this generously — if the app takes 3 minutes in production, allow 6–8 minutes for headroom. The probe passes as soon as the app is ready; the full window is only used if something is wrong.
Try It
# Simulate a slow-starting app: writes /tmp/started after a 20-second delay
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: startup-demo
spec:
initContainers:
- name: slow-init
image: busybox
command: ["sh", "-c", "echo 'Starting slowly...'; sleep 20; echo 'started' > /shared/started"]
volumeMounts:
- name: shared
mountPath: /shared
resources:
limits:
memory: "16Mi"
cpu: "50m"
volumes:
- name: shared
emptyDir: {}
containers:
- name: app
image: nginx:1.25
startupProbe:
exec:
command: ["sh", "-c", "test -f /shared/started"]
periodSeconds: 5
failureThreshold: 12 # 12 × 5s = 60s max startup window
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 0 # Safe! Startup probe covers the wait
periodSeconds: 10
failureThreshold: 3
volumeMounts:
- name: shared
mountPath: /shared
resources:
limits:
memory: "64Mi"
cpu: "100m"
EOF
# Watch — pod is not ready until the startup probe passes (~20s)
kubectl get pod startup-demo -w
Expected progression:
NAME READY STATUS RESTARTS AGE
startup-demo 0/1 Running 0 5s ← startup probe pending
startup-demo 0/1 Running 0 15s ← still starting
startup-demo 1/1 Running 0 22s ← startup passed!
kubectl delete pod startup-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Startup probe gates liveness/readiness | Both are disabled until startup passes |
| 2 | failureThreshold × periodSeconds = max window | Set generously for slow starters |
| 3 | Liveness can have tight timing | Startup handles the long wait |
| 4 | Use for JVM/ML/legacy apps | Any app with variable or long startup |
✅ Quick Check
Q1: A startup probe has failureThreshold: 30 and periodSeconds: 10. The app starts in 45 seconds. When do liveness and readiness probes activate?
Answer
At approximately t=45s — when the startup probe first succeeds. The startup probe runs every 10 seconds. At t=50s (the next check after 45s), it passes. Liveness and readiness probes activate immediately after that first success. The 30-failure limit (5-minute window) is never reached.Q2: The startup probe fails more than failureThreshold times. What happens?
Answer
Kubernetes kills and restarts the container — the same action as a liveness failure. The startup probe failing its maximum means the app couldn't start in the allotted time. The pod shows `RESTARTS: 1` and starts over.Q3: Can you have a startup probe without a liveness probe?
Answer
Yes, but it's unusual and mostly pointless. The startup probe's purpose is to protect liveness from killing slow starters. Without a liveness probe, there's nothing to protect. You'd use a startup probe alone only if you want to delay readiness checks during startup (but a readiness probe's `initialDelaySeconds` or `failureThreshold` could handle that too).10.5 Graceful Shutdown and preStop Hooks
⏱️ ~5 min read
TL;DR: When Kubernetes terminates a pod, it sends SIGTERM and waits
terminationGracePeriodSeconds(default 30s) before force-killing with SIGKILL. Use apreStophook and handle SIGTERM to drain connections cleanly and avoid dropped requests.
The Pod Termination Sequence
sequenceDiagram
participant K as Kubernetes
participant EP as Endpoints Controller
participant LB as Load Balancer / kube-proxy
participant POD as Pod
K->>EP: Remove pod from Endpoints
K->>POD: Execute preStop hook (if defined)
POD->>POD: preStop completes (or times out)
K->>POD: Send SIGTERM to PID 1
Note over LB: iptables rules updated (async!)
POD->>POD: Handle SIGTERM\nStop accepting new connections\nFinish in-flight requests
K->>POD: Wait terminationGracePeriodSeconds (30s default)
POD->>K: Process exits cleanly ✅
Note over K: (or)
K->>POD: Send SIGKILL after grace period ⚠️
The race condition: iptables rules are updated asynchronously — there’s a window where the pod is receiving SIGTERM but the load balancer is still sending it traffic. The preStop sleep hack fills this gap.
The preStop Sleep Hack
The most widely-used production pattern:
spec:
containers:
- name: app
image: myapp:latest
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # Give iptables time to update
# Or for web servers:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "nginx -s quit; while killall -0 nginx; do sleep 1; done"]
Why sleep 5?
- Kubernetes removes the pod from Endpoints and sends
preStopsimultaneously - But iptables rules on all nodes take a few seconds to propagate
- During that window, traffic can still reach the pod
- A 5-second sleep ensures iptables has caught up before SIGTERM is sent
- Result: pod stops receiving new connections, finishes existing ones
terminationGracePeriodSeconds
spec:
terminationGracePeriodSeconds: 60 # Default is 30 seconds
containers:
- name: app
image: myapp:latest
Set this based on your longest expected request duration:
- Web API with p99 latency of 5s → 30s is fine
- Data processing job that takes 2 minutes → set to 180s
- Batch job →
terminationGracePeriodSeconds: 3600
⚠️ Warning:
preStoptime counts againstterminationGracePeriodSeconds. If your hook takes 25s and the grace period is 30s, the app only has 5s to finish requests after SIGTERM.
Handling SIGTERM in Your App
Your app should handle SIGTERM properly — don’t let it die immediately:
# Python example
import signal, sys, time
in_shutdown = False
def handle_sigterm(signum, frame):
global in_shutdown
in_shutdown = True
print("SIGTERM received — finishing in-flight requests...")
# Don't exit here — let the request handlers finish
signal.signal(signal.SIGTERM, handle_sigterm)
@app.get("/")
def handle_request():
if in_shutdown:
return Response("Service Unavailable", 503)
# Process normally
return process()
# Shutdown when all requests done
while not in_shutdown:
time.sleep(0.1)
server.shutdown() # Wait for active connections to drain
sys.exit(0)
// Node.js example
process.on('SIGTERM', () => {
console.log('SIGTERM received. Draining connections...');
server.close(() => {
console.log('All connections drained. Exiting.');
process.exit(0);
});
});
Full Production Pod Spec
Combining everything — probes + graceful shutdown:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: myapp:latest
ports:
- containerPort: 8080
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
startupProbe:
httpGet:
path: /health/started
port: 8080
periodSeconds: 5
failureThreshold: 24 # 2 minute window
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0 # Startup probe handles the wait
periodSeconds: 20
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 3
successThreshold: 2
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | SIGTERM → 30s grace → SIGKILL | Handle SIGTERM or your app is force-killed |
| 2 | preStop: sleep 5 | Closes the iptables update race condition |
| 3 | terminationGracePeriodSeconds | Set ≥ your longest request duration |
| 4 | preStop time counts against grace period | Don’t let preStop eat all your shutdown time |
✅ Quick Check
Q1: Your terminationGracePeriodSeconds is 30 and preStop sleeps for 5 seconds. An in-flight request takes 28 seconds. Does it complete cleanly?
Answer
No. Timeline: `preStop` sleeps 5s → SIGTERM sent at t=5s → 25s remaining for the app → request needs 28s → SIGKILL fires at t=30s → request is aborted. To fix: either increase `terminationGracePeriodSeconds` to 40+ or reduce preStop sleep time.Q2: You don’t handle SIGTERM in your Node.js app — it uses the default behavior. What happens when the pod is terminated?
Answer
Node.js exits immediately on SIGTERM (default signal behavior). Active requests are dropped mid-flight. The graceful period is wasted because the process exits before it's used. Always register a SIGTERM handler in production apps.Q3: Why is removing a pod from Endpoints and sending SIGTERM done simultaneously instead of sequentially?
Answer
Kubernetes designed them to happen in parallel for speed. In theory, Endpoints removal + kube-proxy iptables updates should complete before the preStop hook finishes and SIGTERM is sent. In practice, iptables propagation has network latency, so the preStop sleep is the safety net for that async gap.Lab: Probes and Shutdown Drills
⏱️ ~28 min hands-on
| Prerequisites | Chapter 10 sections 10.1–10.5 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Configure all three probe types, observe each failure mode, simulate app crashes and recoveries, and test graceful shutdown |
Objectives
- Configure a liveness probe and trigger a restart
- Configure a readiness probe and watch pod leave/rejoin Endpoints
- Configure a startup probe for a slow-starting app
- Observe what happens without a readiness probe during rolling updates
- Test graceful shutdown with
preStopandterminationGracePeriodSeconds
Setup
kubectl create namespace health-lab
kubectl config set-context --current --namespace=health-lab
Exercise 1: Liveness Probe — Detect and Restart a Stuck App
What we’re doing: Deploy an app that becomes unhealthy after 30 seconds and watch it get restarted.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: liveness-test
namespace: health-lab
spec:
containers:
- name: app
image: busybox
# App runs fine for 30s, then makes itself "sick"
command:
- sh
- -c
- |
touch /tmp/healthy
sleep 30
rm /tmp/healthy
echo "App is now sick!"
sleep 9999
livenessProbe:
exec:
command:
- test
- -f
- /tmp/healthy
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3 # 3 × 5s = 15s to detect failure
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
# Watch the pod — it starts healthy, goes sick around 30s, restarts around 45s
echo "Watch for RESTARTS count to increment around t=45s..."
kubectl get pod liveness-test -n health-lab -w &
# Wait for a restart (takes ~45s)
sleep 60
kill %1
kubectl describe pod liveness-test -n health-lab | grep -E "Restart|Reason|Last State"
kubectl get pod liveness-test -n health-lab
Expected:
NAME READY STATUS RESTARTS AGE
liveness-test 1/1 Running 0 30s
liveness-test 1/1 Running 0 35s ← /tmp/healthy removed
liveness-test 0/1 Running 0 40s ← probe failing
liveness-test 0/1 Running 1 48s ← restarted!
liveness-test 1/1 Running 1 52s ← healthy again (file recreated)
Exercise 2: Readiness Probe — Traffic Gate
What we’re doing: Watch a pod get removed from and re-added to Service Endpoints based on readiness.
# Deploy a 3-replica Deployment with readiness probe
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: readiness-demo
namespace: health-lab
spec:
replicas: 3
selector:
matchLabels:
app: readiness-demo
template:
metadata:
labels:
app: readiness-demo
spec:
containers:
- name: nginx
image: nginx:1.25
readinessProbe:
httpGet:
path: /ready
port: 80
initialDelaySeconds: 2
periodSeconds: 3
failureThreshold: 2
resources:
limits:
memory: "32Mi"
cpu: "50m"
---
apiVersion: v1
kind: Service
metadata:
name: readiness-svc
namespace: health-lab
spec:
selector:
app: readiness-demo
ports:
- port: 80
EOF
# Wait for pods to start (they'll be NOT READY — /ready doesn't exist in default nginx)
kubectl rollout status deployment/readiness-demo -n health-lab --timeout=30s || true
kubectl get pods -n health-lab -l app=readiness-demo
kubectl get endpoints readiness-svc -n health-lab
You’ll see the Endpoints are empty because nginx doesn’t have a /ready path — readiness probe fails:
# Check Endpoints — all pods not ready
kubectl get endpoints readiness-svc -n health-lab
# Expected: readiness-svc <none>
# Now make ONE pod ready by creating the /ready file
FIRST_POD=$(kubectl get pods -n health-lab -l app=readiness-demo -o name | head -1)
kubectl exec -n health-lab $FIRST_POD -- \
sh -c "echo 'OK' > /usr/share/nginx/html/ready"
# Wait a few seconds
sleep 10
# Only that one pod should be in Endpoints now
kubectl get endpoints readiness-svc -n health-lab
kubectl get pods -n health-lab -l app=readiness-demo
Expected:
NAME READY STATUS RESTARTS
readiness-demo-xxx-aaa 1/1 Running 0 ← only this one is ready
readiness-demo-xxx-bbb 0/1 Running 0
readiness-demo-xxx-ccc 0/1 Running 0
Endpoints: 10.244.0.x:80 ← only one IP
Make the rest ready and watch Endpoints grow:
# Make all pods ready
for POD in $(kubectl get pods -n health-lab -l app=readiness-demo -o name); do
kubectl exec -n health-lab $POD -- \
sh -c "echo 'OK' > /usr/share/nginx/html/ready"
done
sleep 10
kubectl get endpoints readiness-svc -n health-lab
# Expected: 3 IPs in the Endpoints list
Exercise 3: Startup Probe for a Slow App
What we’re doing: Simulate a JVM-style slow-starting app that would kill itself with a basic liveness probe.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: slow-start
namespace: health-lab
spec:
containers:
- name: app
image: busybox
command:
- sh
- -c
- |
echo "Starting... (takes 25 seconds)"
sleep 25
echo "Started! Creating health file."
touch /tmp/started /tmp/live /tmp/ready
sleep 9999
startupProbe:
exec:
command: ["test", "-f", "/tmp/started"]
periodSeconds: 5
failureThreshold: 10 # 50s max startup window
livenessProbe:
exec:
command: ["test", "-f", "/tmp/live"]
periodSeconds: 10
failureThreshold: 3
readinessProbe:
exec:
command: ["test", "-f", "/tmp/ready"]
periodSeconds: 5
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
echo "Watching pod... startup probe keeps liveness from killing it during 25s startup"
kubectl get pod slow-start -n health-lab -w &
sleep 35
kill %1
kubectl describe pod slow-start -n health-lab | grep -E "RESTARTS|Started|startup|liveness"
kubectl get pod slow-start -n health-lab
Expected: RESTARTS: 0 — the startup probe protected the pod during its long startup.
Exercise 4: Rolling Update — With vs Without Readiness
What we’re doing: Compare a rolling update that uses readiness probes vs one without.
# Update to a broken image — readiness probe prevents bad pods from serving traffic
kubectl set image deployment/readiness-demo nginx=nginx:1.25-broken-tag -n health-lab || true
# Actually, do it properly:
# First, remove the readiness probe from one pod manually to observe difference
# Check the rollout (it should fail/pause because new pods can't become ready)
kubectl rollout status deployment/readiness-demo -n health-lab --timeout=20s || \
echo "Rollout paused — new pods not becoming ready"
kubectl get pods -n health-lab -l app=readiness-demo
# Endpoints still has the old ready pods
kubectl get endpoints readiness-svc -n health-lab
# Rollback
kubectl rollout undo deployment/readiness-demo -n health-lab
kubectl rollout status deployment/readiness-demo -n health-lab
Exercise 5: Graceful Shutdown Test
What we’re doing: Deploy a pod with a preStop hook and observe it handles termination cleanly.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: graceful-shutdown
namespace: health-lab
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: busybox
command:
- sh
- -c
- |
# Register SIGTERM handler
trap 'echo "[$(date)] SIGTERM received. Finishing work..."; sleep 3; echo "[$(date)] Done. Exiting cleanly."; exit 0' TERM
echo "[$(date)] App started"
while true; do
echo "[$(date)] Processing..."
sleep 2
done
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "echo '[preStop] Draining connections... sleeping 3s'; sleep 3"
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod graceful-shutdown -n health-lab --for=condition=Ready --timeout=30s
# Start watching logs in background
kubectl logs graceful-shutdown -n health-lab -f &
LOGS_PID=$!
sleep 5
# Delete the pod and observe the shutdown sequence
echo ""
echo "--- Deleting pod ---"
time kubectl delete pod graceful-shutdown -n health-lab --grace-period=30
kill $LOGS_PID 2>/dev/null
Expected log sequence:
[timestamp] App started
[timestamp] Processing...
[timestamp] Processing...
[preStop] Draining connections... sleeping 3s ← preStop runs
[timestamp] SIGTERM received. Finishing work... ← SIGTERM after preStop
[timestamp] Done. Exiting cleanly. ← Clean exit
🔥 Break It! Challenge
What happens when
terminationGracePeriodSecondsis too short?
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: force-killed
namespace: health-lab
spec:
terminationGracePeriodSeconds: 5 # Only 5 seconds!
containers:
- name: app
image: busybox
command:
- sh
- -c
- |
trap 'echo "SIGTERM received — but I need 30s to clean up!"; sleep 30' TERM
echo "App running"
sleep 9999
resources:
limits:
memory: "16Mi"
cpu: "50m"
EOF
kubectl wait pod force-killed -n health-lab --for=condition=Ready --timeout=30s
# Watch the deletion — app wants 30s but only gets 5s
echo "Deleting pod — watch SIGKILL fire after 5 seconds..."
time kubectl delete pod force-killed -n health-lab --grace-period=5
Expected:
Deleting pod... (took ~5 seconds, not 30)
The pod is SIGKILL’d — your “cleanup” code never finished. In production, this means dropped database transactions, incomplete file writes, or lost in-flight requests.
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace health-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Liveness restart | App became sick → probe detected → container restarted |
| 2 | Readiness traffic gate | Pods with failing probe removed from Endpoints |
| 3 | Readiness for rolling updates | Bad rollout paused; old pods kept serving |
| 4 | Startup probe protection | Slow-starting app: RESTARTS: 0 thanks to startup probe |
| 5 | Graceful shutdown sequence | preStop → SIGTERM → clean exit observed in logs |
| 6 | Force-kill danger | Short grace period = SIGKILL before cleanup finishes |
Chapter 11: Resource Management — Requests, Limits, and Autoscaling
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Set CPU and memory requests/limits correctly, understand QoS classes, configure HPA to auto-scale on CPU/memory, and avoid the most common resource management mistakes that cause production outages.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 11.1 | Requests and Limits — The Fundamentals | ~6 min |
| 11.2 | QoS Classes and Pod Eviction | ~5 min |
| 11.3 | Horizontal Pod Autoscaler (HPA) | ~7 min |
| 11.4 | Vertical Pod Autoscaler (VPA) | ~4 min |
| 11.5 | Cluster Autoscaler | ~3 min |
| 11.6 | 🔬 Lab: Resource Limits and HPA in Action | ~30 min |
Prerequisites
- Completed Chapter 4 (Deployments) and Chapter 9 (Namespaces/RBAC)
minikube statusshowsRunning
11.1 Requests and Limits — The Fundamentals
⏱️ ~6 min read
TL;DR:
requestsis what the scheduler uses to place your pod on a node (guaranteed capacity).limitsis the maximum your container can consume before being throttled (CPU) or killed (memory). Never omit both — it causes chaos.
The Two Numbers You Must Set
resources:
requests: # "I need at least this much"
cpu: "250m" # ← Scheduler uses this to find a node with enough capacity
memory: "128Mi" # ← Guaranteed allocation
limits: # "I must never exceed this"
cpu: "500m" # ← Throttled if exceeded (not killed)
memory: "256Mi" # ← OOMKilled if exceeded (container killed)
What each does:
| Field | Used By | What Happens If Exceeded |
|---|---|---|
requests.cpu | Scheduler (node placement) | N/A — just a reservation |
requests.memory | Scheduler (node placement) | N/A — just a reservation |
limits.cpu | Kernel cgroups | Throttled (slowed down, not killed) |
limits.memory | Kernel cgroups | Container killed with OOMKilled |
CPU Units
cpu: "1" # 1 full CPU core
cpu: "0.5" # 500 millicores = half a core
cpu: "250m" # 250 millicores = quarter core (most common)
cpu: "100m" # 100 millicores = 0.1 core
1000m = 1 CPU = one hyperthread on a cloud node. CPU throttling is smooth — a container at its CPU limit gets slowed down proportionally, not killed.
Memory Units
memory: "128Mi" # Mebibytes — use this (powers of 2)
memory: "1Gi" # Gibibytes
memory: "256Mi"
memory: "128M" # Megabytes (powers of 10) — avoid confusion, use Mi
Memory limits are hard. Exceeding the memory limit causes an immediate OOMKilled (Out of Memory Killed) — the container is restarted. This is the most common cause of mysterious pod restarts in production.
How the Scheduler Uses Requests
graph LR
POD["Pod\nrequests:\n cpu: 250m\n memory: 128Mi"] --> SCHED[Scheduler]
SCHED -->|"Finds a node with\n≥250m free CPU\n≥128Mi free memory"| NODE[Node\n4 CPU / 16Gi total\n3.5 CPU / 14Gi free]
NODE --> PLACE[Pod placed ✅]
The scheduler does bin packing based on requests — it doesn’t look at actual usage, only declared requests. This means:
- Overcommit by setting requests too low → nodes get overloaded
- Underutilize by setting requests too high → waste money
Common Patterns
# Web API — bursty CPU, bounded memory
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m" # Allow bursting to 5× request
memory: "256Mi" # Memory rarely changes at runtime
# Background worker — steady CPU, larger memory
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
# Init container — usually runs once, higher CPU burst OK
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "2" # High limit to finish fast
memory: "256Mi"
The No-Resources Anti-Pattern
# ❌ Anti-pattern: no resources set
containers:
- name: app
image: myapp:latest
# No resources block — pod becomes BestEffort QoS!
Without resources, the pod:
- Gets
BestEffortQoS class (lowest priority) - Is first to be evicted under node pressure
- Provides no data for the scheduler to make good placement decisions
- Can consume all node resources, affecting other pods
⚠️ Warning: In production, always set resource requests and limits. Use a LimitRange (Chapter 9) to enforce this namespace-wide.
Right-Sizing: How to Find Good Values
# 1. Deploy with generous limits first
# 2. After running in production for a few days, check actual usage
# See actual CPU and memory usage (requires metrics-server)
kubectl top pods
kubectl top pods --sort-by=memory
kubectl top pods --sort-by=cpu
# Check a specific pod
kubectl top pod my-pod
# VPA (next sections) can recommend values automatically
# Check if metrics-server is running on Minikube
minikube addons enable metrics-server
kubectl top nodes # Should show CPU/memory usage
Try It
# Deploy with resource constraints
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: resource-demo
spec:
containers:
- name: cpu-hog
image: busybox
command: ["sh", "-c", "while true; do echo scale=2000; bc /dev/urandom | head -1; done 2>/dev/null | head -c 100000000 > /dev/null; sleep 9999"]
resources:
requests:
cpu: "100m"
memory: "32Mi"
limits:
cpu: "200m" # CPU capped — will be throttled, not killed
memory: "64Mi"
EOF
kubectl wait pod resource-demo --for=condition=Ready --timeout=30s
# Check the pod's actual resource usage
kubectl top pod resource-demo
# See resource specs via describe
kubectl describe pod resource-demo | grep -A8 "Limits:"
# Cleanup
kubectl delete pod resource-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | requests = scheduler input | Pod placed only on nodes with enough free capacity |
| 2 | limits.cpu = throttle | Excess CPU throttled; container not killed |
| 3 | limits.memory = kill | Exceed memory limit → OOMKilled immediately |
| 4 | Never skip resources | No resources = BestEffort = first evicted |
✅ Quick Check
Q1: A pod requests 500m CPU on a node with 600m free. Another pod needs 400m CPU. Can the second pod be scheduled on the same node?
Answer
No. The scheduler sees 600m total free, but the first pod's 500m request reserves 500m. Only 100m appears available to the scheduler. The second pod's 400m request can't be satisfied, so it's scheduled elsewhere. (Even if the first pod is only using 100m at runtime — the scheduler uses requests, not actual usage.)Q2: Your pod’s memory limit is 256Mi. It allocates 260Mi of memory. What happens?
Answer
The container is killed with `OOMKilled` (Out of Memory). Kubernetes restarts it. If it keeps OOMKilling, the pod enters `CrashLoopBackOff`. The fix: increase the memory limit, fix a memory leak in the app, or reduce the app's memory footprint.Q3: A pod uses 800m CPU but its limit is 500m. Does Kubernetes kill it?
Answer
No. CPU throttling is applied — the kernel cgroup limits the container's CPU time to 500m equivalent. The container continues running but slower. Unlike memory, CPU over-limit doesn't cause a kill — it just reduces the container's processing speed proportionally.11.2 QoS Classes and Pod Eviction
⏱️ ~5 min read
TL;DR: Kubernetes assigns each pod a QoS (Quality of Service) class based on its resource spec. When a node runs out of resources, pods are evicted in QoS order —
BestEffortfirst, thenBurstable,Guaranteedlast.
The Three QoS Classes
| Class | Requirements | Eviction Priority |
|---|---|---|
| Guaranteed | requests == limits for ALL containers, for both CPU and memory | Last to be evicted |
| Burstable | At least one container has a request or limit set | Middle |
| BestEffort | NO resource spec on ANY container | First evicted |
# Guaranteed — requests == limits exactly
resources:
requests:
cpu: "500m"
memory: "256Mi"
limits:
cpu: "500m" # ← Same as request
memory: "256Mi" # ← Same as request
# Burstable — requests < limits (most production apps)
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m" # ← Different from request
memory: "256Mi"
# BestEffort — nothing set (never do this in production)
# (no resources block at all)
What Eviction Looks Like
Under node memory pressure (node is running out of memory):
graph TD
PRESSURE[Node Memory Pressure\nkubelet detects < 10% free] --> E1
E1[Evict BestEffort pods first] -->|Still under pressure?| E2
E2[Evict Burstable pods\nby usage over request ratio] -->|Still under pressure?| E3
E3[Evict Guaranteed pods\nas last resort]
style E1 fill:#ff4444,color:white
style E2 fill:#ff8800,color:white
style E3 fill:#00aa00,color:white
Within the same QoS class, eviction order is determined by how much a pod is using relative to its request:
- Pod using 200Mi with a 128Mi request (156% of request) → evicted before
- Pod using 200Mi with a 500Mi request (40% of request)
Checking a Pod’s QoS Class
kubectl describe pod my-pod | grep "QoS Class:"
# QoS Class: Burstable
# Or with jsonpath
kubectl get pod my-pod -o jsonpath='{.status.qosClass}'
Setting Guaranteed for Critical Pods
For your most critical pods (databases, caches), use Guaranteed:
# PostgreSQL — Guaranteed QoS
spec:
containers:
- name: postgres
image: postgres:16
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "1" # ← Exactly equal
memory: "2Gi" # ← Exactly equal
The tradeoff: Guaranteed pods can’t burst above their limit. You’re trading flexibility for eviction protection.
Eviction vs OOMKilled — The Difference
Both result in pod restarts, but they’re different mechanisms:
| Event | Trigger | Who Does It |
|---|---|---|
| OOMKilled | Container exceeds its own memory limit | Linux kernel / cgroups |
| Eviction | Node overall memory pressure | kubelet (evicts entire pods) |
# Check if a pod was OOMKilled (container exceeded its own limit)
kubectl describe pod my-pod | grep -A5 "Last State:"
# Last State: Terminated
# Reason: OOMKilled ← Container exceeded its memory limit
# Exit Code: 137
# Check node conditions for eviction pressure
kubectl describe node my-node | grep -A5 "Conditions:"
# MemoryPressure True ← kubelet will start evicting BestEffort pods
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Guaranteed = requests == limits | Best protection against eviction |
| 2 | Burstable = most production apps | Good balance of flexibility and protection |
| 3 | BestEffort = no resources | First evicted under node pressure — avoid in production |
| 4 | OOMKilled ≠ Eviction | OOMKilled is per-container; eviction is per-pod |
✅ Quick Check
Q1: A pod has memory requests: 512Mi and limits: 1Gi. It’s using 900Mi. Node memory pressure occurs. Is this pod evicted before a pod using 400Mi with no limits?
Answer
No — the no-limits pod is `BestEffort` (evicted first). The 900Mi pod is `Burstable` (evicted second). BestEffort is always evicted before Burstable, regardless of actual usage. The 900Mi pod is safe until all BestEffort pods are gone.Q2: Your database pod needs eviction protection but also needs occasional memory bursting for large queries. What QoS class do you use?
Answer
`Burstable` — set `requests` to the baseline memory needed (e.g., 2Gi) and `limits` to the max burst capacity (e.g., 4Gi). This gives you more eviction protection than `BestEffort` while allowing memory bursting. For true eviction immunity, use `Guaranteed` (requests == limits) and accept that bursting above the limit causes OOMKill.Q3: An init container has no resources set but the main container has full requests/limits. What QoS class is the pod?
Answer
`Burstable`. For `Guaranteed` QoS, ALL containers (including init containers) must have matching requests and limits. A single container without resources drops the entire pod to at most `Burstable` (and if NO container has resources, it's `BestEffort`).11.3 Horizontal Pod Autoscaler (HPA)
⏱️ ~7 min read
TL;DR: HPA watches CPU (or custom) metrics and automatically scales your Deployment’s replica count up when load increases and down when it drops. It’s the standard autoscaling primitive for stateless apps.
How HPA Works
graph LR
subgraph "HPA Control Loop"
MS[Metrics Server\nPolls pod metrics] -->|Every 15s| HPA[HPA Controller]
HPA -->|Calculates desired replicas| MATH["desiredReplicas =\ncurrentReplicas × (currentUtil / targetUtil)"]
MATH --> DEPLOY[Deployment\nscale replicas]
end
DEPLOY --> PODS[Pods\n2→4→6→8→4→2]
The formula:
desiredReplicas = ceil(currentReplicas × (currentMetricValue / targetMetricValue))
Example: 3 pods, 80% CPU usage, target 50%:
desiredReplicas = ceil(3 × (80 / 50)) = ceil(4.8) = 5 pods
Prerequisites: Metrics Server
HPA requires metrics-server to be running:
# Enable on Minikube
minikube addons enable metrics-server
# Verify it's working
kubectl top pods
kubectl top nodes
Creating an HPA
Imperative (quick):
kubectl autoscale deployment my-app \
--cpu-percent=50 \ # Target: keep average CPU at 50%
--min=2 \ # Never scale below 2 replicas
--max=10 # Never scale above 10 replicas
Declarative YAML (preferred):
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app # The Deployment to scale
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50 # Target: 50% of requests.cpu
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70 # Also scale on memory
⚠️ Warning: HPA requires
resources.requests.cputo be set on the pod. HPA calculates CPU utilization as(actual CPU usage / requests.cpu) × 100%. Withoutrequests.cpu, HPA can’t compute utilization and will not scale.
Watching HPA in Action
# See current state
kubectl get hpa my-app-hpa
# Expected output:
# NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
# my-app-hpa Deployment/my-app 45%/50% (cpu) 2 10 3 5m
# ^^^
# current utilization
# Watch it change
kubectl get hpa -w
# Describe for full details
kubectl describe hpa my-app-hpa
Describe output includes:
Current Metrics:
resource cpu on pods: 45% (target 50%)
Conditions:
ScalingActive True ValidMetricFound
AbleToScale True ReadyForNewScale
ScalingLimited False DesiredWithinRange
Events:
SuccessfulRescale Scaled up to 5 replicas: "cpu utilization above target"
SuccessfulRescale Scaled down to 3 replicas: "all metrics below target"
Scale-Down Cooldown (Stabilization Window)
HPA scales up aggressively but scales down conservatively to prevent flapping:
spec:
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # Default: 300s (5 min)
# Won't scale down until metrics are low for 5 consecutive minutes
policies:
- type: Pods
value: 1 # Remove at most 1 pod per period
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0 # Default: 0 (scale up immediately)
policies:
- type: Percent
value: 100 # Double replicas per period if needed
periodSeconds: 60
💡 Tip: The default 5-minute scale-down window prevents thrashing when a traffic spike briefly drops. Only change it if your workload has very predictable, fast-changing traffic patterns.
Custom and External Metrics
Beyond CPU/memory, HPA can scale on:
metrics:
# Custom metric from your app (via Prometheus Adapter)
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: 1000 # 1000 RPS per pod
# External metric (e.g., SQS queue depth)
- type: External
external:
metric:
name: sqs_queue_depth
target:
type: AverageValue
averageValue: 30 # Scale when queue depth > 30 per pod
This requires a custom metrics adapter (Prometheus Adapter, KEDA) — beyond the scope of this chapter, but important to know it exists.
Try It
# Enable metrics-server
minikube addons enable metrics-server
sleep 30 # Give it time to collect initial metrics
# Deploy an app
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
spec:
replicas: 1
selector:
matchLabels:
app: php-apache
template:
metadata:
labels:
app: php-apache
spec:
containers:
- name: php-apache
image: registry.k8s.io/hpa-example
ports:
- containerPort: 80
resources:
requests:
cpu: "200m" # ← HPA requires this
limits:
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: php-apache
spec:
selector:
app: php-apache
ports:
- port: 80
EOF
kubectl rollout status deployment/php-apache
# Create HPA
kubectl autoscale deployment php-apache \
--cpu-percent=50 \
--min=1 \
--max=5
kubectl get hpa
Generate load in a separate terminal to watch scaling:
# Run this in a SEPARATE terminal to generate load
kubectl run load-generator \
--image=busybox \
--restart=Never \
-- sh -c "while sleep 0.01; do wget -q -O- http://php-apache; done"
# In this terminal, watch HPA scale up
kubectl get hpa -w # Watch CPU% rise and replicas increase
# After a minute or two, stop the load:
kubectl delete pod load-generator
# Watch HPA scale back down (takes ~5 minutes due to cooldown)
kubectl get hpa -w
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | HPA requires metrics-server | Enable with minikube addons enable metrics-server |
| 2 | requests.cpu is mandatory | HPA measures utilization as usage/request |
| 3 | Scales up fast, down slow | 5-min default cooldown prevents thrashing |
| 4 | Custom metrics via adapters | KEDA or Prometheus Adapter for queue depth, RPS |
✅ Quick Check
Q1: Your pod requests.cpu: 200m and is using 300m CPU. HPA target is 50%. What is the CPU utilization HPA sees?
Answer
150% — (300m / 200m) × 100 = 150%. HPA sees 150% utilization against a 50% target. Desired replicas = ceil(1 × 150/50) = 3 pods. HPA would scale to 3 replicas.Q2: You have HPA with minReplicas: 2. Your Deployment currently has replicas: 1 in its YAML. What happens when you apply the HPA?
Answer
HPA takes over replica management. It immediately scales to at least `minReplicas: 2` regardless of what the Deployment YAML says. From this point, the `replicas` field in the Deployment spec is managed by HPA — manual changes to it will be overridden by the next HPA reconciliation cycle.Q3: Traffic drops to zero for 2 minutes. HPA has a 5-minute scale-down window. Are replicas reduced?
Answer
No — not yet. The stabilization window requires metrics to stay below the target for the entire window. At 2 minutes, the window hasn't elapsed. After 5 continuous minutes below target, HPA will scale down. This prevents scaling down during a temporary traffic lull.11.4 Vertical Pod Autoscaler (VPA)
⏱️ ~4 min read
TL;DR: VPA automatically adjusts a pod’s resource requests and limits based on observed actual usage. It solves the “what should I set my requests to?” question — especially useful for workloads with stable traffic patterns.
VPA vs HPA
| HPA | VPA | |
|---|---|---|
| Scales | Number of replicas (out/in) | Pod size — CPU/memory requests (up/down) |
| Best for | Stateless, horizontally scalable apps | Stateful apps, JVM apps, ML workloads |
| Can combine? | ✅ Yes (on different metrics) | ⚠️ Careful — VPA + HPA on CPU can conflict |
| Requires restart? | No | Yes — VPA updates requests by recreating pods |
How VPA Works
graph LR
PODS[Running Pods\nActual usage data] -->|Collects metrics| REC[VPA Recommender\nML-based analysis]
REC -->|Generates recommendation| VPA[VPA Object]
VPA -->|Applies on pod restart\nor evicts to apply| POD[Pod with\nupdated requests]
VPA has three components:
- Recommender — analyzes historical usage and suggests optimal requests
- Updater — evicts pods that have outdated requests (to force a restart)
- Admission Plugin — mutates pod specs at creation time with VPA recommendations
VPA Modes
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: my-app-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
updatePolicy:
updateMode: "Auto" # Options: Off | Initial | Recreate | Auto
resourcePolicy:
containerPolicies:
- containerName: app
minAllowed:
cpu: "50m"
memory: "64Mi"
maxAllowed:
cpu: "4"
memory: "8Gi"
updateMode options:
| Mode | Behavior |
|---|---|
Off | Only generate recommendations — never apply them. Read with kubectl describe vpa. |
Initial | Apply recommendations only when pods are first created. No eviction of running pods. |
Recreate | Evict and recreate pods to apply updated recommendations. |
Auto | Currently same as Recreate. Future: in-place updates when K8s supports it. |
💡 Tip: Start with
updateMode: Offto see recommendations without disrupting running pods. Review for a few days, then switch toInitialfor new pods, thenAutofor full automation.
Reading VPA Recommendations
kubectl describe vpa my-app-vpa
Expected output:
Recommendation:
Container Recommendations:
- Container Name: app
Lower Bound:
Cpu: 25m
Memory: 64Mi
Target:
Cpu: 100m ← VPA recommends setting requests to this
Memory: 256Mi
Uncapped Target:
Cpu: 100m
Memory: 256Mi
Upper Bound:
Cpu: 500m
Memory: 512Mi
Use Target as your recommended requests values.
VPA on Minikube
VPA is not installed by default — it requires a separate installation:
# Clone the VPA repo and install (requires git)
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-up.sh
# Or just use updateMode: Off to see recommendations
# without installing the full VPA admission controller
On Minikube, VPA is primarily useful in Off mode for recommendations. Full Auto mode is better tested in cloud clusters.
VPA + HPA Together
Use VPA for memory (VPA recommends) and HPA for CPU scaling (HPA scales replicas):
# HPA on CPU only — let VPA handle memory right-sizing
spec:
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
# Don't add memory HPA if VPA manages memory
# VPA on memory only — don't conflict with HPA's CPU scaling
spec:
resourcePolicy:
containerPolicies:
- containerName: app
controlledResources: ["memory"] # Only manage memory, not CPU
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | VPA adjusts pod size, not count | Right-sizes requests/limits based on actual usage |
| 2 | Start with Off mode | Get recommendations without disruption |
| 3 | Requires pod restart to apply | VPA evicts pods to apply new request values |
| 4 | Combine carefully with HPA | Use VPA for memory, HPA for CPU/replica count |
✅ Quick Check
Q1: VPA recommends cpu: 100m for your pod which currently has cpu: 500m request. Will VPA immediately change the running pod?
Answer
Not immediately. In `Auto`/`Recreate` mode, VPA evicts the pod (deletes it) and the new replacement pod starts with the updated request of 100m. The eviction is done by the VPA Updater component. In `Off` or `Initial` mode, the running pod is never touched.Q2: Your app has a 5-minute startup time. VPA is in Auto mode and evicts the pod to update requests. What’s the impact?
Answer
The pod is evicted, then a new pod starts with updated requests but takes 5 minutes to become Ready. During that 5 minutes, your capacity is reduced. For slow-starting apps, use `Initial` mode instead — it only applies recommendations to newly created pods (during deployments/rollouts), never evicting running ones.Q3: Is there a way to use VPA without any pod disruption at all?
Answer
Use `updateMode: Off`. VPA collects data and provides recommendations via `kubectl describe vpa`, but never actually modifies or evicts pods. You review the recommendations and manually update your Deployment YAML. Kubernetes 1.27+ added in-place pod resource updates (alpha feature) which will eventually allow VPA to update resources without eviction.11.5 Cluster Autoscaler
⏱️ ~3 min read
TL;DR: While HPA adds more pods and VPA makes pods bigger, the Cluster Autoscaler (CA) adds more nodes when pods can’t be scheduled. It also removes underutilized nodes to save cost.
The Three Autoscalers Together
graph TB
TRAFFIC[Traffic spike] --> HPA
HPA[HPA\nAdds more pods] -->|"Pods pending\n(no space on nodes)"| CA
CA[Cluster Autoscaler\nAdds more nodes] -->|"Pods can now\nbe scheduled"| SCHED[Pods Running ✅]
LOW[Traffic drops] --> HPA2
HPA2[HPA\nReduces pods] -->|"Nodes underutilized\n< 50% for 10min"| CA2
CA2[Cluster Autoscaler\nRemoves empty nodes] --> SAVE[Cost Savings 💰]
| Autoscaler | What It Scales | Time to React |
|---|---|---|
| HPA | Replica count | ~30 seconds |
| VPA | Pod CPU/memory requests | Minutes (requires restart) |
| CA | Node count | 3–5 minutes (cloud provisioning) |
How CA Works
CA is cloud-specific. It integrates with cloud provider APIs:
1. Pod scheduled → no node has enough capacity → Pod is Pending
2. CA detects Pending pods
3. CA calls cloud API: "Add a new node to node group X"
4. Cloud provisions a new node (takes 2–5 minutes)
5. Node registers with the cluster
6. Pending pods get scheduled on the new node
Scale-down:
1. Node is < 50% utilized for 10 consecutive minutes
2. CA checks: can all pods on this node be moved elsewhere?
3. If yes: cordon node (no new scheduling), drain pods, terminate node
CA Configuration Example (AWS EKS)
CA runs as a Deployment in the cluster with IAM permissions to call the Auto Scaling Group API:
# Cluster Autoscaler annotation on the deployment
metadata:
annotations:
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
# Node group annotations for CA
kubectl annotate nodes my-node \
cluster-autoscaler.kubernetes.io/scale-down-disabled="true"
# See CA activity
kubectl logs -n kube-system \
-l app=cluster-autoscaler \
--tail=20
# See which pods are blocking scale-down
kubectl describe node my-node | grep -E "Annotations|Taints"
Preventing CA from Removing a Node
Some pods prevent node scale-down:
- Pods with
PodDisruptionBudgetviolations - Pods with local storage (
hostPathvolumes) - DaemonSet pods (these are expected on every node)
- Pods annotated with
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
💡 Tip: On Minikube, there is no Cluster Autoscaler (single-node cluster). CA is a production-only concept for cloud-managed node groups (AWS ASG, GKE Node Pools, AKS VMSS).
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | CA scales nodes, not pods | Responds to pending pods that can’t be scheduled |
| 2 | Scale-up: 3–5 minutes | Cloud provisioning time; plan your buffers accordingly |
| 3 | Scale-down: 10-minute window | Node must be underutilized for 10 minutes |
| 4 | Works with HPA | HPA adds pods → CA adds nodes if needed |
✅ Quick Check
Q1: HPA scaled a Deployment from 3 to 8 replicas. 4 of the new pods are Pending. What does the Cluster Autoscaler do?
Answer
CA detects the 4 Pending pods, calculates how many nodes are needed to schedule them (based on node size and pod requests), then calls the cloud provider API to add that many nodes. Once nodes are ready (3–5 minutes), the pending pods get scheduled.Q2: You have CA enabled. A developer leaves a test pod running with 8 CPU requests on a node that’s otherwise idle. Will CA remove that node?
Answer
No — CA will not remove a node if any non-DaemonSet pod running on it cannot be moved elsewhere. Even a single pod with 8 CPU requests prevents scale-down of that node until the pod is deleted or moved. This is intentional — CA never evicts pods unless PodDisruptionBudget and eviction policies permit it.Q3: Is it safe to use HPA + CA together?
Answer
Yes — this is the standard production pattern. HPA manages replica count based on application load; CA manages node count based on cluster capacity. They complement each other: HPA reacts in ~30 seconds, CA fills the capacity gap in 3–5 minutes. Set HPA `minReplicas` high enough to absorb traffic during the CA node-provisioning delay.Lab: Resource Limits and HPA in Action
⏱️ ~30 min hands-on
| Prerequisites | Chapter 11 sections 11.1–11.5 read, Minikube running, metrics-server enabled |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Set resource requests and limits, observe OOMKilled behavior, configure HPA, generate load to trigger autoscaling, and inspect QoS classes |
Objectives
- Enable metrics-server and verify it works
- Configure a pod with requests and limits, observe throttling and OOMKill
- Check QoS classes for Guaranteed, Burstable, and BestEffort pods
- Create an HPA and watch it scale under load
- Observe HPA scale-down after load drops
Setup
# Enable metrics-server
minikube addons enable metrics-server
# Wait for it to be ready and collecting metrics
kubectl wait deployment metrics-server \
-n kube-system \
--for=condition=available \
--timeout=120s
# Verify it's working
sleep 30 # Give metrics-server time to collect initial data
kubectl top nodes
kubectl top pods -A | head -10
kubectl create namespace resources-lab
kubectl config set-context --current --namespace=resources-lab
Exercise 1: Observe CPU Throttling
What we’re doing: Create a CPU-hungry pod and watch it get throttled at its limit.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: cpu-stress
namespace: resources-lab
spec:
containers:
- name: stress
image: polinux/stress
command: ["stress"]
args: ["--cpu", "4", "--timeout", "120"] # Wants 4 CPUs
resources:
requests:
cpu: "100m"
limits:
cpu: "200m" # Limited to 200m despite wanting 4 CPUs
memory: "64Mi"
EOF
kubectl wait pod cpu-stress -n resources-lab --for=condition=Ready --timeout=30s
# Watch actual CPU usage — should be capped near 200m despite 4-CPU demand
watch kubectl top pod cpu-stress -n resources-lab
Expected: CPU usage stays around 200m — throttled. The container isn’t killed, just slowed down. Press Ctrl+C after a few seconds.
# Check QoS class — Burstable (requests != limits)
kubectl get pod cpu-stress -n resources-lab \
-o jsonpath='{.status.qosClass}'
echo ""
Exercise 2: Trigger OOMKilled
What we’re doing: Force a container to exceed its memory limit and watch Kubernetes kill it.
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: memory-hog
namespace: resources-lab
spec:
containers:
- name: mem
image: polinux/stress
command: ["stress"]
args: ["--vm", "1", "--vm-bytes", "150M", "--vm-hang", "1"]
resources:
requests:
memory: "50Mi"
limits:
memory: "100Mi" # Will be exceeded by 150M allocation
cpu: "100m"
EOF
# Watch the pod status
echo "Watching for OOMKilled..."
kubectl get pod memory-hog -n resources-lab -w &
sleep 20
kill %1
# Check the restart reason
kubectl describe pod memory-hog -n resources-lab | grep -A10 "Last State:"
kubectl get pod memory-hog -n resources-lab
Expected output:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
NAME READY STATUS RESTARTS AGE
memory-hog 0/1 OOMKilled 1 15s
memory-hog 1/1 Running 1 18s ← restarted, OOMKills again...
Exercise 3: Compare QoS Classes
What we’re doing: Create pods with different resource specs and verify their QoS classes.
# Guaranteed: requests == limits
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: qos-guaranteed
namespace: resources-lab
spec:
containers:
- name: app
image: busybox
command: ["sleep", "3600"]
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "100m" # Exactly equal
memory: "64Mi" # Exactly equal
---
apiVersion: v1
kind: Pod
metadata:
name: qos-burstable
namespace: resources-lab
spec:
containers:
- name: app
image: busybox
command: ["sleep", "3600"]
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "500m" # Different from request
memory: "256Mi" # Different from request
---
apiVersion: v1
kind: Pod
metadata:
name: qos-besteffort
namespace: resources-lab
spec:
containers:
- name: app
image: busybox
command: ["sleep", "3600"]
# No resources at all
EOF
sleep 5
# Check QoS classes
for POD in qos-guaranteed qos-burstable qos-besteffort; do
QOS=$(kubectl get pod $POD -n resources-lab \
-o jsonpath='{.status.qosClass}' 2>/dev/null)
echo "$POD: $QOS"
done
Expected:
qos-guaranteed: Guaranteed
qos-burstable: Burstable
qos-besteffort: BestEffort
Exercise 4: HPA — Scale on CPU Load
What we’re doing: Deploy an app, configure HPA, generate load, and watch replicas increase.
# Deploy the HPA test app (a simple HTTP server that burns CPU)
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: load-app
namespace: resources-lab
spec:
replicas: 1
selector:
matchLabels:
app: load-app
template:
metadata:
labels:
app: load-app
spec:
containers:
- name: app
image: registry.k8s.io/hpa-example
ports:
- containerPort: 80
resources:
requests:
cpu: "200m"
limits:
cpu: "500m"
memory: "128Mi"
---
apiVersion: v1
kind: Service
metadata:
name: load-app-svc
namespace: resources-lab
spec:
selector:
app: load-app
ports:
- port: 80
EOF
kubectl rollout status deployment/load-app -n resources-lab
# Create HPA
cat <<'EOF' | kubectl apply -f -
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: load-app-hpa
namespace: resources-lab
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: load-app
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
EOF
# Wait for metrics to be available
sleep 30
kubectl get hpa load-app-hpa -n resources-lab
Current state (before load):
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
load-app-hpa Deployment/load-app 8%/50% (cpu) 1 5 1
Now generate load:
# START LOAD in background
kubectl run load-gen \
--image=busybox \
--namespace=resources-lab \
--restart=Never \
-- sh -c "while sleep 0.01; do wget -q -O- http://load-app-svc; done" &
echo "Load generator started. Waiting 60 seconds for HPA to react..."
sleep 60
# Check HPA — should see CPU% climb and replicas increase
kubectl get hpa load-app-hpa -n resources-lab
kubectl get pods -n resources-lab -l app=load-app
echo ""
echo "=== HPA Status ==="
kubectl describe hpa load-app-hpa -n resources-lab | grep -A10 "Current Metrics:"
kubectl describe hpa load-app-hpa -n resources-lab | tail -15
Expected (under load):
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
load-app-hpa Deployment/load-app 180%/50% (cpu) 1 5 4
Stop the load and watch scale-down:
kubectl delete pod load-gen -n resources-lab
echo "Load stopped. Watching HPA scale-down (takes ~5 minutes due to cooldown)..."
kubectl get hpa load-app-hpa -n resources-lab -w
Exercise 5: Inspect Scaling Events
# See the full HPA scaling history
kubectl describe hpa load-app-hpa -n resources-lab
# Look for scaling events:
kubectl get events -n resources-lab \
--field-selector reason=SuccessfulRescale \
--sort-by='.lastTimestamp'
Expected events:
Type Reason Message
Normal SuccessfulRescale Scaled up to 4 replicas: cpu utilization above target
Normal SuccessfulRescale Scaled down to 1 replicas: all metrics below target
🔥 Break It! Challenge
What happens when HPA has no
requests.cpuset on the pod?
# Deploy without cpu requests
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: no-requests
namespace: resources-lab
spec:
replicas: 1
selector:
matchLabels:
app: no-requests
template:
metadata:
labels:
app: no-requests
spec:
containers:
- name: app
image: nginx:1.25
# No resources.requests.cpu set!
resources:
limits:
cpu: "200m" # Only a limit, no request
memory: "64Mi"
---
apiVersion: v1
kind: Service
metadata:
name: no-requests-svc
namespace: resources-lab
spec:
selector:
app: no-requests
ports:
- port: 80
EOF
kubectl rollout status deployment/no-requests -n resources-lab
# Create HPA
kubectl autoscale deployment no-requests \
--cpu-percent=50 --min=1 --max=5 \
-n resources-lab
# Check HPA status
sleep 30
kubectl describe hpa no-requests -n resources-lab | grep -A5 "Conditions:"
Expected error:
Conditions:
ScalingActive False FailedGetResourceMetric
Message: the HPA was unable to compute the replica count: ...
missing request for cpu
The HPA can’t compute utilization without a requests.cpu baseline. It shows unknown/50% for targets and won’t scale.
Fix: always set resources.requests.cpu.
kubectl patch deployment no-requests -n resources-lab \
--type='json' \
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources","value":{"requests":{"cpu":"100m","memory":"32Mi"},"limits":{"cpu":"200m","memory":"64Mi"}}}]'
sleep 30
kubectl get hpa no-requests -n resources-lab
# Now shows a real CPU% instead of unknown
Cleanup
kubectl config set-context --current --namespace=default
kubectl delete namespace resources-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | CPU throttling | cpu-stress pod capped at 200m despite 4-CPU demand |
| 2 | Memory OOMKill | memory-hog killed with Exit Code 137, restarted |
| 3 | QoS classes | Verified Guaranteed/Burstable/BestEffort assignment |
| 4 | HPA scale-up | Replicas increased to 4 as CPU% climbed above 50% |
| 5 | HPA scale-down | Replicas reduced after 5-minute cooldown |
| 6 | Scaling events | kubectl get events showed SuccessfulRescale records |
| 7 | Missing requests | HPA showed unknown CPU% without requests.cpu |
Chapter 12: Helm — Package Management
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Install community charts, create your own chart from scratch, manage releases with upgrades and rollbacks, and use values files to customize deployments per environment.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 12.1 | What Helm Is and Why It Exists | ~4 min |
| 12.2 | Installing Charts — helm install and Repositories | ~6 min |
| 12.3 | Chart Anatomy — Templates and Values | ~7 min |
| 12.4 | Creating Your Own Chart | ~5 min |
| 12.5 | Upgrades, Rollbacks, and Release Management | ~4 min |
| 12.6 | 🔬 Lab: Package and Deploy a Multi-Tier App | ~30 min |
Prerequisites
- Completed Chapters 1–11 (all core concepts)
helmCLI installed (helm versionto verify)minikube statusshowsRunning
12.1 What Helm Is and Why It Exists
⏱️ ~4 min read
TL;DR: Helm is the Kubernetes package manager. It bundles all the YAML files for an application (Deployment, Service, Ingress, ConfigMap, Secrets…) into a reusable, versioned package called a chart. One command installs, upgrades, or removes the whole stack.
The Problem: YAML Sprawl
A real production app typically needs:
my-app/
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── configmap.yaml
├── secret.yaml
├── hpa.yaml
├── pdb.yaml # PodDisruptionBudget
├── serviceaccount.yaml
├── role.yaml
└── rolebinding.yaml
Now do this for 3 environments (dev, staging, prod) with different image tags, replica counts, resource limits, and domain names. Without Helm, you maintain 30 YAML files with tiny differences between environments — a maintenance nightmare.
🔗 Docker Parallel: Helm is to Kubernetes what
docker-compose.ymlis to Docker — it describes your entire application stack. But Helm adds versioning, templating, rollbacks, and a package registry.
What Helm Provides
graph LR
CHART["Chart\n(Template YAML\n+ defaults)"] -->|"helm install\n--values prod.yaml"| RELEASE
VALUES["values.yaml\n(prod overrides)"] --> RELEASE
RELEASE["Release\n(running instance\nof the chart)"] --> K8S["Kubernetes\n(actual objects)"]
REPO["Helm Repo\n(ArtifactHub)"] -->|"helm install\nbitnami/postgresql"| RELEASE2
RELEASE2 --> K8S
| Concept | What It Is |
|---|---|
| Chart | A package of Kubernetes YAML templates + default values |
| Release | A named installed instance of a chart in a cluster |
| Repository | A collection of charts (like npm registry but for K8s apps) |
| Values | Variables that customize a chart’s templates at install time |
Helm vs Raw kubectl
| Task | Raw kubectl | Helm |
|---|---|---|
| Install an app | kubectl apply -f dir/ (all files) | helm install my-app bitnami/nginx |
| Upgrade to v2 | Edit YAML, re-apply, hope nothing breaks | helm upgrade my-app bitnami/nginx --set image.tag=2.0 |
| Rollback | Manually revert YAML and re-apply | helm rollback my-app 1 |
| Deploy to dev/prod | Maintain separate YAML directories | Same chart, different values.yaml files |
| Remove completely | kubectl delete -f dir/ (15 files) | helm uninstall my-app (one command) |
| See what’s installed | grep/track manually | helm list |
Key Helm CLI Commands
# Repository management
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update # Fetch latest chart lists
helm search repo nginx # Find charts
# Install / upgrade
helm install my-release bitnami/nginx # Install
helm upgrade my-release bitnami/nginx # Upgrade
helm install --upgrade my-release ... # Install or upgrade (idempotent)
# Release management
helm list # See all releases in current namespace
helm list -A # All namespaces
helm status my-release # Detailed status + notes
helm history my-release # Release history (for rollback)
helm rollback my-release 1 # Roll back to revision 1
# Uninstall
helm uninstall my-release # Remove all resources created by the release
# Inspect before installing
helm show values bitnami/nginx # See all configurable values
helm template my-release bitnami/nginx --values my-values.yaml # Preview YAML
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Chart = package | Versioned, reusable bundle of K8s manifests |
| 2 | Release = installed instance | One chart, many releases (dev/staging/prod) |
| 3 | Values = configuration | Customize behavior without editing templates |
| 4 | Helm manages lifecycle | Install → upgrade → rollback → uninstall |
✅ Quick Check
Q1: You install the bitnami/postgresql chart twice in the same namespace with different release names. What happens?
Answer
Two independent PostgreSQL instances are created. Each release has its own set of Kubernetes objects (pods, services, PVCs, secrets) prefixed with the release name. They don't interfere with each other. This is how you run dev and staging databases in the same cluster.Q2: A colleague updates a value in the chart’s values.yaml file directly. Does the running release change?
Answer
No. Editing the chart's source files doesn't affect already-installed releases. A `helm upgrade` command is needed to apply changes to a running release. This is intentional — Helm releases are immutable snapshots until explicitly upgraded.Q3: What’s the difference between helm install and kubectl apply?
Answer
`kubectl apply` is stateless — it applies YAML to the cluster with no memory of what it applied before. `helm install` creates a managed **release** — Helm tracks exactly what was installed (stored as a Secret in the namespace), enabling upgrades, rollbacks, and clean uninstalls. `helm uninstall` removes all objects in the release; `kubectl delete` requires you to know what to delete.12.2 Installing Charts — helm install and Repositories
⏱️ ~6 min read
TL;DR: Add a chart repository, search for what you need, preview the default values, customize with a
values.yaml, and install. The output includes connection instructions from the chart’s NOTES.txt.
Adding a Repository
# Bitnami hosts production-grade charts for most common services
helm repo add bitnami https://charts.bitnami.com/bitnami
# Other popular repos:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add cert-manager https://charts.jetstack.io
helm repo add prometheus https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
# Update to get the latest chart versions
helm repo update
# List configured repos
helm repo list
Finding Charts
# Search your added repos
helm search repo nginx
helm search repo postgresql
# Search ArtifactHub (the public Helm chart registry)
helm search hub nginx
helm search hub --max-col-width 80 nginx | head -20
ArtifactHub (artifacthub.io) is the central registry — like Docker Hub but for Helm charts.
Inspecting a Chart Before Installing
# See all configurable values and their defaults
helm show values bitnami/nginx
# See what the chart installs (brief description)
helm show chart bitnami/nginx
# Full chart README
helm show readme bitnami/nginx
# Preview the actual YAML that would be installed
helm template my-nginx bitnami/nginx | head -60
# Preview with your custom values applied
helm template my-nginx bitnami/nginx --values my-values.yaml | head -80
💡 Tip: Always run
helm show valuesandhelm templatebefore installing a chart in production. You want to know what’s being created — especially for charts with many components.
Installing a Chart
Minimal install with defaults:
helm install my-nginx bitnami/nginx \
--namespace web \
--create-namespace
With inline value overrides:
helm install my-nginx bitnami/nginx \
--namespace web \
--create-namespace \
--set replicaCount=2 \
--set service.type=NodePort
With a custom values file (recommended for anything beyond trivial):
# nginx-values.yaml
replicaCount: 3
image:
tag: "1.25.3"
service:
type: ClusterIP
ingress:
enabled: true
hostname: myapp.local
ingressClassName: nginx
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPU: 50
helm install my-nginx bitnami/nginx \
--namespace web \
--create-namespace \
--values nginx-values.yaml
After Installation
# Check the release
helm status my-nginx -n web
# See all objects created
kubectl get all -n web
# See the NOTES.txt output (connection instructions from chart author)
helm status my-nginx -n web | grep -A20 "NOTES:"
# See computed values (what was actually used)
helm get values my-nginx -n web # Your overrides only
helm get values my-nginx -n web --all # All values (including defaults)
# See the full manifest that's running
helm get manifest my-nginx -n web
Specifying Chart Versions
# Install a specific version
helm install my-nginx bitnami/nginx --version 15.4.4
# See available versions
helm search repo bitnami/nginx --versions | head -10
--set vs --values Syntax
# --set uses dot-notation for nested values
helm install my-app bitnami/nginx \
--set image.repository=myrepo/myapp \
--set image.tag=v1.2.3 \
--set ingress.enabled=true \
--set "ingress.hosts[0].host=myapp.local" # List items
# --values uses a YAML file (preferred for multiple values)
helm install my-app bitnami/nginx --values values.yaml
# Both can be combined — --set overrides --values
helm install my-app bitnami/nginx \
--values base-values.yaml \
--set image.tag=v1.2.3 # Override just the tag
⚠️ Warning: Don’t use
--setfor secrets (passwords, API keys). The value ends up in shell history and Helm release history. Use a separatesecrets.yamlfile or reference Kubernetes Secrets in the chart values.
Try It
# Add bitnami repo
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
# Preview what nginx chart installs
helm show chart bitnami/nginx | grep -E "description|version|appVersion"
# Preview computed values for a simple install
helm template test-nginx bitnami/nginx \
--set replicaCount=2 \
--set service.type=ClusterIP | grep "kind:" | sort -u
# Install
helm install demo-nginx bitnami/nginx \
--namespace helm-demo \
--create-namespace \
--set replicaCount=1 \
--set service.type=NodePort
# Verify
helm list -n helm-demo
kubectl get all -n helm-demo
# Cleanup
helm uninstall demo-nginx -n helm-demo
kubectl delete namespace helm-demo
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | helm repo add + helm repo update | Register and refresh chart repositories |
| 2 | helm show values | See all configurable options before installing |
| 3 | helm template | Preview YAML without installing |
| 4 | --values file.yaml | Preferred over --set for multiple overrides |
| 5 | helm get manifest | See what’s actually running in the release |
✅ Quick Check
Q1: You need to install two separate PostgreSQL instances (one for auth, one for orders) in the same namespace. How?
Answer
Two `helm install` commands with different release names: `helm install auth-db bitnami/postgresql` and `helm install orders-db bitnami/postgresql`. Each release creates independently named pods, services, PVCs, and secrets. They coexist without conflict.Q2: You have base-values.yaml with shared settings and want to override just the image tag for a hotfix. What command syntax?
Answer
`helm upgrade my-app bitnami/myapp --values base-values.yaml --set image.tag=1.2.4-hotfix`. The `--set` flag overrides the `image.tag` from `base-values.yaml`. Multiple `--values` files and `--set` flags are merged in order — last one wins.Q3: helm template shows the YAML but doesn’t install it. When would you use this in a CI pipeline?
Answer
Linting and validation. Pipe `helm template` output to `kubectl apply --dry-run=server -f -` to validate against a real cluster API server without actually creating objects. Or pipe to `kubeval` or `kubeconform` for schema validation without a cluster. This catches YAML errors before a real deployment.12.3 Chart Anatomy — Templates and Values
⏱️ ~7 min read
TL;DR: A chart is a directory with a
Chart.yaml(metadata),values.yaml(defaults), and atemplates/directory with Go-templated Kubernetes YAML files. The templates use{{ .Values.xyz }}to insert values at render time.
Chart Directory Structure
my-app/ # Chart directory (name = chart name)
├── Chart.yaml # Chart metadata (name, version, description)
├── values.yaml # Default values (can be overridden)
├── charts/ # Sub-charts (dependencies)
├── templates/ # Go template YAML files
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ ├── hpa.yaml
│ ├── serviceaccount.yaml
│ ├── _helpers.tpl # Reusable template functions (partials)
│ └── NOTES.txt # Post-install instructions shown to user
└── .helmignore # Files to exclude from packaging
Chart.yaml — The Metadata File
# Chart.yaml
apiVersion: v2 # Helm 3 API version (always v2 for new charts)
name: my-app
description: A Helm chart for My Application
type: application # application | library
version: 0.1.0 # Chart version — bump on every change
appVersion: "1.2.3" # The app version being packaged (informational)
maintainers:
- name: Your Name
email: you@example.com
dependencies: # Sub-charts this chart requires
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled # Only install if this value is true
values.yaml — Default Configuration
# values.yaml
replicaCount: 2
image:
repository: myrepo/my-app
tag: "1.2.3"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
host: myapp.example.com
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPU: 50
postgresql:
enabled: true # Controls whether the PostgreSQL sub-chart is installed
auth:
password: "changeme"
Template Syntax
Templates are Kubernetes YAML with Go template expressions:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }} # Uses helper function
namespace: {{ .Release.Namespace }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ .Values.replicaCount }} # From values.yaml
selector:
matchLabels:
{{- include "my-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
resources:
{{- toYaml .Values.resources | nindent 12 }} # Dump YAML block
{{- if .Values.autoscaling.enabled }} # Conditional block
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "my-app.fullname" . }}
spec:
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
{{- end }}
Built-in Template Objects
{{ .Release.Name }} # The release name (e.g., "my-nginx")
{{ .Release.Namespace }} # The namespace (e.g., "production")
{{ .Release.IsInstall }} # True if this is a fresh install
{{ .Release.IsUpgrade }} # True if this is an upgrade
{{ .Chart.Name }} # Chart name (e.g., "my-app")
{{ .Chart.Version }} # Chart version (e.g., "0.1.0")
{{ .Chart.AppVersion }} # App version (e.g., "1.2.3")
{{ .Values.xyz }} # Values from values.yaml (or overridden)
{{ .Files.Get "config/app.conf" }} # Read a non-template file
The _helpers.tpl Pattern
Helper functions are defined in _helpers.tpl and called throughout templates:
# templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/*
Common labels applied to all resources.
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
NOTES.txt — Post-Install Instructions
# templates/NOTES.txt
Thank you for installing {{ .Chart.Name }} {{ .Chart.AppVersion }}!
Your release {{ .Release.Name }} is deployed in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Access your application at: http://{{ .Values.ingress.host }}
{{- else if eq .Values.service.type "NodePort" }}
Get the application URL:
export NODE_PORT=$(kubectl get svc {{ include "my-app.fullname" . }} \
-n {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}")
export NODE_IP=$(kubectl get nodes -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else }}
kubectl port-forward svc/{{ include "my-app.fullname" . }} 8080:80 -n {{ .Release.Namespace }}
{{- end }}
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | Chart.yaml = metadata | Name, version, description, dependencies |
| 2 | values.yaml = defaults | All configuration with sensible defaults |
| 3 | templates/ = Go template YAML | {{ .Values.x }} injects values into K8s manifests |
| 4 | _helpers.tpl = shared functions | DRY principle — define once, use everywhere |
| 5 | NOTES.txt = install instructions | Shown after helm install to guide the user |
✅ Quick Check
Q1: A template uses {{ .Values.ingress.enabled | default false }}. The user installs without setting this value. What does the template render?
Answer
`false` — the `default` function returns the specified value when the left-hand value is nil (not set). The template renders `false`, and any `{{- if .Values.ingress.enabled }}` block is skipped.Q2: You want to change the Ingress hostname without modifying the chart. How?
Answer
Create a custom `values.yaml` with just the override: `ingress: { host: myapp.example.com }` and pass it with `helm install --values my-values.yaml`. Or use `helm install --set ingress.host=myapp.example.com`. Your override merges with (and takes precedence over) the chart's default `values.yaml`.Q3: What’s the difference between chart.version and chart.appVersion?
Answer
`version` is the chart's version — incremented every time you change the chart itself (templates, values, structure). `appVersion` is the version of the application being packaged (e.g., nginx 1.25.3) — informational only, doesn't affect Helm behavior. You can update `appVersion` to track a new app release without bumping `version` if the chart templates didn't change.12.4 Creating Your Own Chart
⏱️ ~5 min read
TL;DR:
helm create my-chartscaffolds a complete, working chart with example templates. Strip it down to what you need and fill in your app’s specifics. The scaffold follows best practices (labels, helpers, NOTES.txt) from the start.
Scaffolding a New Chart
# Create a new chart with all the boilerplate
helm create my-app
# What gets created:
tree my-app
my-app/
├── Chart.yaml
├── values.yaml
├── charts/
└── templates/
├── deployment.yaml
├── hpa.yaml
├── ingress.yaml
├── service.yaml
├── serviceaccount.yaml
├── _helpers.tpl
├── NOTES.txt
└── tests/
└── test-connection.yaml
The scaffolded chart deploys nginx by default — replace values and templates for your app.
Minimal Chart Walkthrough
After helm create my-app, the key changes to make:
1. Update Chart.yaml:
name: my-app
description: My Application
version: 0.1.0
appVersion: "1.0.0"
2. Update values.yaml defaults:
replicaCount: 2
image:
repository: myrepo/my-app # ← Your actual image
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080 # ← Your app's port
env: # Custom values section
LOG_LEVEL: info
DB_HOST: postgres-svc
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
3. Update templates/deployment.yaml to use your custom env section:
# In the containers section, add env vars from values:
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
Validating Your Chart
# Lint for common errors
helm lint my-app/
# Expected output (clean):
# ==> Linting my-app/
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed
# Preview rendered YAML (catch template errors)
helm template test-release my-app/ | head -80
# Preview with custom values
helm template test-release my-app/ --values my-prod-values.yaml
# Validate against the cluster API without installing
helm template test-release my-app/ | kubectl apply --dry-run=server -f -
Environment-Specific Values Files
# Directory structure for multi-environment deployment
config/
├── values-base.yaml # Shared defaults
├── values-dev.yaml # Dev overrides
├── values-staging.yaml # Staging overrides
└── values-prod.yaml # Prod overrides
# values-prod.yaml — only what differs from base
replicaCount: 5
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2"
memory: "1Gi"
ingress:
enabled: true
host: app.mycompany.com
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
# Deploy to each environment
helm install my-app ./my-app/ \
--values config/values-base.yaml \
--values config/values-prod.yaml \
--namespace production \
--create-namespace
Packaging and Sharing a Chart
# Package the chart into a .tgz archive
helm package my-app/
# Creates: my-app-0.1.0.tgz
# Install from archive
helm install my-release ./my-app-0.1.0.tgz
# Create a simple file-based chart repository
mkdir -p helm-repo/charts
mv my-app-0.1.0.tgz helm-repo/charts/
helm repo index helm-repo/ # Creates index.yaml
# Host with any static file server (GitHub Pages, S3, etc.)
# Users add it with:
# helm repo add my-team https://my-bucket.s3.amazonaws.com/helm-repo/
Try It
# Create and validate a minimal chart
helm create webapp
# Lint it
helm lint webapp/
# Preview the default nginx install
helm template my-webapp webapp/ | grep "kind:" | sort -u
# Update the image to a different one
sed -i 's|repository: nginx|repository: nginxdemo/hello|' webapp/values.yaml
sed -i 's|tag: ""|tag: "plain-text"|' webapp/values.yaml
# Install it
helm install my-webapp ./webapp/ \
--namespace helm-create-demo \
--create-namespace \
--set service.type=NodePort
helm list -n helm-create-demo
kubectl get all -n helm-create-demo
# Cleanup
helm uninstall my-webapp -n helm-create-demo
kubectl delete namespace helm-create-demo
rm -rf webapp/
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | helm create scaffolds | Generates a complete working chart in seconds |
| 2 | helm lint catches errors | Run before every install or push to CI |
| 3 | helm template previews | See the final YAML before it touches the cluster |
| 4 | Multi-values files | Base + environment-specific overrides scale cleanly |
| 5 | helm package distributes | Creates a .tgz for sharing via a chart repo |
✅ Quick Check
Q1: helm lint shows no errors but the deployment fails after helm install. What could cause this?
Answer
`helm lint` validates chart structure and template syntax, but doesn't check Kubernetes API correctness or cluster-specific rules. The deployment could fail due to: image not existing in the registry, insufficient cluster permissions (RBAC), a node selector with no matching nodes, or an invalid resource reference. Use `helm template | kubectl apply --dry-run=server -f -` for deeper validation against a real cluster API.Q2: You have values-base.yaml with replicaCount: 2 and values-prod.yaml with replicaCount: 5. Which wins if you pass both with --values?
Answer
The last `--values` file wins. `helm install ... --values values-base.yaml --values values-prod.yaml` — `values-prod.yaml` overrides `values-base.yaml`. The replica count is `5`. This is the intentional merge behavior — base first, environment-specific last.Q3: Can you add custom top-level keys to values.yaml (like env: in the example above) that don’t appear in the upstream chart?
Answer
Yes — for your own charts, you define all values yourself, so you can add any structure you want. For third-party charts from repositories, you should only use keys that exist in the chart's `values.yaml` — unknown keys are silently ignored by templates that don't reference them (they don't cause errors, they just have no effect).12.5 Upgrades, Rollbacks, and Release Management
⏱️ ~4 min read
TL;DR:
helm upgradeapplies changes to an existing release and bumps the revision.helm rollbackreverts to any previous revision instantly.helm historyshows the full audit trail. This is the Helm lifecycle that makes production deployments safe.
The Release Revision Model
Every helm install or helm upgrade creates a new revision:
helm history my-app -n production
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 2026-07-01 10:00:00 superseded my-app-0.1.0 1.0.0 Install complete
2 2026-07-08 14:30:00 superseded my-app-0.1.1 1.1.0 Upgrade complete
3 2026-07-15 09:15:00 deployed my-app-0.2.0 1.2.0 Upgrade complete
Helm stores each revision as a Secret in the namespace — this is how rollback works.
Upgrading a Release
# Upgrade with a new values file
helm upgrade my-app ./my-app/ \
--namespace production \
--values config/values-prod.yaml
# Upgrade with an inline override
helm upgrade my-app ./my-app/ \
--namespace production \
--set image.tag=1.3.0
# Install if not exists, upgrade if it does (idempotent — great for CI/CD)
helm upgrade --install my-app ./my-app/ \
--namespace production \
--create-namespace \
--values config/values-prod.yaml
# Preview what would change (dry-run)
helm upgrade my-app ./my-app/ \
--namespace production \
--dry-run \
--values config/values-prod.yaml
Safe Upgrade Flags
helm upgrade my-app ./my-app/ \
--namespace production \
--values config/values-prod.yaml \
--wait \ # Wait for all pods to be Ready before returning
--timeout 5m \ # Fail if not ready in 5 minutes
--atomic \ # Automatically rollback if upgrade fails
--cleanup-on-fail # Remove new resources if upgrade fails
--atomic is the recommended production flag. If the upgrade fails (pods not ready, liveness probe failing), Helm automatically rolls back to the previous revision. No manual intervention needed.
Rolling Back
# Roll back to the previous revision
helm rollback my-app -n production
# Roll back to a specific revision
helm rollback my-app 1 -n production
# Dry-run rollback (preview what would change)
helm rollback my-app 1 -n production --dry-run
# After rollback, check history
helm history my-app -n production
REVISION STATUS DESCRIPTION
1 superseded Install complete
2 superseded Upgrade complete
3 superseded Upgrade failed
4 deployed Rollback to 1 ← rollback creates a new revision
💡 Tip: Rollback creates a new revision (revision 4 in the example above) rather than going back in time. This preserves the full audit trail and allows you to see what happened and when.
Uninstalling a Release
# Remove all resources created by this release
helm uninstall my-app -n production
# Keep the release history (for forensics) but remove all K8s objects
helm uninstall my-app -n production --keep-history
# After uninstall with --keep-history, you can still see history:
helm history my-app -n production
# And even reinstall at a specific old revision:
helm rollback my-app 2 -n production
Release Management Commands
# List all releases (current namespace)
helm list
# All namespaces
helm list -A
# Filter by status
helm list --failed
helm list --deployed
# Release details
helm status my-app -n production
# What values are currently active?
helm get values my-app -n production
helm get values my-app -n production --all # Including defaults
# What YAML is currently deployed?
helm get manifest my-app -n production
The Upgrade → Rollback Safety Pattern
sequenceDiagram
participant DEV as CI/CD Pipeline
participant HELM as Helm
participant K8S as Kubernetes
DEV->>HELM: helm upgrade --atomic my-app v2.0
HELM->>K8S: Apply new manifests
K8S->>K8S: Roll out pods (Deployment rolling update)
K8S-->>HELM: Pods not ready (new version broken)
HELM->>HELM: Wait until timeout
HELM->>K8S: helm rollback (auto, --atomic)
K8S->>K8S: Restore v1.0 manifests
HELM-->>DEV: Upgrade FAILED, rolled back to rev 1
Note over DEV: Pipeline fails — engineers alerted
Key Takeaways
| # | Concept | One-liner |
|---|---|---|
| 1 | helm upgrade --install | Idempotent — safe to run repeatedly in CI/CD |
| 2 | --atomic flag | Auto-rollback if upgrade fails — use in production |
| 3 | helm rollback | Instant revert to any previous revision |
| 4 | Rollback = new revision | Full audit trail preserved |
| 5 | helm history | See the complete lifecycle of a release |
✅ Quick Check
Q1: You run helm upgrade --atomic my-app ./my-app/ and the new pods fail their readiness probes. What happens?
Answer
After the `--timeout` duration (default 5m), Helm marks the upgrade as failed and automatically runs a rollback to the previous revision. The Deployment rolls back to the old pods, which are still running (rolling update didn't complete). The command exits with a non-zero code — your CI/CD pipeline fails and alerts the team.Q2: A developer uninstalls a release without --keep-history. Can the release be restored?
Answer
No. Without `--keep-history`, Helm deletes both the K8s objects and the release history Secrets. There's nothing to roll back to. With `--keep-history`, the history remains and `helm rollback` can restore the release. Always use `--keep-history` for production releases before uninstalling.Q3: You have 20 revisions in history. Does each revision store the full YAML of all objects?
Answer
Yes — each revision stores the complete rendered manifest in a Kubernetes Secret (named `sh.helm.release.v1.RELEASE.vREV`). This is why Helm rollback is instantaneous — it re-applies the stored manifest. In large clusters with many charts, these revision Secrets can accumulate. Helm defaults to keeping 10 revisions; use `--history-max 5` on upgrade to limit storage.Lab: Package and Deploy a Multi-Tier App
⏱️ ~30 min hands-on
| Prerequisites | helm version works, Chapter 12 sections 12.1–12.5 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Install a community chart, create your own chart from scratch, deploy to dev and prod with different values, upgrade, and roll back |
Objectives
- Install and configure the NGINX Ingress Controller via Helm
- Install a community chart (Bitnami nginx) with custom values
- Create a custom Helm chart for a simple web app
- Deploy to “dev” and “prod” namespaces with different values
- Simulate a bad upgrade and roll back
- Inspect release history
Setup
# Verify Helm is installed
helm version --short
# Add Bitnami repo
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
kubectl create namespace helm-lab-dev
kubectl create namespace helm-lab-prod
Exercise 1: Install a Community Chart
What we’re doing: Install Bitnami’s nginx with custom values.
# First, preview configurable values
helm show values bitnami/nginx | head -60
# Create a custom values file
cat <<'EOF' > nginx-values.yaml
replicaCount: 1
service:
type: NodePort
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "100m"
memory: "128Mi"
autoscaling:
enabled: false
serverBlock: |-
server {
listen 0.0.0.0:8080;
location / {
return 200 "Hello from Helm-managed nginx!\n";
add_header Content-Type text/plain;
}
location /health {
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
containerPorts:
http: 8080
EOF
# Install it
helm install community-nginx bitnami/nginx \
--namespace helm-lab-dev \
--values nginx-values.yaml
helm list -n helm-lab-dev
kubectl get pods -n helm-lab-dev
Test it:
# Wait for ready
kubectl rollout status deployment/community-nginx -n helm-lab-dev
# Get the NodePort
NODE_PORT=$(kubectl get svc community-nginx -n helm-lab-dev \
-o jsonpath='{.spec.ports[0].nodePort}')
MINIKUBE_IP=$(minikube ip)
curl http://$MINIKUBE_IP:$NODE_PORT
# Expected: Hello from Helm-managed nginx!
curl http://$MINIKUBE_IP:$NODE_PORT/health
# Expected: OK
Exercise 2: Create Your Own Chart
What we’re doing: Build a chart from scratch for a simple “info-app”.
# Scaffold the chart
helm create info-app
# Update Chart.yaml
cat <<'EOF' > info-app/Chart.yaml
apiVersion: v2
name: info-app
description: A simple info web application
type: application
version: 0.1.0
appVersion: "1.0.0"
EOF
# Update values.yaml
cat <<'EOF' > info-app/values.yaml
replicaCount: 1
image:
repository: nginxdemo/hello
tag: "plain-text"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 80
ingress:
enabled: false
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
env:
APP_COLOR: blue
APP_ENV: development
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPU: 70
podAnnotations: {}
EOF
# Update the deployment template to inject env vars
cat <<'EOF' > info-app/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "info-app.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "info-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "info-app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "info-app.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.targetPort }}
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 10 }}
readinessProbe:
httpGet:
path: /
port: {{ .Values.service.targetPort }}
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: {{ .Values.service.targetPort }}
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
EOF
# Lint and validate
helm lint info-app/
helm template test-release info-app/ | grep "kind:" | sort -u
Expected lint output:
==> Linting info-app/
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
Exercise 3: Deploy to Dev and Prod
What we’re doing: Install the same chart with different values per environment.
# Dev values
cat <<'EOF' > values-dev.yaml
replicaCount: 1
env:
APP_COLOR: blue
APP_ENV: development
service:
type: NodePort
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
EOF
# Prod values
cat <<'EOF' > values-prod.yaml
replicaCount: 2
env:
APP_COLOR: green
APP_ENV: production
service:
type: ClusterIP
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "300m"
memory: "128Mi"
autoscaling:
enabled: false
EOF
# Install to dev
helm install info-app ./info-app/ \
--namespace helm-lab-dev \
--values values-dev.yaml
# Install to prod
helm install info-app ./info-app/ \
--namespace helm-lab-prod \
--values values-prod.yaml
# See both releases
helm list -A | grep info-app
# Verify different replica counts
kubectl get deploy -n helm-lab-dev info-app # 1 replica
kubectl get deploy -n helm-lab-prod info-app # 2 replicas
Exercise 4: Upgrade and Roll Back
What we’re doing: Simulate a chart upgrade and a bad upgrade with rollback.
# First, check current state
helm history info-app -n helm-lab-dev
# Good upgrade: bump replica count
helm upgrade info-app ./info-app/ \
--namespace helm-lab-dev \
--values values-dev.yaml \
--set replicaCount=2
helm history info-app -n helm-lab-dev
kubectl get deploy -n helm-lab-dev info-app # Now 2 replicas
# Simulate a bad upgrade: use an image tag that doesn't exist
helm upgrade info-app ./info-app/ \
--namespace helm-lab-dev \
--values values-dev.yaml \
--set image.tag=this-tag-does-not-exist \
--wait \
--timeout 30s || echo "Upgrade FAILED as expected!"
# Check status
helm list -n helm-lab-dev # Status: failed
kubectl get pods -n helm-lab-dev # New pod in ImagePullBackOff
# Rollback to revision 2 (the last good one)
helm rollback info-app 2 -n helm-lab-dev
# Verify rollback succeeded
helm history info-app -n helm-lab-dev
kubectl get pods -n helm-lab-dev # All pods running with correct image
kubectl get deploy -n helm-lab-dev info-app # Back to correct state
Expected history after rollback:
REVISION STATUS DESCRIPTION
1 superseded Install complete
2 superseded Upgrade complete
3 failed Upgrade failed (... timeout waiting for pods)
4 deployed Rollback to 2
Exercise 5: Release Inspection
# See what values are active in the dev release
helm get values info-app -n helm-lab-dev
# See the full manifest (all YAML being managed)
helm get manifest info-app -n helm-lab-dev | grep "kind:"
# See all releases with status
helm list -A --output table
# Find the Helm history Secrets
kubectl get secrets -n helm-lab-dev | grep helm.release
🔥 Break It! Challenge
What happens when you
helm upgrade --installwith a values file that has a syntax error?
# Create a broken values file
cat <<'EOF' > broken-values.yaml
replicaCount: 2
env:
APP_COLOR blue # ← Missing colon!
APP_ENV: test
EOF
helm upgrade --install info-app ./info-app/ \
--namespace helm-lab-dev \
--values broken-values.yaml
Expected:
Error: YAML parse error on broken-values.yaml: ...
mapping values are not allowed in this context
The upgrade fails before reaching the cluster. The running release is unaffected — Helm validates values files before applying anything. This is one of Helm’s key safety features.
Cleanup
helm uninstall community-nginx -n helm-lab-dev
helm uninstall info-app -n helm-lab-dev
helm uninstall info-app -n helm-lab-prod
kubectl delete namespace helm-lab-dev helm-lab-prod
rm -f nginx-values.yaml values-dev.yaml values-prod.yaml broken-values.yaml
rm -rf info-app/
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Install community chart | Bitnami nginx with custom server block running |
| 2 | Custom values override | nginx returned custom response via serverBlock |
| 3 | Create chart from scratch | helm create → lint passed → template valid |
| 4 | Multi-environment deploy | Dev (1 replica, NodePort) and Prod (2 replicas, ClusterIP) |
| 5 | Upgrade with new values | Replica count changed via helm upgrade |
| 6 | Failed upgrade + rollback | Bad image tag → upgrade failed → rollback to rev 2 |
| 7 | Release inspection | helm get values/manifest/history showed release state |
| 8 | YAML error safety | Broken values file rejected before cluster is touched |
Chapter 13: Observability — Logging, Metrics, and Tracing
⏱️ Total chapter time: ~60 min (30 min reading + 30 min lab)
After this chapter, you will be able to: Use
kubectl logsand Stern to stream multi-pod logs, deploy the Prometheus + Grafana stack via Helm, query metrics with PromQL, build dashboards, and instrument your own app for observability.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 13.1 | The Three Pillars of Observability | ~5 min |
| 13.2 | Logging — kubectl logs, Stern, and Aggregation | ~7 min |
| 13.3 | Metrics — Prometheus and metrics-server | ~8 min |
| 13.4 | Dashboards with Grafana | ~6 min |
| 13.5 | 🔬 Lab: Full Observability Stack on Minikube | ~30 min |
Prerequisites
- Completed Chapters 1–12 (all core concepts + Helm)
helmCLI installed (helm versionto verify)minikube statusshowsRunning- Minikube with at least 4 GB RAM allocated (
minikube config set memory 4096)
13.1 The Three Pillars of Observability
⏱️ ~5 min read
TL;DR: Observability is the ability to understand the internal state of a system from its external outputs. In Kubernetes, this means three things: Logs (what happened), Metrics (how things are behaving), and Traces (why a request was slow). Without all three, you’re flying blind.
Why “Monitoring” Isn’t Enough
Traditional monitoring asks pre-defined questions: “Is CPU above 80%?” This works for known failure modes. But Kubernetes clusters have emergent complexity — failures arise from combinations of normal behavior. You need to ask arbitrary questions of your system at any time.
Observability means your system is instrumented so you can answer any question about its internal state — even questions you didn’t think to ask before it broke.
The Three Pillars
graph TD
subgraph "Observability Stack"
L["📋 LOGS\nDiscrete events\nwith timestamps\n\nkubectl logs\nFluentd / Loki"]
M["📈 METRICS\nNumeric measurements\nover time\n\nPrometheus\nmetrics-server"]
T["🔍 TRACES\nCross-service request\nflow with spans\n\nJaeger / Zipkin\nOpenTelemetry"]
end
L -->|"What happened?"| Q["Answer\nAny Question"]
M -->|"How is it behaving?"| Q
T -->|"Why was it slow?"| Q
| Pillar | Data Type | Question It Answers | Key Tools |
|---|---|---|---|
| Logs | Unstructured / JSON text | What happened at a specific time? | kubectl logs, Fluentd, Loki, EFK stack |
| Metrics | Numbers over time (time series) | How is the system behaving right now and historically? | Prometheus, metrics-server, Grafana |
| Traces | Linked spans across services | Which service in a call chain caused the slowness? | Jaeger, Zipkin, OpenTelemetry |
How They Complement Each Other
The pillars work together, not in isolation:
- Alert fires → Metric threshold exceeded (e.g., error rate > 1%)
- Investigate → Look at logs for that time window to find the error message
- Root cause → Follow the distributed trace to find which downstream service introduced the latency
[Metric Alert] → high error rate on /api/checkout
↓
[Logs] → "connection refused: payment-service:3000"
↓
[Trace] → checkout → payment (timeout after 5s) → db-query (took 4.8s)
↓
Root cause: slow DB query in payment-service causing cascade timeout
The Kubernetes Observability Stack
In this chapter we focus on what you can build on Minikube today:
┌─────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Your │ │Prometheus│ │ Grafana │ │
│ │ Apps │──│ (scrapes │──│ (dashboards + │ │
│ │ (metrics │ │ metrics) │ │ alerting) │ │
│ │endpoint) │ └──────────┘ └──────────────────┘ │
│ └──────────┘ │
│ │ │
│ stdout/stderr → kubelet → node log files │
│ │ │
│ ┌────▼─────┐ │
│ │ kubectl │ (direct log access — no extra infra) │
│ │ logs │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘
Chapter scope: We cover Logs + Metrics + Dashboards in depth. Distributed Tracing with OpenTelemetry/Jaeger is introduced conceptually — a full tracing setup requires a service mesh and is covered in advanced chapters.
The Golden Signals
Google’s SRE team identified four signals that, if measured, cover most failure modes:
| Signal | What It Measures | Example Metric |
|---|---|---|
| Latency | Time to serve requests | http_request_duration_seconds |
| Traffic | Demand on the system | http_requests_total |
| Errors | Rate of failed requests | http_requests_total{status=~"5.."} |
| Saturation | How “full” the system is | CPU/memory utilization |
🎯 Design principle: Instrument every service to expose the four Golden Signals. Alert on them. Use logs and traces to debug when they fire.
✅ Quick Check
Q1: What’s the difference between monitoring and observability?
Answer
Monitoring is about tracking known, pre-defined indicators (dashboards, threshold alerts). Observability means your system is instrumented so you can ask *any* question about its internal state — including questions you didn't anticipate before a new failure mode appeared. Observability is a property of the system; monitoring is what you do with that property.Q2: A checkout request is slow. You have metrics and logs but no traces. What can’t you determine?
Answer
Without traces, you can see that the request was slow (metrics) and possibly find an error message (logs), but you cannot easily determine *which specific service* in the call chain (checkout → payment → inventory → database) caused the slowness, or what the exact breakdown of time spent in each service was. Distributed traces provide a "flamegraph" of a single request across service boundaries.Q3: Which Golden Signal would you alert on first to catch user-facing problems early?
Answer
**Errors** (error rate) and **Latency** are the most directly user-facing. Latency > threshold means users experience slowness; error rate > threshold means requests are failing. Traffic and Saturation are better for capacity planning and predicting *future* problems before they become user-visible.13.2 Logging — kubectl logs, Stern, and Aggregation
⏱️ ~7 min read
TL;DR: Kubernetes collects anything your container writes to stdout/stderr and makes it available via
kubectl logs. For multi-pod scenarios, use Stern for fan-out log tailing. For production-grade aggregation, use the EFK stack (Elasticsearch + Fluentd + Kibana) or the lighter-weight PLG stack (Promtail + Loki + Grafana).
How Kubernetes Logging Works
graph LR
APP["App Process\n(your container)"]
STDOUT["stdout / stderr"]
KUBELET["kubelet\n(node agent)"]
LOGFILE["Node log file\n/var/log/pods/..."]
KUBECTL["kubectl logs\npod/my-app"]
AGG["Log Aggregator\n(Fluentd/Promtail)"]
STORE["Log Store\n(Loki / Elasticsearch)"]
UI["UI\n(Grafana / Kibana)"]
APP --> STDOUT --> KUBELET --> LOGFILE
LOGFILE --> KUBECTL
LOGFILE --> AGG --> STORE --> UI
Rule #1: Write all application logs to stdout/stderr. Never log to files inside the container — those files disappear when the pod restarts and are invisible to Kubernetes tooling.
kubectl logs — The Essential Commands
# Basic log viewing
kubectl logs my-pod # Last ~100 lines
kubectl logs my-pod -f # Follow (tail -f equivalent)
kubectl logs my-pod --tail=50 # Last 50 lines
kubectl logs my-pod --since=5m # Last 5 minutes
kubectl logs my-pod --since-time=2024-01-15T10:00:00Z # Since timestamp
# Multi-container pods
kubectl logs my-pod -c my-container # Specific container
kubectl logs my-pod --all-containers # All containers
# Previous (crashed) container
kubectl logs my-pod --previous # Logs from last crash — very useful!
# Filter with standard unix tools
kubectl logs my-pod | grep ERROR
kubectl logs my-pod | grep -E "(ERROR|WARN)" | tail -20
# Label selector — logs from all matching pods
kubectl logs -l app=my-app # All pods with label app=my-app
kubectl logs -l app=my-app --all-containers
# Using shorthand
kubectl logs deploy/my-deployment # Picks one pod from deployment
kubectl logs svc/my-service # Picks one pod from service
Understanding Log Retention
Kubernetes does not keep logs forever. The kubelet rotates log files based on:
| Setting | Default | Description |
|---|---|---|
containerLogMaxSize | 10Mi | Max size before rotation |
containerLogMaxFiles | 5 | Number of rotated files to keep |
⚠️ Critical: If your pod crashes repeatedly,
kubectl logs my-pod --previousonly shows the immediately previous container. Older logs are lost once rotated. This is why you need a log aggregation system for production.
Structured Logging (JSON)
Always log in JSON in production — it makes filtering and querying vastly easier:
# ❌ Bad — unstructured
print(f"Error processing order {order_id}: {str(e)}")
# ✅ Good — structured JSON
import json, sys
print(json.dumps({
"level": "error",
"event": "order_processing_failed",
"order_id": order_id,
"error": str(e),
"user_id": user_id,
"duration_ms": elapsed_ms
}), file=sys.stderr)
Result: You can now query level=error AND order_id=12345 in Kibana/Grafana Loki instead of grep-ing freeform text.
Stern — Multi-Pod Log Tailing
kubectl logs -l has limitations (max 5 pods, no color coding, single namespace). Stern is the tool that fixes this:
# Install stern
curl -Lo stern https://github.com/stern/stern/releases/latest/download/stern_linux_amd64
chmod +x stern && sudo mv stern /usr/local/bin/
# Or via Homebrew (macOS/Linux)
brew install stern
# Usage
stern my-app # Tail all pods matching "my-app" (regex)
stern my-app --namespace production # Specific namespace
stern my-app -n production -n staging # Multiple namespaces
stern . # ALL pods in current namespace
stern my-app --since 10m # Last 10 minutes
stern my-app --container sidecar # Specific container
stern my-app --output json # JSON output for piping
# With label selectors
stern -l app=checkout -l tier=backend
# Template output (customize per-line format)
stern my-app --template '{{.PodName}} | {{.Message}}'
Stern automatically:
- Color-codes output by pod name
- Shows pod name and container name on each line
- Reconnects automatically when pods restart
- Handles pod creation and deletion during tailing
Log Aggregation: EFK vs PLG
For production, you need logs stored centrally and searchable:
| Stack | Components | Characteristics |
|---|---|---|
| EFK | Elasticsearch + Fluentd + Kibana | Heavy, powerful, full-text search, complex queries |
| PLG | Promtail + Loki + Grafana | Lightweight, label-based (like Prometheus for logs), cheaper storage |
| ELK | Elasticsearch + Logstash + Kibana | EFK variant with Logstash for more complex transforms |
🔗 Today’s choice: For Minikube labs and small clusters, use Loki + Grafana (covered in the lab). For large enterprise clusters with compliance requirements, EFK is the standard.
How Loki Works
Loki is “Prometheus for logs” — it doesn’t index the content of logs, only the labels (like pod name, namespace, container). This makes it much cheaper to run:
Prometheus scrapes metrics → stores as time series by labels
Loki scrapes logs → stores as log streams by labels (no full-text index)
# Loki query language (LogQL) examples
{namespace="production", app="checkout"} # All logs for app in namespace
{app="checkout"} |= "ERROR" # Filter for ERROR lines
{app="checkout"} | json | level="error" # Parse JSON and filter
{app="checkout"} | json | level="error" | rate[5m] # Error rate over 5 min
Log Levels and Best Practices
| Level | When to Use | Example |
|---|---|---|
DEBUG | Detailed internal state, dev only | “Processing item 42 of batch” |
INFO | Normal operations, key lifecycle events | “Server started on :8080” |
WARN | Unexpected but handled, recoverable | “Retry 2/3 for upstream service” |
ERROR | Failed operation, needs attention | “Failed to write to database” |
FATAL | Unrecoverable, process will exit | “Cannot open config file, exiting” |
Best practices:
- Include request IDs in every log line — essential for tracing a request through microservices
- Never log secrets or PII — passwords, tokens, credit card numbers must be redacted
- Log at boundaries — function entry/exit with inputs/outputs at DEBUG level
- Set log level via env var —
LOG_LEVEL=infoin ConfigMap, change without rebuilding
✅ Quick Check
Q1: Your pod crashed and restarted 3 times. How do you see logs from the previous run?
Answer
Use `kubectl logs my-pod --previous` (or `-p` shorthand). This shows logs from the immediately preceding container instance. Note that only the most recent previous container's logs are available — logs from 2 crashes ago are gone unless you have a log aggregation system.Q2: You have a Deployment with 8 replicas. How do you tail logs from ALL of them simultaneously?
Answer
Use **Stern**: `stern my-deployment` or `stern -l app=my-app`. With `kubectl logs`, you can use `kubectl logs -l app=my-app -f --max-log-requests=10` but it has a 5-pod default limit (overridable). Stern handles this transparently, color-codes by pod, and reconnects when pods are replaced.Q3: Why should you never log to files inside a container?
Answer
Container filesystems are ephemeral — they disappear when the container is deleted or crashes. Files inside a container are not accessible via `kubectl logs`, not forwarded to log aggregation systems, and lost when the pod is rescheduled to a different node. Always write to stdout/stderr, which Kubernetes captures automatically.13.3 Metrics — Prometheus and metrics-server
⏱️ ~8 min read
TL;DR: Kubernetes has two layers of metrics: metrics-server (lightweight, powers HPA and
kubectl top) and Prometheus (full-featured time-series database for alerting and dashboards). Prometheus uses a pull model — it scrapes/metricsendpoints from your pods on a schedule. You annotate pods to tell it where to look.
Two Layers of Kubernetes Metrics
graph TD
subgraph "Layer 1: Built-in (metrics-server)"
MS["metrics-server\n(in-cluster)"]
KT["kubectl top pods/nodes"]
HPA["HorizontalPodAutoscaler"]
MS --> KT
MS --> HPA
end
subgraph "Layer 2: Full (Prometheus)"
P["Prometheus\n(scrapes /metrics)"]
AM["AlertManager\n(fire alerts)"]
G["Grafana\n(dashboards)"]
P --> AM
P --> G
end
K8S["Kubernetes\nAPI Server"]
PODS["Your Pods\n(/metrics endpoint)"]
K8S --> MS
PODS --> P
K8S --> P
| metrics-server | Prometheus | |
|---|---|---|
| Purpose | Real-time resource usage | Historical metrics + alerting |
| Retention | In-memory only (no history) | Configurable (days/weeks) |
| Data source | kubelet resource summary | Pod /metrics endpoints |
| Query | kubectl top | PromQL |
| Alerting | ❌ No | ✅ Yes (AlertManager) |
| Dashboards | ❌ No | ✅ Yes (Grafana) |
| Install | minikube addons enable metrics-server | Helm chart |
metrics-server — Quick Access to Resource Usage
# Enable on Minikube
minikube addons enable metrics-server
# Wait ~60 seconds for it to collect data, then:
kubectl top nodes # Node-level CPU and memory
kubectl top pods # Pod-level CPU and memory (all namespaces: -A)
kubectl top pods --sort-by=cpu # Sort by CPU usage
kubectl top pods --sort-by=memory
# Example output
NAME CPU(cores) MEMORY(bytes)
my-app-6d4b8f9c7-xk2pq 12m 45Mi
postgres-0 38m 312Mi
redis-7d9f8b6c-lm9qr 4m 18Mi
⚠️ Limitation:
kubectl topshows only current usage. It has no history. If your pod OOM-killed 10 minutes ago, the resource spike is gone. That’s why Prometheus is essential for production.
How Prometheus Works
Prometheus uses a pull model — instead of your app pushing metrics to a collector, Prometheus periodically scrapes an HTTP endpoint (/metrics) exposed by each target.
# Example /metrics endpoint output (Prometheus text format)
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200",path="/api/users"} 14823
http_requests_total{method="POST",status="201",path="/api/orders"} 3247
http_requests_total{method="GET",status="500",path="/api/checkout"} 42
# HELP http_request_duration_seconds Request duration histogram
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.1"} 12940
http_request_duration_seconds_bucket{le="0.5"} 14790
http_request_duration_seconds_bucket{le="1.0"} 14820
http_request_duration_seconds_count 14823
http_request_duration_seconds_sum 1247.3
Metric Types
| Type | Description | Use Case |
|---|---|---|
| Counter | Monotonically increasing number | Request count, error count, bytes sent |
| Gauge | Can go up or down | Current memory usage, active connections |
| Histogram | Distribution of values in buckets | Request duration, response size |
| Summary | Pre-calculated quantiles | p50/p95/p99 latency (calculated in app) |
Annotating Pods for Prometheus Scraping
Tell Prometheus to scrape your pod using annotations:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
metadata:
labels:
app: my-app
annotations:
prometheus.io/scrape: "true" # Enable scraping
prometheus.io/port: "8080" # Port where /metrics is exposed
prometheus.io/path: "/metrics" # Default, can omit
spec:
containers:
- name: my-app
image: my-app:latest
ports:
- containerPort: 8080
🔗 How it works: The kube-prometheus-stack Helm chart installs a Prometheus operator that watches these annotations (via ServiceMonitors) and automatically adds/removes scrape targets as pods come and go.
PromQL — Prometheus Query Language
PromQL is how you ask Prometheus questions. It’s used in Grafana panels and AlertManager rules.
Basic Syntax
# Selector: metric name + label filters
http_requests_total # All time series for this metric
http_requests_total{status="200"} # Filter: only 200s
http_requests_total{status=~"5.."} # Regex: 5xx errors
http_requests_total{status!="200"} # Negation: not 200s
# Range vectors (over time)
http_requests_total[5m] # All data points in last 5 min
Essential Functions
# rate() — per-second rate of a counter over a time window
rate(http_requests_total[5m]) # Requests/sec over 5 min
# irate() — instant rate (last two data points, more responsive)
irate(http_requests_total[1m])
# increase() — total increase over time window
increase(http_requests_total[1h]) # Total requests in last hour
# Aggregation
sum(rate(http_requests_total[5m])) # Total req/s across all instances
sum by (status) (rate(http_requests_total[5m])) # Req/s grouped by status code
# Histogram quantiles (latency p95)
histogram_quantile(0.95,
sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
# Memory usage in MB
container_memory_usage_bytes{namespace="default"} / 1024 / 1024
# CPU usage percentage
rate(container_cpu_usage_seconds_total[5m]) * 100
The Four Golden Signals in PromQL
# Latency (p99 request duration)
histogram_quantile(0.99,
sum by (le)(rate(http_request_duration_seconds_bucket[5m])))
# Traffic (requests per second)
sum(rate(http_requests_total[5m]))
# Errors (error rate %)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m])) * 100
# Saturation (CPU usage %)
avg(rate(container_cpu_usage_seconds_total[5m])) by (pod) /
avg(kube_pod_container_resource_limits{resource="cpu"}) by (pod) * 100
Installing Prometheus via Helm
The kube-prometheus-stack chart installs everything in one shot: Prometheus, AlertManager, Grafana, and all the Kubernetes-specific recording rules and dashboards.
# Add the Prometheus community Helm repo
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
# Install the full stack (takes 2-3 min on Minikube)
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
--set grafana.adminPassword=admin123
# Check all components are running
kubectl get pods -n monitoring
# Expected pods:
# alertmanager-monitoring-kube-prometheus-alertmanager-0 Running
# monitoring-grafana-xxxxx Running
# monitoring-kube-prometheus-operator-xxxxx Running
# monitoring-kube-state-metrics-xxxxx Running
# monitoring-prometheus-node-exporter-xxxxx Running
# prometheus-monitoring-kube-prometheus-prometheus-0 Running
What Gets Installed
| Component | Purpose |
|---|---|
| Prometheus | Time-series database + scraper |
| AlertManager | Routes and deduplicates alerts |
| Grafana | Dashboard UI |
| kube-state-metrics | Exposes Kubernetes object state as metrics (pod count, deployment replicas, etc.) |
| node-exporter | Exposes host-level metrics (disk, network, CPU) |
| Prometheus Operator | Watches CRDs (ServiceMonitor, PrometheusRule) to configure Prometheus |
Alerting with AlertManager
Define alerting rules as PrometheusRule custom resources:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: my-app-alerts
namespace: monitoring
labels:
release: monitoring # Must match kube-prometheus-stack label selector
spec:
groups:
- name: my-app
interval: 30s
rules:
# Alert if error rate exceeds 1% for 5 minutes
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m])) > 0.01
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate detected"
description: "Error rate is {{ $value | humanizePercentage }} for 5 minutes"
# Alert if pod has been pending for more than 2 minutes
- alert: PodNotReady
expr: kube_pod_status_phase{phase="Pending"} == 1
for: 2m
labels:
severity: critical
annotations:
summary: "Pod {{ $labels.pod }} stuck in Pending"
✅ Quick Check
Q1: What is the fundamental difference between how metrics-server and Prometheus collect data?
Answer
**metrics-server** pulls resource usage data from the kubelet's summary API (CPU, memory) and stores it only in-memory — no history is retained. **Prometheus** scrapes the `/metrics` HTTP endpoint on each pod (a pull model) and stores metrics in a persistent time-series database with configurable retention, enabling historical queries, trend analysis, and alerting.Q2: You want to know the 95th percentile request latency for your service over the past 30 minutes. Which metric type should your app expose, and what PromQL function do you use?
Answer
Your app should expose a **Histogram** metric (e.g., `http_request_duration_seconds`), which records observations in configurable buckets. Then use: `histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[30m])))`. The `histogram_quantile()` function interpolates the quantile value from the bucket counts.Q3: A pod’s /metrics endpoint is on port 9090 but Prometheus isn’t scraping it. What annotation is missing from the pod spec?
Answer
The pod needs these annotations: ```yaml annotations: prometheus.io/scrape: "true" prometheus.io/port: "9090" ``` Without `prometheus.io/scrape: "true"`, Prometheus ignores the pod. Without the correct port annotation, it may try the default port (80) and get a 404.13.4 Dashboards with Grafana
⏱️ ~6 min read
TL;DR: Grafana is the visualization layer for your observability stack. It connects to Prometheus (and Loki) as data sources and turns PromQL queries into live dashboards. The kube-prometheus-stack installs pre-built dashboards for Kubernetes itself — you extend these with your own panels for application-level metrics.
Grafana Architecture
graph LR
subgraph "Data Sources"
PROM["Prometheus\n(metrics)"]
LOKI["Loki\n(logs)"]
PG["PostgreSQL\n(optional)"]
end
subgraph "Grafana"
DS["Data Source\nPlugins"]
PANELS["Panels\n(time series, gauge,\ntable, heatmap…)"]
DASH["Dashboards\n(collections of panels)"]
ALERT["Grafana Alerts\n(unified alerting)"]
end
subgraph "Users"
OPS["Ops Team"]
DEV["Dev Team"]
end
PROM --> DS
LOKI --> DS
PG --> DS
DS --> PANELS --> DASH
DASH --> ALERT
DASH --> OPS
DASH --> DEV
Accessing Grafana on Minikube
After installing kube-prometheus-stack:
# Port-forward Grafana to localhost:3000
kubectl port-forward svc/monitoring-grafana 3000:80 -n monitoring
# Open in browser
open http://localhost:3000
# Login: admin / admin123 (or whatever you set at install)
Tip: To find the exact Grafana service name:
kubectl get svc -n monitoring | grep grafana
Pre-Built Kubernetes Dashboards
The kube-prometheus-stack ships with production-grade dashboards out of the box. Browse them at Dashboards → Browse:
| Dashboard | What You See |
|---|---|
| Kubernetes / Compute Resources / Namespace (Pods) | CPU + memory for all pods in a namespace |
| Kubernetes / Compute Resources / Node | Per-node resource utilization |
| Kubernetes / Networking / Namespace | Network in/out by pod |
| Node Exporter / Full | Host-level metrics (disk I/O, filesystem, network) |
| Kubernetes / Persistent Volumes | PVC usage and status |
| Alertmanager / Overview | Current firing alerts |
Anatomy of a Grafana Dashboard
┌─────────────────────────────────────────────────────────┐
│ Dashboard: My App Overview [Time range: Last 1h]│
│ Variables: [namespace ▼] [pod ▼] │
├─────────────────────────────────────────────────────────┤
│ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Requests/sec │ │ Error Rate │ │ p99 Latency│ │
│ │ [Stat] │ │ [Gauge] │ │ [Stat] │ │
│ │ 142/s │ │ 0.3% │ │ 48ms │ │
│ └───────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ Request Rate over Time [Time Series] ││
│ │ ┌────────────────────────────────────────────────┐ ││
│ │ │ ^ │ ││
│ │ │ │ /\ /\/\ /\ │ ││
│ │ │ │ / \/ \ / \___ │ ││
│ │ │ └──────────────────────────────────────── time │ ││
│ │ └────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
Panel Types
| Panel Type | Best For |
|---|---|
| Time Series | Metrics over time (request rate, latency trends) |
| Stat | Single important number (current RPS, uptime) |
| Gauge | Value with min/max (error rate %, memory %) |
| Table | Multi-column data (pod list with CPU/mem) |
| Bar Gauge | Comparing values across instances |
| Heatmap | Latency distributions over time |
| Logs | Loki log streams embedded in dashboards |
| Alert list | Current firing alerts |
Building Your First Custom Panel
Here’s how to create a Request Rate panel for your app:
Step 1: Add a Panel
- Open a dashboard → click Add → Visualization
- Select data source: Prometheus
Step 2: Write the PromQL Query
# Panel: HTTP Request Rate
sum by (status) (
rate(http_requests_total{namespace="$namespace", pod=~"$pod"}[5m])
)
$namespaceand$podare dashboard variables — dropdown filters at the top.
Step 3: Configure Visualization
- Panel type: Time Series
- Legend:
{{status}} - Unit:
reqps(requests per second) - Thresholds: Yellow at 100/s, Red at 500/s
Step 4: Add More Panels
# Error rate %
sum(rate(http_requests_total{status=~"5..", pod=~"$pod"}[5m]))
/
sum(rate(http_requests_total{pod=~"$pod"}[5m])) * 100
# p95 Latency
histogram_quantile(0.95,
sum by (le)(rate(http_request_duration_seconds_bucket{pod=~"$pod"}[5m])))
# Memory usage MB
container_memory_usage_bytes{pod=~"$pod"} / 1024 / 1024
Dashboard Variables
Variables make dashboards interactive — users pick namespace/pod from dropdowns:
- Go to Dashboard Settings → Variables → Add variable
- Configure:
Name: namespace
Type: Query
Query: label_values(kube_pod_info, namespace)
Name: pod
Type: Query
Query: label_values(kube_pod_info{namespace="$namespace"}, pod)
Now your PromQL queries use $namespace and $pod as filters, and the dashboard dynamically scopes to whatever the user selects.
Dashboard as Code (GitOps)
In production, dashboards should be version-controlled, not clicked together in the UI:
# ConfigMap containing a Grafana dashboard JSON
apiVersion: v1
kind: ConfigMap
metadata:
name: my-app-dashboard
namespace: monitoring
labels:
grafana_dashboard: "1" # Grafana sidecar auto-imports this
data:
my-app.json: |
{
"title": "My App Overview",
"panels": [...],
"templating": { "list": [...] }
}
The grafana-sidecar container in the Grafana pod watches for ConfigMaps with grafana_dashboard: "1" and automatically imports them. Export a dashboard as JSON from the Grafana UI and commit it to Git.
Grafana Alerting
Grafana has unified alerting that can trigger from any data source:
# Alert rule (configured in Grafana UI or as YAML)
Name: High Error Rate
Condition: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01
For: 5m
Notifications:
- Slack channel #alerts
- PagerDuty
- Email
Note: For Kubernetes-native alerting, prefer AlertManager rules (PrometheusRule CRDs) over Grafana alerts — they work even when Grafana is down.
✅ Quick Check
Q1: You built a Grafana dashboard that shows metrics for a specific pod name (hardcoded). A colleague wants to reuse it for their pod. What’s the proper Grafana feature to use?
Answer
**Dashboard Variables.** Create a variable (e.g., `pod`) with a query like `label_values(kube_pod_info, pod)` that dynamically lists all pods. Then replace the hardcoded pod name in all PromQL queries with `$pod`. Users can select any pod from a dropdown at the top of the dashboard.Q2: Your Grafana dashboard shows a spike in errors at 2:15 AM. How do you correlate this with logs for the same time window?
Answer
If you have **Loki** configured as a data source in Grafana, you can add a **Logs panel** to the same dashboard pointing to the relevant log stream (e.g., `{app="my-app"}`). Grafana's time range selector is global — changing the range to around 2:15 AM will show both the metric panel and the log panel for that window simultaneously. You can also click on a specific point in a time series panel and "Explore" the correlated logs.Q3: Why should Grafana dashboards be stored in Git rather than only in the Grafana database?
Answer
Grafana stores dashboards in its internal database (SQLite or PostgreSQL). If Grafana's pod/PVC is deleted (e.g., during a cluster migration, namespace deletion, or accidental `helm uninstall`), all dashboards are lost. Storing dashboards as JSON in Git enables: version history, code review for dashboard changes, automatic re-provisioning via ConfigMaps or Helm values, and consistent dashboards across multiple clusters (dev/staging/prod).Lab: Full Observability Stack on Minikube
⏱️ ~30 min hands-on
| Prerequisites | Sections 13.1–13.4 read, helm version works, Minikube running with ≥4 GB RAM |
| Difficulty | 🟠 Intermediate–Advanced |
| What you’ll do | Deploy a metrics-emitting app, install Prometheus + Grafana via Helm, query metrics with PromQL, set up Loki for logs, and build a custom Grafana dashboard |
Objectives
- Enable metrics-server and use
kubectl top - Install the kube-prometheus-stack via Helm
- Deploy a sample app that exposes Prometheus metrics
- Query metrics using PromQL in the Prometheus UI
- Access Grafana and explore pre-built Kubernetes dashboards
- Build a custom panel for application-level metrics
- Install Loki and view logs in Grafana
- Write an alerting rule and observe it firing
Setup
# Ensure Minikube has enough resources (restart if needed)
minikube config set memory 4096
minikube config set cpus 2
# If Minikube isn't running yet:
# minikube start --memory=4096 --cpus=2
# Verify status
minikube status
kubectl get nodes
# Create dedicated namespace
kubectl create namespace monitoring
kubectl create namespace obs-lab
Exercise 1: metrics-server and kubectl top
What we’re doing: Enable the built-in metrics-server addon and explore resource usage.
# Enable metrics-server
minikube addons enable metrics-server
# Deploy a load-generating workload to have something to measure
kubectl apply -f - <<'EOF' -n obs-lab
apiVersion: apps/v1
kind: Deployment
metadata:
name: load-app
spec:
replicas: 3
selector:
matchLabels:
app: load-app
template:
metadata:
labels:
app: load-app
spec:
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
EOF
# Wait for pods
kubectl rollout status deployment/load-app -n obs-lab
# Wait ~60 seconds for metrics-server to collect data
sleep 60
# View resource usage
kubectl top nodes
kubectl top pods -n obs-lab
kubectl top pods -n obs-lab --sort-by=cpu
Expected output:
NAME CPU(cores) MEMORY(bytes)
load-app-xxx-aaa 2m 3Mi
load-app-xxx-bbb 1m 3Mi
load-app-xxx-ccc 2m 3Mi
Exercise 2: Install Prometheus + Grafana Stack
What we’re doing: Install the production-grade monitoring stack via Helm.
# Add Prometheus community chart repo
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
# Install the full kube-prometheus-stack
# (This takes 2-4 minutes to download and start)
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
--set grafana.adminPassword=admin123 \
--set prometheus.prometheusSpec.retention=1h \
--set alertmanager.alertmanagerSpec.storage.volumeClaimTemplate.spec.resources.requests.storage=512Mi \
--timeout 5m
# Watch pods come up
kubectl get pods -n monitoring -w
# Press Ctrl+C when all are Running
# Verify all components
kubectl get pods -n monitoring
Expected pods (all Running):
alertmanager-monitoring-kube-prometheus-alertmanager-0 2/2 Running
monitoring-grafana-xxxxx 3/3 Running
monitoring-kube-prometheus-operator-xxxxx 1/1 Running
monitoring-kube-state-metrics-xxxxx 1/1 Running
monitoring-prometheus-node-exporter-xxxxx 1/1 Running (on each node)
prometheus-monitoring-kube-prometheus-prometheus-0 2/2 Running
Exercise 3: Deploy a Metrics-Emitting App
What we’re doing: Deploy a real Python app that exposes Prometheus metrics on /metrics.
# Create the app — a Flask server with prometheus_client
kubectl apply -f - <<'EOF' -n obs-lab
apiVersion: v1
kind: ConfigMap
metadata:
name: metrics-app-code
data:
app.py: |
from flask import Flask, Response
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
import time, random, threading
app = Flask(__name__)
# Define metrics
REQUEST_COUNT = Counter('http_requests_total', 'Total HTTP requests',
['method', 'endpoint', 'status'])
REQUEST_LATENCY = Histogram('http_request_duration_seconds', 'Request duration',
['endpoint'],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5])
ACTIVE_REQUESTS = Gauge('http_active_requests', 'Currently active requests')
ERROR_SIMULATION = Gauge('error_injection_active', '1 if errors are being injected')
error_mode = False
@app.route('/')
def home():
with ACTIVE_REQUESTS.track_inprogress():
start = time.time()
time.sleep(random.uniform(0.01, 0.05))
status = "500" if error_mode and random.random() < 0.3 else "200"
REQUEST_COUNT.labels(method='GET', endpoint='/', status=status).inc()
REQUEST_LATENCY.labels(endpoint='/').observe(time.time() - start)
if status == "500":
return "Internal Server Error", 500
return "Hello from metrics-app! Visit /metrics to see Prometheus data.\n"
@app.route('/slow')
def slow():
with ACTIVE_REQUESTS.track_inprogress():
start = time.time()
time.sleep(random.uniform(0.5, 2.0))
REQUEST_COUNT.labels(method='GET', endpoint='/slow', status='200').inc()
REQUEST_LATENCY.labels(endpoint='/slow').observe(time.time() - start)
return "That was slow!\n"
@app.route('/error-on')
def error_on():
global error_mode
error_mode = True
ERROR_SIMULATION.set(1)
return "Error injection ON — 30% of / requests will return 500\n"
@app.route('/error-off')
def error_off():
global error_mode
error_mode = False
ERROR_SIMULATION.set(0)
return "Error injection OFF\n"
@app.route('/metrics')
def metrics():
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: metrics-app
labels:
app: metrics-app
spec:
replicas: 2
selector:
matchLabels:
app: metrics-app
template:
metadata:
labels:
app: metrics-app
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
initContainers:
- name: install-deps
image: python:3.11-slim
command: ["pip", "install", "--target=/app/deps", "flask", "prometheus-client"]
volumeMounts:
- name: app-deps
mountPath: /app/deps
containers:
- name: app
image: python:3.11-slim
command: ["python", "/app/code/app.py"]
env:
- name: PYTHONPATH
value: "/app/deps"
ports:
- containerPort: 8080
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
readinessProbe:
httpGet:
path: /metrics
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
volumeMounts:
- name: app-code
mountPath: /app/code
- name: app-deps
mountPath: /app/deps
volumes:
- name: app-code
configMap:
name: metrics-app-code
- name: app-deps
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: metrics-app
labels:
app: metrics-app
spec:
selector:
app: metrics-app
ports:
- port: 80
targetPort: 8080
nodePort: 30880
type: NodePort
EOF
# Wait for pods to be ready (init container installs deps, may take 1-2 min)
kubectl rollout status deployment/metrics-app -n obs-lab
# Test the app
MINIKUBE_IP=$(minikube ip)
curl http://$MINIKUBE_IP:30880/
curl http://$MINIKUBE_IP:30880/metrics | head -30
Expected metrics output (excerpt):
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{endpoint="/",method="GET",status="200"} 3.0
# HELP http_request_duration_seconds Request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{endpoint="/",le="0.05"} 3.0
...
Generate Some Traffic
# Send 200 requests to generate meaningful metrics
for i in $(seq 1 200); do
curl -s http://$MINIKUBE_IP:30880/ > /dev/null
sleep 0.1
done &
# Also hit the slow endpoint
for i in $(seq 1 20); do
curl -s http://$MINIKUBE_IP:30880/slow > /dev/null &
done
echo "Traffic generation running in background..."
Exercise 4: Explore the Prometheus UI
What we’re doing: Use the Prometheus UI to run PromQL queries.
# Port-forward Prometheus UI
kubectl port-forward svc/monitoring-kube-prometheus-prometheus 9090:9090 -n monitoring &
echo "Open http://localhost:9090 in your browser"
In the Prometheus UI (http://localhost:9090):
- Click Status → Targets — confirm
obs-lab/metrics-apppods appear withUPstate - Go to the Graph tab and run these queries:
-- Paste each query and click Execute --
-- Request rate (req/s)
rate(http_requests_total{namespace="obs-lab"}[5m])
-- Total requests by status code
sum by (status) (rate(http_requests_total{namespace="obs-lab"}[5m]))
-- p95 latency
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket{namespace="obs-lab"}[5m])))
-- Active pods in obs-lab
count(kube_pod_status_phase{namespace="obs-lab", phase="Running"})
-- Memory usage in MB
container_memory_usage_bytes{namespace="obs-lab", container="app"} / 1024 / 1024
- Click the Graph tab (not Table) for each query to see the time series visualization.
Exercise 5: Grafana Dashboards
What we’re doing: Access Grafana and build a custom dashboard.
# Port-forward Grafana (use a new terminal or kill the Prometheus port-forward first)
kubectl port-forward svc/monitoring-grafana 3000:80 -n monitoring &
echo "Open http://localhost:3000"
echo "Login: admin / admin123"
Step A: Explore Pre-Built Dashboards
- Log in at
http://localhost:3000 - Go to Dashboards → Browse
- Open “Kubernetes / Compute Resources / Namespace (Pods)”
- Set the namespace dropdown to
obs-lab - Observe CPU and memory for your
metrics-appandload-apppods
Step B: Create a Custom Dashboard
- Click Dashboards → New → New Dashboard
- Click Add visualization
- Select Prometheus as the data source
Panel 1 — Request Rate:
sum by (status) (rate(http_requests_total{namespace="obs-lab"}[5m]))
- Panel type: Time Series
- Title:
HTTP Request Rate - Unit:
reqps - Legend:
Status {{status}}
- Click Apply then Add → Visualization for the next panel.
Panel 2 — p95 Latency:
histogram_quantile(0.95,
sum by (le)(rate(http_request_duration_seconds_bucket{namespace="obs-lab"}[5m])))
- Panel type: Time Series
- Title:
p95 Request Latency - Unit:
seconds
Panel 3 — Error Rate %:
(sum(rate(http_requests_total{namespace="obs-lab",status=~"5.."}[5m]))
/
sum(rate(http_requests_total{namespace="obs-lab"}[5m]))) * 100
- Panel type: Gauge
- Title:
Error Rate - Unit:
percent (0-100) - Thresholds: Green=0, Yellow=1, Red=5
- Click Save dashboard → Name it
"obs-lab: My App"
Exercise 6: Inject Errors and Watch Metrics Change
What we’re doing: Simulate a production incident and watch metrics react in real time.
# Enable error injection in the app
curl http://$MINIKUBE_IP:30880/error-on
# Expected: Error injection ON — 30% of / requests will return 500
# Generate traffic with errors
for i in $(seq 1 300); do
curl -s http://$MINIKUBE_IP:30880/ > /dev/null
sleep 0.1
done
- Watch your Grafana dashboard — the Error Rate gauge should climb to ~30%
- In Prometheus UI (
http://localhost:9090), query:
sum(rate(http_requests_total{status="500"}[1m]))
/
sum(rate(http_requests_total[1m])) * 100
- Turn off errors and watch recovery:
curl http://$MINIKUBE_IP:30880/error-off
Exercise 7: Write an Alerting Rule
What we’re doing: Create a PrometheusRule that fires when error rate exceeds 5%.
kubectl apply -f - <<'EOF' -n monitoring
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: obs-lab-alerts
labels:
release: monitoring
spec:
groups:
- name: obs-lab
interval: 15s
rules:
- alert: ObsLabHighErrorRate
expr: |
(sum(rate(http_requests_total{namespace="obs-lab",status=~"5.."}[2m]))
/
sum(rate(http_requests_total{namespace="obs-lab"}[2m]))) * 100 > 5
for: 1m
labels:
severity: warning
team: platform
annotations:
summary: "High error rate in obs-lab"
description: "Error rate is {{ $value | printf \"%.1f\" }}% (threshold: 5%)"
- alert: ObsLabPodNotReady
expr: |
kube_pod_status_phase{namespace="obs-lab", phase!="Running"} == 1
for: 2m
labels:
severity: critical
annotations:
summary: "Pod {{ $labels.pod }} is not Running"
EOF
# Verify the rule was picked up by Prometheus
kubectl get prometheusrule -n monitoring
# Check in Prometheus UI → Alerts tab
# Open http://localhost:9090/alerts
Trigger the alert:
# Turn errors back on and generate enough traffic to exceed the 5% threshold
curl http://$MINIKUBE_IP:30880/error-on
for i in $(seq 1 500); do
curl -s http://$MINIKUBE_IP:30880/ > /dev/null
sleep 0.05
done
# Check Prometheus → Alerts — should show ObsLabHighErrorRate as Firing
echo "Check http://localhost:9090/alerts"
Exercise 8: Loki — Logs in Grafana (Optional, Extra Credit)
What we’re doing: Install Loki and view Kubernetes logs directly in Grafana.
# Add Grafana Helm repo
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
# Install Loki + Promtail (lightweight log collector)
helm install loki grafana/loki-stack \
--namespace monitoring \
--set grafana.enabled=false \
--set promtail.enabled=true \
--set loki.persistence.enabled=false
# Wait for Loki and Promtail
kubectl rollout status statefulset/loki -n monitoring
kubectl get pods -n monitoring | grep -E "loki|promtail"
Add Loki as a Grafana data source:
- In Grafana → Configuration → Data Sources → Add data source
- Select Loki
- URL:
http://loki:3100 - Click Save & Test — should show “Data source connected”
Explore logs:
- Go to Explore (compass icon in left sidebar)
- Select Loki data source
- Run these LogQL queries:
# All logs from obs-lab namespace
{namespace="obs-lab"}
# Filter for error lines
{namespace="obs-lab"} |= "500"
# Python app logs only
{namespace="obs-lab", container="app"}
# Count error lines per minute (metric query)
sum(count_over_time({namespace="obs-lab"} |= "500" [1m]))
- Switch to Logs view to see individual log lines with timestamp
🔥 Break It! Challenge
Scale your
metrics-appto 0 replicas and observe what happens to each layer of observability.
# Scale to zero
kubectl scale deployment metrics-app --replicas=0 -n obs-lab
# Wait 2 minutes then check:
# 1. kubectl top pods -n obs-lab → metrics-app gone
# 2. Prometheus → Targets → metrics-app targets "down" or missing
# 3. Grafana Request Rate panel → line drops to 0
# 4. Prometheus → Alerts → ObsLabPodNotReady should fire
# 5. Loki → {namespace="obs-lab"} → no new log lines
# Now scale back and observe recovery
kubectl scale deployment metrics-app --replicas=2 -n obs-lab
kubectl rollout status deployment/metrics-app -n obs-lab
# 6. Generate traffic again and watch metrics recover
for i in $(seq 1 100); do curl -s http://$MINIKUBE_IP:30880/ > /dev/null; sleep 0.1; done
What did you observe?
- How long did it take for Prometheus to detect the targets were down?
- How long before the alert fired (remember
for: 2m)? - How long after scale-up before metrics reappeared?
- Were there any log gaps?
Cleanup
# Stop port-forwards
kill %1 %2 2>/dev/null || true
# Remove the monitoring stack
helm uninstall monitoring -n monitoring
helm uninstall loki -n monitoring 2>/dev/null || true
# Remove the lab namespace and all its resources
kubectl delete namespace obs-lab
# Remove the monitoring namespace
kubectl delete namespace monitoring
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | metrics-server | kubectl top pods showed live CPU/memory |
| 2 | Prometheus via Helm | Full kube-prometheus-stack running in monitoring |
| 3 | App instrumentation | metrics-app exposed /metrics with 4 Golden Signals |
| 4 | Prometheus UI + PromQL | Queried request rate, latency, error rate |
| 5 | Grafana dashboards | Explored pre-built K8s dashboards |
| 6 | Custom panels | Built dashboard with 3 panels for app metrics |
| 7 | Live incident simulation | Injected 30% errors, watched metrics react in real time |
| 8 | Alerting rules | PrometheusRule fired ObsLabHighErrorRate alert |
| 9 | Loki logs | Installed Loki, queried logs via LogQL in Grafana |
Chapter 14: Scheduling and Placement
⏱️ Total chapter time: ~55 min (25 min reading + 30 min lab)
After this chapter, you will be able to: Explain how the Kubernetes scheduler selects nodes, pin pods to specific nodes with node selectors and affinity rules, repel pods from nodes using taints and tolerations, and co-locate or spread pods across failure domains with pod affinity and anti-affinity.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 14.1 | The Kubernetes Scheduler — How It Picks a Node | ~5 min |
| 14.2 | Node Affinity and Node Selectors | ~6 min |
| 14.3 | Taints and Tolerations | ~6 min |
| 14.4 | Pod Affinity and Anti-Affinity | ~6 min |
| 14.5 | 🔬 Lab: Control Where Pods Land | ~30 min |
Prerequisites
- Completed Chapters 1–13
minikube statusshowsRunning- Multi-node Minikube recommended:
minikube start --nodes=3(single-node also works with labels)
14.1 The Kubernetes Scheduler — How It Picks a Node
⏱️ ~5 min read
TL;DR: The
kube-schedulerwatches for unscheduled pods and picks the best node for each one by running every candidate node through two phases: Filtering (eliminate nodes that can’t run the pod) and Scoring (rank the survivors; highest score wins). You influence both phases using the placement mechanisms covered in this chapter.
The Scheduling Pipeline
Every pod starts life as Pending — it exists in etcd but has no nodeName. The scheduler’s job is to assign one.
graph TD
P["New Pod\n(Pending, no nodeName)"] --> WATCH["kube-scheduler\nwatching API server"]
WATCH --> FILTER["Phase 1: Filtering\n(eliminate unfit nodes)"]
FILTER --> SCORE["Phase 2: Scoring\n(rank remaining nodes)"]
SCORE --> BIND["Bind: write nodeName\nto pod spec"]
BIND --> KUBELET["kubelet on chosen node\npulls image + starts containers"]
subgraph "Filter Plugins (examples)"
F1["NodeResourcesFit\n(enough CPU/memory?)"]
F2["NodeAffinity\n(label matches?)"]
F3["TaintToleration\n(tolerates taints?)"]
F4["PodTopologySpread\n(spread constraints met?)"]
end
subgraph "Score Plugins (examples)"
S1["LeastAllocated\n(spread load)"]
S2["NodeAffinity\n(preferred labels)"]
S3["ImageLocality\n(image already pulled?)"]
end
FILTER -.-> F1 & F2 & F3 & F4
SCORE -.-> S1 & S2 & S3
Phase 1 — Filtering (Hard Constraints)
A node is eliminated if any filter fails. Common filter checks:
| Filter | What It Checks |
|---|---|
| NodeResourcesFit | Node has enough free CPU and memory for the pod’s requests |
| NodeAffinity | Node labels match requiredDuringSchedulingIgnoredDuringExecution rules |
| TaintToleration | Pod tolerates all NoSchedule taints on the node |
| PodTopologySpread | Placing here wouldn’t violate topologySpreadConstraints |
| VolumeBinding | Required PersistentVolumes are available in this zone |
| NodeUnschedulable | Node is not cordoned (kubectl cordon) |
If zero nodes survive filtering, the pod stays
Pending. This is the most common cause of pods stuck in Pending — always checkkubectl describe pod <name>for the scheduler’s message.
Phase 2 — Scoring (Soft Preferences)
Surviving nodes are each scored 0–100 by each scoring plugin, then weighted and summed. Highest total wins.
| Score Plugin | Goal |
|---|---|
| LeastAllocated | Prefer nodes with the most free resources (spread load) |
| MostAllocated | Prefer fuller nodes (bin-pack, save money) |
| NodeAffinity | Boost score for nodes matching preferredDuringScheduling rules |
| ImageLocality | Prefer nodes that already have the container image cached |
| InterPodAffinity | Boost nodes near pods you want to be co-located with |
Diagnosing Scheduling Problems
# Pod stuck in Pending — check why:
kubectl describe pod my-pod | grep -A 20 "Events:"
Common messages and their meaning:
| Event Message | Root Cause |
|---|---|
0/3 nodes are available: 3 Insufficient memory | All nodes have less free memory than requests.memory |
0/3 nodes are available: 3 node(s) had untolerated taint | Nodes are tainted, pod lacks matching toleration |
0/3 nodes are available: 3 node(s) didn't match node affinity | Required node label doesn’t exist on any node |
0/1 nodes are available: 1 node(s) were unschedulable | Node is cordoned |
no persistent volumes available | PVC can’t be bound (wrong StorageClass or zone) |
# See all pending pods across the cluster
kubectl get pods -A | grep Pending
# Check scheduler events
kubectl get events --field-selector reason=FailedScheduling -A
# See what resources are actually available on nodes
kubectl describe nodes | grep -A 5 "Allocated resources"
Manual Scheduling (Bypass the Scheduler)
You can hard-pin a pod to a specific node by setting nodeName directly — the scheduler is skipped entirely:
apiVersion: v1
kind: Pod
metadata:
name: pinned-pod
spec:
nodeName: minikube-m02 # ← Bypass scheduler; go directly to this node
containers:
- name: nginx
image: nginx:alpine
⚠️ Avoid in production. If that node goes down, the pod is not rescheduled — it just stays Failed. Use node affinity rules instead so the scheduler can pick an equivalent node.
The NodeName Flow vs Scheduler Flow
Manual (nodeName): Pod → API Server → kubelet on named node
(no scheduler involved, no rescheduling if node fails)
Scheduler Flow: Pod → API Server → Scheduler → Filter → Score → Bind
(rescheduled on failure, respects resource availability)
✅ Quick Check
Q1: A pod has requests.memory: 512Mi but all nodes only have 400Mi free. What happens?
Answer
The pod stays **Pending**. The `NodeResourcesFit` filter eliminates all nodes because none can satisfy the memory request. You'll see an event like `0/N nodes are available: N Insufficient memory`. Fix options: reduce the pod's memory request, scale up a node, add a new node, or free up memory by evicting lower-priority pods.Q2: What’s the difference between a node surviving Filtering vs being chosen after Scoring?
Answer
**Filtering** is a hard gate — a node either passes or is eliminated. All surviving nodes are *capable* of running the pod. **Scoring** is a soft ranking — it picks the *best* node from the survivors based on preferences (spread load, match labels, image cached, etc.). A pod can run on any node that passes filtering; scoring just picks the optimal one.Q3: Your pod is stuck in Pending. What’s the first command you run?
Answer
`kubectl describe pod MY-POD-NAME` — look at the **Events** section at the bottom. The scheduler emits a `FailedScheduling` event with an exact explanation of why no node was chosen (e.g., "3 Insufficient memory", "3 node(s) had untolerated taint"). This tells you precisely what constraint is blocking placement.14.2 Node Affinity and Node Selectors
⏱️ ~6 min read
TL;DR: Node Selectors are the simple, legacy way to pin pods to nodes with matching labels (
key: value). Node Affinity is the modern replacement — it supportsIn,NotIn,Exists,Gt/Ltoperators, and crucially, apreferred(soft) mode that lets the scheduler fall back to other nodes if the preferred ones are unavailable.
Why Pin Pods to Specific Nodes?
Common use cases:
| Scenario | Requirement |
|---|---|
| GPU workloads | Pod must land on a node with a GPU |
| Zone-aware data | DB pod should land in the same zone as its PV |
| Hardware tiers | CPU-intensive workloads on high-cpu nodes |
| Compliance | Regulated workloads only on nodes in specific datacenters |
| Edge/IoT | Pod must run on arm64 nodes at the edge |
The mechanism: label nodes → tell pods which labels they require → scheduler only places the pod on matching nodes.
Node Labels
# View existing node labels
kubectl get nodes --show-labels
kubectl describe node minikube | grep -A 10 "Labels:"
# Common built-in labels (auto-set by Kubernetes)
kubernetes.io/hostname=minikube
kubernetes.io/arch=amd64
kubernetes.io/os=linux
node.kubernetes.io/instance-type=Standard_D4s_v3 # Cloud providers
topology.kubernetes.io/zone=us-east-1a # Cloud zone
topology.kubernetes.io/region=us-east-1 # Cloud region
# Add a custom label to a node
kubectl label node minikube disk=ssd
kubectl label node minikube-m02 gpu=true
kubectl label node minikube-m03 tier=spot
# Remove a label
kubectl label node minikube disk-
Option 1: nodeSelector (Simple, Legacy)
The simplest approach — pod only schedules on nodes where ALL key-value pairs match:
apiVersion: v1
kind: Pod
metadata:
name: ssd-pod
spec:
nodeSelector:
disk: ssd # Node must have exactly this label
kubernetes.io/arch: amd64
containers:
- name: app
image: nginx:alpine
Limitations of nodeSelector:
- Only supports exact equality (
key: value) - No
ORlogic, noNotIn, no ranges - No soft/“preferred” mode — if no node matches, pod is Pending forever
Option 2: Node Affinity (Recommended)
Node Affinity has two modes:
| Mode | Scheduling Behavior | If No Node Matches |
|---|---|---|
requiredDuringSchedulingIgnoredDuringExecution | Hard — must match | Pod stays Pending |
preferredDuringSchedulingIgnoredDuringExecution | Soft — try to match | Fall back to any node |
The
IgnoredDuringExecutionsuffix means: if a running pod’s node later loses the label, the pod is not evicted. A futureRequiredDuringExecutionmode (in alpha) will evict pods if nodes stop matching.
Hard Requirement — Pod Must Land on SSD Node
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
spec:
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disk
operator: In
values:
- ssd
- nvme # SSD OR NVMe is fine
- key: kubernetes.io/arch
operator: In
values:
- amd64 # AND must be AMD64
containers:
- name: db
image: postgres:15
Soft Preference — Prefer GPU Node, Fall Back If Unavailable
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80 # Higher weight = stronger preference (1–100)
preference:
matchExpressions:
- key: gpu
operator: Exists # Any node that has a "gpu" label at all
- weight: 20
preference:
matchExpressions:
- key: tier
operator: NotIn
values:
- spot # Mildly prefer to avoid spot instances
Combining Hard and Soft
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/os
operator: In
values: [linux] # Hard: Linux only
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: zone
operator: In
values: [us-east-1a] # Soft: prefer this zone
Supported Operators
| Operator | Meaning | Example |
|---|---|---|
In | Label value is in the list | disk In [ssd, nvme] |
NotIn | Label value is NOT in the list | tier NotIn [spot, preemptible] |
Exists | Label key exists (any value) | gpu Exists |
DoesNotExist | Label key is absent | maintenance DoesNotExist |
Gt | Label value > number (as string) | cpu-count Gt 8 |
Lt | Label value < number (as string) | memory-gb Lt 32 |
Multiple nodeSelectorTerms — OR Logic
Multiple nodeSelectorTerms entries are OR-ed; multiple matchExpressions within one term are AND-ed:
nodeSelectorTerms:
- matchExpressions: # Term 1: disk=ssd AND zone=us-east-1a
- key: disk
operator: In
values: [ssd]
- key: zone
operator: In
values: [us-east-1a]
- matchExpressions: # OR Term 2: disk=nvme (any zone)
- key: disk
operator: In
values: [nvme]
nodeSelector vs nodeAffinity — Decision Guide
Use nodeSelector when:
✓ Simple exact match needed
✓ Quick pinning in a hurry
✗ No OR logic, no Exists, no preferred fallback
Use nodeAffinity when:
✓ Multiple acceptable values (In/NotIn)
✓ Soft preference with hard fallback
✓ Need Exists/DoesNotExist operators
✓ Production workloads
✅ Quick Check
Q1: You want a pod to prefer nodes with tier=premium but still schedule on any node if no premium nodes are available. Which affinity type do you use?
Answer
`preferredDuringSchedulingIgnoredDuringExecution` — this is the soft/best-effort mode. The scheduler boosts the score of matching nodes but won't block scheduling if none are available. Set a `weight` between 1–100 to control how strongly the preference is expressed relative to other scoring factors.Q2: What’s the difference between Exists and In operators in a matchExpression?
Answer
- `Exists`: The label key must be present on the node, but its value doesn't matter. Use when you've labeled nodes `gpu=true` or `gpu=nvidia` or `gpu=amd` and just want any GPU node. - `In`: The label key must be present AND its value must be one of the listed values. Use when you care about the specific value, e.g., `disk In [ssd, nvme]` (but not `hdd`).Q3: A pod has a requiredDuringScheduling rule for disk=ssd. You then remove the disk=ssd label from the node it’s running on. What happens to the running pod?
Answer
Nothing — the pod **continues running**. The `IgnoredDuringExecution` suffix means the rule is only enforced at scheduling time, not continuously. Existing pods are not evicted if their node's labels change. A future `RequiredDuringExecution` mode will add runtime enforcement, but it is not yet GA.14.3 Taints and Tolerations
⏱️ ~6 min read
TL;DR: Taints are marks you put on nodes that repel pods. Tolerations are declarations on pods that say “I can handle that taint.” The combination lets you dedicate nodes to specific workloads (e.g., GPU nodes for ML jobs only) or protect critical infrastructure nodes (like control-plane nodes) from regular workloads.
The Mental Model: Bodyguard vs VIP Pass
Taint on Node = "No regular pods allowed here" (bodyguard at the door)
Toleration on Pod = "I have a VIP pass for that taint" (badge that gets you in)
Without a matching toleration → pod is repelled (Pending or evicted)
With a matching toleration → pod is allowed (but not forced) onto the node
⚠️ Key insight: A toleration does NOT force a pod onto a tainted node. It only allows it. You still need node affinity if you want to guarantee the pod lands on that specific node.
Taint Structure
A taint has three parts:
key=value:effect
| Part | Description | Example |
|---|---|---|
| key | Identifier (label-like) | dedicated, gpu, node-role.kubernetes.io/control-plane |
| value | Optional qualifier | ml-team, nvidia, true |
| effect | What happens to non-tolerating pods | NoSchedule, PreferNoSchedule, NoExecute |
The Three Effects
| Effect | Scheduling | Running Pods |
|---|---|---|
NoSchedule | New pods without toleration won’t be scheduled here | Existing pods stay |
PreferNoSchedule | Scheduler avoids this node (soft) | Existing pods stay |
NoExecute | New pods blocked AND existing non-tolerating pods evicted | Evicted after tolerationSeconds |
Working with Taints
# Add a taint
kubectl taint node minikube-m02 dedicated=ml-team:NoSchedule
kubectl taint node minikube-m02 gpu=true:NoSchedule
# View taints on nodes
kubectl describe node minikube-m02 | grep Taints
# Taints: dedicated=ml-team:NoSchedule, gpu=true:NoSchedule
kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints
# Remove a taint (append a minus sign)
kubectl taint node minikube-m02 dedicated=ml-team:NoSchedule-
kubectl taint node minikube-m02 gpu- # Remove all taints with key "gpu"
Tolerations on Pods
apiVersion: apps/v1
kind: Deployment
metadata:
name: ml-training
spec:
template:
spec:
tolerations:
# Exact match: key=dedicated, value=ml-team, effect=NoSchedule
- key: "dedicated"
operator: "Equal"
value: "ml-team"
effect: "NoSchedule"
# Key exists with any value, specific effect
- key: "gpu"
operator: "Exists"
effect: "NoSchedule"
# Tolerate NoExecute with an eviction grace period
- key: "node.kubernetes.io/not-ready"
operator: "Exists"
effect: "NoExecute"
tolerationSeconds: 300 # Tolerate for 5 min before eviction
containers:
- name: trainer
image: tensorflow/tensorflow:latest-gpu
Toleration Operators
| Operator | Matches When |
|---|---|
Equal | key, value, AND effect all match exactly |
Exists | key matches (ignores value); effect must still match or be omitted |
If you omit
effectin a toleration, it matches all effects for that key. If you omitkey, you match all keys (a wildcard — tolerates every taint on the node).
Real-World Patterns
Pattern 1: Dedicated Node Pool
Reserve a node exclusively for one team. No other pods can land there:
# Taint the dedicated node
kubectl taint node gpu-node-1 dedicated=gpu-team:NoSchedule
# Label it too (so we can use affinity to force pods HERE specifically)
kubectl label node gpu-node-1 dedicated=gpu-team
# GPU team's Deployment
spec:
template:
spec:
tolerations:
- key: dedicated
operator: Equal
value: gpu-team
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: dedicated
operator: In
values: [gpu-team]
Pattern 2: Control-Plane Node Isolation
Kubernetes automatically taints control-plane nodes:
kubectl describe node minikube | grep Taint
# Taints: node-role.kubernetes.io/control-plane:NoSchedule
Regular workloads don’t tolerate this taint → they never land on the control-plane node. If you deliberately want to run a pod on the control-plane (e.g., a DaemonSet for monitoring), add:
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
Pattern 3: NoExecute for Node Maintenance
NoExecute is powerful for draining workloads from a failing or under-maintenance node:
# Mark a node as draining (adds NoExecute taint)
kubectl taint node worker-1 maintenance=true:NoExecute
# Pods without tolerations are immediately evicted
# Pods with tolerationSeconds=300 get 5 minutes to finish work
Kubernetes itself uses NoExecute taints for node conditions:
| Automatic Taint | Condition |
|---|---|
node.kubernetes.io/not-ready | Node is not ready |
node.kubernetes.io/unreachable | Node unreachable from controller |
node.kubernetes.io/memory-pressure | Node has memory pressure |
node.kubernetes.io/disk-pressure | Node has disk pressure |
node.kubernetes.io/network-unavailable | Node network not configured |
Taints vs Node Affinity — When to Use Each
| Use Case | Mechanism |
|---|---|
| “Only ML jobs on GPU nodes” | Taint GPU nodes + toleration on ML jobs + affinity to force them there |
| “Prefer SSD nodes but fallback OK” | Node affinity (preferredDuringScheduling) |
| “Protect control-plane nodes” | Taint (Kubernetes does this automatically) |
| “Evict pods from a failing node” | NoExecute taint (Kubernetes does this automatically) |
| “Different hardware for DB vs web” | Node affinity (required, label-based) |
✅ Quick Check
Q1: You taint a node gpu=true:NoSchedule. A regular nginx Deployment pod ends up running there anyway. How?
Answer
The pod was **already running** on that node before the taint was added. `NoSchedule` only blocks **new** pods from being scheduled — it does not evict existing pods. To evict running pods, you'd need a `NoExecute` taint. Or, kubectl drain the node first (which cordons it and evicts all pods), then apply the taint.Q2: What’s the minimal toleration that tolerates ALL taints on a node?
Answer
A toleration with no key and `operator: Exists` matches all taints: ```yaml tolerations: - operator: Exists ``` This is what DaemonSets typically use so they can run on every node regardless of taints (e.g., the Prometheus node-exporter DaemonSet).Q3: A pod has a NoExecute toleration with tolerationSeconds: 60. The node becomes “not ready”. What happens?
Answer
Kubernetes automatically adds a `node.kubernetes.io/not-ready:NoExecute` taint to the node. The pod **tolerates** this taint for 60 seconds (grace period). If the node recovers within 60 seconds, the pod continues running. If not, the pod is evicted after 60 seconds and rescheduled on another node. This is how Kubernetes balances availability with stability — it doesn't immediately reschedule on transient network blips.14.4 Pod Affinity and Anti-Affinity
⏱️ ~6 min read
TL;DR: Pod Affinity co-locates pods with other pods (e.g., put my cache next to my app). Pod Anti-Affinity spreads pods apart (e.g., never put two replicas on the same node). Both work by examining what pods are already running on candidate nodes/zones, not node labels. The result is placement decisions that are dynamic and topology-aware.
Pod Affinity vs Node Affinity
Node Affinity: "Schedule me on nodes with these LABELS"
→ Static; based on node properties
Pod Affinity: "Schedule me near nodes that are running pods with THESE labels"
→ Dynamic; based on what OTHER PODS are already running where
Topology Keys — The Scope of “Near”
Pod affinity/anti-affinity operates within a topology domain. The topologyKey defines the scope:
| topologyKey | Scope of “same” |
|---|---|
kubernetes.io/hostname | Same node |
topology.kubernetes.io/zone | Same availability zone |
topology.kubernetes.io/region | Same region |
custom-label | Any custom grouping label on nodes |
Pod Affinity — Co-locate Pods
Use Case: Cache Sidecar Pattern
You want your Redis cache pods to land on the same nodes as your app pods (to minimize latency):
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-cache
spec:
replicas: 3
template:
metadata:
labels:
app: redis-cache
role: cache
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [web-app] # Co-locate with web-app pods
topologyKey: kubernetes.io/hostname # On the SAME node
containers:
- name: redis
image: redis:7-alpine
Soft Co-location: Prefer Same Zone
spec:
affinity:
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: my-app
topologyKey: topology.kubernetes.io/zone # Prefer same AZ
Pod Anti-Affinity — Spread Pods Apart
Use Case: High Availability — One Replica Per Node
Never put two replicas of the same Deployment on the same node:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3
template:
metadata:
labels:
app: web-app
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [web-app] # Don't be near other web-app pods
topologyKey: kubernetes.io/hostname # On the SAME node
containers:
- name: web
image: nginx:alpine
⚠️ Watch out: Hard anti-affinity with
requiredDuringSchedulingcan make pods Pending if there aren’t enough nodes. If you have 3 replicas and 2 nodes, the third replica can never schedule. UsepreferredDuringSchedulingfor safety.
Soft Anti-Affinity: Prefer Different Zones
For truly high availability, spread across zones (not just nodes):
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: web-app
topologyKey: topology.kubernetes.io/zone
TopologySpreadConstraints — The Modern Alternative
For evenly spreading pods, topologySpreadConstraints (available since 1.18) is simpler and more powerful than anti-affinity:
spec:
topologySpreadConstraints:
# Constraint 1: Spread evenly across zones (max 1 pod difference between zones)
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # Hard (use ScheduleAnyway for soft)
labelSelector:
matchLabels:
app: web-app
# Constraint 2: Also spread evenly across nodes within each zone
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # Soft
labelSelector:
matchLabels:
app: web-app
| Field | Values | Meaning |
|---|---|---|
maxSkew | Integer ≥ 1 | Max allowed difference in pod count between any two topology domains |
topologyKey | Label key | Groups nodes into topology domains |
whenUnsatisfiable | DoNotSchedule / ScheduleAnyway | Hard or soft constraint |
Comparison:
| Pod Anti-Affinity | TopologySpreadConstraints | |
|---|---|---|
| Goal | Avoid specific pods | Even distribution |
| Control | Binary (near/not near) | Skew-based (quantitative) |
| Multi-constraint | Combine multiple rules | Native multi-constraint support |
| Best for | Co-location patterns | Even spreading (HA) |
Complete Production Pattern: HA + Co-location
Putting it all together — a 3-replica web app spread across zones, with its Redis co-located per node:
# web-app: spread across zones, never two on same node
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 3
template:
metadata:
labels:
app: web-app
tier: frontend
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web-app
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web-app
containers:
- name: web
image: my-web-app:latest
---
# redis: co-located with web-app on each node
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-local
spec:
replicas: 3
template:
metadata:
labels:
app: redis-local
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web-app
topologyKey: kubernetes.io/hostname
containers:
- name: redis
image: redis:7-alpine
✅ Quick Check
Q1: You have 4 replicas of a Deployment with hard anti-affinity (requiredDuringScheduling) using topologyKey: kubernetes.io/hostname. Your cluster has 3 nodes. What happens?
Answer
3 replicas schedule (one per node), but the **4th replica stays Pending** indefinitely — there's no node that doesn't already have one of its own pods. You'll see a `FailedScheduling` event like "3 node(s) didn't match pod anti-affinity rules". Fix: switch to `preferredDuringScheduling` (soft), or add a 4th node, or reduce replicas to 3.Q2: What’s the difference between topologyKey: kubernetes.io/hostname and topologyKey: topology.kubernetes.io/zone in an anti-affinity rule?
Answer
- `kubernetes.io/hostname`: The topology domain is each individual node. Anti-affinity means "don't put two of my pods on the same **node**". - `topology.kubernetes.io/zone`: The topology domain is an availability zone (group of nodes). Anti-affinity means "don't put two of my pods in the same **zone**", so they land in different AZs for HA. With zone-level anti-affinity, two pods can still land on different nodes in the same zone.Q3: When should you use topologySpreadConstraints instead of pod anti-affinity?
Answer
Use `topologySpreadConstraints` when you want **even, quantitative distribution** — e.g., "keep the count difference between zones ≤ 1". Anti-affinity is binary (avoid or don't avoid a specific set of pods) and doesn't natively control how many pods per domain. For HA spreading of stateless replicas, `topologySpreadConstraints` is simpler, more flexible (supports `maxSkew`), and is the modern recommended approach over complex anti-affinity rules.Lab: Control Where Pods Land
⏱️ ~30 min hands-on
| Prerequisites | Sections 14.1–14.4 read, Minikube running |
| Difficulty | 🟡 Intermediate |
| What you’ll do | Label nodes, use node selectors and affinity to pin pods, taint nodes to repel workloads, simulate node failure with NoExecute, and spread replicas using anti-affinity and topology spread constraints |
Objectives
- Label nodes and use
nodeSelectorto pin a pod - Use hard node affinity to require a specific node label
- Use soft node affinity with fallback behavior
- Taint a node and observe pod repulsion
- Add a toleration to allow a pod onto a tainted node
- Use pod anti-affinity to spread replicas across nodes
- Use
topologySpreadConstraintsfor even distribution - Observe what happens when scheduling constraints can’t be satisfied
Setup: Multi-Node Minikube
For this lab, a 3-node cluster gives the most interesting results:
# Start a 3-node cluster (or add nodes to existing)
# If you have a running single-node Minikube, add nodes:
minikube node add
minikube node add
# Verify 3 nodes exist
kubectl get nodes
# NAME STATUS ROLES AGE
# minikube Ready control-plane ...
# minikube-m02 Ready <none> ...
# minikube-m03 Ready <none> ...
Single-node workaround: If you can’t add nodes, you can still complete most exercises by using fake labels. The anti-affinity exercises will show the “Pending” behavior directly — which is actually a useful learning outcome.
# Create a namespace for this lab
kubectl create namespace sched-lab
kubectl config set-context --current --namespace=sched-lab
Exercise 1: Node Labels and nodeSelector
What we’re doing: Label nodes to represent different hardware tiers and pin pods using nodeSelector.
# View current labels on all nodes
kubectl get nodes --show-labels | grep -v "node.kubernetes"
# Label the nodes to simulate different hardware
kubectl label node minikube disk=hdd tier=standard
kubectl label node minikube-m02 disk=ssd tier=premium
kubectl label node minikube-m03 disk=ssd tier=premium gpu=true
# Verify
kubectl get nodes -L disk,tier,gpu
# NAME DISK TIER GPU
# minikube hdd standard
# minikube-m02 ssd premium
# minikube-m03 ssd premium true
Deploy a pod that requires SSD:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: ssd-pod
namespace: sched-lab
spec:
nodeSelector:
disk: ssd
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
# Check which node it landed on
kubectl get pod ssd-pod -o wide -n sched-lab
# Should be on minikube-m02 or minikube-m03 (both have disk=ssd)
Verify it won’t schedule on the HDD node:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: impossible-pod
namespace: sched-lab
spec:
nodeSelector:
disk: ssd
gpu: "true"
tier: standard # Conflict! No node has ssd+gpu+standard
containers:
- name: app
image: nginx:alpine
EOF
kubectl get pod impossible-pod -n sched-lab
# STATUS: Pending
kubectl describe pod impossible-pod -n sched-lab | grep -A 5 "Events:"
# Event: 0/3 nodes are available: 1 node(s) didn't match node selector, ...
# Clean up
kubectl delete pod impossible-pod -n sched-lab
Exercise 2: Hard Node Affinity
What we’re doing: Use requiredDuringScheduling affinity with the In operator.
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: premium-app
namespace: sched-lab
spec:
replicas: 3
selector:
matchLabels:
app: premium-app
template:
metadata:
labels:
app: premium-app
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: tier
operator: In
values: [premium]
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
kubectl rollout status deployment/premium-app -n sched-lab
# Verify: ALL pods should be on premium nodes (minikube-m02 or minikube-m03)
kubectl get pods -n sched-lab -l app=premium-app -o wide
Expected: All 3 pods on minikube-m02 or minikube-m03, none on minikube (which has tier=standard).
Exercise 3: Soft Node Affinity with Fallback
What we’re doing: Use preferredDuringScheduling and watch the fallback behavior.
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: soft-pref-app
namespace: sched-lab
spec:
replicas: 4
selector:
matchLabels:
app: soft-pref-app
template:
metadata:
labels:
app: soft-pref-app
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: gpu
operator: Exists # Strongly prefer GPU node (minikube-m03)
- weight: 50
preference:
matchExpressions:
- key: tier
operator: In
values: [premium] # Also prefer premium nodes
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
kubectl get pods -n sched-lab -l app=soft-pref-app -o wide
Observe: With 4 replicas and 3 nodes, most pods should cluster on the GPU node and premium nodes, but the scheduler may place some on the standard node rather than stack all 4 on one node — soft constraints guide, not force.
Exercise 4: Taints and Tolerations
What we’re doing: Taint a node to repel most pods, then add a toleration to allow a specific pod.
# Taint minikube-m03 to simulate a GPU-dedicated node
kubectl taint node minikube-m03 dedicated=gpu-team:NoSchedule
# Try to schedule a regular pod — it should avoid minikube-m03
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: regular-app
namespace: sched-lab
spec:
replicas: 4
selector:
matchLabels:
app: regular-app
template:
metadata:
labels:
app: regular-app
spec:
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
kubectl get pods -n sched-lab -l app=regular-app -o wide
# Verify: NO pods on minikube-m03 (it's tainted)
Now deploy a pod WITH the toleration:
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpu-app
namespace: sched-lab
spec:
replicas: 2
selector:
matchLabels:
app: gpu-app
template:
metadata:
labels:
app: gpu-app
spec:
tolerations:
- key: dedicated
operator: Equal
value: gpu-team
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: gpu
operator: Exists # Force onto the GPU node
containers:
- name: trainer
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
kubectl get pods -n sched-lab -l app=gpu-app -o wide
# All pods should be on minikube-m03 — the tainted GPU node
Exercise 5: Pod Anti-Affinity for High Availability
What we’re doing: Ensure no two replicas of the same app land on the same node.
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-app
namespace: sched-lab
spec:
replicas: 3
selector:
matchLabels:
app: ha-app
template:
metadata:
labels:
app: ha-app
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [ha-app]
topologyKey: kubernetes.io/hostname # One per node
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "50m"
memory: "32Mi"
EOF
kubectl get pods -n sched-lab -l app=ha-app -o wide
# Each pod should be on a different node
Now try to scale beyond the node count:
kubectl scale deployment ha-app --replicas=4 -n sched-lab
# Wait and observe
kubectl get pods -n sched-lab -l app=ha-app
# The 4th replica will be Pending — no node available that doesn't already have one
kubectl describe pod -n sched-lab -l app=ha-app | grep -A 3 "Events:"
# Event: 0/3 nodes are available: 3 node(s) didn't match pod anti-affinity rules
# Scale back
kubectl scale deployment ha-app --replicas=3 -n sched-lab
Exercise 6: TopologySpreadConstraints
What we’re doing: Use topologySpreadConstraints for even distribution with maxSkew.
# First, remove the taint from minikube-m03 so all 3 nodes are available
kubectl taint node minikube-m03 dedicated=gpu-team:NoSchedule-
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: spread-app
namespace: sched-lab
spec:
replicas: 6
selector:
matchLabels:
app: spread-app
template:
metadata:
labels:
app: spread-app
spec:
topologySpreadConstraints:
- maxSkew: 1 # Max 1 pod difference between nodes
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule # Hard constraint
labelSelector:
matchLabels:
app: spread-app
containers:
- name: app
image: nginx:alpine
resources:
requests:
cpu: "30m"
memory: "16Mi"
EOF
kubectl rollout status deployment/spread-app -n sched-lab
kubectl get pods -n sched-lab -l app=spread-app -o wide | awk '{print $7}' | sort | uniq -c
# Expected: 2 pods per node (6 pods / 3 nodes = 2 each, maxSkew=1 satisfied)
Test the constraint:
# Scale to 7 — one node will get 3, others 2, maxSkew=1 is met (3-2=1)
kubectl scale deployment spread-app --replicas=7 -n sched-lab
kubectl get pods -n sched-lab -l app=spread-app -o wide | awk '{print $7}' | sort | uniq -c
# Scale to 10 — now one node would need 4 but others have 3, skew=1, still OK
kubectl scale deployment spread-app --replicas=10 -n sched-lab
kubectl get pods -n sched-lab -l app=spread-app -o wide | awk '{print $7}' | sort | uniq -c
🔥 Break It! Challenge
What happens when you combine hard node affinity (only premium nodes) with hard pod anti-affinity (one per node), and you have more replicas than premium nodes?
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: impossible-spread
namespace: sched-lab
spec:
replicas: 5 # 5 replicas, but only 2 premium nodes
selector:
matchLabels:
app: impossible-spread
template:
metadata:
labels:
app: impossible-spread
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: tier
operator: In
values: [premium] # Only 2 nodes qualify
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: impossible-spread
topologyKey: kubernetes.io/hostname # Only 1 per node
containers:
- name: app
image: nginx:alpine
EOF
kubectl get pods -n sched-lab -l app=impossible-spread
# 2 pods Running (one per premium node), 3 pods Pending
kubectl describe pod -n sched-lab -l app=impossible-spread | grep -A 5 "Events:"
Expected message: 0/3 nodes are available: 1 node(s) didn't match node affinity, 2 node(s) didn't match pod anti-affinity rules
This is the most common production scheduling trap: over-constrained configurations that silently leave replicas Pending.
Cleanup
# Remove labels added to nodes
kubectl label node minikube disk- tier-
kubectl label node minikube-m02 disk- tier-
kubectl label node minikube-m03 disk- tier- gpu-
# Remove any remaining taints
kubectl taint node minikube-m03 dedicated- 2>/dev/null || true
# Delete the namespace (removes all resources)
kubectl delete namespace sched-lab
# Reset default namespace
kubectl config set-context --current --namespace=default
# Optionally remove extra Minikube nodes
# minikube node delete minikube-m02
# minikube node delete minikube-m03
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | Node labels | Labeled 3 nodes with disk, tier, gpu |
| 2 | nodeSelector | ssd-pod only ran on SSD-labeled nodes |
| 3 | Hard node affinity | premium-app only on tier=premium nodes |
| 4 | Soft node affinity | soft-pref-app clustered on GPU/premium but didn’t stay Pending |
| 5 | Taints | regular-app never placed on tainted minikube-m03 |
| 6 | Tolerations | gpu-app tolerated the taint and was pinned to the GPU node |
| 7 | Pod anti-affinity (hard) | ha-app spread one-per-node; 4th replica stayed Pending |
| 8 | TopologySpreadConstraints | spread-app maintained maxSkew=1 across all 3 nodes |
| 9 | Over-constrained diagnosis | impossible-spread showed the scheduling deadlock pattern |
Chapter 15: Security Hardening
⏱️ Total chapter time: ~60 min (30 min reading + 30 min lab)
After this chapter, you will be able to: Enforce Pod Security Standards on namespaces, write Network Policies to microsegment traffic, handle Secrets securely with encryption-at-rest and external vault integration, scan container images for CVEs, and apply a defence-in-depth checklist to a production namespace.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 15.1 | Pod Security Standards and Admission | ~8 min |
| 15.2 | Network Policies — Microsegmentation | ~8 min |
| 15.3 | Secrets Management — Encryption and External Vaults | ~7 min |
| 15.4 | Image Security — Scanning and Supply Chain | ~6 min |
| 15.5 | 🔬 Lab: Harden a Namespace End-to-End | ~30 min |
Prerequisites
- Completed Chapters 1–14
minikube statusshowsRunning- Minikube started with the
--cni=calicoflag for Network Policy support:minikube start --cni=calico(orminikube start --network-plugin=cni --cni=calico)
15.1 Pod Security Standards and Admission
⏱️ ~8 min read
TL;DR: Pod Security Standards (PSS) are built-in Kubernetes policies that prevent containers from running with dangerous privileges — root access, host network sharing, mounting host paths, etc. You apply them at the namespace level using labels. The three levels are Privileged (anything goes), Baseline (known escapes blocked), and Restricted (hardened — best practice for production).
Why Pods Are a Security Risk by Default
A freshly created pod, without any restrictions, can:
- Run as root inside the container
- Mount the host filesystem (
hostPath) - Use the host network (see all traffic on the node)
- Use privileged mode (full root on the host — equivalent to
sudoon the node) - Run
CAP_SYS_ADMINand other dangerous Linux capabilities
Any one of these can allow a compromised container to break out of the container sandbox and compromise the node or the entire cluster.
Pod Security Standards — Three Levels
graph LR
subgraph "Privileged"
P["No restrictions\nAll capabilities allowed\nOnly for: system daemons,\nCNI plugins"]
end
subgraph "Baseline"
B["Blocks known escapes:\n✗ privileged containers\n✗ hostPID/hostNetwork\n✗ dangerous capabilities\n✓ Allows: non-root user optional"]
end
subgraph "Restricted"
R["Hardened:\n✗ All baseline blocks\n✓ Must run as non-root\n✓ Must drop ALL capabilities\n✓ Must have read-only root filesystem\n✓ Seccomp profile required"]
end
P --> B --> R
| Level | Use Case | What It Blocks |
|---|---|---|
| Privileged | System namespaces (kube-system), CNI plugins | Nothing |
| Baseline | General workloads, legacy apps | privileged, hostPID, hostNetwork, hostPath, dangerous capabilities |
| Restricted | Production, regulated workloads | Everything in Baseline + requires non-root, dropped capabilities, seccomp |
Applying PSS to a Namespace
Enforce via namespace labels — no extra controllers needed (built into kube-apiserver since 1.23 GA):
# Enforce Baseline (blocks creation of violating pods)
kubectl label namespace my-app \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=latest
# Warn + Audit on Restricted (visible in API response and audit log, but doesn't block)
kubectl label namespace my-app \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=latest \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=latest
Three Modes
| Label Mode | Effect |
|---|---|
enforce | Blocks the pod — returns a 400 error if it violates the policy |
warn | Allows the pod but returns a warning in the API response |
audit | Allows the pod and writes a record to the audit log |
Graduated rollout strategy: Start with
warnonly in production, fix all warnings, then switch toenforce. This prevents surprise outages from tightening policy too quickly.
What Restricted Requires
A pod that passes restricted must satisfy all of these:
spec:
# 1. Must NOT be privileged
securityContext:
runAsNonRoot: true # Must run as non-root user
seccompProfile:
type: RuntimeDefault # Must have seccomp profile
containers:
- name: app
image: my-app:latest
securityContext:
allowPrivilegeEscalation: false # Cannot escalate to root
capabilities:
drop: ["ALL"] # Drop ALL Linux capabilities
add: ["NET_BIND_SERVICE"] # Only add back what's needed
readOnlyRootFilesystem: true # No writes to container root FS
runAsUser: 1000 # Explicit non-root UID
runAsGroup: 1000
Common Violations and Fixes
| Violation | Error | Fix |
|---|---|---|
| Running as root | runAsNonRoot fails | Add runAsUser: 1000 to securityContext |
privileged: true | Blocked by Baseline | Remove; use specific capabilities instead |
hostNetwork: true | Blocked by Baseline | Use a Service instead |
Missing allowPrivilegeEscalation: false | Blocked by Restricted | Add to container securityContext |
Missing capabilities.drop: [ALL] | Blocked by Restricted | Explicitly drop all caps |
| Missing seccomp | Blocked by Restricted | Add seccompProfile.type: RuntimeDefault |
SecurityContext — The Per-Pod and Per-Container Settings
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
# Pod-level security context (applies to all containers)
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001 # Group for mounted volumes
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:alpine
# Container-level overrides pod-level for its specific container
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # Needed only if binding port < 1024
# If readOnlyRootFilesystem=true, mount writable dirs as emptyDir
volumeMounts:
- name: tmp
mountPath: /tmp
- name: var-run
mountPath: /var/run/nginx
volumes:
- name: tmp
emptyDir: {}
- name: var-run
emptyDir: {}
Admission Controllers Beyond PSS
PSS is built-in, but you can extend admission control with:
| Tool | What It Does |
|---|---|
| OPA Gatekeeper | Custom policy-as-code using Rego; validates any Kubernetes object |
| Kyverno | Policy engine with YAML-native rules; can also generate/mutate resources |
| Falco | Runtime security — detects anomalous behavior (shell in container, file writes) |
Recommended path: Start with PSS
enforce=baselineeverywhere,enforce=restrictedfor prod namespaces. Add Kyverno or Gatekeeper for org-specific rules (naming conventions, required labels, image registries).
✅ Quick Check
Q1: What’s the difference between enforce and warn modes for Pod Security Standards?
Answer
`enforce` **blocks** the pod — the API server rejects the create/update request with a 400 error if the pod violates the policy. `warn` **allows** the pod but adds a warning message to the API response (visible in `kubectl apply` output). Both modes let you apply the same PSS level; `warn` is used for gradual rollout to surface violations before blocking them.Q2: A pod running nginx needs to bind to port 80 (below 1024). What’s the minimal securityContext to pass restricted and still bind to port 80?
Answer
```yaml securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"] # Allows binding ports below 1024 without root ``` Alternatively, configure nginx to listen on port 8080 (above 1024) and drop `NET_BIND_SERVICE` too, which is the cleaner solution. A Service or Ingress maps external port 80 to internal port 8080.Q3: Your kube-system namespace runs CNI plugins and system daemons that need host access. Should you apply restricted to it?
Answer
No — `kube-system` should remain **Privileged** (the default). System components like the CNI plugin (Calico, Cilium), DNS (CoreDNS), and metrics-server legitimately need elevated access to function. Applying `restricted` or even `baseline` to `kube-system` would break these components. Apply restrictive PSS only to application namespaces, not infrastructure namespaces.15.2 Network Policies — Microsegmentation
⏱️ ~8 min read
TL;DR: By default, every pod in a Kubernetes cluster can talk to every other pod — there are no network firewalls between them. Network Policies are Kubernetes-native firewall rules that restrict which pods can talk to which, on which ports. The golden rule for production: default-deny all traffic, then explicitly allow only what’s needed.
The Problem: Flat Network by Default
Without Network Policies:
[frontend pod] ←──────────────→ [database pod] ✓ (allowed)
[redis pod] ←──────────────→ [database pod] ✓ (allowed)
[attacker pod] ←──────────────→ [database pod] ✓ (ALSO allowed!)
Any compromised pod can freely reach any other pod or service.
With Network Policies (default-deny + allow-list):
[frontend pod] ──────────────→ [database pod] ✓ (explicitly allowed)
[redis pod] ──────────────→ [database pod] ✗ (no matching allow rule)
[attacker pod] ──────────────→ [database pod] ✗ (blocked)
How Network Policies Work
graph TD
NP["NetworkPolicy\n(applied to pods via podSelector)"]
ING["Ingress Rules\n(who can send TO these pods)"]
EGR["Egress Rules\n(where these pods can send TO)"]
NP --> ING & EGR
ING --> SRC["Sources:\npodSelector\nnamespaceSelector\nipBlock (CIDR)"]
EGR --> DST["Destinations:\npodSelector\nnamespaceSelector\nipBlock (CIDR)"]
CNI Requirement: Network Policies require a CNI plugin that enforces them — Calico, Cilium, or Weave. The default Minikube CNI (kindnet) does not enforce Network Policies. Use
minikube start --cni=calico.
Network Policy Structure
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-policy
namespace: production
spec:
podSelector: # Which pods this policy APPLIES TO
matchLabels:
app: postgres
policyTypes:
- Ingress # Control incoming traffic
- Egress # Control outgoing traffic
ingress:
- from: # Allow FROM these sources
- podSelector: # Pods with this label (in same namespace)
matchLabels:
app: backend
- namespaceSelector: # Pods in namespaces with this label
matchLabels:
env: production
ports: # Only on these ports
- protocol: TCP
port: 5432
egress:
- to:
- podSelector:
matchLabels:
app: redis
ports:
- protocol: TCP
port: 6379
The Three Selectors
| Selector | Matches | Example |
|---|---|---|
podSelector | Pods by label (within same namespace) | app: backend |
namespaceSelector | All pods in namespaces matching label | env: production |
ipBlock | External CIDR ranges | 10.0.0.0/8 (exclude 10.96.0.0/12) |
AND vs OR Logic
# AND: must match BOTH selectors (same array item)
- from:
- podSelector:
matchLabels: {app: backend}
namespaceSelector: # ← same item as podSelector = AND
matchLabels: {env: prod}
# Means: pods with app=backend AND in namespace with env=prod
# OR: either selector is sufficient (separate array items)
- from:
- podSelector:
matchLabels: {app: backend} # ← separate item = OR
- namespaceSelector:
matchLabels: {env: prod}
# Means: pods with app=backend OR any pod in env=prod namespace
Essential Patterns
Pattern 1: Default Deny All (Start Here)
Always create this first in every application namespace:
# Deny ALL ingress and egress by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # {} = matches ALL pods in namespace
policyTypes:
- Ingress
- Egress
After applying this, nothing can talk to anything. Then add allow policies layer by layer.
Pattern 2: Allow DNS (Essential After Default Deny)
All pods need DNS resolution. Without this, no pod can resolve service names:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Pattern 3: Frontend → Backend → Database
# Allow frontend to reach backend on port 8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
---
# Allow backend to reach database on port 5432
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-db
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 5432
Pattern 4: Allow Monitoring to Scrape All Pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-scrape
namespace: production
spec:
podSelector: {} # All pods in namespace
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: prometheus
ports:
- protocol: TCP
port: 9090
- protocol: TCP
port: 8080
Pattern 5: Namespace Isolation
All pods in namespace A can talk to each other, but not to namespace B:
# Label your namespace first:
# kubectl label namespace team-a team=a
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: namespace-isolation
namespace: team-a
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
team: a # Only allow traffic from same namespace
Testing Network Policies
# Test connectivity between pods
kubectl run test-pod --image=busybox:1.36 --restart=Never -n production -- sleep 3600
# Test if frontend can reach backend (should succeed)
kubectl exec -n production test-pod -- wget -O- http://backend:8080 --timeout=3
# Test if test-pod can reach database (should be blocked)
kubectl exec -n production test-pod -- wget -O- http://postgres:5432 --timeout=3
# Expected: wget: can't connect to remote host (10.x.x.x): Connection timed out
# Check policies in a namespace
kubectl get networkpolicies -n production
kubectl describe networkpolicy default-deny-all -n production
✅ Quick Check
Q1: You apply default-deny-all to your namespace. Immediately your pods stop being able to resolve my-service by DNS. What policy do you need to add?
Answer
An **egress allow rule for DNS** on port 53 (UDP and TCP) targeting the `kube-system` namespace where CoreDNS runs. Without it, all DNS resolution fails because the `default-deny-all` policy also blocks egress. This is the most common mistake when first applying default-deny — always add the DNS egress allow immediately after.Q2: You have two rules in a from block: one podSelector and one namespaceSelector as separate list items (not nested together). What logic applies?
Answer
**OR logic** — traffic is allowed if it matches EITHER the `podSelector` OR the `namespaceSelector`. To get AND logic (pod with that label AND in that namespace), you must nest both selectors within the **same list item** (as a single `from` entry with both fields on the same object). This is one of the most confusing parts of Network Policy syntax.Q3: Does a Network Policy on the destination pod control traffic, or does the source pod’s network policy control it?
Answer
Both ends independently — but typically it's the **destination pod's ingress policy** that controls who can reach it, and the **source pod's egress policy** that controls where it can connect to. For traffic to succeed, the egress policy on the source AND the ingress policy on the destination must both permit it. If either blocks it, the connection fails. This "both ends" model is what makes default-deny effective — even if a source has permissive egress, a restrictive destination ingress can still block it.15.3 Secrets Management — Encryption and External Vaults
⏱️ ~7 min read
TL;DR: Kubernetes Secrets are base64-encoded, not encrypted by default — anyone with etcd access reads them as plaintext. The two defences: Encryption at Rest (encrypt Secrets in etcd using a KMS provider) and External Secret Stores (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) so the actual secret value never touches etcd at all.
The Secrets Problem
# What a Secret actually looks like in etcd (without encryption at rest)
# base64 is NOT encryption:
echo "bXktc3VwZXItc2VjcmV0LXBhc3N3b3Jk" | base64 -d
# Output: my-super-secret-password
Anyone who can:
- Read etcd directly (
etcdctl get /registry/secrets/...) - Call
kubectl get secret my-secret -o yaml - Access the node’s filesystem where secrets are mounted
- Read environment variables of a running process (
/proc/PID/environ)
…can access your plaintext secret values.
Layer 1: RBAC on Secrets
The first line of defence is access control — minimize who can read Secrets:
# Never grant wildcard secret access
# ❌ Bad — grants access to ALL secrets
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
# ✅ Good — grant access only to a named secret
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["my-app-db-credentials"] # Specific secret only
verbs: ["get"]
# Audit who can read secrets in a namespace
kubectl auth can-i get secrets -n production --as system:serviceaccount:production:default
kubectl auth can-i list secrets -n production --as developer-user
# View who has RBAC access to secrets
kubectl get rolebindings,clusterrolebindings -A -o json | \
jq '.items[] | select(.roleRef.name | test("secret|admin")) | .metadata.name'
Layer 2: Encryption at Rest
Enable encryption of Secrets (and other sensitive resources) in etcd. Requires access to the API server config:
# /etc/kubernetes/encryption-config.yaml (on control-plane node)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
- configmaps # Optional: also encrypt ConfigMaps
providers:
# AES-GCM with a 32-byte key (generate: head -c 32 /dev/urandom | base64)
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
# Fallback: identity = no encryption (for reading old unencrypted data)
- identity: {}
# Enable on kube-apiserver (add to /etc/kubernetes/manifests/kube-apiserver.yaml):
# --encryption-provider-config=/etc/kubernetes/encryption-config.yaml
# Verify encryption is active (after enabling):
# Write a new secret
kubectl create secret generic test-encryption \
--from-literal=key=supersecret -n default
# Check etcd directly — should show encrypted bytes, not plaintext
ETCDCTL_API=3 etcdctl get /registry/secrets/default/test-encryption \
--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 | hexdump -C | head
# Should see "k8s:enc:aescbc:v1:key1:..." prefix — encrypted!
On managed clouds: GKE, EKS, and AKS all support KMS-based encryption at rest via their managed key services (Cloud KMS, AWS KMS, Azure Key Vault). Enable it in the cluster settings — it’s usually a checkbox.
Layer 3: External Secret Stores (The Gold Standard)
With external secrets, the actual secret value never enters etcd. The workflow:
graph LR
DEV["Developer\npushes secret to\nVault/AWS SM"] --> STORE["External Secret Store\n(HashiCorp Vault /\nAWS Secrets Manager)"]
STORE --> OP["External Secrets\nOperator (in-cluster)"]
OP --> |"Creates/syncs\nKubernetes Secret"| SEC["Kubernetes Secret\n(synced, auto-rotated)"]
SEC --> POD["Pod\n(reads as normal Secret)"]
External Secrets Operator (ESO)
ESO is the most popular solution — it watches ExternalSecret CRDs and syncs values from external stores into native Kubernetes Secrets:
# Install ESO via Helm
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets --create-namespace
# Configure a SecretStore (points to the secret backend)
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-backend
namespace: production
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "my-app-role"
---
# ExternalSecret: which keys to fetch and how to map them
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: production
spec:
refreshInterval: 1h # Re-sync every hour (auto-rotation!)
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: db-credentials # Name of the resulting Kubernetes Secret
creationPolicy: Owner
data:
- secretKey: username # Key in the K8s Secret
remoteRef:
key: production/db # Path in Vault
property: username # Field within that Vault secret
- secretKey: password
remoteRef:
key: production/db
property: password
The resulting db-credentials Secret is a standard Kubernetes Secret — pods reference it exactly as they would any other secret. The magic is that ESO keeps it automatically rotated when the Vault value changes.
Layer 4: Runtime Secret Hygiene
Even with external stores, runtime hygiene matters:
# ❌ Bad: secret as environment variable (visible in ps, /proc/PID/environ)
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-creds
key: password
# ✅ Better: mount as file (only accessible to the process, not leaked to env)
volumes:
- name: db-creds
secret:
secretName: db-creds
defaultMode: 0400 # Read-only for owner only (chmod 400)
volumeMounts:
- name: db-creds
mountPath: /etc/secrets
readOnly: true
# App reads /etc/secrets/password at startup — not in env
Additional runtime hardening:
# Prevent secret values from appearing in pod spec (use secretKeyRef, not literals)
# Use imagePullSecrets for registry auth (never hardcode in Dockerfile)
# Set automountServiceAccountToken: false if the pod doesn't call the K8s API
spec:
automountServiceAccountToken: false
Secret Rotation Strategy
| Approach | Rotation Trigger | Downtime? |
|---|---|---|
| Manual update | Human runs `kubectl create secret –dry-run | kubectl apply` |
| External Secrets Operator | refreshInterval + app reads file on each request | Zero (file-mounted secrets update in-place within ~1 min) |
| Vault Agent Sidecar | Vault lease expiry | Zero (sidecar refreshes the file; app rereads) |
| CSI Secret Store Driver | Mounted via CSI; updates when pod restarts | Restart needed (or live-reload if app watches file) |
✅ Quick Check
Q1: Someone asks: “Our Secrets are safe because they’re base64 encoded.” What do you say?
Answer
Base64 is **encoding, not encryption** — it's trivially reversible with `base64 -d`. Anyone with kubectl access to `get secret` or direct etcd access can instantly decode it. Kubernetes base64-encodes secrets purely for safe transport of binary data (not printable characters), not for security. Real protection requires RBAC (restrict who can `get secrets`), encryption at rest in etcd, and ideally external secret stores.Q2: What is the main advantage of the External Secrets Operator over native Kubernetes Secrets?
Answer
The actual secret value lives in an external, purpose-built secret store (Vault, AWS Secrets Manager, etc.) that has: audit logging of every access, fine-grained access control, versioning, and automatic rotation. The Kubernetes Secret becomes a short-lived, auto-synced cache. If the K8s cluster is compromised and etcd is dumped, attackers get a value that may already be expired/rotated. Native secrets have no automatic rotation, and the value lives indefinitely in etcd.Q3: A pod mounts a secret as a file at /etc/secrets/api-key. The ESO rotates the value in Vault. Does the pod need to restart to get the new value?
Answer
Not necessarily. Kubernetes **automatically updates mounted secret files** within roughly 1 minute of the underlying Secret object changing (controlled by `kubelet`'s `syncFrequency`). If the application **re-reads the file on each request** (or watches the file for changes), it picks up the new value without a restart. If the app reads the value only at startup and caches it in memory, it needs a restart to pick up the rotation. This is why file-based secrets are preferred over environment variables for rotation-friendly apps.15.4 Image Security — Scanning and Supply Chain
⏱️ ~6 min read
TL;DR: Your application is only as secure as the container image it runs in. Image security has three layers: scanning (find known CVEs in image layers), supply chain hardening (build minimal images, sign them, verify provenance), and runtime admission (only allow images from trusted registries with no critical CVEs). A compromised image is the most common entry point for container-level attacks.
The Attack Surface of a Container Image
Your App Container Image contains:
├── Base OS layer ← 200+ packages, many with CVEs
├── Language runtime ← python3.9, node18, java17 (often outdated)
├── Your dependencies ← npm/pip/maven packages (supply chain risk)
└── Your application ← your code (your responsibility)
A typical ubuntu:22.04 base image ships with 20-30 medium/high CVEs out of the box.
A distroless image ships with 0-3.
Image Scanning
Scanning analyses each layer of an image against CVE databases (NVD, OSV, GitHub Advisories):
# Trivy — the most popular open-source scanner
# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan an image
trivy image nginx:latest
trivy image python:3.11
# Scan with severity filter (only HIGH and CRITICAL)
trivy image --severity HIGH,CRITICAL nginx:latest
# Scan and fail CI/CD if critical CVEs found
trivy image --exit-code 1 --severity CRITICAL nginx:latest
# Scan a local Dockerfile before building
trivy config ./Dockerfile
# Scan a running Kubernetes cluster's images
trivy k8s --report summary cluster
# Output as JSON for automation
trivy image --format json --output results.json nginx:latest
Example output:
nginx:latest (debian 12.5)
Total: 143 (UNKNOWN: 0, LOW: 89, MEDIUM: 44, HIGH: 10, CRITICAL: 0)
┌─────────────────┬────────────────┬──────────┬──────────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├─────────────────┼────────────────┼──────────┼──────────────────┤
│ libssl3 │ CVE-2024-xxxx │ HIGH │ 3.0.14-1~deb12u1 │
│ zlib1g │ CVE-2023-xxxx │ MEDIUM │ 1:1.2.13.dfsg-1 │
└─────────────────┴────────────────┴──────────┴──────────────────┘
Building Minimal Images
Fewer packages = fewer CVEs = smaller attack surface:
# ❌ Bad: full OS base with a package manager left in
FROM ubuntu:22.04
RUN apt-get install -y python3 python3-pip
COPY app.py .
CMD ["python3", "app.py"]
# Result: ~150MB, 50+ CVEs, bash/curl available for attacker
# ✅ Better: language-specific slim image
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
USER 1001 # Non-root user
CMD ["python", "app.py"]
# Result: ~70MB, 10-15 CVEs
# ✅ Best: distroless (no shell, no package manager, no OS utilities)
# Multi-stage build: build in full image, run in distroless
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY app.py .
USER 65532 # nonroot user in distroless
CMD ["app.py"]
# Result: ~40MB, 0-3 CVEs, no shell available for attacker
Multi-Stage Build Benefits
# Common pattern for compiled languages (Go, Rust, Java)
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Run stage: scratch (literally empty) or distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
USER 65532:65532
ENTRYPOINT ["/app"]
# Result: ~8MB, 0 CVEs, no OS whatsoever
Image Supply Chain — Signing and Verification
Cosign — Sign and Verify Images
# Install cosign
curl -O -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign-linux-amd64 && sudo mv cosign-linux-amd64 /usr/local/bin/cosign
# Generate a key pair
cosign generate-key-pair
# Sign an image (after pushing to registry)
cosign sign --key cosign.key registry.example.com/my-app:v1.2.3
# Verify a signature before deploying
cosign verify --key cosign.pub registry.example.com/my-app:v1.2.3
# Keyless signing (uses OIDC identity — great for CI/CD)
# In GitHub Actions:
cosign sign --yes registry.example.com/my-app:v1.2.3
Admission Enforcement — Kyverno Policy Example
Block unsigned images at the cluster level:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce # Block violating pods
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
namespaces: [production]
verifyImages:
- imageReferences:
- "registry.example.com/my-app:*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
<your-cosign-public-key>
-----END PUBLIC KEY-----
Registry Admission Control
Restrict pods to only pull from your trusted registry:
# Kyverno: block images from Docker Hub in production
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: allowed-registries
spec:
validationFailureAction: Enforce
rules:
- name: check-registry
match:
any:
- resources:
kinds: [Pod]
namespaces: [production, staging]
validate:
message: "Only images from registry.example.com are allowed"
pattern:
spec:
containers:
- image: "registry.example.com/*"
Image Security Checklist
| Check | Tool | What to Enforce |
|---|---|---|
| No CRITICAL CVEs | Trivy in CI | trivy image --exit-code 1 --severity CRITICAL |
| Minimal base image | Dockerfile review | Use distroless or -slim variants |
| Non-root user | Dockerfile / PSS | USER 1001 in Dockerfile; runAsNonRoot: true in Pod spec |
| No latest tag | Kyverno policy | Require image: name:SHA256@... or specific semver |
| Signed images | Cosign + Kyverno | Verify signature before admission |
| Allowed registry | Kyverno policy | Allowlist your internal registry |
| SBOM attached | Syft / Cosign | cosign attach sbom for audit trail |
✅ Quick Check
Q1: What’s the difference between a slim image and a distroless image?
Answer
A `-slim` image (e.g., `python:3.12-slim`) is a trimmed version of the full OS image — it removes many unnecessary packages but still has a shell (`bash`/`sh`), `apt`, and other OS utilities. An attacker who gets code execution can still explore the system. A **distroless** image contains *only* the application runtime and its dependencies — no shell, no package manager, no `/bin/ls`. If an attacker gets code execution, they have almost nothing to work with. Distroless images also have dramatically fewer CVEs since there are fewer packages.Q2: You pin all images to a specific digest (image@sha256:abc123...). Why is this more secure than using a version tag like :v1.2.3?
Answer
Version tags are **mutable** — a registry can have the tag `v1.2.3` repointed to a different (potentially malicious) image without you knowing. A **content-addressable digest** (`sha256:abc123...`) is the cryptographic hash of the image content — it uniquely identifies exactly one image layer set and cannot be spoofed. Even if the registry is compromised and the tag is overwritten, the digest still points to the exact image you tested and signed.Q3: At what stages in the software delivery lifecycle should image scanning happen?
Answer
**All three stages** for defence in depth: 1. **Developer workstation** — scan before committing (pre-commit hooks with `trivy image`) 2. **CI/CD pipeline** — block merge/deploy if CRITICAL CVEs found (`trivy --exit-code 1`) 3. **Runtime admission** — scan images at pod creation time (Trivy Operator, Anchore) and continuously rescan deployed images as new CVEs are publishedScanning only in CI is insufficient because new CVEs are discovered daily. An image that was clean yesterday can have a CRITICAL CVE today, so continuous runtime scanning of deployed images is essential for production security.
Lab: Harden a Namespace End-to-End
⏱️ ~30 min hands-on
| Prerequisites | Sections 15.1–15.4 read, Minikube running |
| Difficulty | 🟠 Intermediate–Advanced |
| What you’ll do | Apply Pod Security Standards to block privileged pods, write Network Policies to microsegment a 3-tier app, verify secret RBAC, scan a container image with Trivy, and run a defence-in-depth checklist against a namespace |
Objectives
- Apply PSS
baselineenforcement to a namespace - Observe pods blocked for security violations
- Fix a pod spec to comply with
restrictedPSS - Deploy a 3-tier app and apply Network Policies (default-deny + allow-list)
- Verify network segmentation with connectivity tests
- Audit secret RBAC and fix overly broad permissions
- Scan a container image with Trivy
- Run the full namespace security checklist
Setup
# Verify Minikube is running
minikube status
# Note: Full Network Policy enforcement requires Calico CNI.
# If your Minikube was started WITHOUT --cni=calico, Network Policy
# exercises will show policies being created but NOT enforced.
# Check your CNI:
kubectl get pods -n kube-system | grep -E "calico|cilium|weave|flannel"
# Create the lab namespace
kubectl create namespace sec-lab
# Label the monitoring namespace (needed for Network Policy exercises)
kubectl label namespace kube-system kubernetes.io/metadata.name=kube-system --overwrite
Exercise 1: Pod Security Standards — Enforce Baseline
What we’re doing: Apply PSS baseline enforcement and observe what it blocks.
# Apply baseline enforcement + restricted warnings to sec-lab
kubectl label namespace sec-lab \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=latest
# Verify labels
kubectl get namespace sec-lab -o jsonpath='{.metadata.labels}' | jq .
Try to create a privileged pod — it should be blocked:
kubectl apply -f - <<'EOF' -n sec-lab
apiVersion: v1
kind: Pod
metadata:
name: bad-pod
spec:
containers:
- name: app
image: nginx:alpine
securityContext:
privileged: true # ← Violates Baseline
EOF
# Expected: Error from server (Forbidden):
# pods "bad-pod" is forbidden: violates PodSecurity "baseline:latest":
# privileged (container "app" must not set securityContext.privileged=true)
Try a pod without securityContext — it’s allowed by baseline but triggers restricted warning:
kubectl apply -f - <<'EOF' -n sec-lab
apiVersion: v1
kind: Pod
metadata:
name: warn-pod
spec:
containers:
- name: app
image: nginx:alpine
EOF
# Allowed (no baseline violation), but you'll see warnings like:
# Warning: would violate PodSecurity "restricted:latest":
# allowPrivilegeEscalation != false (container "app" ...)
# unrestricted capabilities (container "app" must set ...)
# runAsNonRoot != true (pod or container "app" must set ...)
# seccompProfile (pod or container "app" must set ...)
Exercise 2: Fix a Pod to Pass Restricted
What we’re doing: Update the pod spec to satisfy restricted PSS.
# First, upgrade the namespace to enforce restricted
kubectl label namespace sec-lab \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
--overwrite
# Now try the same pod — it will be blocked
kubectl delete pod warn-pod -n sec-lab 2>/dev/null || true
kubectl apply -f - <<'EOF' -n sec-lab
apiVersion: v1
kind: Pod
metadata:
name: warn-pod
spec:
containers:
- name: app
image: nginx:alpine
EOF
# Expected: Forbidden — multiple restricted violations
# Deploy the COMPLIANT version
kubectl apply -f - <<'EOF' -n sec-lab
apiVersion: v1
kind: Pod
metadata:
name: hardened-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101 # nginx user in alpine image
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:alpine
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# nginx needs writable dirs even with readOnlyRootFilesystem
volumeMounts:
- name: tmp
mountPath: /tmp
- name: nginx-run
mountPath: /var/run
- name: nginx-cache
mountPath: /var/cache/nginx
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
volumes:
- name: tmp
emptyDir: {}
- name: nginx-run
emptyDir: {}
- name: nginx-cache
emptyDir: {}
EOF
kubectl get pod hardened-pod -n sec-lab
# STATUS: Running — passes restricted!
Exercise 3: Network Policies — 3-Tier App
What we’re doing: Deploy frontend, backend, and database pods, then microsegment them with Network Policies.
# Downgrade namespace back to baseline for this exercise
# (the backend image runs as non-root but baseline is sufficient for the demo)
kubectl label namespace sec-lab \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=latest \
--overwrite
# Deploy the 3-tier app
kubectl apply -n sec-lab -f - <<'EOF'
# Frontend
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 1
selector:
matchLabels:
app: frontend
tier: web
template:
metadata:
labels:
app: frontend
tier: web
spec:
containers:
- name: frontend
image: nginx:alpine
ports:
- containerPort: 80
resources:
requests: {cpu: "50m", memory: "32Mi"}
---
# Backend
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
tier: api
template:
metadata:
labels:
app: backend
tier: api
spec:
containers:
- name: backend
image: nginx:alpine
ports:
- containerPort: 80
resources:
requests: {cpu: "50m", memory: "32Mi"}
---
# Database (simulated with redis)
apiVersion: apps/v1
kind: Deployment
metadata:
name: database
spec:
replicas: 1
selector:
matchLabels:
app: database
tier: db
template:
metadata:
labels:
app: database
tier: db
spec:
containers:
- name: db
image: redis:7-alpine
ports:
- containerPort: 6379
resources:
requests: {cpu: "50m", memory: "32Mi"}
---
# Services
apiVersion: v1
kind: Service
metadata:
name: frontend
spec:
selector:
app: frontend
ports:
- port: 80
---
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: backend
ports:
- port: 80
---
apiVersion: v1
kind: Service
metadata:
name: database
spec:
selector:
app: database
ports:
- port: 6379
EOF
kubectl rollout status deployment/frontend deployment/backend deployment/database -n sec-lab
Test connectivity BEFORE Network Policies (everything works):
# Get the pod names
FRONTEND_POD=$(kubectl get pod -n sec-lab -l app=frontend -o jsonpath='{.items[0].metadata.name}')
BACKEND_POD=$(kubectl get pod -n sec-lab -l app=backend -o jsonpath='{.items[0].metadata.name}')
# Frontend → backend (should succeed)
kubectl exec -n sec-lab $FRONTEND_POD -- wget -q -O- http://backend --timeout=3 && echo "OK"
# Frontend → database (should succeed — NO policies yet)
kubectl exec -n sec-lab $FRONTEND_POD -- wget -q -O- http://database:6379 --timeout=3 2>&1 | head -3
Apply Network Policies:
kubectl apply -n sec-lab -f - <<'EOF'
# 1. Default deny all
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# 2. Allow DNS for all pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# 3. Allow frontend → backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 80
---
# 4. Allow backend → database only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-db
spec:
podSelector:
matchLabels:
app: database
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 6379
---
# 5. Allow backend egress to database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-egress-db
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 6379
---
# 6. Allow frontend egress to backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-egress-backend
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 80
EOF
kubectl get networkpolicies -n sec-lab
Verify segmentation AFTER Network Policies (requires Calico CNI):
# Frontend → backend: ALLOWED
kubectl exec -n sec-lab $FRONTEND_POD -- \
wget -q -O- http://backend --timeout=5 && echo "✓ frontend→backend: ALLOWED"
# Frontend → database: BLOCKED
kubectl exec -n sec-lab $FRONTEND_POD -- \
wget -q -O- http://database:6379 --timeout=5 2>&1 | grep -q "timed out" && \
echo "✓ frontend→database: BLOCKED" || echo "⚠ frontend→database: REACHED (CNI may not enforce policies)"
# Backend → database: ALLOWED (via redis-cli or nc)
kubectl exec -n sec-lab $BACKEND_POD -- \
nc -zv database 6379 --wait=3 2>&1 | grep -q "succeeded" && \
echo "✓ backend→database: ALLOWED"
Exercise 4: Secret RBAC Audit
What we’re doing: Create a secret, check who can access it, and tighten permissions.
# Create a sensitive secret
kubectl create secret generic app-db-creds \
--from-literal=username=admin \
--from-literal=password=s3cr3t-passw0rd \
-n sec-lab
# Check if the default service account can read secrets (often it can!)
kubectl auth can-i get secrets -n sec-lab \
--as system:serviceaccount:sec-lab:default
# If "yes" — this is a security gap!
# Create a restricted role that ONLY allows access to the named secret
kubectl apply -n sec-lab -f - <<'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: app-secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["app-db-creds"] # Only this specific secret
verbs: ["get"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: app-sa-reads-secret
subjects:
- kind: ServiceAccount
name: app-sa
roleRef:
kind: Role
name: app-secret-reader
apiGroup: rbac.authorization.k8s.io
EOF
# Verify the service account can read only the named secret
kubectl auth can-i get secret/app-db-creds -n sec-lab \
--as system:serviceaccount:sec-lab:app-sa
# Expected: yes
kubectl auth can-i list secrets -n sec-lab \
--as system:serviceaccount:sec-lab:app-sa
# Expected: no
kubectl auth can-i get secret/other-secret -n sec-lab \
--as system:serviceaccount:sec-lab:app-sa
# Expected: no
Exercise 5: Image Scanning with Trivy
What we’re doing: Scan images used in the lab and understand CVE output.
# Install Trivy if not already installed
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scan the images we're using
trivy image --severity HIGH,CRITICAL nginx:alpine 2>&1 | tail -30
trivy image --severity HIGH,CRITICAL redis:7-alpine 2>&1 | tail -30
# Scan a notoriously vulnerable image (for comparison)
trivy image --severity CRITICAL python:3.8 2>&1 | tail -20
# Scan and get exit code (0=no vulns, 1=vulns found) for CI/CD
trivy image --exit-code 1 --severity CRITICAL nginx:alpine
echo "Exit code: $?"
# Show a full SBOM (software bill of materials)
trivy image --format table nginx:alpine 2>&1 | head -50
Exercise 6: Namespace Security Checklist
What we’re doing: Run a comprehensive check against the sec-lab namespace.
#!/usr/bin/env bash
# Namespace Security Audit Script
NS="sec-lab"
echo "========================================"
echo " Security Audit for namespace: $NS"
echo "========================================"
# 1. PSS labels
echo ""
echo "--- Pod Security Standards ---"
kubectl get namespace $NS -o jsonpath='{.metadata.labels}' | \
jq 'with_entries(select(.key | startswith("pod-security")))'
# 2. Network Policies
echo ""
echo "--- Network Policies ---"
NP_COUNT=$(kubectl get networkpolicies -n $NS --no-headers 2>/dev/null | wc -l)
echo "Network policies in namespace: $NP_COUNT"
kubectl get networkpolicies -n $NS 2>/dev/null
# 3. Pods running as root
echo ""
echo "--- Pods running as root (runAsUser=0 or not set) ---"
kubectl get pods -n $NS -o json | jq -r '
.items[] |
select(
(.spec.securityContext.runAsUser == null or .spec.securityContext.runAsUser == 0) and
(.spec.containers[].securityContext.runAsUser == null or .spec.containers[].securityContext.runAsUser == 0)
) |
.metadata.name'
# 4. Pods with privileged containers
echo ""
echo "--- Privileged containers ---"
kubectl get pods -n $NS -o json | jq -r '
.items[] |
select(.spec.containers[].securityContext.privileged == true) |
.metadata.name'
# 5. Secrets in namespace
echo ""
echo "--- Secrets ---"
kubectl get secrets -n $NS --no-headers | grep -v "default-token\|service-account"
# 6. ServiceAccounts with secret access
echo ""
echo "--- RBAC: who can 'list secrets'? ---"
kubectl auth can-i list secrets -n $NS --as system:serviceaccount:$NS:default
EOF
echo "========================================"
# Run the audit
bash << 'EOF'
NS="sec-lab"
echo "=== Pod Security Standards ==="
kubectl get namespace $NS -o jsonpath='{.metadata.labels}' | tr ',' '\n' | grep pod-security
echo ""
echo "=== Network Policies ==="
kubectl get networkpolicies -n $NS
echo ""
echo "=== Pods with no runAsNonRoot ==="
kubectl get pods -n $NS -o json | jq -r '.items[] | .metadata.name + " | runAsNonRoot: " + ((.spec.securityContext.runAsNonRoot // false) | tostring)'
echo ""
echo "=== Default SA can list secrets? ==="
kubectl auth can-i list secrets -n $NS --as system:serviceaccount:$NS:default
echo ""
echo "=== app-sa can list secrets? ==="
kubectl auth can-i list secrets -n $NS --as system:serviceaccount:$NS:app-sa
EOF
🔥 Break It! Challenge
What happens when you label the namespace with
enforce=restrictedAFTER pods are already running that violate the policy?
# Relabel to restricted
kubectl label namespace sec-lab \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
--overwrite
# Existing pods are NOT evicted — PSS only applies at admission time
kubectl get pods -n sec-lab
# All pods still Running
# But try to restart one:
kubectl rollout restart deployment/frontend -n sec-lab
# The new pod will be BLOCKED because it doesn't meet restricted requirements
kubectl get pods -n sec-lab -l app=frontend
# New pod stays Pending/Error; old pod still Running
# Check the events
kubectl describe replicaset -n sec-lab -l app=frontend | grep -A 5 "Events:"
# Error creating: pods "frontend-xxx" is forbidden: violates PodSecurity "restricted:latest": ...
This is the critical insight: PSS doesn’t evict existing pods — it only blocks new ones. A misconfigured production namespace can appear healthy while blocking all future rollouts. Always test PSS with warn first.
Cleanup
kubectl delete namespace sec-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | PSS baseline enforcement | privileged: true pod rejected with Forbidden error |
| 2 | PSS restricted compliance | Fixed pod spec with all required security fields |
| 3 | Default-deny Network Policy | Applied 6 Network Policies to 3-tier app |
| 4 | Network segmentation | frontend→database blocked; frontend→backend allowed |
| 5 | Secret RBAC | app-sa can get named secret only; cannot list secrets |
| 6 | Trivy scanning | Scanned real images, compared CVE counts |
| 7 | PSS rollout trap | Labeling namespace restricted doesn’t evict running pods |
Defence-in-Depth Summary
Layer 1: Image Security → Minimal images, scan for CVEs, sign with Cosign
Layer 2: Pod Security → PSS restricted, non-root, drop capabilities, seccomp
Layer 3: Network Policies → Default deny + explicit allow-list per tier
Layer 4: RBAC → Least-privilege service accounts, named secret access
Layer 5: Secret Management → Encryption at rest, External Secrets Operator
Layer 6: Admission Control → Kyverno/Gatekeeper for org policies, registry allowlist
Layer 7: Runtime Detection → Falco for anomaly detection (exec in container, etc.)
Chapter 16: CI/CD and GitOps
⏱️ Total chapter time: ~60 min (30 min reading + 30 min lab)
After this chapter, you will be able to: Explain the GitOps model and why it outperforms push-based CD for Kubernetes, build a CI pipeline that builds, scans, and pushes images, validate manifests with kubeval/kyverno in CI, and deploy ArgoCD to manage continuous delivery declaratively.
What’s Inside
| Section | Topic | Time |
|---|---|---|
| 16.1 | GitOps Principles — Git as the Source of Truth | ~6 min |
| 16.2 | Container Image CI Pipeline | ~8 min |
| 16.3 | Kubernetes Manifest Validation in CI | ~6 min |
| 16.4 | ArgoCD — Declarative CD for Kubernetes | ~8 min |
| 16.5 | 🔬 Lab: Full GitOps Pipeline | ~30 min |
Prerequisites
- Completed Chapters 1–15
minikube statusshowsRunninghelm versionworks- A GitHub account (for the CI pipeline examples)
16.1 GitOps Principles — Git as the Source of Truth
⏱️ ~6 min read
TL;DR: GitOps means the desired state of your cluster lives in Git. A reconciliation loop inside the cluster continuously compares the desired state (Git) with the actual state (cluster) and corrects any drift — automatically. You never run
kubectl applymanually in production. Everything goes through a pull request.
Traditional CD vs GitOps
Traditional Push-Based CD
Developer → git push → CI builds image → CI runs kubectl apply → Cluster
↑
CI has cluster credentials with write access
→ Single point of failure
→ Audit trail: who ran what pipeline when?
→ Cluster state might drift if someone runs kubectl manually
GitOps Pull-Based CD
Developer → git push (to manifest repo) → Pull Request → Merge
↓
Git (Source of Truth)
↓
┌─── ArgoCD/Flux (in-cluster) ───┐
│ Watches Git, detects drift │
│ Pulls changes, applies them │
└────────────────────────────────┘
↓
Cluster
The key shift: the cluster pulls its state from Git rather than CI pushing changes to the cluster. CI never needs kubectl access or kubeconfig credentials.
The Four GitOps Principles (OpenGitOps)
| # | Principle | Meaning |
|---|---|---|
| 1 | Declarative | Desired system state expressed as declarations (YAML), not imperative scripts |
| 2 | Versioned & Immutable | State stored in Git — every change has a commit, full history, rollback via git revert |
| 3 | Pulled Automatically | Software agents continuously observe and apply the desired state — not pushed from CI |
| 4 | Continuously Reconciled | If the cluster drifts from Git (someone runs kubectl edit), the agent corrects it |
What Lives in Each Repository
GitOps typically uses two repositories (or a monorepo with two trees):
├── app-repo/ ← Application Code Repository
│ ├── src/
│ ├── Dockerfile
│ ├── .github/workflows/
│ │ └── ci.yaml ← Build, test, push image → update image tag in config-repo
│ └── tests/
│
└── config-repo/ ← GitOps Configuration Repository (cluster desired state)
├── base/
│ ├── deployment.yaml ← image: my-app:v1.2.3 ← Only this changes on deploy
│ ├── service.yaml
│ └── kustomization.yaml
├── overlays/
│ ├── dev/ ← Dev-specific patches
│ └── prod/ ← Prod-specific patches
└── helm/
└── my-app/values.yaml
Why two repos? App code and cluster configuration change at different rates and need different review processes. A change to
deployment.yamlshould go through an infra review; a business logic change shouldn’t block on it.
The GitOps Workflow End-to-End
sequenceDiagram
participant DEV as Developer
participant APP as App Repo (GitHub)
participant CI as CI (GitHub Actions)
participant REG as Registry (GHCR)
participant CFG as Config Repo
participant ARGO as ArgoCD (in-cluster)
participant K8S as Kubernetes
DEV->>APP: git push (code change)
APP->>CI: trigger workflow
CI->>CI: build & test
CI->>CI: trivy scan
CI->>REG: docker push :v1.2.4
CI->>CFG: bump image tag to v1.2.4 (PR or direct commit)
CFG->>ARGO: webhook / poll (new commit detected)
ARGO->>CFG: git pull (fetch desired state)
ARGO->>ARGO: diff desired vs actual
ARGO->>K8S: kubectl apply (only the diff)
K8S->>ARGO: sync status: Healthy
GitOps vs Traditional CD — Comparison
| Concern | Traditional Push CD | GitOps Pull CD |
|---|---|---|
| Cluster credentials in CI | Yes — CI needs kubeconfig | No — agent inside cluster pulls |
| Audit trail | CI logs (limited) | Git commit history (complete) |
| Rollback | Re-run old pipeline or kubectl rollout undo | git revert → auto-applied |
| Drift detection | None | Continuous (agent reconciles every N min) |
| Multi-cluster | Complex; separate pipeline per cluster | Simple; each cluster runs its own agent |
| Approval workflow | Bespoke per CI system | Pull Requests — standard Git workflow |
| Disaster recovery | Rebuild from pipeline | Apply config-repo to new cluster |
Kustomize vs Helm in GitOps
Both work well in GitOps. Choose based on team familiarity:
| Kustomize | Helm | |
|---|---|---|
| Built into kubectl | Yes (kubectl apply -k) | No (separate binary) |
| Templating | Patch-based (overlays on base YAML) | Full Go templates |
| Config-repo | kustomization.yaml per environment | values.yaml per environment |
| Learning curve | Low | Medium |
| Best for | Simple env-specific patches | Complex apps with many configurable knobs |
✅ Quick Check
Q1: In GitOps, why doesn’t the CI pipeline need kubectl access to the production cluster?
Answer
Because GitOps uses a **pull model** — the CI pipeline only needs to push an image to the registry and update an image tag in the config repo. An **agent running inside the cluster** (ArgoCD, Flux) watches the config repo and applies changes itself. The agent has in-cluster RBAC permissions — no credentials need to leave the cluster. This eliminates a major attack surface: a compromised CI system can no longer directly `kubectl apply` malicious workloads.Q2: A developer edits a Deployment directly in the cluster using kubectl edit. What happens in a GitOps system?
Answer
The GitOps agent (ArgoCD/Flux) detects **drift** — the cluster's actual state no longer matches the Git-declared desired state. Depending on configuration: with **auto-sync** enabled, the agent reverts the manual edit on the next reconciliation cycle (typically within minutes). With **manual sync**, the ArgoCD UI/CLI shows the Application as "OutOfSync" and an operator must confirm the sync. Either way, the git-declared state wins.Q3: What is the advantage of using git revert for rollbacks over kubectl rollout undo?
Answer
`kubectl rollout undo` only reverts the Deployment's pod template — it doesn't update ConfigMaps, Services, Ingress rules, or any other resources that may have changed in the same release. `git revert` creates a new commit that undoes **all changes** in that release (the entire manifest set), and the GitOps agent applies the complete rollback atomically. It also leaves a clear audit trail in Git showing who rolled back, why, and when.16.2 Container Image CI Pipeline
⏱️ ~8 min read
TL;DR: A solid container image CI pipeline has five stages: Test (unit + integration tests), Build (multi-stage Docker build), Scan (Trivy for CVEs), Push (to registry with immutable tag), and Update (bump the image tag in the config repo to trigger GitOps CD). The tag should always be the git commit SHA — never
latest.
Why latest Is Dangerous in CI/CD
# ❌ Bad — non-reproducible, ambiguous
docker push my-app:latest
# ✓ Good — immutable, traceable to a specific commit
docker push my-app:v1.3.2
docker push my-app:sha-a3f8c1d # Git SHA
docker push my-app:sha-a3f8c1d@sha256:abc... # Content digest (most secure)
With latest you can’t tell which code version is running in production, can’t do targeted rollbacks, and cache invalidation becomes non-deterministic.
The Five-Stage Pipeline
graph LR
A["1️⃣ Test\nunit tests\nintegration tests\nlint"] --> B["2️⃣ Build\nmulti-stage\nDockerfile"]
B --> C["3️⃣ Scan\ntrivy image\n--severity CRITICAL\n--exit-code 1"]
C --> D["4️⃣ Push\nregistry.io/app:SHA\n+ semver tag if release"]
D --> E["5️⃣ Update Config\nbump image tag\nin config-repo\n(triggers ArgoCD)"]
Complete GitHub Actions Workflow
# .github/workflows/ci.yaml
name: CI — Build, Scan, Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} # e.g., myorg/my-app
jobs:
# ─── Stage 1: Test ────────────────────────────────────
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Run tests
run: pytest --cov=src --cov-report=xml -v
- name: Upload coverage
uses: codecov/codecov-action@v4
# ─── Stage 2 + 3: Build & Scan ────────────────────────
build-and-scan:
name: Build and Scan Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
security-events: write
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=short # sha-a3f8c1d
type=semver,pattern={{version}} # v1.2.3 (on git tags)
type=semver,pattern={{major}}.{{minor}} # v1.2
- name: Build (and push only on main branch)
id: build
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache for layers
cache-to: type=gha,mode=max
# Build locally first (no push) on PRs to get an image to scan:
load: ${{ github.event_name == 'pull_request' }}
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: "1" # Fail pipeline on CRITICAL CVEs
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
# ─── Stage 4: Sign Image ──────────────────────────────
sign:
name: Sign Image with Cosign
runs-on: ubuntu-latest
needs: build-and-scan
if: github.event_name == 'push'
permissions:
contents: read
packages: write
id-token: write # Required for keyless OIDC signing
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Sign image (keyless, using GitHub OIDC)
run: |
cosign sign --yes \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-scan.outputs.image-digest }}
# ─── Stage 5: Update Config Repo ──────────────────────
update-config:
name: Update Image Tag in Config Repo
runs-on: ubuntu-latest
needs: [build-and-scan, sign]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout config repo
uses: actions/checkout@v4
with:
repository: myorg/k8s-config # The GitOps config repo
token: ${{ secrets.CONFIG_REPO_TOKEN }}
- name: Update image tag
run: |
NEW_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
# For Kustomize-based config:
cd overlays/production
kustomize edit set image \
ghcr.io/myorg/my-app=ghcr.io/myorg/my-app:${NEW_TAG}
# Or for raw YAML with yq:
# yq e -i '.spec.template.spec.containers[0].image = "ghcr.io/myorg/my-app:'${NEW_TAG}'"' \
# base/deployment.yaml
- name: Commit and push
run: |
git config user.name "ci-bot"
git config user.email "ci-bot@example.com"
git add .
git diff --staged --quiet || git commit -m \
"ci: update my-app to sha-$(echo ${{ github.sha }} | cut -c1-7)
Triggered by: ${{ github.actor }}
Commit: ${{ github.sha }}
Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
git push
Key Pipeline Decisions
Tag Strategy
| Tag Type | When to Use | Example |
|---|---|---|
sha-<short> | Every main branch push | sha-a3f8c1d |
v<semver> | On git tags/releases | v1.3.2 |
main-<date>-<sha> | Nightly builds | main-20240115-a3f8c1d |
pr-<number> | PR builds (for preview envs) | pr-142 |
Never use
latestin a production deployment manifest. If the registry has a network hiccup during a rollout, Kubernetes might pull a cachedlatestthat’s a different version than intended.
Build Caching
# Layer caching dramatically reduces build times:
cache-from: type=gha # GitHub Actions cache
cache-from: type=registry,ref=ghcr.io/myorg/my-app:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/my-app:buildcache,mode=max
Multi-Architecture Builds
- name: Set up QEMU (for cross-compilation)
uses: docker/setup-qemu-action@v3
- name: Build multi-arch
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64 # For Apple Silicon + cloud
push: true
tags: ${{ steps.meta.outputs.tags }}
✅ Quick Check
Q1: Why should CI fail (exit code 1) on CRITICAL CVEs found by Trivy, rather than just reporting them?
Answer
If the pipeline only reports CVEs without failing, developers will notice them in logs but face no pressure to fix them — the image gets pushed and deployed anyway. Making the pipeline fail on CRITICAL CVEs creates a hard gate: a vulnerable image **cannot reach production**. The team is forced to either fix the dependency, update the base image, or explicitly acknowledge and accept the risk via a documented exception. Soft warnings get ignored; hard failures get fixed.Q2: The CI pipeline pushes an image tagged sha-a3f8c1d and commits the updated tag to the config repo. It doesn’t directly run kubectl apply. How does the cluster get updated?
Answer
The config repo commit is detected by the GitOps agent (ArgoCD or Flux) running inside the cluster, which continuously watches the repo. It pulls the new commit, sees the image tag changed from `sha-prev123` to `sha-a3f8c1d`, and applies the diff to the cluster — triggering a Deployment rollout. No CI credentials or kubectl access to the cluster are needed — the agent's in-cluster service account does the apply.Q3: Your Dockerfile has 8 layers. On a typical push, only 1 layer (your app code) changes. Without build caching, every layer rebuilds from scratch. With GitHub Actions cache, what’s the expected time saving?
Answer
The cached layers (typically: base image, OS package installs, dependency installs) are pulled from cache rather than rebuilt — often reducing build time by 60-90%. For example, `pip install -r requirements.txt` for a large Python app might take 3-5 minutes; with caching it takes ~5 seconds (just a cache hit). Only the changed layer and all layers after it need rebuilding. This is why proper layer ordering matters: put slow-changing layers (OS, deps) before fast-changing ones (your app code).16.3 Kubernetes Manifest Validation in CI
⏱️ ~6 min read
TL;DR: Validating Kubernetes manifests in CI catches broken YAML, schema violations, security misconfigurations, and policy violations before they reach the cluster — where they’d cause failed deployments or runtime security issues. The three layers: syntax (
kubectl --dry-run), schema (kubeconform), and policy (Kyverno CLI or OPA conftest).
Why Validate Manifests in CI?
Without manifest validation in CI, problems surface only after kubectl apply:
| Problem | Caught by | Without CI validation, discovered when… |
|---|---|---|
| YAML syntax error | kubectl / kubeconform | kubectl apply fails |
| Unknown API field typo | kubeconform | Pod silently ignores field; bug at runtime |
Wrong API version (extensions/v1beta1 removed) | kubeconform | kubectl apply returns API not found |
| Missing resource limits | Kyverno/OPA | HPA can’t scale; node OOM |
Image uses latest tag | Kyverno/OPA | Non-reproducible deployments |
| Missing required labels | Kyverno/OPA | Monitoring alerts don’t fire |
| Privileged container | Kyverno/OPA | Security incident |
Layer 1: kubectl dry-run (Syntax + Server-Side Validation)
# Client-side dry-run (no cluster needed): checks YAML syntax
kubectl apply -f manifests/ --dry-run=client
# Server-side dry-run (requires cluster): validates against live API schema
# (catches deprecated API versions, field validation, admission webhooks)
kubectl apply -f manifests/ --dry-run=server
# Useful for Kustomize and Helm in CI:
kustomize build overlays/production | kubectl apply --dry-run=server -f -
helm template my-app ./chart --values values-prod.yaml | kubectl apply --dry-run=server -f -
Limitation:
--dry-run=serverrequires cluster access. In CI, you’d connect to a staging or test cluster.
Layer 2: kubeconform (Schema Validation, No Cluster Needed)
kubeconform validates manifests against the Kubernetes JSON schema — no cluster required. It catches deprecated API versions and unknown fields:
# Install
curl -L https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \
| tar -xz && sudo mv kubeconform /usr/local/bin/
# Validate a directory of manifests
kubeconform -summary -output tap manifests/
# Validate against a specific Kubernetes version (important for upgrade planning)
kubeconform -kubernetes-version 1.29.0 manifests/
# Validate Kustomize output
kustomize build overlays/production | kubeconform -summary -
# Validate Helm output
helm template my-app ./chart --values values-prod.yaml | kubeconform -summary -
# Include CRD schemas (for ArgoCD Application, Prometheus Rules, etc.)
kubeconform \
-schema-location default \
-schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
manifests/
Example output:
Summary: 12 resources found in 4 files - Valid: 11, Invalid: 1, Errors: 0, Skipped: 0
manifests/ingress.yaml - Ingress networking.k8s.io/v1beta1 my-ingress failed validation:
For Kubernetes 1.29.0: could not find schema for networking.k8s.io/v1beta1/Ingress
(API removed in 1.22, use networking.k8s.io/v1)
Layer 3: Kyverno CLI (Policy Validation)
Kyverno can run in the CLI (without a cluster) to evaluate policies against manifests:
# Install Kyverno CLI
curl -LO https://github.com/kyverno/kyverno/releases/latest/download/kyverno-cli_linux_x86_64.tar.gz
tar -xvf kyverno-cli_linux_x86_64.tar.gz && sudo mv kyverno /usr/local/bin/
# Apply a policy file against manifests
kyverno apply policies/ --resource manifests/
# Apply built-in Pod Security policies
kyverno apply \
https://github.com/kyverno/policies/raw/main/pod-security/restricted/require-run-as-non-root-user/require-run-as-non-root-user.yaml \
--resource manifests/deployment.yaml
Example custom policies for CI:
# policies/require-resource-limits.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Enforce
rules:
- name: check-container-limits
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet]
validate:
message: "All containers must have CPU and memory limits defined"
pattern:
spec:
template:
spec:
containers:
- resources:
limits:
cpu: "?*"
memory: "?*"
---
# policies/no-latest-tag.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: no-latest-tag
spec:
validationFailureAction: Enforce
rules:
- name: check-image-tag
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet, Pod]
validate:
message: "Images must not use 'latest' tag"
pattern:
spec:
=(initContainers):
- image: "!*:latest"
containers:
- image: "!*:latest"
---
# policies/require-labels.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-labels
spec:
validationFailureAction: Enforce
rules:
- name: check-labels
match:
any:
- resources:
kinds: [Deployment]
validate:
message: "Deployments must have 'app', 'version', and 'team' labels"
pattern:
metadata:
labels:
app: "?*"
version: "?*"
team: "?*"
Layer 4: OPA Conftest (General Policy Engine)
Conftest uses Open Policy Agent (OPA) Rego for more complex, custom policies:
# Install conftest
curl -L https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz \
| tar -xz && sudo mv conftest /usr/local/bin/
# Run policies against manifests
conftest test manifests/ --policy policies/
# Example Rego policy (policies/deny-root.rego):
# policies/deny-root.rego
package main
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.securityContext.runAsNonRoot
msg := sprintf("Container '%s' must set runAsNonRoot=true", [container.name])
}
deny[msg] {
input.kind == "Deployment"
not input.spec.template.spec.securityContext.runAsNonRoot
msg := sprintf("Deployment '%s' must set pod-level runAsNonRoot=true", [input.metadata.name])
}
warn[msg] {
input.kind == "Deployment"
not input.metadata.labels.team
msg := sprintf("Deployment '%s' is missing 'team' label", [input.metadata.name])
}
Complete CI Validation Job (GitHub Actions)
# .github/workflows/validate-manifests.yaml
name: Validate Kubernetes Manifests
on:
pull_request:
paths:
- 'k8s/**'
- 'helm/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubeconform
run: |
curl -L https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \
| tar -xz && sudo mv kubeconform /usr/local/bin/
- name: Install Kyverno CLI
run: |
curl -LO https://github.com/kyverno/kyverno/releases/latest/download/kyverno-cli_linux_x86_64.tar.gz
tar -xvf kyverno-cli_linux_x86_64.tar.gz && sudo mv kyverno /usr/local/bin/
- name: Install Kustomize
run: |
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
sudo mv kustomize /usr/local/bin/
# Step 1: kubectl dry-run client (syntax only)
- name: kubectl dry-run (client)
run: kubectl apply -f k8s/base/ --dry-run=client
# Step 2: Schema validation with kubeconform
- name: kubeconform schema validation
run: |
kustomize build k8s/overlays/production | \
kubeconform -summary -kubernetes-version 1.29.0 -strict -
# Step 3: Policy validation with Kyverno
- name: Kyverno policy check
run: |
kustomize build k8s/overlays/production > /tmp/manifests.yaml
kyverno apply policies/ --resource /tmp/manifests.yaml
# Step 4: Helm lint + schema check
- name: Helm lint
run: helm lint ./helm/my-app/ --values helm/my-app/values-prod.yaml
- name: Helm kubeconform
run: |
helm template my-app ./helm/my-app/ --values helm/my-app/values-prod.yaml | \
kubeconform -summary -kubernetes-version 1.29.0 -
✅ Quick Check
Q1: What’s the difference between kubectl apply --dry-run=client and --dry-run=server?
Answer
`--dry-run=client` validates only YAML syntax and basic structure on the local machine — no cluster needed. `--dry-run=server` sends the request to the API server which validates against the live schema, checks field types, evaluates admission webhooks, and detects deprecated/removed API versions — but requires cluster access. For CI without cluster access, use `kubeconform` for schema validation instead of server-side dry-run.Q2: Why is kubeconform preferred over kubectl apply --dry-run=client for schema validation in CI?
Answer
`kubectl --dry-run=client` performs minimal validation — it catches YAML parse errors but doesn't fully validate field names or types against the Kubernetes JSON schema. `kubeconform` validates every field against the Kubernetes OpenAPI schema, catches unknown/misspelled fields, detects removed API versions, and can be pinned to a specific Kubernetes version — all without needing cluster access. It's also much faster and can be run on raw YAML piped from Kustomize or Helm.Q3: You have a Kyverno policy that blocks latest tags. A developer adds image: nginx:latest to a Deployment. At which point in the GitOps pipeline is this caught?
Answer
It's caught at **two points** with a well-configured pipeline: 1. **In CI** — the Kyverno CLI job on the PR fails, blocking the merge 2. **At the cluster** — if somehow it reaches the cluster (bypassing CI), the Kyverno admission webhook rejects the pod creationThe CI check is the fast, cheap gate (seconds, no cluster needed). The admission webhook is the defense-in-depth gate. Both are necessary — CI catches it before it’s ever committed; the webhook catches anything that slips through (e.g., direct kubectl apply bypassing the GitOps flow).
16.4 ArgoCD — Declarative CD for Kubernetes
⏱️ ~8 min read
TL;DR: ArgoCD is a GitOps continuous delivery controller that runs inside your cluster. It watches a Git repository, compares the desired state (Git) with the actual state (cluster), and automatically syncs the difference. Every deployment, rollback, and status check goes through ArgoCD’s UI or CLI —
kubectl applyin production becomes a thing of the past.
ArgoCD Architecture
graph TD
subgraph "Git Repository"
REPO["Config Repo\n(Kustomize / Helm / raw YAML)"]
end
subgraph "Kubernetes Cluster"
API["API Server"]
subgraph "argocd namespace"
APPCTRL["Application\nController\n(reconcile loop)"]
APISERVER["ArgoCD\nAPI Server"]
REPOSERVER["Repo\nServer\n(render templates)"]
APPSET["ApplicationSet\nController"]
end
YOURNS["Your\nNamespace\n(Deployments, Services…)"]
end
subgraph "Users"
UI["ArgoCD UI\n(browser)"]
ARGOCLI["argocd CLI"]
end
REPO -->|"poll / webhook"| REPOSERVER
REPOSERVER -->|"rendered YAML"| APPCTRL
APPCTRL -->|"compare + apply"| API
API --> YOURNS
UI & ARGOCLI -->|"manage apps"| APISERVER
APISERVER --> APPCTRL
Core Concepts
| Concept | Description |
|---|---|
| Application | An ArgoCD CRD that maps a Git repo path to a cluster namespace |
| AppProject | Groups Applications; defines RBAC and source/destination restrictions |
| Sync | The act of applying the Git state to the cluster |
| Sync Status | Synced / OutOfSync — does Git match the cluster? |
| Health Status | Healthy / Degraded / Progressing — are cluster resources working? |
| Auto-Sync | Automatically apply Git changes without manual approval |
| Self-Heal | Revert manual cluster changes back to Git state |
Installing ArgoCD
# Create namespace and install
kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Wait for all pods to be Running
kubectl rollout status deployment/argocd-server -n argocd
kubectl get pods -n argocd
# Get the initial admin password
kubectl get secret argocd-initial-admin-secret -n argocd \
-o jsonpath="{.data.password}" | base64 -d && echo
# Port-forward the ArgoCD UI
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Or install the argocd CLI
curl -sSL -o argocd \
https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x argocd && sudo mv argocd /usr/local/bin/
# Login via CLI
argocd login localhost:8080 \
--username admin \
--password $(kubectl get secret argocd-initial-admin-secret -n argocd \
-o jsonpath="{.data.password}" | base64 -d) \
--insecure
Defining an Application
An Application CRD is the core ArgoCD object. It says: “track this Git path and apply it to this cluster/namespace.”
Kustomize Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app-production
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io # Clean up resources on deletion
spec:
project: default
source:
repoURL: https://github.com/myorg/k8s-config.git
targetRevision: HEAD # Branch, tag, or commit SHA
path: overlays/production # Path within the repo
destination:
server: https://kubernetes.default.svc # In-cluster (same cluster ArgoCD is in)
namespace: production
syncPolicy:
automated: # Auto-sync on Git changes
prune: true # Delete resources removed from Git
selfHeal: true # Revert manual cluster changes
syncOptions:
- CreateNamespace=true # Create namespace if missing
- PrunePropagationPolicy=foreground # Wait for resources to be deleted
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
Helm Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
spec:
source:
repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: "55.5.0" # Pinned chart version
helm:
releaseName: monitoring
values: |
grafana:
adminPassword: supersecret
prometheus:
prometheusSpec:
retention: 7d
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
ArgoCD CLI — Day-to-Day Operations
# List all applications
argocd app list
# Check app status
argocd app get my-app-production
# Manual sync (when auto-sync is off)
argocd app sync my-app-production
# Sync only a specific resource
argocd app sync my-app-production --resource Deployment:my-app
# Check what would change (diff, like helm diff)
argocd app diff my-app-production
# Roll back to a previous revision
argocd app history my-app-production # List revisions
argocd app rollback my-app-production 3 # Roll back to revision 3
# Hard refresh (clear cache, re-fetch from Git)
argocd app get my-app-production --hard-refresh
# Delete an application (and its resources if finalizer is set)
argocd app delete my-app-production
Sync Waves and Hooks — Controlling Deployment Order
For ordered deployments (e.g., run DB migrations before deploying app):
# 1. Database migration Job runs first (wave -1)
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
annotations:
argocd.argoproj.io/sync-wave: "-1" # Lower number = runs first
argocd.argoproj.io/hook: PreSync # Only run before sync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
template:
spec:
containers:
- name: migrate
image: my-app:sha-a3f8c1d
command: ["python", "manage.py", "migrate"]
restartPolicy: Never
---
# 2. Application Deployment (wave 0, default)
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
annotations:
argocd.argoproj.io/sync-wave: "0"
| Hook | When It Runs |
|---|---|
PreSync | Before any resources are applied |
Sync | During the sync, alongside other resources |
PostSync | After all resources are healthy |
SyncFail | If sync fails (for cleanup/notification) |
ApplicationSet — Multi-Cluster and Multi-Environment
ApplicationSet auto-generates Application objects from a template:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: my-app-all-envs
namespace: argocd
spec:
generators:
- list:
elements:
- env: dev
cluster: https://dev-cluster:6443
namespace: my-app-dev
revision: main
- env: staging
cluster: https://staging-cluster:6443
namespace: my-app-staging
revision: main
- env: production
cluster: https://prod-cluster:6443
namespace: my-app-prod
revision: v1.2.3 # Prod is pinned to a release tag
template:
metadata:
name: "my-app-{{env}}"
spec:
project: default
source:
repoURL: https://github.com/myorg/k8s-config.git
targetRevision: "{{revision}}"
path: "overlays/{{env}}"
destination:
server: "{{cluster}}"
namespace: "{{namespace}}"
syncPolicy:
automated:
prune: true
selfHeal: "{{env}}" != "production" # Manual sync for prod
✅ Quick Check
Q1: What’s the difference between Synced and Healthy in ArgoCD?
Answer
**Synced** means the cluster resources **match** what's declared in Git (the desired state). **Healthy** means those resources are **working correctly** — pods are Running, Deployments have the expected number of ready replicas, Services have endpoints, etc. An app can be `Synced` but `Degraded` (e.g., the manifest is correctly applied but pods are crash-looping). It can also be `OutOfSync` but `Healthy` (someone manually edited a ConfigMap — Git doesn't match, but everything still works).Q2: You have selfHeal: true enabled. An on-call engineer adds a temporary environment variable to a Deployment using kubectl edit during an incident. What happens?
Answer
ArgoCD detects the drift within the next reconciliation cycle (default: every 3 minutes) and **reverts the manual change** — removing the env var. This is by design: in GitOps, Git is the single source of truth. For emergencies, the correct approach is to make the change in Git (fast PR or direct commit to a branch), let ArgoCD sync it, and revert when the incident is over. Some teams disable `selfHeal` for production to allow temporary manual overrides, at the cost of drift risk.Q3: How does ArgoCD enable rolling back a deployment without running kubectl rollout undo?
Answer
ArgoCD tracks the Git revision history of the config repo. Each sync corresponds to a specific Git commit. To roll back, you use `argocd app rollback my-app REVISION` which instructs ArgoCD to sync to a previous commit's state, applying all the manifests from that commit — not just the Deployment template. This is more complete than `kubectl rollout undo` which only reverts the pod template spec, not ConfigMaps, Services, or other resources that may have changed.Lab: Full GitOps Pipeline
⏱️ ~30 min hands-on
| Prerequisites | Sections 16.1–16.4 read, Minikube running, helm installed |
| Difficulty | 🟠 Intermediate–Advanced |
| What you’ll do | Install ArgoCD on Minikube, create a Kustomize-based config structure, deploy an Application, observe sync and health, simulate a code deploy by updating an image tag, simulate drift and watch ArgoCD self-heal, and trigger a rollback |
Objectives
- Install ArgoCD on Minikube and access the UI
- Build a Kustomize config structure with base + overlays
- Create an ArgoCD Application pointing at a local Git repo
- Deploy the application and verify sync + health
- Simulate a new release by updating the image tag
- Manually edit a Deployment and watch ArgoCD self-heal
- Roll back to a previous revision via CLI
- Validate manifests with kubeconform
Setup
# Ensure Minikube is running with enough resources
minikube status
kubectl get nodes
# Install ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Wait for all ArgoCD pods to be running (takes ~2 min)
kubectl rollout status deployment/argocd-server -n argocd
kubectl rollout status deployment/argocd-repo-server -n argocd
kubectl rollout status deployment/argocd-application-controller -n argocd
kubectl get pods -n argocd
Expected pods (all Running):
argocd-application-controller-0 1/1 Running
argocd-dex-server-xxx 1/1 Running
argocd-notifications-controller-xxx 1/1 Running
argocd-redis-xxx 1/1 Running
argocd-repo-server-xxx 1/1 Running
argocd-server-xxx 1/1 Running
# Get the ArgoCD initial admin password
ARGOCD_PASSWORD=$(kubectl get secret argocd-initial-admin-secret -n argocd \
-o jsonpath="{.data.password}" | base64 -d)
echo "ArgoCD password: $ARGOCD_PASSWORD"
# Port-forward the ArgoCD UI (run in background)
kubectl port-forward svc/argocd-server -n argocd 8443:443 &
echo "ArgoCD UI: https://localhost:8443 (login: admin / $ARGOCD_PASSWORD)"
# Install ArgoCD CLI
curl -sSL -o /tmp/argocd \
https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x /tmp/argocd && sudo mv /tmp/argocd /usr/local/bin/argocd
# Login
argocd login localhost:8443 \
--username admin \
--password "$ARGOCD_PASSWORD" \
--insecure
Exercise 1: Build a Kustomize Config Structure
What we’re doing: Create a local Git repo with Kustomize base + overlays that ArgoCD will track.
# Create the config repo directory
mkdir -p ~/gitops-lab && cd ~/gitops-lab
git init
# Base manifests (shared across all environments)
mkdir -p base
cat > base/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
labels:
app: web-app
version: v1.0.0
team: platform
spec:
replicas: 2
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
version: v1.0.0
spec:
containers:
- name: web
image: nginxdemo/hello:plain-text
ports:
- containerPort: 80
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
EOF
cat > base/service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: web-app
labels:
app: web-app
spec:
selector:
app: web-app
ports:
- port: 80
targetPort: 80
type: ClusterIP
EOF
cat > base/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
commonLabels:
managed-by: argocd
EOF
# Dev overlay (lighter resources)
mkdir -p overlays/dev
cat > overlays/dev/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namePrefix: dev-
namespace: gitops-dev
resources:
- ../../base
patches:
- patch: |-
- op: replace
path: /spec/replicas
value: 1
target:
kind: Deployment
name: web-app
EOF
# Create the dev namespace
kubectl create namespace gitops-dev 2>/dev/null || true
# Commit initial state
git add -A
git commit -m "Initial GitOps config: base + dev overlay"
echo "Local git repo initialized at ~/gitops-lab"
echo "HEAD: $(git rev-parse --short HEAD)"
Validate with kubeconform:
# Install kubeconform
curl -L https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \
| tar -xz && sudo mv kubeconform /usr/local/bin/ 2>/dev/null || true
# Validate the kustomize output
kustomize build ~/gitops-lab/overlays/dev | kubeconform -summary -
# Expected: Valid: 2, Invalid: 0, Errors: 0
Exercise 2: Create an ArgoCD Application
What we’re doing: Point ArgoCD at the local git repo and deploy the app.
# Get the absolute path of the local repo
REPO_PATH=$(realpath ~/gitops-lab)
# Create ArgoCD Application pointing to local filesystem
# (In production this would be a HTTPS/SSH git URL)
argocd app create web-app \
--repo "file://${REPO_PATH}" \
--path overlays/dev \
--dest-server https://kubernetes.default.svc \
--dest-namespace gitops-dev \
--sync-policy automated \
--auto-prune \
--self-heal \
--sync-option CreateNamespace=true
# Check status
argocd app get web-app
argocd app list
Expected output:
Name: argocd/web-app
Project: default
Server: https://kubernetes.default.svc
Namespace: gitops-dev
URL: https://localhost:8443/applications/web-app
Source: file:///home/.../gitops-lab (Path: overlays/dev)
SyncStatus: Synced
HealthStatus: Healthy
# Verify the app is deployed
kubectl get pods,svc -n gitops-dev
# Should see:
# pod/dev-web-app-xxx Running
# svc/dev-web-app ClusterIP
Open ArgoCD UI:
Navigate to https://localhost:8443 — you should see the web-app application as a green box with Synced and Healthy.
Exercise 3: Simulate a New Release
What we’re doing: Update the image tag in Git (simulating a CI pipeline bump) and watch ArgoCD deploy it.
cd ~/gitops-lab
# Simulate CI bumping the image (new "version" of the app)
# We'll switch from nginxdemo/hello:plain-text to nginxdemo/hello:latest
# In a real pipeline, CI would update to a new SHA tag
cat > base/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
labels:
app: web-app
version: v1.1.0
team: platform
spec:
replicas: 2
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
version: v1.1.0
spec:
containers:
- name: web
image: nginxdemo/hello:latest # "new version"
ports:
- containerPort: 80
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
EOF
git add -A
git commit -m "ci: update web-app to v1.1.0"
echo "Committed. Waiting for ArgoCD to detect the change..."
sleep 5 # ArgoCD polls local repos more frequently than remote
# Check if ArgoCD detected the change
argocd app get web-app
# Watch the sync happen
argocd app sync web-app --watch 2>/dev/null || true
# Or just wait and check
sleep 30
kubectl get pods -n gitops-dev
argocd app get web-app
# Verify the new image is deployed
kubectl get deploy dev-web-app -n gitops-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}' && echo
# Expected: nginxdemo/hello:latest
Exercise 4: Observe Self-Healing
What we’re doing: Make a manual change to the cluster and watch ArgoCD revert it.
# Check ArgoCD app sync policy (should have selfHeal=true)
argocd app get web-app | grep "Auto-Sync"
# Manually change the replica count directly in the cluster
kubectl scale deployment dev-web-app --replicas=5 -n gitops-dev
# Immediately check
kubectl get deploy dev-web-app -n gitops-dev
# Shows 5 replicas (our manual change)
# Wait for ArgoCD to reconcile (up to 3 minutes for local repos)
echo "Waiting for ArgoCD self-heal (up to 3 min)..."
sleep 30
# Check again - ArgoCD should have corrected it back to 1 (dev overlay value)
kubectl get deploy dev-web-app -n gitops-dev
# Expected: 1 replica (Git state wins)
# You can also force immediate sync
argocd app sync web-app
kubectl get deploy dev-web-app -n gitops-dev
# Should be back to 1 replica
Exercise 5: Rollback
What we’re doing: Roll back to the previous deployment using ArgoCD revision history.
# View revision history
argocd app history web-app
# Expected output (your revision numbers may vary):
# ID DATE REVISION
# 0 2024-XX-XX XX:XX:XX ...commit-sha-1 (initial deploy with plain-text)
# 1 2024-XX-XX XX:XX:XX ...commit-sha-2 (v1.1.0 with latest tag)
# Roll back to revision 0 (the previous state)
argocd app rollback web-app 0
# Check the image after rollback
kubectl get deploy dev-web-app -n gitops-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}' && echo
# Expected: nginxdemo/hello:plain-text (back to original)
argocd app get web-app
# Note: After rollback, app shows OutOfSync (cluster differs from latest Git)
# This is expected — rollback pins to an old revision, not Git HEAD
# To restore auto-sync to HEAD:
argocd app sync web-app
Exercise 6: Manifest Drift Detection
What we’re doing: Observe the ArgoCD diff when Git and cluster are out of sync.
# Make a deliberate change to the git repo WITHOUT syncing
cd ~/gitops-lab
# Add a ConfigMap to the base
cat > base/configmap.yaml << 'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: web-app-config
data:
APP_ENV: "development"
MAX_CONNECTIONS: "100"
EOF
# Update the kustomization to include it
cat > base/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
commonLabels:
managed-by: argocd
EOF
git add -A
git commit -m "feat: add web-app ConfigMap"
# Disable auto-sync temporarily to observe OutOfSync state
argocd app set web-app --sync-policy none
# Now check the diff (Git has ConfigMap, cluster doesn't yet)
argocd app diff web-app
# Expected: shows + lines for the new ConfigMap that doesn't exist in cluster
# Manually trigger the sync to apply it
argocd app sync web-app
# Verify the ConfigMap was created
kubectl get configmap dev-web-app-config -n gitops-dev
# Re-enable auto-sync
argocd app set web-app \
--sync-policy automated \
--auto-prune \
--self-heal
🔥 Break It! Challenge
What happens when you delete an ArgoCD Application object? Does it delete the cluster resources it manages?
# Check the finalizer on the Application
kubectl get application web-app -n argocd -o yaml | grep finalizer
# The finalizer: resources-finalizer.argocd.argoproj.io
# This controls cascade deletion behavior.
# Option 1: Delete WITH cascade (deletes all managed resources)
argocd app delete web-app --cascade
# Option 2: Delete WITHOUT cascade (orphans the resources)
# argocd app delete web-app --cascade=false
# Try option 2 first to see the resources remain:
argocd app delete web-app --cascade=false --yes 2>/dev/null || \
kubectl delete application web-app -n argocd
# Check: namespace and resources should STILL EXIST
kubectl get pods,svc,configmap -n gitops-dev
# Re-create the application to demonstrate idempotency
REPO_PATH=$(realpath ~/gitops-lab)
argocd app create web-app \
--repo "file://${REPO_PATH}" \
--path overlays/dev \
--dest-server https://kubernetes.default.svc \
--dest-namespace gitops-dev \
--sync-policy automated \
--auto-prune \
--self-heal \
--sync-option CreateNamespace=true
argocd app get web-app
The cascade behavior is critical to understand in production — accidentally deleting an ArgoCD Application with the finalizer present wipes the namespace’s resources. Many teams set --cascade=false for production applications and control deletion through Git (removing manifests and syncing).
Cleanup
# Stop port-forward
kill %1 2>/dev/null || true
# Delete ArgoCD and all it manages
argocd app delete web-app --cascade --yes 2>/dev/null || true
kubectl delete namespace argocd
kubectl delete namespace gitops-dev
# Remove local lab directory
rm -rf ~/gitops-lab
What We Learned
| # | Skill | Verified By |
|---|---|---|
| 1 | ArgoCD install | All ArgoCD pods running in argocd namespace |
| 2 | Kustomize base + overlay | Base YAML + dev overlay with namespace prefix |
| 3 | kubeconform validation | 2 resources validated against K8s schema |
| 4 | ArgoCD Application | web-app app created, Synced + Healthy in UI |
| 5 | GitOps deploy | Image tag updated in Git → ArgoCD rolled out new pods |
| 6 | Self-healing | Manual kubectl scale reverted by ArgoCD |
| 7 | Rollback | argocd app rollback restored previous image |
| 8 | Diff detection | argocd app diff showed new ConfigMap before sync |
| 9 | Cascade delete | Understood Application finalizer behavior |
Chapter 17: Kubernetes Internals
Kubernetes Internals
17.1 Pod Creation Journey
17.2 etcd
17.3 Container Runtimes
17.4 CNI
17.5 CSI
Chapter 18: Troubleshooting Playbook
Troubleshooting Playbook
18.1 The Debugging Mental Model
18.2 Pod Failures
18.3 Networking Failures
18.4 Storage Issues
18.5 Troubleshooting Cheat Sheet
Appendix A: YAML Crash Course
🚧 Coming soon. This section is being written.
Appendix B: kubectl Cheat Sheet
🚧 Coming soon. This section is being written.
Appendix C: Common K8s Error Reference
🚧 Coming soon. This section is being written.
Appendix D: Resource Manifests Reference
🚧 Coming soon. This section is being written.