Intermediate · Kubernetes · CKAD Track

Kubernetes Intermediate ⎈

Namespaces, RBAC, autoscaling, network policies, Helm, and the full CKAD exam object model. This is where K8s gets serious.

⭐ ⭐ ⭐ ⭐ ⭐
📚 Your Progress
0%
Lesson 1 of 9

🗂️ Namespaces & RBAC INTERMEDIATE CKAD

A fresh cluster has one flat space where everything lives together. In production, teams, environments, and security boundaries demand isolation. Namespaces and RBAC are how you build that.

📋 Prerequisites: Complete the Kubernetes Beginner course first. You should be comfortable with Pods, Deployments, Services, ConfigMaps, and Secrets before continuing here.
🗂️ What Are Namespaces?

A Namespace is a virtual partition inside a single Kubernetes cluster. Resources inside one namespace are isolated from resources in another — you can have a Service named web in namespace: dev and a completely separate Service also named web in namespace: production, with no conflicts.

Namespaces are how teams share a cluster without stepping on each other. Common patterns:

  • By environment: dev, staging, production — same cluster, isolated environments
  • By team: team-payments, team-auth, team-notifications
  • By application: app-frontend, app-backend, monitoring

K8s ships with four built-in namespaces: default (where resources go if you don't specify), kube-system (K8s own components), kube-public (publicly readable resources), and kube-node-lease (node heartbeat objects).

Terminal — Working With Namespaces
# Create namespaces
kubectl create namespace dev
kubectl create namespace production

# Or declaratively:
# apiVersion: v1
# kind: Namespace
# metadata:
#   name: dev

# Deploy into a specific namespace with -n flag
kubectl apply -f deployment.yaml -n production

# List pods in a specific namespace
kubectl get pods -n production

# List pods across ALL namespaces
kubectl get pods --all-namespaces
kubectl get pods -A            # shorthand

# Set a default namespace for your session (so you stop typing -n every time)
kubectl config set-context --current --namespace=production

# Check which namespace is currently active
kubectl config view --minify | grep namespace

# Cross-namespace DNS: reach a Service in another namespace
# Format: <service-name>.<namespace>.svc.cluster.local
# Example: curl http://api-service.production.svc.cluster.local
🔐 RBAC — Role-Based Access Control

By default, anyone with cluster access can do anything. RBAC lets you define precisely who (users, service accounts) can do what (verbs: get, list, create, delete) on which resources (pods, deployments, secrets) in which namespaces.

RBAC has four core objects:

  • Role — A set of permissions scoped to a single namespace. "In namespace dev, allow listing Pods."
  • ClusterRole — Same as Role, but applies cluster-wide across all namespaces. Used for cluster-level resources (Nodes, PersistentVolumes) that have no namespace.
  • RoleBinding — Grants a Role to a user or ServiceAccount within a namespace.
  • ClusterRoleBinding — Grants a ClusterRole to a user or ServiceAccount cluster-wide.
RBAC Object Model Role (namespace-scoped) ClusterRole (cluster-scoped) └─ rules: └─ rules: verbs: [get, list, watch] verbs: [get, list] resources: [pods, services] resources: [nodes, persistentvolumes] apiGroups: [""] bound to a subject via RoleBinding (namespace-scoped) ClusterRoleBinding (cluster-scoped) └─ roleRef: → Role or ClusterRole └─ roleRef: → ClusterRole └─ subjects: └─ subjects: - kind: User - kind: ServiceAccount - kind: ServiceAccount - kind: Group - kind: Group
rbac.yaml — Role + RoleBinding Example
# A Role that allows READ-ONLY access to Pods and logs in the "dev" namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: dev
rules:
  - apiGroups: [""]             # "" = core API group (pods, services, configmaps)
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]

---
# Bind the Role to a ServiceAccount named "ci-runner" in the same namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-pods-binding
  namespace: dev
subjects:
  - kind: ServiceAccount
    name: ci-runner
    namespace: dev
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io
🤖 ServiceAccounts — Identity for Pods

Every Pod that needs to call the Kubernetes API (e.g., a CI tool, a custom controller, a monitoring agent) needs a ServiceAccount. A ServiceAccount is K8s's identity system for processes — the equivalent of a user account for a Pod.

Every namespace has a default ServiceAccount, but best practice is to create dedicated ServiceAccounts with the minimum permissions needed for each workload. The ServiceAccount's token is automatically mounted into the Pod at /var/run/secrets/kubernetes.io/serviceaccount/token.

