Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

ToolVersionCheckExpected Output
Docker20+docker --versionDocker version 20.x...
kubectl1.28+kubectl version --clientClient Version: v1.28...
Minikube1.32+minikube versionminikube version: v1.3...
Helm3.xhelm versionversion.BuildInfo{Version:"v3...
Gitanygit --versiongit 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

#ChapterTimeYou’ll Be Able To
1The Container Orchestration Problem~45 minExplain why K8s exists and navigate a live cluster
2kubectl — Your Swiss Army Knife~40 minQuery and control any K8s resource from the CLI
3Pods — The Atomic Unit~50 minCreate, debug, and destroy pods with confidence
4Workload Controllers~55 minDeploy apps with zero-downtime rolling updates
5Services~45 minConnect pods across namespaces using DNS
6Ingress — HTTP Routing~50 minRoute external traffic with NGINX and TLS
7ConfigMaps and Secrets~40 minExternalize config and manage sensitive data
8Storage~45 minPersist data across pod restarts
9KubeShop Project~60 minContainerize a real 5-service microservice app
10Deploying KubeShop~60 minDeploy a full microservices stack to Minikube
11Health Checks & Self-Healing~45 minConfigure probes and run chaos experiments
12Helm~55 minPackage and template K8s apps with Helm
13Scheduling & Autoscaling~50 minAutoscale under load and control pod placement
14Security~55 minLock down a cluster with RBAC and Network Policies
15Observability~60 minSet up Prometheus, Grafana, and log aggregation
16CI/CD with GitHub Actions~55 minBuild a full GitOps pipeline with ArgoCD
17Azure Kubernetes Service~60 minDeploy KubeShop to a production cloud cluster
18K8s Internals~40 minExplain what happens when you run kubectl apply
19Troubleshooting Playbook~35 minDebug 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

SectionTopicTime
1.1From Docker to Orchestration — Why Compose Isn’t Enough~8 min
1.2Kubernetes Architecture — The 10,000ft View~8 min
1.3Control Plane Deep Dive~6 min
1.4Worker Nodes and the Kubelet~5 min
1.5🔬 Lab: Your First Cluster — Minikube Setup & Exploration~30 min

Prerequisites

  • Docker installed and working (docker ps succeeds)
  • kubectl installed (kubectl version --client)
  • minikube installed (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: always in 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
ProblemComposeKubernetes
Container crashesStays dead (or restarts on same host only)Automatically restarted anywhere in the cluster
Need more instancesManual --scale, same machinekubectl scale, spread across nodes
Deploy new versionDowntime or custom scriptsBuilt-in rolling updates
Multi-hostNot supportedNative — designed for it
Load balancingDIYBuilt in via Services
Secrets management.env filesEncrypted Secrets objects
Health checksBasic 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

#ConceptOne-liner
1Compose is single-hostIt cannot span multiple machines
2No self-healing in ComposeCrashed containers stay crashed
3Desired StateK8s continuously reconciles reality with your declaration
4K8s is an orchestratorIt 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.

ComponentRoleAnalogy
API ServerThe single entry point for all cluster operationsHotel reception desk — every request goes through here
etcdDistributed key-value store — the source of truth for all cluster stateThe hotel’s booking ledger
SchedulerDecides which node a new pod runs onHotel concierge assigning rooms
Controller ManagerRuns control loops that watch state and reconcileFloor 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.

ComponentRole
KubeletThe K8s agent on each node — receives pod specs and ensures containers are running
kube-proxyMaintains network rules so pods can communicate via Services
Container RuntimeActually 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

#ConceptOne-liner
1Control PlaneThe brain — makes decisions, never runs your workloads
2Worker NodesThe muscle — where your pods actually run
3API ServerEvery operation goes through it; it’s the cluster’s front door
4etcdThe single source of truth for all cluster state
5MinikubePacks 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:

ControllerWhat it watchesWhat it does
ReplicaSetReplicaSetsEnsures desired pod count is maintained
DeploymentDeploymentsManages rolling updates via ReplicaSets
NodeNodesMarks nodes as unreachable, evicts pods
JobJobsEnsures batch jobs run to completion
EndpointsServicesKeeps 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 DownImmediate ImpactPods Still Running?
API ServerNo kubectl, no changes possible✅ Yes
etcdAPI Server can’t read/write state✅ Yes (cached state)
SchedulerNew pods stay Pending forever✅ Yes (existing pods)
Controller ManagerNo 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

#ConceptOne-liner
1API Server is the hubNothing talks to etcd except the API Server
2etcd = cluster brainLosing it loses all cluster state — back it up
3Scheduler = placementPicks which node, then hands off to kubelet
4Controllers = reconcilersEach 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:

  1. Watches the API Server for pods assigned to its node
  2. Tells the container runtime to pull images and start containers
  3. Runs health checks (liveness/readiness probes) on containers
  4. 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.

RuntimeUsed ByNotes
containerdMost clusters (GKE, AKS, EKS)Default runtime since K8s 1.24
CRI-OOpenShiftLightweight, OCI-compliant
Docker EngineLegacyRemoved 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 containerd
  • Kubelet Version — the K8s version running on this node
  • Allocatable — how much CPU/memory is available for pods
  • Conditions — should be Ready: True

Key Takeaways

#ConceptOne-liner
1Kubelet is the node agentWatches API Server and manages pods on its node
2Kubelet is not a podRuns as a systemd service — it can’t manage itself
3kube-proxy = Service networkingPrograms iptables/IPVS rules for Service routing
4Container runtime = OCI layerPulls 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

Prerequisitesminikube, kubectl, and Docker installed
Difficulty🟢 Beginner
What you’ll doStart 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, and cluster-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-info shows the API Server URL and any running cluster services. The IP 192.168.49.2 is 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:

SectionWhat it tells you
ConditionsIs the node healthy? DiskPressure, MemoryPressure, Ready
AllocatableHow much CPU/memory pods can use
Allocated resourcesHow much is already used by running pods
EventsRecent 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
NamespacePurpose
defaultWhere your workloads go unless you specify otherwise
kube-systemK8s internal components
kube-publicReadable by all; used for cluster info bootstrap
kube-node-leaseNode 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 kubectl entirely and spoke directly to the Kubernetes API Server. This is exactly what kubectl does 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

#SkillVerified By
1Start and verify a K8s clusterkubectl get nodes shows Ready
2Inspect control plane podskubectl get pods -n kube-system
3Understand node resource allocationkubectl describe node minikube
4Navigate namespaceskubectl get all --all-namespaces
5Call the raw Kubernetes APIcurl localhost:8001/api/v1/namespaces
6Observe Scheduler failure impactPod 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

SectionTopicTime
2.1Anatomy of a kubectl Command~5 min
2.2Imperative vs Declarative — Two Ways to Talk to K8s~5 min
2.3Context, Namespaces, and kubeconfig~5 min
2.4Output Formatting, Filtering, and JSONPath~6 min
2.5🔬 Lab: kubectl Power User Drills~25 min

Prerequisites

  • Completed Chapter 1 (Minikube cluster running)
  • minikube status shows Running

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:

VerbWhat it doesExample
getList one or more resourceskubectl get pods
describeDetailed info + events for a resourcekubectl describe pod my-pod
createCreate a resource from a filekubectl create -f pod.yaml
applyCreate or update a resource from a filekubectl apply -f deployment.yaml
deleteDelete a resourcekubectl delete pod my-pod
editOpen a resource in your editor livekubectl edit deployment my-app
logsStream container logskubectl logs my-pod
execRun a command inside a containerkubectl exec -it my-pod -- bash
port-forwardForward a local port to a podkubectl port-forward pod/my-pod 8080:80
scaleChange replica countkubectl scale deployment my-app --replicas=5
rolloutManage deployment rolloutskubectl rollout status deployment/my-app

The Resources

Resources are what K8s manages. You refer to them by their kind name (or shorthand):

Full NameShorthandWhat it is
podspoThe atomic unit
deploymentsdeployManages ReplicaSets
servicessvcNetwork endpoint
namespacesnsIsolation boundary
nodesnoCluster machines
configmapscmConfiguration data
secretsSensitive data
persistentvolumeclaimspvcStorage requests
ingressesingHTTP 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

FlagShortWhat it does
--namespace-nTarget a specific namespace
--all-namespaces-ATarget all namespaces
--output-oChange output format (yaml, json, wide)
--filename-fSpecify a file
--watch-wStream changes live
--selector-lFilter by label
--dry-run=clientPreview changes without applying
--forceForce 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

#ConceptOne-liner
1kubectl [verb] [resource] [name]The universal pattern for all commands
2Shorthand aliasespo, deploy, svc, cm, ns save keystrokes
3kubectl explainBuilt-in API reference — use it instead of Googling
4-n / -A flagsScope 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 -fkubectl apply -f
Resource doesn’t exist✅ Creates it✅ Creates it
Resource already exists❌ Error✅ Updates it
Safe to run repeatedlyNoYes
Tracks changesNoYes (via annotation)
Use in CI/CDNeverAlways
# 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 yaml is 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

SituationUse
Quick debugging — “just run this pod”Imperative (kubectl run)
Generating a starter YAMLImperative + --dry-run=client -o yaml
Any real deploymentDeclarative (kubectl apply -f)
CI/CD pipelineDeclarative only
Sharing with the teamDeclarative (YAML in Git)
One-off admin tasksImperative OK

🏭 In Production: Imperative commands have no audit trail. In production, every change should go through kubectl apply backed 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

#ConceptOne-liner
1ImperativeFast, not reproducible — use for experiments only
2Declarativeapply -f is idempotent and Git-friendly
3create vs applyapply is always safer — use it by default
4--dry-run=client -o yamlGenerate 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: kubeconfig stores 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 delete against production when you meant staging. Always verify your current context before destructive commands. Some teams alias kubectl to 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 namespace

They 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

#ConceptOne-liner
1kubeconfigStores cluster URLs, credentials, and context definitions
2ContextNamed combo of cluster + user + namespace
3NamespaceVirtual partition within a cluster; resources isolated by name
4Cross-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 get is 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 json to 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

#ConceptOne-liner
1-o wideExtra columns: IP, node, nominated node
2-o yamlFull resource spec — use for copying/debugging
3-l key=valueFilter by label — the primary K8s query mechanism
4-o jsonpath='{...}'Extract a single field from any resource
5--sort-bySort 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

PrerequisitesMinikube running, Chapter 2 sections 2.1–2.4 read
Difficulty🟢 Beginner
What you’ll doRun a gauntlet of kubectl drills covering every technique from this chapter

Objectives

  • Use kubectl explain to look up API fields without Googling
  • Generate YAML using --dry-run=client and 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 image is an imperative way to update a container image. The deployment controller immediately starts a rolling update. In production, you’d update the YAML and kubectl 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

#SkillVerified By
1Use kubectl explain as API referenceFound livenessProbe.httpGet fields without Googling
2Generate YAML with --dry-runCreated an editable deployment manifest
3Filter by labels and fieldsUsed -l, --field-selector, --show-labels
4Extract data with JSONPathGot pod names, images, IPs in custom format
5Work across namespacesUsed -n and -A to scope commands
6Debug a failing deploymentUsed 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 CrashLoopBackOff and OOMKilled.

What’s Inside

SectionTopicTime
3.1What Is a Pod, Really?~5 min
3.2Pod Lifecycle and Phases~6 min
3.3Multi-Container Pods: Sidecars, Init, and Ambassadors~7 min
3.4Resource Requests and Limits~6 min
3.5🔬 Lab: Run, Inspect, Break, and Debug Pods~30 min

Prerequisites

  • Completed Chapter 2 (kubectl confident)
  • minikube status shows Running

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
ResourceShared?Meaning
Network namespace✅ YesSame IP, can talk via localhost
Ports✅ YesPort conflicts between containers ARE possible
VolumesOptionalVolumes must be explicitly mounted by each container
Filesystem❌ NoEach container has its own isolated filesystem
Process namespaceOptionalCan 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.

  1. Atomic scheduling — all containers in a pod land on the same node together
  2. Shared lifecycle — pod-level restart policies apply to all containers
  3. 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: containerPort in 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

#ConceptOne-liner
1Pod ≠ ContainerA pod wraps 1+ containers that share network and optionally storage
2Shared networkContainers in a pod use localhost to talk to each other
3containerPort is cosmeticIt doesn’t publish anything — Services do the actual networking
4Always pin image tagsnginx: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
PhaseMeaning
PendingPod accepted, but not all containers are running yet. Could be scheduling, image pulling, or init container running.
RunningPod bound to a node; at least one container is running (or starting/restarting)
SucceededAll containers exited with code 0. Terminal state.
FailedAll containers have exited; at least one exited non-zero. Terminal state.
UnknownPod 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 StateMeaning
WaitingNot running yet. Check reason — usually ContainerCreating, ImagePullBackOff, or CrashLoopBackOff
RunningContainer started and process is alive
TerminatedContainer 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:

ConditionMeaning
PodScheduledPod has been assigned to a node
InitializedAll init containers completed successfully
ContainersReadyAll containers are ready
ReadyPod 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:

PolicyBehaviorUse Case
AlwaysAlways restart, regardless of exit codeLong-running services (default)
OnFailureRestart only if exit code != 0Batch jobs that should succeed
NeverNever restartOne-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

#ConceptOne-liner
15 pod phasesPending → Running → Succeeded/Failed/Unknown
2Container statesWaiting / Running / Terminated — separate from pod phase
3CrashLoopBackOffContainer keeps crashing — check logs --previous
4OOMKilled = exitCode 137Container exceeded memory limit
5restartPolicyAlways (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 pod shows Init:0/2 then Init:1/2 etc.
# 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

#PatternWhen to Use
1SidecarAugment main container: log shipping, metrics, proxy
2Init ContainerPre-flight checks, DB migrations, dependency waiting
3AmbassadorProtocol translation, local proxy to external service
4emptyDirEphemeral 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:

ResourceOver Limit Behavior
CPUThrottled — the container is slowed down but keeps running
MemoryOOMKilled — 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: 100m and limits: 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) and Mi (mebibytes) are different. Use Mi to 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 ClassConditionEviction Priority
Guaranteedrequests == limits for all containersLast to be evicted
Burstablerequests < limits, or only one setMiddle priority
BestEffortNo requests or limits setFirst 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

#ConceptOne-liner
1requests = schedulingMinimum guaranteed resources; used by the Scheduler
2limits = enforcementHard cap: CPU throttles, memory kills
3OOMKilled = exit 137Container exceeded memory limit — raise the limit or fix the leak
4Always set requestsWithout them, the Scheduler flies blind
5QoS classesGuaranteed > 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

PrerequisitesChapters 3.1–3.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doCreate 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 exec to 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 be Burstable
  • Node: — which minikube node
  • IP: — the pod’s internal IP
  • Events: — 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 exec opens a shell inside the running container’s filesystem and process space. port-forward tunnels 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

#SkillVerified By
1Create pods imperatively and declarativelyBoth nginx pods ran successfully
2Exec into a containerRan hostname, curl localhost inside the pod
3Read and tail logsCaptured nginx access logs with -f
4Debug CrashLoopBackOffUsed logs --previous to find the error message
5Diagnose OOMKilledConfirmed exit code 137 and OOMKilled reason
6Use init containersWatched Init:0/1 gate the main container
7Understand scheduling failuresSaw 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

SectionTopicTime
4.1ReplicaSets — Desired State and Self-Healing~5 min
4.2Deployments — Rolling Updates, Rollbacks, and Strategy~8 min
4.3DaemonSets — One Per Node~5 min
4.4StatefulSets — When Identity Matters~6 min
4.5Jobs 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 status shows Running

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

#ConceptOne-liner
1ReplicaSet = desired count enforcerContinuously reconciles actual pod count to replicas
2Label selector = ownershipRS owns any pod whose labels match its selector
3Don’t use ReplicaSets directlyUse Deployments — they add rolling updates and rollback
4Old RS kept at 0 replicasEnables 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
maxSurgemaxUnavailableBehavior
10Zero-downtime; slower (one at a time)
25%25%Balanced; default K8s behavior
100%0Blue-green style (doubles pod count briefly)
01Takes 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) or kubernetes.io/change-cause so rollout history is 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

#ConceptOne-liner
1Deployment manages ReplicaSetsCreates a new RS per rollout; keeps old ones for rollback
2maxSurge + maxUnavailableControl speed vs safety tradeoff in rolling updates
3Readiness probe gates rolloutNew pod must be Ready before old one is deleted
4rollout undo = instant rollbackOld RS scales back up — no re-download needed
5Recreate = downtime strategyUse 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 CaseExample
Log collectionFluentd, Filebeat — collect logs from the node’s filesystem
Metrics collectionPrometheus Node Exporter — scrape node-level metrics
Network pluginsCalico, Flannel — implement pod networking (CNI)
Storage agentsCeph, Longhorn — manage node-local storage
Security agentsFalco — monitor syscalls on each node

📝 Note: Look at your kube-system namespace — kube-proxy is 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: hostPath volumes are powerful and dangerous. A pod with a hostPath mount to / can read the entire node filesystem. In production, restrict DaemonSet permissions with Pod Security Standards (Chapter 14).


DaemonSets vs Deployments

DeploymentDaemonSet
Pod count controlYou set replicasOne per node (automatic)
SchedulingScheduler picks nodesRuns on ALL nodes (or subset via nodeSelector)
ScalingManual or HPAAutomatic as nodes are added/removed
Use caseStateless appsNode-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

#ConceptOne-liner
1One pod per nodeDaemonSet guarantees node-level coverage
2Auto-scales with clusterNew node → new pod; removed node → pod cleaned up
3Uses hostPathTypically mounts the node’s own filesystem
4kube-proxy is a DaemonSetNode 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-0 so others can find it
  • db-0 needs 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-0mongo-0
  • data-mongo-1mongo-1
  • data-mongo-2mongo-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
FeatureDeploymentStatefulSet
Pod namesRandom (abc-7f4-x8p)Ordinal (db-0, db-1)
Pod DNSRandompod-name.svc-name.ns.svc.cluster.local
StorageShared or noneEach pod gets its own PVC
Start orderParallelSequential (0, 1, 2…)
Delete orderParallelReverse 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

#ConceptOne-liner
1Stable pod namespod-0, pod-1 — never random hashes
2volumeClaimTemplatesEach pod gets its own persistent disk that follows it
3Headless Service requiredProvides per-pod DNS for stable network identity
4Ordered start/stopPods 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: OnFailure or Never. The default Always is 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

PolicyBehavior
Allow (default)Multiple Jobs can run concurrently
ForbidSkip new run if previous is still running
ReplaceKill 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

#ConceptOne-liner
1Job = run-to-completionPod exits 0 = success; retried up to backoffLimit on failure
2restartPolicy: OnFailureRequired for Jobs — Always is forbidden
3parallelism + completionsControl parallel batch processing workloads
4CronJob = scheduled JobStandard cron syntax; creates a new Job each trigger
5concurrencyPolicy: ForbidPrevents 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

PrerequisitesChapter 4 sections 4.1–4.5 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doDeploy 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 maxUnavailable causes 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

#SkillVerified By
1Deploy with Deployment3-replica nginx running from YAML
2Scale up/downkubectl scale changed pod count correctly
3ReplicaSet self-healsDeleted pod was recreated within seconds
4Rolling updateUpdated from nginx:1.24 → 1.25 with zero downtime
5Safe rollbackBad image rolled back with rollout undo
6DaemonSetOne pod per node; can’t be scaled manually
7CronJobScheduled execution with job history
8maxUnavailable riskSaw 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

SectionTopicTime
5.1ClusterIP — Internal Communication~6 min
5.2NodePort — Exposing to the Outside~5 min
5.3LoadBalancer — Cloud-Native Exposure~5 min
5.4Headless Services and DNS~5 min
5.5🔬 Lab: Service Discovery and Connectivity Debugging~25 min

Prerequisites

  • Completed Chapter 4 (comfortable with Deployments)
  • minikube status shows Running

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

#ConceptOne-liner
1ClusterIP = stable virtual IPPod IPs change; Service IP never does
2Label selector = routingService routes to pods matching its selector
3Endpoints objectMaintained automatically; lists live pod IPs
4DNS auto-registeredEvery Service gets a DNS name via CoreDNS
5port vs targetPortService 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:

  1. External client hits any node on port 30080
  2. Node forwards to the Service’s ClusterIP
  3. 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

FeatureClusterIPNodePort
Accessible fromInside cluster onlyInside cluster + external
Includes ClusterIP✅ Yes (NodePort extends it)
Port rangeAny30000–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

#ConceptOne-liner
1NodePort opens on every nodeTraffic to ANY node on that port reaches your Service
2Range: 30000–32767Avoids conflicts with system/app ports
3Extends ClusterIPNodePort Services also have a ClusterIP for internal use
4Minikube needs minikube ipThe 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 tunnel to 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:

  1. A ClusterIP (internal routing)
  2. A NodePort (on each node)
  3. 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 tunnel requires elevated privileges and must stay running. If you close the terminal, the tunnel closes and EXTERNAL-IP goes back to pending.


When to Use LoadBalancer vs Ingress

ScenarioUse
Single service to exposeLoadBalancer — simple, one command
Multiple services on same domainIngress — HTTP routing, one LB shared
TCP/UDP (non-HTTP)LoadBalancer — Ingress is HTTP-only
TLS termination at L7Ingress — 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

#ConceptOne-liner
1LoadBalancer = cloud LB + NodePort + ClusterIPThree layers stacked
2Cloud controller provisions the LBCalls AWS/Azure/GCP API automatically
3minikube tunnel simulates it locallyRoutes 127.0.0.1 to the Service
4Prefer Ingress for HTTP in productionOne 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 QueryRegular ServiceHeadless Service
A record for serviceReturns ClusterIPReturns all pod IPs
SRV recordReturns service ClusterIP + portReturns 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

#ConceptOne-liner
1clusterIP: None = headlessNo virtual IP; DNS returns real pod IPs
2StatefulSet per-pod DNSpod-N.svc.ns.svc.cluster.local per pod
3Client-side load balancingThe client app chooses which pod IP to use
4Selector-less headlessBridge 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

PrerequisitesChapter 5 sections 5.1–5.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doWire 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 tunnel with 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-svc via CoreDNS to the ClusterIP, which kube-proxy forwarded to one of the 3 backend pods. The 8080→80 port 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

#SkillVerified By
1ClusterIP DNS routingbackend-svc:8080 resolved and returned HTTP 200
2DNS mechanicsRead /etc/resolv.conf and ran nslookup from inside a pod
3Live EndpointsWatched IPs appear/disappear as pods scaled
4NodePort external accessReached app on minikube-ip:30500 from host
5Broken selector debuggingFound empty Endpoints → fixed selector → connectivity restored
6LoadBalancer + tunnelUsed minikube tunnel to get a real external IP
7ClusterIP is cluster-onlyConfirmed 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

SectionTopicTime
6.1Ingress Controllers and Resources~5 min
6.2Setting Up NGINX Ingress on Minikube~5 min
6.3Path-Based and Host-Based Routing~7 min
6.4TLS Termination~6 min
6.5🔬 Lab: Multi-Service Ingress with TLS~30 min

Prerequisites

  • Completed Chapter 5 (Services)
  • minikube status shows Running

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:

ControllerUse Case
NGINX IngressMost widely used; great for Minikube and most production setups
TraefikAuto-discovers services; popular with Docker/K8s
HAProxy IngressHigh-performance TCP/HTTP
KongAPI gateway features built in
AWS ALB IngressNative AWS Application Load Balancer
GKE IngressNative 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:

PathTypeBehavior
PrefixMatches path and all sub-paths: /api matches /api, /api/users, /api/v2/...
ExactExact match only: /api matches ONLY /api, not /api/users
ImplementationSpecificController-defined behavior (rarely used)

Key Takeaways

#ConceptOne-liner
1Controller ≠ ResourceThe controller does the routing; the Resource declares the rules
2NGINX Ingress is the defaultMost common controller; what Minikube’s addon uses
3ingressClassName is requiredTells which controller owns this Ingress resource
4One IP, many servicesThe 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 tunnel or 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

#ConceptOne-liner
1minikube addons enable ingressOne command installs NGINX Ingress Controller
2Controller runs in ingress-nginx namespaceCheck pod health there, not default
3IngressClass nginx is auto-createdUse ingressClassName: nginx in Ingress Resources
4Use /etc/hosts or Host: headerRoute 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 use pathType: 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 Prefix paths. Always put more specific paths first (e.g., /api before /). 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

#ConceptOne-liner
1Path routingSame host, different URL paths → different Services
2Host routingDifferent Host headers → different Services
3rewrite-targetStrip routing prefix before forwarding to backend
4Specificity orderMore specific paths should come before catch-alls
5defaultBackendCatch-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

#ConceptOne-liner
1TLS termination at the controllerBackends only see plain HTTP
2kubernetes.io/tls SecretHolds tls.crt + tls.key
3ssl-redirect annotationAutomatically redirects HTTP → HTTPS
4cert-manager for productionAuto-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

PrerequisitesNGINX Ingress enabled (minikube addons enable ingress), Minikube running
Difficulty🟡 Intermediate
What you’ll doDeploy 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 Host header 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

#SkillVerified By
1NGINX Ingress setupController pod Running in ingress-nginx namespace
2Path-based routing/ → web-svc, /api → api-svc with correct responses
3Host-based routingadmin.local → admin-svc independently
4TLS terminationHTTPS working with self-signed cert, HTTP redirects to HTTPS
5Debug 503 errorsTraced to missing backend service via describe + controller logs
6Duplicate rulesObserved 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

SectionTopicTime
7.1ConfigMaps — Externalizing Configuration~5 min
7.2Secrets — Handling Sensitive Data~6 min
7.3Environment Variables vs Volume Mounts~5 min
7.4Immutable ConfigMaps and Secret Rotation~4 min
7.5🔬 Lab: Configure a 12-Factor App~25 min

Prerequisites

  • Completed Chapter 3 (Pods)
  • minikube status shows Running

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 .env files or environment: 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

#ConceptOne-liner
1ConfigMap = configuration containerStores strings: env vars, config file contents
2Decouples image from configSame image, different ConfigMaps per environment
31 MiB limitFor config only — not bulk data
4YAML preferredDeclarative 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

TypeUse CaseCreated By
OpaqueGeneric key-value pairsYou
kubernetes.io/tlsTLS certificateskubectl create secret tls
kubernetes.io/dockerconfigjsonDocker registry authkubectl create secret docker-registry
kubernetes.io/service-account-tokenService account tokensKubernetes automatically
kubernetes.io/basic-authHTTP basic authYou
kubernetes.io/ssh-authSSH private keysYou

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:

  1. RBAC — restricts who can get or list Secrets
  2. etcd encryption at rest — encrypts Secrets in the etcd database (must be enabled)
  3. Namespace isolation — Secrets in namespace A can’t be read by pods in namespace B
  4. 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 stringData in 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

#ConceptOne-liner
1Secrets = base64-encoded ConfigMapsSame structure; different intent and access controls
2base64 ≠ encryptionAnyone with kubectl get secret can decode it
3RBAC protects SecretsRestrict get/list on Secrets to only pods that need them
4stringData for authoringK8s auto-encodes; use for readable YAML
5etcd encryption at restThe 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 envFrom become 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 VarsVolume Mounts
Setup complexityLowMedium
Live config updates❌ No — pod restart needed✅ Yes — ~60s propagation
Access patternos.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.environ in Python, process.env in 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

#ConceptOne-liner
1Env vars from ConfigMapconfigMapKeyRef for specific keys, configMapRef for all
2Volume mounts = filesEach ConfigMap/Secret key becomes a file in the mount path
3Live updatesOnly volume mounts update without pod restart
4Secrets as filesPrefer over env vars for sensitive data
5defaultMode: 0400Set 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: true for 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:

  1. Performance — kubelet stops watching the ConfigMap/Secret for changes. At scale (thousands of ConfigMaps), this reduces API server load significantly
  2. Safety — protects against accidental kubectl edit changes that would silently affect running pods
  3. 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

#ConceptOne-liner
1immutable: truePrevents accidental changes; improves kubelet performance
2Volume mount rotationUpdate Secret → kubelet propagates within ~60s (zero restart)
3Env var rotationRequires pod restart to pick up new values
4Versioned ConfigMapsExplicit 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

PrerequisitesChapter 7 sections 7.1–7.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doDeploy 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

#SkillVerified By
1Create ConfigMaps and SecretsBoth deployed successfully with kubectl
2Inject as env varsenv and envFrom blocks populated correctly
3Mount as filesNginx config and credentials visible as files
4Live config updateConfigMap file updated in ~60s without pod restart
5Secret rotationPassword changed via volume mount, no restart needed
6Missing ConfigMap debuggingFound configmap not found error via describe
7Delete while runningRunning 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

SectionTopicTime
8.1The Storage Problem and Volume Types~5 min
8.2PersistentVolumes and PersistentVolumeClaims~7 min
8.3StorageClasses and Dynamic Provisioning~6 min
8.4Access 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 status shows Running

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 TypeWhat It IsUse Case
emptyDirTemporary dir, lives with the podSharing data between containers in a pod (sidecar, init)
hostPathMounts a directory from the nodeDaemonSets, single-node dev — avoid in production
configMap / secretK8s objects as filesInject config and credentials (Chapter 7)
persistentVolumeClaimDynamically provisioned storageDatabases, file stores — the standard production approach
nfsNetwork File SystemShared read-write storage across nodes
csiContainer Storage Interface pluginCloud 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: hostPath volumes 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

#ConceptOne-liner
1Container filesystem is ephemeralData gone when container restarts
2emptyDir = pod-lifetime storageSurvives container restart, not pod rescheduling
3hostPath = node directoryUse only for DaemonSets or single-node dev
4PVC = the production answerAbstracts 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 storageClassName exists
  • 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

#ConceptOne-liner
1PV = physical storageAdmin-provisioned; represents real disk
2PVC = storage requestDeveloper-written; K8s binds it to a PV
3Data survives pod deletionThe PV/PVC live independently of pods
4PVC Pending = no matching PVCheck 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:

CloudProvisionerStorage Type
AWS EKSebs.csi.aws.comgp3/io2 EBS volumes
GKEpd.csi.storage.gke.ioStandard/SSD Persistent Disks
AKSdisk.csi.azure.comAzure Managed Disks
Minikubek8s.io/minikube-hostpathLocal hostPath (dev only)
On-premiserancher.io/local-pathNode-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 WaitForFirstConsumer for cloud block storage StorageClasses. Immediate can 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

#ConceptOne-liner
1StorageClass = provisioner configTells K8s HOW to create volumes on demand
2Dynamic provisioningPVC created → storage provisioned automatically
3Default StorageClassPVCs without storageClassName use it
4WaitForFirstConsumerPrevents 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

ModeShortMeaningStorage Type
ReadWriteOnceRWOOne node can mount read-writeCloud block storage (EBS, Azure Disk)
ReadOnlyManyROXMultiple nodes can mount read-onlyS3-backed, shared config
ReadWriteManyRWXMultiple nodes can mount read-writeNFS, CephFS, Azure Files
ReadWriteOncePodRWOPOne pod (not just one node) read-writeHigh-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 apps
  • ReadWriteMany — 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?

PolicyBehaviorUse Case
DeletePV and underlying storage deletedDefault for dynamically provisioned; avoids orphaned cloud volumes
RetainPV stays (in Released state); data preservedProduction databases; manual cleanup required
RecycleDeprecated — basic scrub then make available againDon’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: Delete with a production database is a common disaster scenario. A developer runs kubectl delete pvc prod-db-pvc thinking it’s a dev environment — the EBS volume and all its data are gone instantly, with no recycle bin. Always use Retain for 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:

StatusMeaning
AvailableReady to be claimed
BoundClaimed by a PVC
ReleasedPVC deleted; data still exists (Retain policy)
FailedAutomatic 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

#ConceptOne-liner
1RWO = one node at a timeMost cloud block storage; fine for single-replica apps
2RWX = multi-node sharedRequires NFS/CephFS; for multi-replica shared storage
3Delete policy = riskyDefault; auto-deletes data when PVC deleted
4Retain for productionData 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

PrerequisitesChapter 8 sections 8.1–8.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doDeploy 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 Immediate binding mode, the PVC may bind immediately. On cloud clusters with WaitForFirstConsumer, it stays Pending until 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/pgdata exactly 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

#SkillVerified By
1Dynamic PVC provisioningPVC created without a pre-existing PV
2PostgreSQL with PVCData written, pod deleted, data survived in new pod
3StatefulSet PVC-per-podThree pods, three separate PVCs, isolated data
4Pod identity preserveddata-store-1 got same PVC after deletion
5PVCs outlive StatefulSetPVCs remain after kubectl delete statefulset
6Missing PVC debuggingFound 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

SectionTopicTime
9.1Namespaces — Cluster Partitioning~5 min
9.2RBAC — Roles, ClusterRoles, and Bindings~8 min
9.3ServiceAccounts — Pod Identities~6 min
9.4Multi-Tenancy Patterns~5 min
9.5🔬 Lab: Lock Down a Namespace~30 min

Prerequisites

  • Comfortable with kubectl (Chapter 2)
  • minikube status shows Running

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-svc Services 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:

NamespacePurpose
defaultWhere resources go if you don’t specify a namespace
kube-systemKubernetes system components (CoreDNS, kube-proxy, etc.)
kube-publicPublicly readable (ConfigMap with cluster info)
kube-node-leaseNode 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-ScopedCluster-Scoped
Pods, Deployments, ServicesNodes
ConfigMaps, SecretsPersistentVolumes
PVCsStorageClasses
Roles, RoleBindingsClusterRoles, ClusterRoleBindings
IngressIngressClass
# 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

#ConceptOne-liner
1Namespace = virtual clusterName isolation; RBAC scope; quota boundaries
2Network is NOT isolated by defaultCross-namespace pod traffic is allowed without NetworkPolicy
3Nodes and PVs are cluster-scopedThey exist outside any namespace
4ResourceQuota limits namespace consumptionPrevents 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:

ObjectScopePurpose
RoleNamespacePermissions within one namespace
ClusterRoleClusterPermissions cluster-wide (or reusable across namespaces)
RoleBindingNamespaceGrants a Role/ClusterRole to a subject in one namespace
ClusterRoleBindingClusterGrants 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:

VerbHTTP MethodMeaning
getGETRead a single resource
listGET (collection)List all resources of a type
watchGET (streaming)Stream updates to resources
createPOSTCreate a new resource
updatePUTReplace an existing resource
patchPATCHPartially update a resource
deleteDELETEDelete a resource
deletecollectionDELETE (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:

ClusterRoleWhat It Allows
viewRead-only access to most resources
editRead-write access to most resources (not Roles/RoleBindings)
adminFull access within a namespace, including Roles
cluster-adminFull 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

#ConceptOne-liner
1Role = permission setDefines what verbs on what resources
2RoleBinding = assignmentGives a Role to a subject in a namespace
3ClusterRole = reusableSame as Role but cluster-scoped or namespace-reusable
4Built-in rolesview/edit/admin/cluster-admin cover most needs
5kubectl auth can-iTest 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 default ServiceAccount, which has almost no permissions.


Why Pods Need Identities

Some workloads need to interact with the Kubernetes API:

WorkloadAPI 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 operatorWatch/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

#ConceptOne-liner
1ServiceAccount = pod identityAllows pods to authenticate with the K8s API
2Default SA has minimal permissionsDon’t add permissions to it
3Token auto-mounted at /var/run/...Short-lived JWT used for API calls
4automountServiceAccountToken: falseDisable for pods that don’t need API access
5SA subject formatsystem: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 LevelMechanismSecurityCostUse When
SoftNamespace + RBAC + QuotaMediumLowInternal teams; trusted users
HardSeparate clustersHighHighDifferent customers; regulatory boundaries
vClusterVirtual cluster per tenantHighMediumSaaS 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

#PatternOne-liner
1Namespace + RBAC + QuotaSoft multi-tenancy; sufficient for internal teams
2Separate clustersHard multi-tenancy; for different customers or compliance
3LimitRangeSets defaults so no pod is BestEffort by accident
4NetworkPolicyIsolates traffic; requires a compatible CNI plugin
5Namespace templateApply 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

PrerequisitesChapter 9 sections 9.1–9.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doCreate 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-user Role 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

#SkillVerified By
1ResourceQuota per namespaceCreated with CPU/memory/pod limits
2LimitRange defaultsPod without resources got 100m/64Mi automatically
3Role for developer personaalice has create/update but not delete/secrets
4Namespace isolationalice can’t see other namespaces
5ServiceAccount API accessPod called K8s API, got ConfigMaps, denied Secrets
6Quota enforcement6th pod rejected with exceeded quota error
7LimitRange max enforcementPod 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

SectionTopicTime
10.1Why Probes Exist — The Problem They Solve~4 min
10.2Liveness Probes — Restart the Stuck~6 min
10.3Readiness Probes — Control Traffic~5 min
10.4Startup Probes — Slow-Starting Apps~4 min
10.5Graceful 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 status shows Running

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

ProbeQuestionAction 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 initialDelaySeconds to your app’s average startup time × 2. Too short = false failures during startup. Too long = slow detection of real crashes.


Key Takeaways

#ConceptOne-liner
1K8s can’t read your codeWithout probes, it only checks process existence
2Liveness = restart triggerFor stuck/deadlocked apps that need a restart
3Readiness = traffic gateFor apps that need time to warm up
4Startup = slow-start protectionPrevents 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 failureThreshold times 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

MistakeProblemFix
No initialDelaySecondsApp restarts before it finishes startingSet to 2x startup time
Checking DB in livenessDB slowness triggers restart loopOnly check internal state
failureThreshold: 1One blip restarts the containerUse 3+ to avoid noise
timeoutSeconds too shortSlow but healthy response = failureTest actual response times
No liveness probe at allStuck apps stay stuck foreverAlways 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

#ConceptOne-liner
1Liveness = restart triggerContainer killed and restarted after failureThreshold failures
2Check internal state onlyNever check databases or external services
3initialDelaySeconds criticalPrevents restart loops during normal startup
43+ failure thresholdTolerates 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

#ConceptOne-liner
1Readiness = traffic gateFailure removes from Endpoints; no restart
2Can check dependenciesUnlike liveness — DB/cache checks are fine here
3Critical for rolling updatesPrevents traffic to not-yet-ready new pods
4successThreshold: 2Prevents 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 initialDelaySeconds on 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

#ConceptOne-liner
1Startup probe gates liveness/readinessBoth are disabled until startup passes
2failureThreshold × periodSeconds = max windowSet generously for slow starters
3Liveness can have tight timingStartup handles the long wait
4Use for JVM/ML/legacy appsAny 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 a preStop hook 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 preStop simultaneously
  • 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: preStop time counts against terminationGracePeriodSeconds. 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

#ConceptOne-liner
1SIGTERM → 30s grace → SIGKILLHandle SIGTERM or your app is force-killed
2preStop: sleep 5Closes the iptables update race condition
3terminationGracePeriodSecondsSet ≥ your longest request duration
4preStop time counts against grace periodDon’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

PrerequisitesChapter 10 sections 10.1–10.5 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doConfigure 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 preStop and terminationGracePeriodSeconds

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 terminationGracePeriodSeconds is 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

#SkillVerified By
1Liveness restartApp became sick → probe detected → container restarted
2Readiness traffic gatePods with failing probe removed from Endpoints
3Readiness for rolling updatesBad rollout paused; old pods kept serving
4Startup probe protectionSlow-starting app: RESTARTS: 0 thanks to startup probe
5Graceful shutdown sequencepreStop → SIGTERM → clean exit observed in logs
6Force-kill dangerShort 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

SectionTopicTime
11.1Requests and Limits — The Fundamentals~6 min
11.2QoS Classes and Pod Eviction~5 min
11.3Horizontal Pod Autoscaler (HPA)~7 min
11.4Vertical Pod Autoscaler (VPA)~4 min
11.5Cluster 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 status shows Running

11.1 Requests and Limits — The Fundamentals

⏱️ ~6 min read

TL;DR: requests is what the scheduler uses to place your pod on a node (guaranteed capacity). limits is 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:

FieldUsed ByWhat Happens If Exceeded
requests.cpuScheduler (node placement)N/A — just a reservation
requests.memoryScheduler (node placement)N/A — just a reservation
limits.cpuKernel cgroupsThrottled (slowed down, not killed)
limits.memoryKernel cgroupsContainer 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:

  1. Gets BestEffort QoS class (lowest priority)
  2. Is first to be evicted under node pressure
  3. Provides no data for the scheduler to make good placement decisions
  4. 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

#ConceptOne-liner
1requests = scheduler inputPod placed only on nodes with enough free capacity
2limits.cpu = throttleExcess CPU throttled; container not killed
3limits.memory = killExceed memory limit → OOMKilled immediately
4Never skip resourcesNo 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 — BestEffort first, then Burstable, Guaranteed last.


The Three QoS Classes

ClassRequirementsEviction Priority
Guaranteedrequests == limits for ALL containers, for both CPU and memoryLast to be evicted
BurstableAt least one container has a request or limit setMiddle
BestEffortNO resource spec on ANY containerFirst 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:

EventTriggerWho Does It
OOMKilledContainer exceeds its own memory limitLinux kernel / cgroups
EvictionNode overall memory pressurekubelet (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

#ConceptOne-liner
1Guaranteed = requests == limitsBest protection against eviction
2Burstable = most production appsGood balance of flexibility and protection
3BestEffort = no resourcesFirst evicted under node pressure — avoid in production
4OOMKilled ≠ EvictionOOMKilled 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.cpu to be set on the pod. HPA calculates CPU utilization as (actual CPU usage / requests.cpu) × 100%. Without requests.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

#ConceptOne-liner
1HPA requires metrics-serverEnable with minikube addons enable metrics-server
2requests.cpu is mandatoryHPA measures utilization as usage/request
3Scales up fast, down slow5-min default cooldown prevents thrashing
4Custom metrics via adaptersKEDA 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

HPAVPA
ScalesNumber of replicas (out/in)Pod size — CPU/memory requests (up/down)
Best forStateless, horizontally scalable appsStateful apps, JVM apps, ML workloads
Can combine?✅ Yes (on different metrics)⚠️ Careful — VPA + HPA on CPU can conflict
Requires restart?NoYes — 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:

ModeBehavior
OffOnly generate recommendations — never apply them. Read with kubectl describe vpa.
InitialApply recommendations only when pods are first created. No eviction of running pods.
RecreateEvict and recreate pods to apply updated recommendations.
AutoCurrently same as Recreate. Future: in-place updates when K8s supports it.

💡 Tip: Start with updateMode: Off to see recommendations without disrupting running pods. Review for a few days, then switch to Initial for new pods, then Auto for 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

#ConceptOne-liner
1VPA adjusts pod size, not countRight-sizes requests/limits based on actual usage
2Start with Off modeGet recommendations without disruption
3Requires pod restart to applyVPA evicts pods to apply new request values
4Combine carefully with HPAUse 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 💰]
AutoscalerWhat It ScalesTime to React
HPAReplica count~30 seconds
VPAPod CPU/memory requestsMinutes (requires restart)
CANode count3–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 PodDisruptionBudget violations
  • Pods with local storage (hostPath volumes)
  • 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

#ConceptOne-liner
1CA scales nodes, not podsResponds to pending pods that can’t be scheduled
2Scale-up: 3–5 minutesCloud provisioning time; plan your buffers accordingly
3Scale-down: 10-minute windowNode must be underutilized for 10 minutes
4Works with HPAHPA 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

PrerequisitesChapter 11 sections 11.1–11.5 read, Minikube running, metrics-server enabled
Difficulty🟡 Intermediate
What you’ll doSet 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.cpu set 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

#SkillVerified By
1CPU throttlingcpu-stress pod capped at 200m despite 4-CPU demand
2Memory OOMKillmemory-hog killed with Exit Code 137, restarted
3QoS classesVerified Guaranteed/Burstable/BestEffort assignment
4HPA scale-upReplicas increased to 4 as CPU% climbed above 50%
5HPA scale-downReplicas reduced after 5-minute cooldown
6Scaling eventskubectl get events showed SuccessfulRescale records
7Missing requestsHPA 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

SectionTopicTime
12.1What Helm Is and Why It Exists~4 min
12.2Installing Charts — helm install and Repositories~6 min
12.3Chart Anatomy — Templates and Values~7 min
12.4Creating Your Own Chart~5 min
12.5Upgrades, 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)
  • helm CLI installed (helm version to verify)
  • minikube status shows Running

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.yml is 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
ConceptWhat It Is
ChartA package of Kubernetes YAML templates + default values
ReleaseA named installed instance of a chart in a cluster
RepositoryA collection of charts (like npm registry but for K8s apps)
ValuesVariables that customize a chart’s templates at install time

Helm vs Raw kubectl

TaskRaw kubectlHelm
Install an appkubectl apply -f dir/ (all files)helm install my-app bitnami/nginx
Upgrade to v2Edit YAML, re-apply, hope nothing breakshelm upgrade my-app bitnami/nginx --set image.tag=2.0
RollbackManually revert YAML and re-applyhelm rollback my-app 1
Deploy to dev/prodMaintain separate YAML directoriesSame chart, different values.yaml files
Remove completelykubectl delete -f dir/ (15 files)helm uninstall my-app (one command)
See what’s installedgrep/track manuallyhelm 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

#ConceptOne-liner
1Chart = packageVersioned, reusable bundle of K8s manifests
2Release = installed instanceOne chart, many releases (dev/staging/prod)
3Values = configurationCustomize behavior without editing templates
4Helm manages lifecycleInstall → 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 values and helm template before 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 --set for secrets (passwords, API keys). The value ends up in shell history and Helm release history. Use a separate secrets.yaml file 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

#ConceptOne-liner
1helm repo add + helm repo updateRegister and refresh chart repositories
2helm show valuesSee all configurable options before installing
3helm templatePreview YAML without installing
4--values file.yamlPreferred over --set for multiple overrides
5helm get manifestSee 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 a templates/ 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

#ConceptOne-liner
1Chart.yaml = metadataName, version, description, dependencies
2values.yaml = defaultsAll configuration with sensible defaults
3templates/ = Go template YAML{{ .Values.x }} injects values into K8s manifests
4_helpers.tpl = shared functionsDRY principle — define once, use everywhere
5NOTES.txt = install instructionsShown 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-chart scaffolds 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

#ConceptOne-liner
1helm create scaffoldsGenerates a complete working chart in seconds
2helm lint catches errorsRun before every install or push to CI
3helm template previewsSee the final YAML before it touches the cluster
4Multi-values filesBase + environment-specific overrides scale cleanly
5helm package distributesCreates 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 upgrade applies changes to an existing release and bumps the revision. helm rollback reverts to any previous revision instantly. helm history shows 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

#ConceptOne-liner
1helm upgrade --installIdempotent — safe to run repeatedly in CI/CD
2--atomic flagAuto-rollback if upgrade fails — use in production
3helm rollbackInstant revert to any previous revision
4Rollback = new revisionFull audit trail preserved
5helm historySee 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

Prerequisiteshelm version works, Chapter 12 sections 12.1–12.5 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doInstall 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 --install with 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

#SkillVerified By
1Install community chartBitnami nginx with custom server block running
2Custom values overridenginx returned custom response via serverBlock
3Create chart from scratchhelm create → lint passed → template valid
4Multi-environment deployDev (1 replica, NodePort) and Prod (2 replicas, ClusterIP)
5Upgrade with new valuesReplica count changed via helm upgrade
6Failed upgrade + rollbackBad image tag → upgrade failed → rollback to rev 2
7Release inspectionhelm get values/manifest/history showed release state
8YAML error safetyBroken 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 logs and 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

SectionTopicTime
13.1The Three Pillars of Observability~5 min
13.2Logging — kubectl logs, Stern, and Aggregation~7 min
13.3Metrics — Prometheus and metrics-server~8 min
13.4Dashboards with Grafana~6 min
13.5🔬 Lab: Full Observability Stack on Minikube~30 min

Prerequisites

  • Completed Chapters 1–12 (all core concepts + Helm)
  • helm CLI installed (helm version to verify)
  • minikube status shows Running
  • 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
PillarData TypeQuestion It AnswersKey Tools
LogsUnstructured / JSON textWhat happened at a specific time?kubectl logs, Fluentd, Loki, EFK stack
MetricsNumbers over time (time series)How is the system behaving right now and historically?Prometheus, metrics-server, Grafana
TracesLinked spans across servicesWhich service in a call chain caused the slowness?Jaeger, Zipkin, OpenTelemetry

How They Complement Each Other

The pillars work together, not in isolation:

  1. Alert fires → Metric threshold exceeded (e.g., error rate > 1%)
  2. Investigate → Look at logs for that time window to find the error message
  3. 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:

SignalWhat It MeasuresExample Metric
LatencyTime to serve requestshttp_request_duration_seconds
TrafficDemand on the systemhttp_requests_total
ErrorsRate of failed requestshttp_requests_total{status=~"5.."}
SaturationHow “full” the system isCPU/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:

SettingDefaultDescription
containerLogMaxSize10MiMax size before rotation
containerLogMaxFiles5Number of rotated files to keep

⚠️ Critical: If your pod crashes repeatedly, kubectl logs my-pod --previous only 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:

StackComponentsCharacteristics
EFKElasticsearch + Fluentd + KibanaHeavy, powerful, full-text search, complex queries
PLGPromtail + Loki + GrafanaLightweight, label-based (like Prometheus for logs), cheaper storage
ELKElasticsearch + Logstash + KibanaEFK 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

LevelWhen to UseExample
DEBUGDetailed internal state, dev only“Processing item 42 of batch”
INFONormal operations, key lifecycle events“Server started on :8080”
WARNUnexpected but handled, recoverable“Retry 2/3 for upstream service”
ERRORFailed operation, needs attention“Failed to write to database”
FATALUnrecoverable, process will exit“Cannot open config file, exiting”

Best practices:

  1. Include request IDs in every log line — essential for tracing a request through microservices
  2. Never log secrets or PII — passwords, tokens, credit card numbers must be redacted
  3. Log at boundaries — function entry/exit with inputs/outputs at DEBUG level
  4. Set log level via env varLOG_LEVEL=info in 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 /metrics endpoints 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-serverPrometheus
PurposeReal-time resource usageHistorical metrics + alerting
RetentionIn-memory only (no history)Configurable (days/weeks)
Data sourcekubelet resource summaryPod /metrics endpoints
Querykubectl topPromQL
Alerting❌ No✅ Yes (AlertManager)
Dashboards❌ No✅ Yes (Grafana)
Installminikube addons enable metrics-serverHelm 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 top shows 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

TypeDescriptionUse Case
CounterMonotonically increasing numberRequest count, error count, bytes sent
GaugeCan go up or downCurrent memory usage, active connections
HistogramDistribution of values in bucketsRequest duration, response size
SummaryPre-calculated quantilesp50/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

ComponentPurpose
PrometheusTime-series database + scraper
AlertManagerRoutes and deduplicates alerts
GrafanaDashboard UI
kube-state-metricsExposes Kubernetes object state as metrics (pod count, deployment replicas, etc.)
node-exporterExposes host-level metrics (disk, network, CPU)
Prometheus OperatorWatches 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:

DashboardWhat You See
Kubernetes / Compute Resources / Namespace (Pods)CPU + memory for all pods in a namespace
Kubernetes / Compute Resources / NodePer-node resource utilization
Kubernetes / Networking / NamespaceNetwork in/out by pod
Node Exporter / FullHost-level metrics (disk I/O, filesystem, network)
Kubernetes / Persistent VolumesPVC usage and status
Alertmanager / OverviewCurrent 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 TypeBest For
Time SeriesMetrics over time (request rate, latency trends)
StatSingle important number (current RPS, uptime)
GaugeValue with min/max (error rate %, memory %)
TableMulti-column data (pod list with CPU/mem)
Bar GaugeComparing values across instances
HeatmapLatency distributions over time
LogsLoki log streams embedded in dashboards
Alert listCurrent firing alerts

Building Your First Custom Panel

Here’s how to create a Request Rate panel for your app:

Step 1: Add a Panel

  1. Open a dashboard → click AddVisualization
  2. 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])
)

