Week 11: Kubernetes Fundamentals

You've spent the last two weeks writing Terraform to declare AWS infrastructure and letting a control loop reconcile reality to match it — Kubernetes applies that exact same idea one layer up, inside a cluster of machines, to keep your containers running. The kubeconfig and service account tokens you'll use here follow the same keypair-trust model as the SSH keys from Week 1 and the AWS access keys from Week 8. This week gets you from zero to a running app on a local cluster: Pods, Deployments and Services, the control plane/worker node split, declarative YAML with kubectl, and ConfigMaps and Secrets — the foundation Week 12 builds on for Ingress, autoscaling and Helm.

Module 8 of 22 Week 11 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what Pods, Deployments and Services are for, and how labels wire them together
  • Describe the control plane vs. worker node split and write declarative YAML manifests
  • Configure ConfigMaps and Secrets, and run a real app on a local kind cluster

1. Pods, Deployments & Services

A Pod is the smallest deployable unit in Kubernetes — not a container. A Pod wraps one or more containers that share a network namespace (one IP address, one localhost) and can share storage volumes. In practice you'll run one container per Pod almost always, with a second "sidecar" container only for things tightly coupled to the main one, like a log shipper.

pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: web-single
  labels:
    app: web
spec:
  containers:
    - name: web
      image: ghcr.io/acme/web-app:1.4.0
      ports:
        - containerPort: 8080

You'll almost never create a bare Pod, though — Pods are ephemeral and Kubernetes won't reschedule one that dies on its own. A Deployment wraps Pods in a desired-state description: how many replicas, which container image, how to roll out updates. Under the hood a Deployment manages a ReplicaSet, which manages the Pods themselves — you'll interact with the Deployment and rarely touch the ReplicaSet directly.

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: ghcr.io/acme/web-app:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"

Pods get new IP addresses every time they're rescheduled, so nothing should ever talk to a Pod IP directly. A Service gives a stable virtual IP and DNS name that load-balances across every Pod matching its label selector — Pods come and go, the Service address doesn't move.

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: web-app
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080

The selector: app: web here is the entire mechanism — the Service doesn't know about the Deployment at all, it just watches for any Pod whose labels match and routes traffic to it. This is how you'll swap a Deployment's Pods out from under a Service with zero downtime during a rolling update in Week 12.

Labels are the whole game

Kubernetes has almost no concept of "this Deployment owns that Service." Everything is wired together loosely through label selectors. When something isn't receiving traffic, the first thing to check is always whether the Service's selector actually matches the Pod's labels — a typo here is one of the most common real-world Kubernetes outages.

2. Control Plane vs. Worker Nodes

A Kubernetes cluster is a set of machines split into two roles. The control plane makes decisions; worker nodes run your containers. In a managed service like EKS or GKE you never see the control plane machines directly, but understanding what runs there explains almost every piece of kubectl behavior.

control plane components
kube-apiserver     # the front door -- every kubectl command talks to this over HTTPS
etcd               # distributed key-value store holding all cluster state
kube-scheduler     # decides which worker node a new Pod should run on
kube-controller-manager  # runs the reconciliation loops (Deployment, ReplicaSet, ...)
worker node components
kubelet            # agent that talks to the API server and runs Pods on this node
kube-proxy         # programs network rules so Services route traffic correctly
container runtime  # containerd (or CRI-O) -- actually pulls images and runs containers

The reconciliation model is the same idea as Terraform's plan/ apply loop, just running continuously instead of on demand. You declare "3 replicas of web-app" in a Deployment; the controller manager compares that desired state against etcd's record of actual state every few seconds, and if a Pod has died it creates a replacement without you doing anything. That's the mechanism behind Kubernetes "self-healing" — there's no magic, just a loop that never stops running.

Desired state, not commands

You never tell Kubernetes "start a Pod" the way you'd tell Docker "run a container." You tell it what the end state should look like, and every controller's entire job is closing the gap between that and reality. This mental model is why kubectl delete pod on a Pod owned by a Deployment doesn't actually remove it for long — a new one appears within seconds, because the desired replica count hasn't changed.