Terminal — RBAC Verification
# Check what the current user is allowed to do
kubectl auth can-i create pods
kubectl auth can-i delete deployments -n production

# Check what a specific ServiceAccount can do (impersonate it)
kubectl auth can-i list pods \
  --as=system:serviceaccount:dev:ci-runner

# List all Role and ClusterRole bindings
kubectl get rolebindings -A
kubectl get clusterrolebindings

# Create a ServiceAccount
kubectl create serviceaccount ci-runner -n dev

# Quick RBAC: give a ServiceAccount edit access to a namespace
kubectl create rolebinding ci-editor \
  --clusterrole=edit \
  --serviceaccount=dev:ci-runner \
  -n dev

# Built-in ClusterRoles you can reuse:
# view   — read-only access to most resources
# edit   — read-write, no RBAC or secrets changes
# admin  — full namespace control
# cluster-admin — superuser, full cluster control
⎈ CKAD Exam Focus

The exam often gives you a scenario: "Create a ServiceAccount named deployer in namespace apps. Give it permission to create and delete Deployments." You'll need to create the ServiceAccount, write a Role with the right verbs and resources, and bind them with a RoleBinding — all from memory in the terminal. Practice until this is automatic.

🧠 Check Your Understanding
You want to give a ServiceAccount read-only access to Pods in just the "staging" namespace. Which combination of objects do you need?
🧠 Check Your Understanding
What does kubectl auth can-i delete pods --as=system:serviceaccount:dev:ci-runner do?
Lesson 2 of 9

📈 Horizontal Pod Autoscaler INTERMEDIATE CKAD

Manually running kubectl scale is not a production strategy. The Horizontal Pod Autoscaler watches real metrics and scales your Deployment automatically — up when load increases, down when it drops.

⚡ How the HPA Works

The Horizontal Pod Autoscaler (HPA) is a control loop that runs every 15 seconds. It queries the Metrics Server for current resource utilization, compares it against your target, and adjusts the replica count on the target Deployment (or StatefulSet) accordingly.

The formula K8s uses to calculate the desired replica count:

HPA Scaling Formula
# desiredReplicas = ceil( currentReplicas × (currentMetricValue / desiredMetricValue) )

# Example: target 50% CPU, currently 3 replicas running at 80% average CPU
# desiredReplicas = ceil( 3 × (80 / 50) ) = ceil(4.8) = 5 replicas

# Another example: 5 replicas running at 20% CPU, target 50%
# desiredReplicas = ceil( 5 × (20 / 50) ) = ceil(2.0) = 2 replicas → scale DOWN

The HPA respects the minReplicas and maxReplicas bounds you set — it will never scale below the minimum or above the maximum.

⚠️
Metrics Server is Required
The HPA depends on the Metrics Server being installed in your cluster. It collects CPU and memory data from the kubelet on each node. On minikube: minikube addons enable metrics-server. On cloud clusters (EKS, GKE, AKS) it's usually pre-installed. Without it, kubectl top pods won't work and HPAs will show <unknown> for current metrics.
hpa.yaml — CPU and Memory Autoscaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-deployment     # The Deployment to autoscale
  minReplicas: 2             # Never go below 2 (for availability)
  maxReplicas: 20            # Never exceed 20 (cost ceiling)
  metrics:
    # Scale when average CPU across all pods exceeds 60%
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    # Also scale when average memory exceeds 512Mi per pod
    - type: Resource
      resource:
        name: memory
        target:
          type: AverageValue
          averageValue: 512Mi
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # Wait 5min before scaling down (prevents flapping)
      policies:
        - type: Pods
          value: 2                         # Remove at most 2 pods per minute
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0    # Scale up immediately — don't wait
      policies:
        - type: Percent
          value: 100                       # Can double replica count per 15s
          periodSeconds: 15
Terminal — HPA Commands
# Create an HPA imperatively (quick way — good for the CKAD exam)
kubectl autoscale deployment web-deployment \
  --min=2 --max=20 --cpu-percent=60

# Watch the HPA in real time (see current vs desired replicas)
kubectl get hpa -w

# Output columns:
# NAME      REFERENCE            TARGETS   MINPODS MAXPODS REPLICAS
# web-hpa   Deployment/web       45%/60%   2       20      3

# See resource usage per pod (requires Metrics Server)
kubectl top pods -n production
kubectl top nodes

# Generate load to test autoscaling (run in a separate terminal)
kubectl run load-generator \
  --image=busybox --rm -it --restart=Never -- \
  /bin/sh -c "while true; do wget -q -O- http://web-service; done"