$namespace and $pod are 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:

  1. Go to Dashboard Settings → Variables → Add variable
  2. 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

PrerequisitesSections 13.1–13.4 read, helm version works, Minikube running with ≥4 GB RAM
Difficulty🟠 Intermediate–Advanced
What you’ll doDeploy 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):

  1. Click Status → Targets — confirm obs-lab/metrics-app pods appear with UP state
  2. 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
  1. 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

  1. Log in at http://localhost:3000
  2. Go to Dashboards → Browse
  3. Open “Kubernetes / Compute Resources / Namespace (Pods)”
  4. Set the namespace dropdown to obs-lab
  5. Observe CPU and memory for your metrics-app and load-app pods

Step B: Create a Custom Dashboard

  1. Click Dashboards → New → New Dashboard
  2. Click Add visualization
  3. 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}}
  1. Click Apply then AddVisualization 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
  1. 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
  1. Watch your Grafana dashboard — the Error Rate gauge should climb to ~30%
  2. In Prometheus UI (http://localhost:9090), query:
sum(rate(http_requests_total{status="500"}[1m]))
  /
sum(rate(http_requests_total[1m])) * 100
  1. 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:

  1. In Grafana → Configuration → Data Sources → Add data source
  2. Select Loki
  3. URL: http://loki:3100
  4. Click Save & Test — should show “Data source connected”

Explore logs:

  1. Go to Explore (compass icon in left sidebar)
  2. Select Loki data source
  3. 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]))
  1. Switch to Logs view to see individual log lines with timestamp

