Session 01 demos – Containers & Docker: the mental model
Four short demos woven into Session 01. Demos 1–2 belong to Block A (after the “what is a container” slides), Demo 3 opens Block B, Demo 4 closes it and hands over to Lab 01.
Before the session
~5 minutes, with network:
docker pull hello-world
docker pull alpine:3.22
docker pull nginx:1.28-alpine
docker pull postgres:17-alpine
- Terminal: two panes side by side (left = “inside the container”, right = “on the
host”). Font ≥ 16 pt, prompt shortened (
PS1='$ ') so the container ID is not lost in a long path. - Make sure nothing is listening on 8080 (
ss -ltnp | grep 8080). sudomust work without a password prompt on the instructor machine (Demo 2 reads/proc/<pid>/ns). If it does not, run that step as root in a pre-opened root shell.- Start from a clean slate:
docker ps -ashould be empty (docker rm -f $(docker ps -aq)). - Nothing from
src/is needed yet — Session 01 only uses public images.
Demo 1: your first containers and the lifecycle (≈ 7 min)
What to show / say
A container is a process that starts, runs, exits and is then still there until you
remove it — run is not start, and stop is not rm.
Steps
-
The smallest possible container. It prints and exits — and it is still there.
docker run hello-world docker ps docker ps -aCONTAINER ID IMAGE COMMAND CREATED STATUS NAMES 3f9c1c2a77b1 hello-world "/hello" 4 seconds ago Exited (0) 3 seconds ago nifty_mendel👉 point at
docker psbeing empty whiledocker ps -ashows the container in stateExited (0). “The process ended. The container object did not.” -
An interactive container — this is the “aha” moment of the session.
docker run -it --name box alpine:3.22 shInside, in the left pane:
hostname ps aux ls / cat /etc/os-release | head -2 exit/ # hostname 7a1f0c9e42b8 / # ps aux PID USER TIME COMMAND 1 root 0:00 sh 7 root 0:00 ps aux👉 point at PID 1 is
shand the process list has two entries. Then runhostnameandps aux | wc -lin the right pane (the host) and compare: same kernel, same machine, completely different view. “Nothing was virtualised. The process just cannot see the rest.” -
A long-running, detached, published container.
docker run -d --name web -p 8080:80 nginx:1.28-alpine curl -s localhost:8080 | head -4 docker logs web👉 point at the log line produced by your
curl— “the container’s stdout is the log; nobody configured a log file”. -
Get a shell inside the running container, then walk the lifecycle.
docker exec -it web sh -c 'hostname; ls /usr/share/nginx/html' docker stop web && docker ps -a --filter name=web docker start web && curl -s -o /dev/null -w '%{http_code}\n' localhost:8080 docker rm -f web👉 after
stop, point atExited (0); afterstart, the same container is back (same ID, same name) —runwould have created a new one.rmis what actually deletes it.
Demo 2: what is a container, really? (≈ 8 min)
What to show / say
Namespaces + cgroups + a layered filesystem. No hypervisor, no guest kernel — and none of it is Docker-specific.
Steps
-
Look at the isolation from the inside:
docker run --rm -it alpine:3.22 shps aux cat /proc/self/cgroup ls /proc/1/ns exit/ # cat /proc/self/cgroup 0::/ / # ls /proc/1/ns cgroup ipc mnt net pid time user uts👉
0::/— “the container thinks it is at the root of the cgroup tree. It is not.” -
Namespaces without Docker — this kills the “Docker is magic” idea:
unshare --user --pid --fork --mount-proc ps auxPID USER TIME COMMAND 1 root 0:00 ps aux👉 “One kernel call, no Docker, no image — and I am PID 1. Docker’s job is to make this repeatable and to give the process a filesystem.”
-
Now the same container seen from the host — this is the key slide made real:
docker run -d --name web -p 8080:80 nginx:1.28-alpine pid=$(docker inspect --format '' web); echo "$pid" ps -o pid,ppid,cmd -p "$pid" sudo ls -l /proc/$pid/ns/ | awk '{print $9, $10, $11}' sudo ls -l /proc/1/ns/ | awk '{print $9, $10, $11}' cat /proc/$pid/cgroupnginx: master process nginx -g daemon off; net -> net:[4026532546] pid -> pid:[4026532548] mnt -> mnt:[4026532544] ... 0::/system.slice/docker-9c1f….scope👉 point at the inode numbers:
net,pid,mnt,uts,ipcdiffer from PID 1’s;userandtimeare usually the same inode (no user-namespace remapping by default). Then point at the host-side cgroup path — “that is where--memoryand--cpuswill write limits in Session 03.” -
The filesystem — union/overlay mounts:
docker inspect --format '' web | python3 -m json.tool{ "Data": { "LowerDir": "/var/lib/docker/overlay2/…/diff:/var/lib/docker/overlay2/…/diff", "MergedDir": "/var/lib/docker/overlay2/…/merged", "UpperDir": "/var/lib/docker/overlay2/…/diff", "WorkDir": "/var/lib/docker/overlay2/…/work" }, "Name": "overlay2" }👉
LowerDir= the read-only image layers,UpperDir= the container’s writable layer,MergedDir= what the process sees as/. “Delete the container and the UpperDir goes with it. Remember that for Session 03.” -
Clean up before Demo 3:
docker rm -f web
Demo 3: image names, tags and digests (≈ 5 min)
What to show / say
nginx is shorthand for docker.io/library/nginx:latest. A tag is a moving
pointer; a digest is the content itself.
Steps
-
Pull an explicitly named image and read its digest:
docker pull nginx:1.28-alpine docker image inspect --format '' nginx:1.28-alpinenginx@sha256:d5f5f6f5b0f4b0a5f6ee9b5a0f9e64f9c1b3f9f1b1b8f1a2c3d4e5f60718293a👉 read the full name aloud, piece by piece:
docker.io/library/nginx:1.28-alpine@sha256:…— registry / namespace / repository / tag / digest. -
Pull by digest and show that it lands on the same image:
digest=$(docker image inspect --format '' nginx:1.28-alpine) docker pull "$digest" docker image ls --digests nginxREPOSITORY TAG DIGEST IMAGE ID SIZE nginx 1.28-alpine sha256:d5f5f6f5b0f4… a1b2c3d4e5f6 52.3MB nginx <none> sha256:d5f5f6f5b0f4… a1b2c3d4e5f6 52.3MB👉 same
IMAGE ID, same digest, one entry has no tag. “The digest is the identity. The tag is a label someone can move tomorrow.” -
The
latesttrap, in ten seconds:docker pull nginx docker image ls nginx👉 “
latestis not ‘the newest version’. It is just the tag you get when you don’t ask for one — and on many images it is months old, or points at a different variant. In this course every image is named with an explicit tag.”
Demo 4: CLI patterns you’ll use all week (≈ 6 min)
What to show / say
Six flags and three inspection commands cover 90 % of daily Docker use — and they are exactly what Lab 01 needs.
Steps
-
--rmfor throwaway commands:docker run --rm alpine:3.22 date -u docker ps -a | wc -l👉 “No leftovers. Use
--rmfor anything you run once.” -
Configuration is environment variables — including when it fails:
docker run --rm postgres:17-alpineError: Database is uninitialized and superuser password is not specified. You must specify POSTGRES_PASSWORD to a non-empty value for the superuser.👉 “The image told us its contract. Read the error, then pass
-e.” -
Now with the env vars Lab 01 uses:
docker run -d --name pinboard-db \ -e POSTGRES_USER=pinboard \ -e POSTGRES_PASSWORD=pinboard-secret \ -e POSTGRES_DB=pinboard \ postgres:17-alpine docker logs pinboard-db | tail -3 docker inspect --format '' pinboard-db | head -4database system is ready to accept connections👉 “Same container you will start in the lab in twenty minutes.”
-
Live resource usage:
docker stats --no-streamCONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O PIDS b2c3d4e5f6a7 pinboard-db 0.05% 31.2MiB / 15.6GiB 0.20% 1.1kB/0B 7👉 point at
MEM USAGE / LIMIT: the limit is the whole host — “no limit is a limit you didn’t set. Session 03 fixes that.” -
Where the disk went:
docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 5 2 412.7MB 287.1MB (69%) Containers 3 1 1.2kB 0B (0%) Local Volumes 1 1 41.2MB 0B (0%) Build Cache 0 0 0B 0B👉 “
docker system prune(andprune -a, and-f) reclaims that. Know what-adeletes before you type it on a machine you care about.”
Reset
Lab 01 expects: no containers running, the images already pulled, no volumes.
docker rm -f pinboard-db web box 2>/dev/null
docker container prune -f
docker volume prune -f
docker image ls # keep alpine:3.22, nginx:1.28-alpine, postgres:17-alpine, hello-world
Verify before handing over to the lab:
docker ps -a # empty
docker volume ls # empty