# Watch pods scale up in another terminal
kubectl get pods -w
📐
Resource Requests Are Required for HPA The HPA calculates CPU utilization as a percentage of the Pod's requested CPU, not the node's total. If your Deployment's containers don't have resources.requests.cpu set, the HPA has nothing to calculate a percentage against and will show <unknown>. Always set resource requests on any Deployment you intend to autoscale.
🧠 Check Your Understanding
Your HPA has minReplicas: 3, maxReplicas: 10, target CPU: 50%. Current state: 4 pods running at 90% average CPU. What does the HPA do?
Lesson 3 of 9

⚙️ Jobs & CronJobs INTERMEDIATE CKAD

Deployments keep Pods running forever. But some workloads need to run once and finish — database migrations, report generation, data exports. That's what Jobs are for. CronJobs schedule them on a timer.

⚙️ Jobs — Run to Completion

A Job creates one or more Pods and ensures they complete successfully. Unlike a Deployment (which restarts Pods if they exit), a Job considers a Pod "done" when its container exits with code 0. If it fails (non-zero exit), the Job retries it up to backoffLimit times.

Jobs are perfect for:

  • Database schema migrations before a new app version rolls out
  • One-off data processing or ETL tasks
  • Sending a batch of emails or notifications
  • Generating a report and uploading it to cloud storage
  • Seeding a database with initial data