3. kubectl & Declarative YAML

kubectl is your one interface to the API server, and it reads its connection details — cluster address, credentials, default namespace — from a kubeconfig file, usually ~/.kube/config. You'll switch between clusters (a local kind cluster, a staging EKS cluster) by switching contexts.

terminal
kubectl config get-contexts        # list every cluster/user combo kubectl knows about
kubectl config use-context kind-devops   # switch the active context
kubectl config set-context --current --namespace=web-app  # default namespace for this context

Almost everything you'll do falls into one of two styles: imperative commands that mutate the cluster directly, and declarative manifests applied with kubectl apply. This course uses declarative YAML almost exclusively, for the same reason it uses Terraform instead of clicking through the AWS console: the file is the source of truth, it's reviewable in a pull request, and it's the exact same mechanism CI will use to deploy in later weeks.

terminal
kubectl apply -f deployment.yaml -f service.yaml
kubectl get deployments
kubectl get pods -o wide
kubectl describe pod web-app-7d9f6c5b8-x2k4p   # events, restarts, resource usage
kubectl logs web-app-7d9f6c5b8-x2k4p            # container stdout/stderr
kubectl logs -f web-app-7d9f6c5b8-x2k4p         # follow, like tail -f
kubectl exec -it web-app-7d9f6c5b8-x2k4p -- sh  # shell into a running container
kubectl delete -f deployment.yaml -f service.yaml

Pod names get a random suffix appended by the ReplicaSet, so you'll rarely type them from memory — copy them from kubectl get pods, or use kubectl logs deployment/web-app to target the Deployment directly and let Kubernetes pick a Pod for you.

Generate YAML instead of memorizing it

kubectl create deployment web-app --image=ghcr.io/acme/web-app:1.4.0 --dry-run=client -o yaml prints a valid Deployment manifest without touching the cluster. Real Kubernetes engineers scaffold manifests this way constantly and edit the result — nobody writes every field from memory.

4. ConfigMaps & Secrets

Baking configuration into a container image means rebuilding the image every time a setting changes — the exact problem environment variables solved for docker run back in Week 3. Kubernetes has two objects for this: ConfigMaps for ordinary configuration, and Secrets for anything sensitive.

configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: web-app-config
data:
  LOG_LEVEL: "info"
  FEATURE_NEW_CHECKOUT: "true"
  API_TIMEOUT_MS: "3000"
terminal — create a Secret from literals
kubectl create secret generic web-app-db \
  --from-literal=DB_USER=app_svc \
  --from-literal=DB_PASSWORD='S7!kq2vLpR' \
  --dry-run=client -o yaml > db-secret.yaml

Both get consumed the same way — as environment variables or as mounted files — which is why the split exists at all: it's purely about who's allowed to read the value and how it's stored, not how the app accesses it.

deployment.yaml — consuming both
spec:
  containers:
    - name: web
      image: ghcr.io/acme/web-app:1.4.0
      envFrom:
        - configMapRef:
            name: web-app-config
      env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: web-app-db
              key: DB_PASSWORD

Critically: a Kubernetes Secret is base64-encoded, not encrypted, by default. kubectl get secret web-app-db -o jsonpath='{.data.DB_PASSWORD}' | base64 -d reveals the plaintext to anyone with read access to that namespace. That's enough to keep credentials out of your YAML in Git, but it is not a substitute for a real secrets manager — Week 14 covers encrypting Secrets at rest and integrating an external vault.

Never commit a raw Secret manifest

Treat a Secret YAML file exactly like a plaintext password file, because that's effectively what it is. Generate it with kubectl create secret ... --dry-run=client -o yaml into a file that's in .gitignore, or better, use a tool like Sealed Secrets or an external secrets operator so only an encrypted version ever reaches your repository.

5. Running a Local Cluster with kind or minikube

You don't need a cloud account to learn Kubernetes. kind ("Kubernetes in Docker") runs an entire cluster as Docker containers on your laptop, and is fast enough to spin up and tear down as part of a CI job — which is exactly how you'll use it starting in Week 12.

