style(no-slop): remove every em-dash + banned words across all modules + capstone

Apply the no-ai-slop standard (now binding in AGENTS.md): the em-dash character is
banned outright (restructured, not blind-replaced), plus the banned word/phrase
list (delve, leverage, robust, seamless, truly, unlock, etc.). 0 em-dashes remain
in modules + capstone; the only "robust" left is the planted M10 ai-change.patch
trap. Module H1 titles use a colon separator.

All deliberate teaching devices preserved; labs compile/parse (py/sh/yaml/json);
no junk. AGENTS.md updated with the hard no-slop rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
This commit is contained in:
2026-06-22 23:21:09 -04:00
parent 513d7e7ac8
commit 389ac2e460
99 changed files with 1324 additions and 1315 deletions
@@ -1,4 +1,4 @@
# Module 16 Containers and Reproducible Environments
# Module 16: Containers and Reproducible Environments
> **"Works on my machine" is a confession, not a defense.** A container ships the machine with the
> code, so your app, your CI, and your deploy target all run the exact same environment. It also
@@ -8,12 +8,12 @@
## Prerequisites
- **Module 1** the `tasks-app` running on your machine, an editor, and a terminal.
- **Module 2** version control. A Dockerfile is committed, diffable config like any other file;
- **Module 1**: the `tasks-app` running on your machine, an editor, and a terminal.
- **Module 2**: version control. A Dockerfile is committed, diffable config like any other file;
the environment becomes something you review in a PR, not something you reconstruct from memory.
- **Module 14** Continuous Integration. CI already runs your checks on a clean machine. This
- **Module 14**: Continuous Integration. CI already runs your checks on a clean machine. This
module is what makes that clean machine *identical* to your laptop and to where you'll deploy.
- **Module 15** security scanning and dependency hygiene. Important here as a boundary: a
- **Module 15**: security scanning and dependency hygiene. Important here as a boundary: a
container faithfully reproduces your dependencies, including the vulnerable ones. Containers are
**not** a substitute for the hygiene Module 15 taught; they're downstream of it.
@@ -27,11 +27,11 @@ that same throwaway box becomes the place you let an agent run.
By the end of this module you can:
1. Explain what a container actually is image vs. container vs. registry and what
1. Explain what a container actually is (image vs. container vs. registry) and what
"reproducible" buys you that "it works for me" never could.
2. Write a Dockerfile for a real app, build an image, and run the app from inside the container.
3. Prove the image behaves identically in a clean container with nothing of yours on it.
4. Use a disposable container as a sandbox to run a command or an agent you don't fully trust.
4. Use a disposable container as a sandbox to run a command, or an agent, you don't fully trust.
5. State precisely where containers stop helping: not a security boundary by default, image bloat,
and not a replacement for dependency hygiene.
@@ -60,20 +60,20 @@ that runs the same everywhere. You stop shipping just the code and start shippin
Four words that get used loosely. Pin them down, because the rest of the module leans on the
distinction:
- **Image** a built, read-only, layered filesystem snapshot: the language runtime, your code, its
- **Image**: a built, read-only, layered filesystem snapshot: the language runtime, your code, its
dependencies, all frozen together. The artifact. Analogous to a class.
- **Container** a running (or stopped) instance of an image. You can start many from one image;
- **Container**: a running (or stopped) instance of an image. You can start many from one image;
each gets its own writable scratch layer on top. Analogous to an instance of that class.
- **Registry** where images are stored and shared, the way a Git remote (Module 8) stores repos.
- **Registry**: where images are stored and shared, the way a Git remote (Module 8) stores repos.
You `push` an image to a registry and `pull` it elsewhere. (Most git hosts now bundle one.)
- **Dockerfile** the plain-text recipe that *builds* an image. This is the part you version. It is
- **Dockerfile**: the plain-text recipe that *builds* an image. This is the part you version. It is
the executable, reviewable specification of the environment, the same instinct as committing the
AI's config in Module 5, applied to the whole machine.
### It is not a virtual machine
The ops reframe that matters: a container is **not** a VM. A VM virtualizes hardware and boots a
whole guest OS its own kernel, gigabytes, slow to start. A container shares the **host's kernel**
whole guest OS: its own kernel, gigabytes, slow to start. A container shares the **host's kernel**
and isolates only the process and its filesystem view. It's much closer to a souped-up `chroot`
or a BSD jail with packaging and distribution bolted on than to a hypervisor. That's why containers
start in milliseconds and weigh megabytes instead of gigabytes.
@@ -88,7 +88,7 @@ Here's a Dockerfile for the `tasks-app`. The full version is in
```dockerfile
FROM python:3.12-slim # base image: the invisible stack, made explicit and pinned
ENV PYTHONUNBUFFERED=1 # environment, frozen in no more "did you set that var?"
ENV PYTHONUNBUFFERED=1 # environment, frozen in; no more "did you set that var?"
WORKDIR /app # a fixed path that's the same on every machine
COPY tasks.py cli.py ./ # your code goes in
RUN useradd appuser && chown appuser /app # don't run as root (hygiene, not a fence)
@@ -111,7 +111,7 @@ levers that close that gap:
- **Pin the base image.** `python:3.12-slim` is better than `python:latest`, but the `3.12-slim`
tag still moves as it gets patched. For bit-for-bit reproducibility, pin the digest:
`FROM python:3.12-slim@sha256:…`. Choose your point on the spectrum deliberately a moving tag
`FROM python:3.12-slim@sha256:…`. Choose your point on the spectrum deliberately; a moving tag
picks up security patches automatically; a pinned digest never changes under you. Both are valid;
silence is not.
- **Pin your dependencies.** This is Module 15's lesson, and the container is where it bites. A
@@ -149,8 +149,8 @@ Docker itself you may already know. What makes containers matter *more* in AI-as
the AI changes how the environment is built, it arrives as a diff in a PR (Module 10), the same
win as committing the AI's config in Module 5, extended to the whole machine.
- **A container is a sandbox for an agent you don't fully trust.** This is the forward-looking one.
As you let AI do bolder things run commands, install packages, execute its own code, and
eventually (Units 45) operate as an agent you want a blast radius. A throwaway container gives
As you let AI do bolder things, run commands, install packages, execute its own code, and
eventually (Units 45) operate as an agent, you want a blast radius. A throwaway container gives
you one: mount only what it needs, drop the network if it doesn't need it, let the agent do its
worst, then `docker rm` the whole thing. The host never saw it. This is the practical foundation
for running less-trusted agents, and we'll build on it when MCP servers and skills (Unit 4) start
@@ -174,14 +174,14 @@ containerize and run the app you already have.
choice; **Podman** works too and the commands below map 1:1 (`podman` for `docker`). Verify with
`docker --version` (or `podman --version`). **The engine must be *running* before you build:**
`docker --version` reports the client version even when the engine is stopped, so it's false
reassurance `docker build` then fails with "Cannot connect to the Docker daemon." On
reassurance; `docker build` then fails with "Cannot connect to the Docker daemon." On
macOS/Windows start it first (launch Docker Desktop, or `podman machine start`); confirm the daemon
is up with `docker info` (or `podman info`), which only succeeds when the engine is actually live.
- The starter files from this module's `lab/`: [`Dockerfile`](lab/Dockerfile) and
[`dockerignore-starter`](lab/dockerignore-starter).
- Your coding agent (Claude Code is the worked example; sub your own).
### Part A Build the image
### Part A: Build the image
1. Get the two starter files into your `tasks-app` folder. Direct your agent (Claude Code is the
worked example; sub your own) to do the placement: *"Copy this module's lab/Dockerfile into
@@ -198,7 +198,7 @@ containerize and run the app you already have.
The first build pulls the base image and runs each instruction as a layer. Watch the output: that
is the invisible stack being made explicit.
### Part B Run the app from inside the container
### Part B: Run the app from inside the container
2. Run the CLI *inside* the container. The `--rm` flag deletes the container when it exits, so you
don't pile up dead ones:
@@ -209,16 +209,16 @@ containerize and run the app you already have.
docker run --rm tasks-app list
```
Notice the third command shows **no** "containerize it" task. That's not a bug it's a lesson:
Notice the third command shows **no** "containerize it" task. That's not a bug; it's a lesson:
each `--rm` run is a fresh container with a fresh writable layer, and `tasks.json` is written
*inside* that layer, which is destroyed on exit. Containers reproduce the **environment**, not
your **state**. (Persisting state means mounting a volume a deliberate choice, covered when we
your **state**. (Persisting state means mounting a volume, a deliberate choice, covered when we
deploy in Module 18.)
### Part C Prove it's reproducible on a clean machine
### Part C: Prove it's reproducible on a clean machine
3. The honest test of "works on my machine, solved" is: run it somewhere that has *nothing* of
yours. The container already is that place it has no access to your installed Python, your
yours. The container already is that place; it has no access to your installed Python, your
packages, or your paths. Confirm with the inverse experiment: run the **same base image** with
*only* the engine and look for your app:
@@ -226,7 +226,7 @@ containerize and run the app you already have.
docker run --rm python:3.12-slim python -c "import sys; print(sys.version)"
```
That's a clean Python with none of your code. Now confirm CI-grade reproducibility run the
That's a clean Python with none of your code. Now confirm CI-grade reproducibility: run the
Module 14 test suite in a clean, throwaway container that mounts your code and runs it with the
standard-library `unittest` runner: nothing to install, and no test tooling baked into your app
image (that keeps it lean; see *Where it breaks*):
@@ -237,23 +237,23 @@ containerize and run the app you already have.
```
> **On Windows:** this step bind-mounts your code, so the host path matters. Run it from WSL (or
> Git Bash), or from PowerShell `${PWD}` resolves correctly in each. The other `docker run`
> Git Bash), or from PowerShell; `${PWD}` resolves correctly in each. The other `docker run`
> commands mount nothing of yours and are identical everywhere.
> **On native Linux:** the container runs as root by default, and the bind mount maps that straight
> onto your real project folder so the `__pycache__` directories Python writes during the test
> onto your real project folder, so the `__pycache__` directories Python writes during the test
> run land in your repo owned by `root:root`, and you can't delete them without `sudo rm -rf`.
> Prevent it by telling Python not to write bytecode in the container: add
> `-e PYTHONDONTWRITEBYTECODE=1` to the `docker run` line (with pytest you'd also pass
> `pytest -p no:cacheprovider` to suppress `.pytest_cache`). A `.gitignore` won't help it hides
> `pytest -p no:cacheprovider` to suppress `.pytest_cache`). A `.gitignore` won't help; it hides
> the files from Git but they're still on disk and still sudo-only to remove. Avoid `--user
> $(id -u):$(id -g)` here: it fixes ownership but breaks any in-container `pip install` into the
> image's root-owned site-packages.
This is, in miniature, exactly what containerized CI does. If it passes here, it passes the same
way on any machine with the engine your laptop's local Python version is now irrelevant.
way on any machine with the engine; your laptop's local Python version is now irrelevant.
### Part D Use the container as a sandbox (the AI angle, hands-on)
### Part D: Use the container as a sandbox (the AI angle, hands-on)
4. Now use a disposable container as a blast-radius box for something you don't fully trust. Ask your
agent (Claude Code is the worked example; sub your own) for a one-line shell command that
@@ -287,7 +287,7 @@ containerize and run the app you already have.
## Where it breaks
Be honest about the limits this audience will find them the hard way otherwise.
Be honest about the limits; this audience will find them the hard way otherwise.
- **A container is not a security boundary by default.** It shares the host kernel and, out of the
box, runs with more privilege than people assume. A process running as root inside a default
@@ -316,7 +316,7 @@ Be honest about the limits — this audience will find them the hard way otherwi
family of honesty as Module 2: the tool captures exactly one slice of reality, and you have to know
which slice.
- **The host abstraction is leaky off Linux.** On macOS and Windows the engine runs a hidden Linux
VM, so containers there aren't quite native bind-mount performance differs, file permissions and
VM, so containers there aren't quite native: bind-mount performance differs, file permissions and
line endings can surprise you, and architecture (arm64 vs amd64) can bite when an image built on an
Apple-silicon laptop lands on an x86 server. Build for the architecture you'll run on.
@@ -327,11 +327,11 @@ Be honest about the limits — this audience will find them the hard way otherwi
**You're done when:**
- `docker build -t tasks-app .` succeeds and `docker run --rm tasks-app list` prints the app's
output your app runs in an environment that has nothing of yours on it.
output; your app runs in an environment that has nothing of yours on it.
- You ran the Module 14 test suite inside a clean container and watched it pass without relying on
your local Python.
- You ran a command you didn't fully trust inside a throwaway, network-less container and can explain
why the host was safe *and* can name one case where it wouldn't have been.
why the host was safe, *and* can name one case where it wouldn't have been.
- You can state, without looking back: a container is not a VM, it's not a security boundary by
default, and it doesn't replace dependency hygiene from Module 15.
- Your `Dockerfile` and `.dockerignore` are committed: the environment is now version-controlled,
@@ -344,7 +344,7 @@ ready for Module 17, which handles the one thing you must *not* bake into that i
## Verify-before-publish
Expansion-zone module container tooling and base images move. Re-check at build/publish time:
Expansion-zone module: container tooling and base images move. Re-check at build/publish time:
- [ ] **Base image tag.** Confirm `python:3.12-slim` (in the README and `lab/Dockerfile`) is still a
current, supported tag, and that it matches the version Module 14's CI pins. Bump both together
@@ -355,7 +355,7 @@ Expansion-zone module — container tooling and base images move. Re-check at bu
- [ ] **Rootless / security defaults.** Container engines are steadily hardening defaults (rootless,
user namespaces). Re-check that the "not a security boundary by default" framing and the named
hardening tools (gVisor, Kata, seccomp/AppArmor) are still accurate and current.
- [ ] **Bundled registries.** The "most git hosts now bundle a registry" aside confirm it's still
- [ ] **Bundled registries.** The "most git hosts now bundle a registry" aside: confirm it's still
true of the major hosts at publish time rather than from memory.
- [ ] **`useradd` on the base.** Confirm the Debian-slim base still ships `useradd` (it does today;
a future minimal base might not), or switch to the engine's documented non-root pattern.