job.yaml — Database Migration Job
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration-v2
spec:
  completions: 1          # How many pods must succeed (default 1)
  parallelism: 1          # How many pods to run simultaneously
  backoffLimit: 4         # Retry failed pods up to 4 times before Job fails
  activeDeadlineSeconds: 300 # Kill the Job if it hasn't finished in 5 minutes
  ttlSecondsAfterFinished: 3600 # Auto-delete the Job 1 hour after it completes
  template:
    spec:
      restartPolicy: OnFailure  # REQUIRED for Jobs — Never or OnFailure only
      containers:
        - name: migrate
          image: myapp:v2.0
          command: ["python", "manage.py", "migrate"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: url
🕐 CronJobs — Scheduled Jobs

A CronJob creates Jobs on a schedule defined by a cron expression — the same syntax used by Unix cron. The CronJob controller creates a new Job object at each scheduled time, which in turn creates the Pod.

Cron expression format: minute hour day-of-month month day-of-week

  • 0 2 * * * — Every day at 2:00 AM
  • */15 * * * * — Every 15 minutes
  • 0 9 * * 1-5 — Weekdays at 9:00 AM
  • 0 0 1 * * — First day of every month at midnight
cronjob.yaml — Nightly Report Generator
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"      # Run every day at 2:00 AM UTC
  timeZone: "America/Chicago" # K8s 1.27+ supports timezone spec
  concurrencyPolicy: Forbid  # Don't start a new job if the previous is still running
  successfulJobsHistoryLimit: 3  # Keep last 3 successful Job objects
  failedJobsHistoryLimit: 1     # Keep last 1 failed Job for debugging
  startingDeadlineSeconds: 120  # Skip if can't start within 2min of scheduled time
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report-generator
              image: myapp:v2.0
              command: ["python", "generate_report.py", "--upload-s3"]
Terminal — Job and CronJob Commands
# Apply and watch a Job run to completion
kubectl apply -f job.yaml
kubectl get jobs -w

# See which pods the Job created
kubectl get pods --selector=job-name=db-migration-v2

# Read the logs from the completed job pod
kubectl logs job/db-migration-v2

# Manually trigger a CronJob immediately (useful for testing)
kubectl create job manual-run --from=cronjob/nightly-report

# List CronJobs and their last schedule time
kubectl get cronjobs

# Suspend a CronJob (stops future runs without deleting it)
kubectl patch cronjob nightly-report -p '{"spec":{"suspend":true}}'
⎈ CKAD Exam Focus

Jobs and CronJobs appear on the exam regularly. Know the three concurrencyPolicy values cold: Allow (default — concurrent runs allowed), Forbid (skip new run if previous still running), and Replace (kill the running job and start a new one). Also remember that restartPolicy: Always is not valid for Jobs — only OnFailure or Never.

🧠 Check Your Understanding
A Job's Pod exits with a non-zero code. The Job has backoffLimit: 3. What happens?
Lesson 4 of 9

🔥 Network Policies INTERMEDIATE CKAD

By default, every Pod in a Kubernetes cluster can talk to every other Pod — no firewall, no restrictions. In production, that's a security disaster. Network Policies are K8s's firewall rules.

🚨
The Default is Wide Open
Without any Network Policies, a compromised Pod can reach your database, your internal APIs, your secret stores — anything in the cluster. Most K8s security incidents involve lateral movement: attacker compromises one Pod, then pivots to others freely. Network Policies stop this cold.
🧠 How Network Policies Work

A NetworkPolicy selects a group of Pods (via label selectors) and defines rules for their ingress (incoming) and egress (outgoing) traffic. Once a Pod is selected by at least one NetworkPolicy, all traffic that isn't explicitly allowed is denied.

Important: Network Policies are enforced by the CNI plugin (Container Network Interface) — the networking layer of your cluster. Not all CNI plugins support Network Policies. Calico, Cilium, and Weave do. The default kubenet on many managed clusters does not — check your cluster's CNI before relying on them.

Network Policy Enforcement Model No NetworkPolicy on a Pod: All ingress allowed ✓ All egress allowed ✓ NetworkPolicy targets a Pod (ingress rule defined): Allowed ingress: only traffic matching the rules ✓ All other ingress: DENIED ✗ Egress: still fully open (no egress rule defined) ✓ NetworkPolicy targets a Pod (both ingress AND egress rules defined): Allowed ingress: only matching rules ✓ All other ingress: DENIED ✗ Allowed egress: only matching rules ✓ All other egress: DENIED ✗
networkpolicy.yaml — Restrict Database Access
# POLICY 1: Default-deny all ingress in the production namespace
# Apply this FIRST, then selectively open traffic below
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: production
spec:
  podSelector: {}   # Empty selector = select ALL pods in this namespace
  policyTypes:
    - Ingress
  # No ingress rules defined = deny all ingress

---
# POLICY 2: Only allow the web Pods to reach the database Pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-web-to-db
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: database        # This policy applies to database pods
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: web       # Only Pods labeled app=web can connect
      ports:
        - protocol: TCP
          port: 5432         # Only on the PostgreSQL port

---
# POLICY 3: Allow ingress from a specific namespace (e.g., monitoring)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-monitoring
  namespace: production
spec:
  podSelector: {}           # All pods in production
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring   # From the monitoring namespace
🔬
Testing Network Policies Network Policies are silent — a blocked connection just times out. Use a debug pod to test: kubectl run test --image=busybox --rm -it -n production -- wget -qO- --timeout=3 http://database-service:5432. If it times out, the policy is working. Also useful: the Cilium Network Policy Editor at networkpolicy.io — a visual tool for building and understanding policies.
🧠 Check Your Understanding
You apply a NetworkPolicy with an empty podSelector: {} and policyTypes: [Ingress] but no ingress rules. What is the effect on all Pods in that namespace?
Lesson 5 of 9

🐘 StatefulSets — Ordered, Stable, Persistent INTERMEDIATE CKAD

Deployments treat all their Pods as identical and interchangeable. StatefulSets give each Pod a permanent identity — a stable name, a stable network hostname, and its own dedicated persistent storage that follows it across rescheduling.

🤔 Why Databases Break With Deployments

When you run PostgreSQL in a Deployment and the Pod restarts, a few things go wrong:

  • The Pod gets a random new name (postgres-7d8f9b) — breaking any peer discovery in a cluster
  • The Pod gets a new IP — breaking connection strings that somehow relied on it
  • If you scaled to 3 replicas, all three would try to write to the same storage, corrupting the database

StatefulSets solve these problems by guaranteeing three things that Deployments don't:

  • Stable, predictable Pod names: postgres-0, postgres-1, postgres-2 — always, across restarts
  • Stable network identity: Each Pod gets its own DNS entry: postgres-0.postgres-service.namespace.svc.cluster.local
  • Dedicated per-Pod PVCs: Each Pod gets its own PersistentVolumeClaim that travels with it. postgres-0 always gets data-postgres-0, not a shared volume.
Deployment vs StatefulSet Deployment (interchangeable pods): web-7d8f9b-abc web-7d8f9b-def web-7d8f9b-ghi random names, shared or no storage, any order StatefulSet (stable identity): postgres-0 postgres-1 postgres-2 data-postgres-0 data-postgres-1 data-postgres-2 predictable names, own PVC each, ordered start/stop DNS entries (via Headless Service): postgres-0.postgres.ns.svc.cluster.local → Pod IP postgres-1.postgres.ns.svc.cluster.local → Pod IP postgres-2.postgres.ns.svc.cluster.local → Pod IP Scale down order: 2 → 1 → 0 (reverse ordinal, graceful) Scale up order: 0 → 1 → 2 (forward ordinal, each waits for previous)
statefulset.yaml — PostgreSQL StatefulSet
# A Headless Service is required — it provides the stable DNS entries
# ClusterIP: None means no load balancing — each pod gets its own DNS record
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  clusterIP: None           # Headless! No load balancing.
  selector:
    app: postgres
  ports:
    - port: 5432

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: "postgres"    # Must match the Headless Service name above
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  # volumeClaimTemplates creates a dedicated PVC per Pod automatically!
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources:
          requests:
            storage: 10Gi
  # Result: creates data-postgres-0, data-postgres-1, data-postgres-2
🧠 Check Your Understanding
A StatefulSet named "kafka" has 3 replicas. You scale it down to 1. Which pods are terminated and in what order?
Lesson 6 of 9

⚖️ Resource Quotas & LimitRanges INTERMEDIATE CKAD

Resource limits on individual Pods prevent one container from consuming too much. Quotas and LimitRanges enforce those rules at the namespace level — preventing entire teams from consuming more than their fair share of the cluster.

📊 ResourceQuota — Namespace-Level Caps

A ResourceQuota sets hard limits on the total amount of resources that can be consumed by all objects in a namespace. If a new Pod would push the namespace over its quota, the API server rejects it.

Quotas can cap: CPU requests and limits, memory requests and limits, number of Pods, number of Services, number of PVCs, total storage requested, and more.

resourcequota.yaml — Team Namespace Budget
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-payments-quota
  namespace: team-payments
spec:
  hard:
    # Compute resources (requests = guaranteed, limits = maximum)
    requests.cpu: "4"          # Total CPU requests across all pods: 4 cores
    requests.memory: 8Gi      # Total memory requests: 8 GiB
    limits.cpu: "8"            # Total CPU limits: 8 cores
    limits.memory: 16Gi

    # Object count limits
    pods: "20"                 # Max 20 pods in this namespace
    services: "10"
    persistentvolumeclaims: "5"

    # Storage limits
    requests.storage: 50Gi   # Total storage across all PVCs
Terminal — Inspecting Quota Usage
# See current quota usage vs limits for a namespace
kubectl describe resourcequota -n team-payments

# Output:
# Name:              team-payments-quota
# Namespace:         team-payments
# Resource           Used    Hard
# --------           ----    ----
# limits.cpu         3       8
# limits.memory      6Gi     16Gi
# pods               7       20
# requests.cpu       1500m   4
# requests.memory    3Gi     8Gi
# requests.storage   20Gi    50Gi
📐 LimitRange — Per-Object Defaults and Bounds

A LimitRange operates at the individual resource level within a namespace. It defines:

  • Default requests and limits — applied automatically to containers that don't specify them. This prevents the <unknown> HPA problem from the Autoscaling lesson.
  • Minimum and maximum bounds — a container can't request less than the min or more than the max. Prevents both resource starvation and runaway resource claims.
  • Max limit-to-request ratio — prevents Pods from requesting 100m CPU but claiming a 64-core limit.
limitrange.yaml — Namespace Defaults and Bounds
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: team-payments
spec:
  limits:
    - type: Container
      default:                  # Applied if container has no limits set
        cpu: 500m
        memory: 256Mi
      defaultRequest:           # Applied if container has no requests set
        cpu: 100m
        memory: 128Mi
      min:                      # Container must request at least this much
        cpu: 50m
        memory: 64Mi
      max:                      # Container cannot claim more than this
        cpu: "2"
        memory: 2Gi
      maxLimitRequestRatio:
        cpu: 4                  # Limit can be at most 4× the request
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 20Gi           # No single PVC can claim more than 20Gi
🧠 Check Your Understanding
A namespace has a ResourceQuota of pods: "5" and currently has 5 running Pods. A developer applies a new Deployment with 2 replicas. What happens?
Lesson 7 of 9

🪆 Init & Sidecar Containers INTERMEDIATE CKAD

Multi-container Pods unlock powerful patterns. Init containers run setup tasks before your app starts. Sidecar containers extend it without modifying the app image itself.

🚀 Init Containers — Guaranteed Setup Before Start

Init containers run to completion before any of the main containers in a Pod start. They run in order — init-1 must finish before init-2 starts, init-2 before the main container starts. If an init container fails, the whole Pod restarts.

This ordering guarantee solves a class of race conditions that are extremely hard to handle otherwise:

  • Database migrations — wait for the DB to be ready, run migrations, then start the app
  • Dependency checking — wait for another service to be available before starting
  • Configuration injection — fetch a TLS cert or secret from Vault, write it to a shared volume, then the main container reads it
  • File permission setup — chown a mounted volume directory before the app (running as non-root) tries to write to it
pod-with-inits.yaml — Wait for DB, Then Run Migrations
apiVersion: v1
kind: Pod
metadata:
  name: app-with-init
spec:
  initContainers:

    # Init 1: Wait until PostgreSQL is ready to accept connections
    - name: wait-for-db
      image: busybox
      command: ['sh', '-c',
        'until nc -z postgres-service 5432; do echo waiting for db; sleep 2; done']

    # Init 2: Run database migrations (only after DB is ready)
    - name: run-migrations
      image: myapp:v2.0
      command: ["python", "manage.py", "migrate"]
      env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: url

  containers:
    # Main container only starts after BOTH init containers succeed
    - name: web
      image: myapp:v2.0
      ports:
        - containerPort: 8080
🔧 Sidecar Pattern — Extend Without Modifying

A sidecar is a regular container that runs alongside the main container in the same Pod — sharing its network and storage. Sidecars add capabilities to the main app without the app needing to know they exist.

The three classic multi-container patterns:

  • Sidecar — Enhances or extends the main container. Example: a log shipper that reads logs from a shared volume and forwards them to a central aggregator. The app just writes logs to disk; the sidecar handles shipping.
  • Ambassador — Proxies network traffic for the main container. Example: a local proxy that handles service discovery, retries, and circuit breaking. The app talks to localhost:8080; the ambassador handles routing to the real service.
  • Adapter — Standardizes the main container's output. Example: a container that converts proprietary metrics format into Prometheus format. The app exposes metrics its own way; the adapter translates them.
pod-with-sidecar.yaml — Log Shipper Sidecar
apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  volumes:
    # A shared in-memory volume both containers can read/write
    - name: shared-logs
      emptyDir: {}

  containers:
    # Main container: writes application logs to the shared volume
    - name: web-app
      image: myapp:v2.0
      volumeMounts:
        - name: shared-logs
          mountPath: /var/log/app

    # Sidecar: reads from the shared volume and ships to Loki
    # The main app has NO IDEA this is happening
    - name: log-shipper
      image: grafana/promtail:latest
      args:
        - -config.file=/etc/promtail/config.yaml
      volumeMounts:
        - name: shared-logs
          mountPath: /var/log/app
          readOnly: true
      resources:
        requests:
          cpu: 50m
          memory: 32Mi
        limits:
          cpu: 100m
          memory: 64Mi
⎈ CKAD Exam Focus

Init containers appear frequently on the exam. Common tasks: "Add an init container that waits for a service to be available" or "Add an init container that copies a file to a shared emptyDir volume before the main app starts." Know that init containers have their own image, command, and resources, and that the main containers don't start until all inits succeed. Also know the difference between emptyDir (ephemeral shared volume) and a PVC.

🧠 Check Your Understanding
A Pod has two init containers and one main container. Init container 1 succeeds. Init container 2 fails. What happens?
Lesson 8 of 9

⛵ Helm — The Kubernetes Package Manager INTERMEDIATE

Deploying a real application means managing 10-20 YAML files — Deployments, Services, ConfigMaps, Secrets, Ingress, RBAC, HPAs. Helm packages all of them into a single, versioned, configurable unit called a chart.

📦 What is Helm?

Helm is to Kubernetes what apt is to Ubuntu or npm is to Node.js — a package manager. It introduces three concepts:

  • Chart — A package of pre-configured K8s resource templates. Like a Dockerfile for your entire application stack. Charts can depend on other charts.
  • Release — A specific instance of a chart deployed into a cluster. You can install the same chart multiple times with different names and configs — e.g., helm install prod myapp/myapp and helm install staging myapp/myapp --set replicas=1.
  • Repository — A collection of charts hosted somewhere. Artifact Hub (artifacthub.io) is the public registry with thousands of charts for open-source tools.
Terminal — Essential Helm Commands
# Add a chart repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Search for charts in a repo
helm search repo bitnami/postgresql

# See all configurable values for a chart
helm show values bitnami/postgresql

# Install a chart as a release named "my-db" into the "production" namespace
helm install my-db bitnami/postgresql \
  -n production \
  --set auth.postgresPassword=mysecret \
  --set primary.persistence.size=20Gi

# Install using a values file (better than --set for many values)
helm install my-db bitnami/postgresql -f values-prod.yaml

# List all deployed Helm releases
helm list -A

# Upgrade a release (like applying new config or a new chart version)
helm upgrade my-db bitnami/postgresql -f values-prod.yaml

# Roll back a release to the previous revision
helm rollback my-db 1

# See revision history for a release
helm history my-db

# Uninstall a release (removes all K8s objects it created)
helm uninstall my-db -n production

# Preview what a chart will generate without installing (dry run)
helm install my-db bitnami/postgresql --dry-run --debug
🏗️ Writing Your Own Chart

Charts have a specific directory structure. Helm generates this scaffold for you with helm create:

myapp/ ← chart root directory Chart.yaml ← chart name, version, description values.yaml ← default configurable values values-prod.yaml ← environment-specific overrides templates/ ← K8s YAML with Go template syntax deployment.yaml ← {{ .Values.replicas }}, etc. service.yaml ingress.yaml configmap.yaml hpa.yaml _helpers.tpl ← reusable template snippets NOTES.txt ← instructions shown after install charts/ ← chart dependencies (subcharts)
values.yaml + deployment template — Parameterized Config
# values.yaml — the defaults, overridable at install time
replicaCount: 3
image:
  repository: myapp
  tag: "v1.2.0"
service:
  port: 80
resources:
  limits:
    cpu: 500m
    memory: 256Mi
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10

---
# templates/deployment.yaml — uses Go template syntax to inject values
apiVersion: apps/v1
kind: Deployment
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

# Override at install: helm install prod . --set image.tag=v2.0.0 --set replicaCount=5
Install Popular Tools Instantly The fastest way to add infrastructure to your cluster is Helm. Prometheus + Grafana stack: helm install monitoring prometheus-community/kube-prometheus-stack. Cert-manager: helm install cert-manager jetstack/cert-manager. Nginx Ingress: helm install ingress-nginx ingress-nginx/ingress-nginx. What used to take hours of YAML wrangling is now one command with sane defaults and full configurability.
🧠 Check Your Understanding
You deployed your app with helm install v2 myapp/myapp and it broke in production. How do you instantly get back to the previous working version?
Lesson 9 of 9

🏆 CKAD Exam Prep — Pass on the First Try

The CKAD is a 2-hour, hands-on, terminal-only exam. No multiple choice. You get a live cluster and 15-20 real tasks to complete. Speed and muscle memory are the difference between passing and failing.

📋 What the CKAD Covers (Official Domains)
DomainWeightKey Topics
Application Design & Build20%Multi-container pods, init containers, Jobs, CronJobs, volumes
Application Deployment20%Deployments, rolling updates, Helm, blue-green strategies
Application Observability & Maintenance15%Probes, logging, debugging, API deprecations
Application Environment, Config & Security25%ConfigMaps, Secrets, SecurityContext, ServiceAccounts, RBAC, resource limits
Services & Networking20%Services, Ingress, Network Policies
⚡ The Speed Toolkit — Aliases and Shortcuts

Set these up the moment the exam starts. They save hundreds of keystrokes over 2 hours:

Terminal — Set These Immediately at Exam Start
# The single most important alias — type k instead of kubectl everywhere
alias k=kubectl

# Enable kubectl autocomplete (tab-complete resource names and types)
source <(kubectl completion bash)
complete -F __start_kubectl k

# Set your default namespace to whatever the question specifies
k config set-context --current --namespace=target-namespace

# Dry-run shortcut — generate YAML without creating anything
export DO="--dry-run=client -o yaml"
# Usage: k run mypod --image=nginx $DO > pod.yaml
# Usage: k create deploy web --image=nginx $DO > deploy.yaml

# Set your editor to vim (faster than nano for YAML editing)
export KUBE_EDITOR="vim"
🧠 Generate, Don't Memorize — The Imperative Shortcut

The fastest CKAD approach: use imperative commands to generate a YAML scaffold, redirect it to a file, edit only what you need to change, then apply. Never write YAML from scratch.

Terminal — Generate YAML Scaffolds Fast
# Generate Pod YAML
k run web --image=nginx:alpine --port=80 --labels=app=web \
  --dry-run=client -o yaml > pod.yaml

# Generate Deployment YAML
k create deploy web --image=nginx:alpine --replicas=3 \
  --dry-run=client -o yaml > deploy.yaml

# Generate Service YAML (expose a deployment)
k expose deploy web --port=80 --target-port=80 --type=ClusterIP \
  --dry-run=client -o yaml > svc.yaml

# Generate ConfigMap
k create configmap app-config --from-literal=LOG_LEVEL=info \
  --dry-run=client -o yaml > cm.yaml

# Generate Secret
k create secret generic db-secret --from-literal=password=s3cr3t \
  --dry-run=client -o yaml > secret.yaml

# Generate ServiceAccount
k create serviceaccount deployer --dry-run=client -o yaml > sa.yaml

# Generate Role + RoleBinding (can't dry-run bind, but can generate each)
k create role pod-reader \
  --verb=get,list,watch --resource=pods \
  --dry-run=client -o yaml > role.yaml

k create rolebinding read-pods \
  --role=pod-reader --serviceaccount=default:deployer \
  --dry-run=client -o yaml > rb.yaml

# Generate CronJob
k create cronjob cleanup \
  --image=busybox --schedule="0 2 * * *" \
  -- /bin/sh -c "echo cleaning up" \
  --dry-run=client -o yaml > cj.yaml

# Look up ANY field you forget — this is like man pages for K8s
k explain pod.spec.initContainers
k explain deployment.spec.strategy.rollingUpdate
k explain networkpolicy.spec.ingress
🔍 Debugging Patterns — What to Do When Things Break

A significant portion of the CKAD is debugging existing broken configurations. Work through this checklist systematically:

Terminal — The Debugging Workflow
# Step 1: Get the big picture
k get all -n target-namespace

# Step 2: What's the Pod status? (CrashLoopBackOff? Pending? OOMKilled?)
k get pods -n target-namespace

# Step 3: Describe the failing Pod — look at Events at the bottom!
# "Back-off restarting failed container" → check logs
# "Insufficient cpu/memory" → quota or node capacity issue
# "Failed to pull image" → wrong image name or tag
# "Readiness probe failed" → app not ready on that port/path
k describe pod failing-pod-name

# Step 4: Check the logs
k logs failing-pod-name
k logs failing-pod-name --previous    # logs from the crashed previous container

# Step 5: Get a shell in a running (or debug) pod
k exec -it pod-name -- sh

# Step 6: Run a temporary debug pod
k run debug --image=busybox --rm -it --restart=Never -- sh

# Step 7: Test connectivity from inside the cluster
k run curl-test --image=curlimages/curl --rm -it --restart=Never -- \
  curl -s http://web-service.default.svc.cluster.local

# Step 8: Edit the broken resource in place
k edit pod failing-pod-name
k edit deployment web
🎯 Capstone: Full CKAD Practice Scenario

Time yourself — you should finish in under 25 minutes

  1. Create namespace ckad-practice. Set it as your default context.
  2. Create a ServiceAccount named app-runner. Create a Role that allows get, list, and watch on pods and configmaps. Bind it with a RoleBinding.
  3. Create a ConfigMap named app-config with LOG_LEVEL=debug and MAX_WORKERS=4.
  4. Create a Secret named app-secret with API_KEY=abc123.
  5. Create a Deployment named web with 3 replicas using nginx:alpine. Inject the ConfigMap as env vars and the Secret as an env var. Add liveness and readiness probes on port 80, path /. Set resource requests and limits. Use the app-runner ServiceAccount.
  6. Create a ClusterIP Service exposing the Deployment on port 80.
  7. Create an HPA targeting 70% CPU, min 2, max 8 replicas.
  8. Create a LimitRange setting default CPU request to 100m and default memory limit to 256Mi.
  9. Create a NetworkPolicy that only allows ingress to the web pods from pods labeled role=frontend.
  10. Create a CronJob that runs echo "hello" every 5 minutes, with concurrencyPolicy: Forbid.
  11. Verify everything: kubectl get all,cm,secret,hpa,netpol,cronjob,limitrange,sa,role,rolebinding -n ckad-practice
⎈ Before You Book the Exam

Must-do preparation:

  • Complete all scenarios on killer.sh (included free with your CKAD registration — 2 attempts). It's harder than the real exam. If you can pass killer.sh, you'll pass the CKAD.
  • Practice on KodeKloud's CKAD course — the labs give you a real cluster in the browser. Hands-on, not videos.
  • Know your vim basics: i (insert), Esc, :wq (save), :q! (quit no save), dd (delete line), yy/p (copy/paste), :%s/old/new/g (find/replace). You will be in vim on the exam.
  • During the exam: read each question twice, note the namespace, note the context switch command at the top of each question and run it before doing anything else.
🏆 Final Mastery Quiz
On the CKAD exam, you need to create a Pod with an init container that runs echo "ready" before the main nginx container starts. You want to generate the base YAML quickly. What command do you run first?
🏆 Final Mastery Quiz
A Pod is stuck in Pending state. What is the most likely cause and where do you look first?
Roadmap