terminal — install & create a cluster
# macOS
brew install kind kubectl

# Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind

kind create cluster --name devops
kubectl cluster-info --context kind-devops

For a multi-node cluster that more closely mirrors production, give kind a config file instead of relying on the single-node default:

kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
terminal
kind create cluster --name devops --config kind-config.yaml
kubectl get nodes
# NAME                     STATUS   ROLES           AGE   VERSION
# devops-control-plane     Ready    control-plane   45s   v1.30.0
# devops-worker            Ready    <none>          30s   v1.30.0
# devops-worker2           Ready    <none>          30s   v1.30.0

minikube is the older, single-node-by-default alternative and is still common in tutorials — minikube start gets you a similar result, with a built-in dashboard (minikube dashboard) that's handy while you're still building a mental model of the objects involved. Either tool is fine for this course; examples assume kind because it starts faster and matches what you'll use in a GitHub Actions job later.

Loading local images into kind

A kind cluster can't see images sitting in your local Docker daemon by default — docker build -t web-app:dev . followed by kind load docker-image web-app:dev --name devops copies it into the cluster's nodes. Forgetting this step is the single most common reason a fresh kind deployment sits in ErrImageNeverPull.

6. Hands-on Exercise

Hands-on

Deploy a configurable app to a local kind cluster

Put a Deployment, Service, and ConfigMap together into a real running app you can scale and inspect.

Requirements:

  1. Install kind and kubectl, then create a two-worker cluster named devops using a kind-config.yaml like the one above.
  2. Write a ConfigMap named web-app-config with at least two keys, and a Deployment for a small container image (e.g. nginxdemos/hello) that consumes it via envFrom.
  3. Write a ClusterIP Service in front of the Deployment, matching its Pod labels with a selector.
  4. Apply all three manifests with a single kubectl apply -f command, then confirm three Pods are Running with kubectl get pods -o wide.
  5. Scale the Deployment to 5 replicas with kubectl scale deployment web-app --replicas=5 and confirm the Service still balances across all of them using kubectl port-forward svc/web-app 8080:80 plus a few curl localhost:8080 calls.
  6. Delete one Pod by name with kubectl delete pod <name> and confirm a replacement appears within seconds — capture the before/after output of kubectl get pods.
Hint

If a Pod stays in Pending, run kubectl describe pod <name> and read the Events section at the bottom first — it's almost always a scheduling reason (insufficient resources, an image pull failure) spelled out in plain English, and it's the fastest debugging tool you have in Kubernetes.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a Service sit in front of Pods instead of clients addressing Pod IPs directly?

Pod IPs are not stable — a Pod that crashes or is rescheduled gets a brand-new IP address, and a Deployment can add or remove Pods at any time during scaling or a rollout. A Service provides one fixed virtual IP and DNS name, and load-balances across whichever Pods currently match its label selector, so clients never need to track individual Pod addresses.

Q2

You run kubectl delete pod on a Pod owned by a Deployment with replicas: 3. What happens, and why?

A new Pod appears within seconds. The Deployment's desired state still says 3 replicas; deleting a Pod only changes actual state, and the controller manager's reconciliation loop notices the mismatch and asks the scheduler to place a replacement. To actually reduce the count you have to change the desired state itself, e.g. kubectl scale --replicas=2 or editing the manifest.

Q3

Why is a Kubernetes Secret not sufficient on its own to keep credentials secure?

By default a Secret's values are only base64-encoded, which is trivially reversible and not encryption — anyone with API read access to that namespace, or access to an unencrypted etcd backup, can recover the plaintext. It keeps secrets out of plain YAML committed to Git, but production clusters need encryption at rest for etcd and, usually, an external secrets manager as the actual source of truth.

Q4

You deploy an image to a fresh kind cluster and every Pod sits in ErrImageNeverPull. What's the most likely cause?

The image was built locally with docker build but never loaded into the kind cluster's nodes, and the manifest's image pull policy prevents it from trying to fetch the tag from a remote registry. Running kind load docker-image <tag> --name <cluster> copies the image into every node so the kubelet can find it without a registry pull.