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:
@@ -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 4–5) 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 4–5) 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.
|
||||
|
||||
Reference in New Issue
Block a user