Module 5 — Containers with Podman, and a Taste of Kubernetes
Goal: understand images vs containers, build and run them with Podman, wire multi-container apps together, and get an honest conceptual (plus small practical) introduction to Kubernetes.
Podman is used throughout: daemonless, rootless-friendly, Docker-CLI-compatible
(alias docker=podman genuinely works), and it's what runs your CI runner in
module 6.
5.1 What a container actually is
Not a VM. A container is a normal Linux process wearing blinkers:
- namespaces — its own view of PIDs, network, filesystem mounts, hostname;
- cgroups — CPU/memory limits;
- an image — a layered, read-only filesystem snapshot it starts from.
| VM | Container | |
|---|---|---|
| Boots | full OS kernel | just a process |
| Startup | minutes | milliseconds |
| Size | GBs | MBs |
| Isolation | hardware-level | kernel-level |
| Analogy | separate exchange building | line cards in one chassis |
Why the industry moved: "works on my machine" dies — the image ships the app and its entire runtime environment, identical from laptop to production.
Lab 5.1 — First containers
podman --version
podman run hello-world? true # (registry note below)
podman run docker.io/library/alpine echo "hello from a container"
podman run -it docker.io/library/alpine sh # inside: ps aux; ls /; exit
podman ps -a # dead containers remain
podman run -d --name web -p 8080:80 docker.io/library/nginx
curl localhost:8080
podman logs web
podman exec -it web sh # shell inside a RUNNING container
podman stop web && podman rm web
podman images
Prove the "just a process" claim: while nginx runs, on the host:
ps aux | grep nginx — there it is, in the host's process table.
5.2 Building images
~/labs/module5/app/server.py — a tiny Python web app:
from http.server import HTTPServer, BaseHTTPRequestHandler
import os, socket
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = f"Hello from {socket.gethostname()} v{os.getenv('APP_VERSION','1')}\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain"); self.end_headers()
self.wfile.write(body)
HTTPServer(("", 8000), H).serve_forever()
Containerfile (same syntax as a Dockerfile):
FROM docker.io/library/python:3.12-alpine
WORKDIR /app
COPY server.py .
ENV APP_VERSION=1
EXPOSE 8000
CMD ["python", "server.py"]
podman build -t fieldapp:1 .
podman run -d --name fieldapp -p 8000:8000 fieldapp:1
curl localhost:8000
podman image tree fieldapp:1 # see the layers
Layer caching lesson: change APP_VERSION to 2, rebuild, and watch which layers
rebuild vs cache. Order your Containerfile from least- to most-frequently-changed.
Tag and understand registries:
podman tag fieldapp:1 ghcr.io/<user>/fieldapp:1 # push comes in module 6
5.3 Networks, volumes, compose
Containers are ephemeral — anything not in a volume dies with the container. Container networks give containers DNS names for each other (service discovery in one line).
Lab 5.3 — Two containers, wired properly
podman network create appnet
podman volume create pgdata
podman run -d --name db --network appnet -v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=labpass docker.io/library/postgres:16-alpine
podman run -it --rm --network appnet docker.io/library/postgres:16-alpine \
psql -h db -U postgres # note: reaches it by NAME "db"
Kill and recreate the db container — the data survives (the volume). This is tier-2/tier-3 separation from module 2, in miniature.
Then the declarative version with podman-compose — compose.yaml:
services:
app:
build: .
ports: ["8000:8000"]
environment: { APP_VERSION: "3" }
depends_on: [db]
db:
image: docker.io/library/postgres:16-alpine
environment: { POSTGRES_PASSWORD: labpass }
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
podman-compose up -d && podman-compose ps && podman-compose down
Spot the pattern: compose is to podman run what Terraform is to the console.
Declarative wins again.
5.4 Kubernetes — the honest introduction
The problem k8s solves: you have many containers on many machines and need scheduling, self-healing, service discovery, rolling deploys and scaling — a container exchange management system.
Core objects (concepts only, one diagram):
- Pod — smallest unit; one or more containers sharing network/storage.
- Deployment — "I want 3 replicas of this pod, always" + rolling updates.
- Service — stable virtual IP/DNS in front of ever-changing pods.
- Ingress — layer-7 routing from outside (the ALB analogue).
- ConfigMap/Secret — config injected into pods.
- Control plane (desired state reconciler) vs worker nodes (kubelet). The reconciliation loop is Terraform's plan/apply running continuously.
Lab 5.4 — Tiny but real: k3s
On lab-box (k3s = lightweight single-node k8s, fine on a t3.medium):
curl -sfL https://get.k3s.io | sh -
sudo k3s kubectl get nodes
sudo k3s kubectl create deployment web --image=nginx --replicas=3
sudo k3s kubectl get pods -o wide
sudo k3s kubectl delete pod <one-of-them> # watch it resurrect
sudo k3s kubectl expose deployment web --port=80 --type=NodePort
sudo k3s kubectl get svc web # note the NodePort
curl localhost:<nodeport>
sudo k3s kubectl scale deployment web --replicas=5
sudo k3s kubectl get pods -w # watch scaling live; Ctrl-C
The self-healing demo (delete a pod, it comes back) is the "aha" moment — nothing in modules 1–5 did that.
Honest framing for the student: most small/medium workloads don't need k8s; managed services (ECS, Cloud Run, plain EC2+ASG) are often the right answer. Know the concepts, recognise when the complexity pays for itself (many services, many teams, high deploy frequency).
Clean up when done (k3s is memory-hungry): sudo /usr/local/bin/k3s-uninstall.sh.