🔥 Break It! Challenge

Scale your metrics-app to 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

#SkillVerified By
1metrics-serverkubectl top pods showed live CPU/memory
2Prometheus via HelmFull kube-prometheus-stack running in monitoring
3App instrumentationmetrics-app exposed /metrics with 4 Golden Signals
4Prometheus UI + PromQLQueried request rate, latency, error rate
5Grafana dashboardsExplored pre-built K8s dashboards
6Custom panelsBuilt dashboard with 3 panels for app metrics
7Live incident simulationInjected 30% errors, watched metrics react in real time
8Alerting rulesPrometheusRule fired ObsLabHighErrorRate alert
9Loki logsInstalled 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

SectionTopicTime
14.1The Kubernetes Scheduler — How It Picks a Node~5 min
14.2Node Affinity and Node Selectors~6 min
14.3Taints and Tolerations~6 min
14.4Pod Affinity and Anti-Affinity~6 min
14.5🔬 Lab: Control Where Pods Land~30 min

Prerequisites

  • Completed Chapters 1–13
  • minikube status shows Running
  • 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-scheduler watches 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:

FilterWhat It Checks
NodeResourcesFitNode has enough free CPU and memory for the pod’s requests
NodeAffinityNode labels match requiredDuringSchedulingIgnoredDuringExecution rules
TaintTolerationPod tolerates all NoSchedule taints on the node
PodTopologySpreadPlacing here wouldn’t violate topologySpreadConstraints
VolumeBindingRequired PersistentVolumes are available in this zone
NodeUnschedulableNode 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 check kubectl 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 PluginGoal
LeastAllocatedPrefer nodes with the most free resources (spread load)
MostAllocatedPrefer fuller nodes (bin-pack, save money)
NodeAffinityBoost score for nodes matching preferredDuringScheduling rules
ImageLocalityPrefer nodes that already have the container image cached
InterPodAffinityBoost 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 MessageRoot Cause
0/3 nodes are available: 3 Insufficient memoryAll nodes have less free memory than requests.memory
0/3 nodes are available: 3 node(s) had untolerated taintNodes are tainted, pod lacks matching toleration
0/3 nodes are available: 3 node(s) didn't match node affinityRequired node label doesn’t exist on any node
0/1 nodes are available: 1 node(s) were unschedulableNode is cordoned
no persistent volumes availablePVC 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 supports In, NotIn, Exists, Gt/Lt operators, and crucially, a preferred (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:

ScenarioRequirement
GPU workloadsPod must land on a node with a GPU
Zone-aware dataDB pod should land in the same zone as its PV
Hardware tiersCPU-intensive workloads on high-cpu nodes
ComplianceRegulated workloads only on nodes in specific datacenters
Edge/IoTPod must run on arm64 nodes at the edge

The mechanism: label nodestell 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 OR logic, no NotIn, no ranges
  • No soft/“preferred” mode — if no node matches, pod is Pending forever

Node Affinity has two modes:

ModeScheduling BehaviorIf No Node Matches
requiredDuringSchedulingIgnoredDuringExecutionHard — must matchPod stays Pending
preferredDuringSchedulingIgnoredDuringExecutionSoft — try to matchFall back to any node

The IgnoredDuringExecution suffix means: if a running pod’s node later loses the label, the pod is not evicted. A future RequiredDuringExecution mode (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

OperatorMeaningExample
InLabel value is in the listdisk In [ssd, nvme]
NotInLabel value is NOT in the listtier NotIn [spot, preemptible]
ExistsLabel key exists (any value)gpu Exists
DoesNotExistLabel key is absentmaintenance DoesNotExist
GtLabel value > number (as string)cpu-count Gt 8
LtLabel 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
PartDescriptionExample
keyIdentifier (label-like)dedicated, gpu, node-role.kubernetes.io/control-plane
valueOptional qualifierml-team, nvidia, true
effectWhat happens to non-tolerating podsNoSchedule, PreferNoSchedule, NoExecute

The Three Effects

EffectSchedulingRunning Pods
NoScheduleNew pods without toleration won’t be scheduled hereExisting pods stay
PreferNoScheduleScheduler avoids this node (soft)Existing pods stay
NoExecuteNew pods blocked AND existing non-tolerating pods evictedEvicted 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

OperatorMatches When
Equalkey, value, AND effect all match exactly
Existskey matches (ignores value); effect must still match or be omitted

If you omit effect in a toleration, it matches all effects for that key. If you omit key, 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 TaintCondition
node.kubernetes.io/not-readyNode is not ready
node.kubernetes.io/unreachableNode unreachable from controller
node.kubernetes.io/memory-pressureNode has memory pressure
node.kubernetes.io/disk-pressureNode has disk pressure
node.kubernetes.io/network-unavailableNode network not configured

Taints vs Node Affinity — When to Use Each

Use CaseMechanism
“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:

topologyKeyScope of “same”
kubernetes.io/hostnameSame node
topology.kubernetes.io/zoneSame availability zone
topology.kubernetes.io/regionSame region
custom-labelAny 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 requiredDuringScheduling can make pods Pending if there aren’t enough nodes. If you have 3 replicas and 2 nodes, the third replica can never schedule. Use preferredDuringScheduling for 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
FieldValuesMeaning
maxSkewInteger ≥ 1Max allowed difference in pod count between any two topology domains
topologyKeyLabel keyGroups nodes into topology domains
whenUnsatisfiableDoNotSchedule / ScheduleAnywayHard or soft constraint

Comparison:

Pod Anti-AffinityTopologySpreadConstraints
GoalAvoid specific podsEven distribution
ControlBinary (near/not near)Skew-based (quantitative)
Multi-constraintCombine multiple rulesNative multi-constraint support
Best forCo-location patternsEven 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

PrerequisitesSections 14.1–14.4 read, Minikube running
Difficulty🟡 Intermediate
What you’ll doLabel 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 nodeSelector to 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 topologySpreadConstraints for 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

#SkillVerified By
1Node labelsLabeled 3 nodes with disk, tier, gpu
2nodeSelectorssd-pod only ran on SSD-labeled nodes
3Hard node affinitypremium-app only on tier=premium nodes
4Soft node affinitysoft-pref-app clustered on GPU/premium but didn’t stay Pending
5Taintsregular-app never placed on tainted minikube-m03
6Tolerationsgpu-app tolerated the taint and was pinned to the GPU node
7Pod anti-affinity (hard)ha-app spread one-per-node; 4th replica stayed Pending
8TopologySpreadConstraintsspread-app maintained maxSkew=1 across all 3 nodes
9Over-constrained diagnosisimpossible-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

SectionTopicTime
15.1Pod Security Standards and Admission~8 min
15.2Network Policies — Microsegmentation~8 min
15.3Secrets Management — Encryption and External Vaults~7 min
15.4Image Security — Scanning and Supply Chain~6 min
15.5🔬 Lab: Harden a Namespace End-to-End~30 min

Prerequisites

  • Completed Chapters 1–14
  • minikube status shows Running
  • Minikube started with the --cni=calico flag for Network Policy support: minikube start --cni=calico (or minikube 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 sudo on the node)
  • Run CAP_SYS_ADMIN and 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
LevelUse CaseWhat It Blocks
PrivilegedSystem namespaces (kube-system), CNI pluginsNothing
BaselineGeneral workloads, legacy appsprivileged, hostPID, hostNetwork, hostPath, dangerous capabilities
RestrictedProduction, regulated workloadsEverything 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 ModeEffect
enforceBlocks the pod — returns a 400 error if it violates the policy
warnAllows the pod but returns a warning in the API response
auditAllows the pod and writes a record to the audit log

Graduated rollout strategy: Start with warn only in production, fix all warnings, then switch to enforce. 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

ViolationErrorFix
Running as rootrunAsNonRoot failsAdd runAsUser: 1000 to securityContext
privileged: trueBlocked by BaselineRemove; use specific capabilities instead
hostNetwork: trueBlocked by BaselineUse a Service instead
Missing allowPrivilegeEscalation: falseBlocked by RestrictedAdd to container securityContext
Missing capabilities.drop: [ALL]Blocked by RestrictedExplicitly drop all caps
Missing seccompBlocked by RestrictedAdd 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:

ToolWhat It Does
OPA GatekeeperCustom policy-as-code using Rego; validates any Kubernetes object
KyvernoPolicy engine with YAML-native rules; can also generate/mutate resources
FalcoRuntime security — detects anomalous behavior (shell in container, file writes)

Recommended path: Start with PSS enforce=baseline everywhere, enforce=restricted for 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

SelectorMatchesExample
podSelectorPods by label (within same namespace)app: backend
namespaceSelectorAll pods in namespaces matching labelenv: production
ipBlockExternal CIDR ranges10.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

ApproachRotation TriggerDowntime?
Manual updateHuman runs `kubectl create secret –dry-runkubectl apply`
External Secrets OperatorrefreshInterval + app reads file on each requestZero (file-mounted secrets update in-place within ~1 min)
Vault Agent SidecarVault lease expiryZero (sidecar refreshes the file; app rereads)
CSI Secret Store DriverMounted via CSI; updates when pod restartsRestart 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

CheckToolWhat to Enforce
No CRITICAL CVEsTrivy in CItrivy image --exit-code 1 --severity CRITICAL
Minimal base imageDockerfile reviewUse distroless or -slim variants
Non-root userDockerfile / PSSUSER 1001 in Dockerfile; runAsNonRoot: true in Pod spec
No latest tagKyverno policyRequire image: name:SHA256@... or specific semver
Signed imagesCosign + KyvernoVerify signature before admission
Allowed registryKyverno policyAllowlist your internal registry
SBOM attachedSyft / Cosigncosign 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 published

Scanning 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

PrerequisitesSections 15.1–15.4 read, Minikube running
Difficulty🟠 Intermediate–Advanced
What you’ll doApply 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 baseline enforcement to a namespace
  • Observe pods blocked for security violations
  • Fix a pod spec to comply with restricted PSS
  • 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=restricted AFTER 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

#SkillVerified By
1PSS baseline enforcementprivileged: true pod rejected with Forbidden error
2PSS restricted complianceFixed pod spec with all required security fields
3Default-deny Network PolicyApplied 6 Network Policies to 3-tier app
4Network segmentationfrontend→database blocked; frontend→backend allowed
5Secret RBACapp-sa can get named secret only; cannot list secrets
6Trivy scanningScanned real images, compared CVE counts
7PSS rollout trapLabeling 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

SectionTopicTime
16.1GitOps Principles — Git as the Source of Truth~6 min
16.2Container Image CI Pipeline~8 min
16.3Kubernetes Manifest Validation in CI~6 min
16.4ArgoCD — Declarative CD for Kubernetes~8 min
16.5🔬 Lab: Full GitOps Pipeline~30 min

Prerequisites

  • Completed Chapters 1–15
  • minikube status shows Running
  • helm version works
  • 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 apply manually 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)

#PrincipleMeaning
1DeclarativeDesired system state expressed as declarations (YAML), not imperative scripts
2Versioned & ImmutableState stored in Git — every change has a commit, full history, rollback via git revert
3Pulled AutomaticallySoftware agents continuously observe and apply the desired state — not pushed from CI
4Continuously ReconciledIf 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.yaml should 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

ConcernTraditional Push CDGitOps Pull CD
Cluster credentials in CIYes — CI needs kubeconfigNo — agent inside cluster pulls
Audit trailCI logs (limited)Git commit history (complete)
RollbackRe-run old pipeline or kubectl rollout undogit revert → auto-applied
Drift detectionNoneContinuous (agent reconciles every N min)
Multi-clusterComplex; separate pipeline per clusterSimple; each cluster runs its own agent
Approval workflowBespoke per CI systemPull Requests — standard Git workflow
Disaster recoveryRebuild from pipelineApply config-repo to new cluster

Kustomize vs Helm in GitOps

Both work well in GitOps. Choose based on team familiarity:

KustomizeHelm
Built into kubectlYes (kubectl apply -k)No (separate binary)
TemplatingPatch-based (overlays on base YAML)Full Go templates
Config-repokustomization.yaml per environmentvalues.yaml per environment
Learning curveLowMedium
Best forSimple env-specific patchesComplex 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 TypeWhen to UseExample
sha-<short>Every main branch pushsha-a3f8c1d
v<semver>On git tags/releasesv1.3.2
main-<date>-<sha>Nightly buildsmain-20240115-a3f8c1d
pr-<number>PR builds (for preview envs)pr-142

Never use latest in a production deployment manifest. If the registry has a network hiccup during a rollout, Kubernetes might pull a cached latest that’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:

ProblemCaught byWithout CI validation, discovered when…
YAML syntax errorkubectl / kubeconformkubectl apply fails
Unknown API field typokubeconformPod silently ignores field; bug at runtime
Wrong API version (extensions/v1beta1 removed)kubeconformkubectl apply returns API not found
Missing resource limitsKyverno/OPAHPA can’t scale; node OOM
Image uses latest tagKyverno/OPANon-reproducible deployments
Missing required labelsKyverno/OPAMonitoring alerts don’t fire
Privileged containerKyverno/OPASecurity 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=server requires 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 creation

The 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 apply in 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

ConceptDescription
ApplicationAn ArgoCD CRD that maps a Git repo path to a cluster namespace
AppProjectGroups Applications; defines RBAC and source/destination restrictions
SyncThe act of applying the Git state to the cluster
Sync StatusSynced / OutOfSync — does Git match the cluster?
Health StatusHealthy / Degraded / Progressing — are cluster resources working?
Auto-SyncAutomatically apply Git changes without manual approval
Self-HealRevert 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"
HookWhen It Runs
PreSyncBefore any resources are applied
SyncDuring the sync, alongside other resources
PostSyncAfter all resources are healthy
SyncFailIf 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

PrerequisitesSections 16.1–16.4 read, Minikube running, helm installed
Difficulty🟠 Intermediate–Advanced
What you’ll doInstall 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

#SkillVerified By
1ArgoCD installAll ArgoCD pods running in argocd namespace
2Kustomize base + overlayBase YAML + dev overlay with namespace prefix
3kubeconform validation2 resources validated against K8s schema
4ArgoCD Applicationweb-app app created, Synced + Healthy in UI
5GitOps deployImage tag updated in Git → ArgoCD rolled out new pods
6Self-healingManual kubectl scale reverted by ArgoCD
7Rollbackargocd app rollback restored previous image
8Diff detectionargocd app diff showed new ConfigMap before sync
9Cascade deleteUnderstood 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.