Containers: from the problem to operating them
A continuous build from "works on my machine" to operating containers: the problem, Dockerfiles line-by-line, a real web app, debugging with exec, lifecycle modeling, limits, and a team base-image standard.
A container packages your app with its whole environment so it runs identically everywhere. This chapter is a continuous build: understand the problem, write your first Dockerfile, containerize a real web app, then learn to operate, debug, and model containers the way production teams do. Docker commands run in your terminal; every Python block runs as-is, offline.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| container | an isolated, packaged app + dependencies, running as a process. |
| image | the built blueprint; a container is a running instance of it. |
| Dockerfile | the recipe that builds an image, step by step. |
| registry | where images are stored/shared (Docker Hub, GHCR, ECR). |
| port mapping | exposing a container port to your host (-p 8000:8000). |
What you need before starting:
- Command line (DF1) + a small app to containerize.
- Install Docker Desktop;
docker --versionto check. - Python blocks run without Docker; the Docker labs need it installed.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Explain containers vs VMs and the isolation they provide.
- Write, build, run, and debug a Dockerfile step by step.
- Containerize a real web app with dependencies and a health endpoint.
- Operate the lifecycle and model container behavior in Python for tooling.
code/cd1-why-containers/. Python runs offline; configs are ready to use.1 · The problem containers actually solve essential
Here's a Friday afternoon you may recognize. Your feature works perfectly. Tests pass. You push, the deploy goes green, and then the pager buzzes: it's crashing in production. You didn't change the code — you changed machines. Three hours later you find it: the server has Python 3.9, your laptop has 3.12, and one library you rely on behaves differently between them. You lost an afternoon to a difference you couldn't even see. Multiply that across a team and it's the single most expensive class of bug in software: "but it works on my machine!"
Why it keeps happening — the invisible-state problem. Your app doesn't run on "Python." It runs on your particular Python, sitting on your particular operating system, next to your particular set of system libraries, with your particular environment variables that you set six months ago and forgot. All of that is real, load-bearing state — and none of it lives in your Git repo. Your code is version-controlled and reviewed; its environment is a pile of assumptions that happen to be true on your laptop and nowhere else guaranteed. The bug isn't in your code. The bug is that the environment is implicit.
An analogy that makes it click. Think of shipping a houseplant to a friend. You could mail the plant alone and write "keep it at 21°C, water twice a week, this specific soil" — and hope their house matches. Or you could ship the plant in its own pot, with its own soil, sealed in its own climate-controlled terrarium. The second one arrives alive no matter what their house is like, because it brought its environment with it. A container is the terrarium: it packages your app together with its exact runtime, libraries, and settings so the whole thing travels as one sealed artifact that behaves identically wherever it lands.
The common mistake is thinking "I'll just document the setup" — a README with install steps, or a wiki page listing versions. Documentation drifts the moment someone upgrades a package or a new OS image ships, and it relies on every human following it perfectly every time. A container flips this from a description you hope people honor into an artifact that is the environment. You don't tell the server what to install; you hand it the finished, frozen thing.
You build an app on your laptop; it fails on a server. Why? A different Python version, a missing system library, a different OS, an env var you set months ago and forgot. The environment is invisible state. A container makes that state explicit and shippable — the app plus its exact runtime travel together as one artifact.
Compare the two halves left and right: the divider splits "code alone" from "code sealed in a container". Follow each arrow from the left box out to the target machines.
- The left grey box is your code shipped by itself, with no runtime attached. It fans out to two machines that happen to have different Python versions.
- The two red boxes are the result: the laptop has py 3.12 and runs (✓), but the server only has py 3.9 and breaks (✗). Same code, different invisible environment.
- The green box on the right bundles code and runtime together, so both target machines (green) see the identical 3.12 environment and both run — that matched pair of ✓ is the payoff.
In short: Code alone inherits whatever environment it lands in (and drifts); a container brings its environment along, so "works on my machine" becomes "works everywhere".
env_drift.py# Why "works on my machine" happens: two environments differ in invisible ways.
def env_diff(dev, prod):
keys = set(dev) | set(prod)
return {k: (dev.get(k, "MISSING"), prod.get(k, "MISSING"))
for k in keys if dev.get(k) != prod.get(k)}
dev = {"python": "3.12", "libssl": "3.0", "TZ": "UTC", "PILLOW": "10.2"}
prod = {"python": "3.9", "libssl": "1.1", "TZ": "UTC"} # older, missing Pillow
for k, (d, pr_) in env_diff(dev, prod).items():
print(f"{k:8} dev={d:8} prod={pr_}")
print("^ every one of these is a potential 'works on my machine' bug")
python dev=3.12 prod=3.9
libssl dev=3.0 prod=1.1
PILLOW dev=10.2 prod=MISSING
^ every one of these is a potential 'works on my machine' bug
This tiny program makes the famous "but it works on my machine!" problem visible. Your laptop (dev) and the server (prod) are each just a bag of settings — Python version, system libraries, time zone, installed packages. When those bags quietly differ, your app breaks in one place but not the other. This code lists exactly what differs.
devandprodare two dictionaries — each is a name→value list describing an environment (e.g."python": "3.12").env_diffcollects every key seen in either environment (set(dev) | set(prod)is the union), then keeps only the keys whose values disagree.dev.get(k, "MISSING")returns"MISSING"when a key exists in one environment but not the other.- The
forloop prints each mismatch askey dev=… prod=…so you can eyeball the drift.
What the output means: Three lines: Python is 3.12 on dev but 3.9 on prod, libssl differs, and Pillow is installed on dev but MISSING on prod. Each mismatch is a bug waiting to happen — and a container fixes it by shipping one frozen environment everywhere.
Try this: Add "NUMPY": "1.26" to dev only and re-run — it shows up as MISSING on prod. That is the invisible state containers make explicit.
2 · Container vs virtual machine essential
If you've used cloud servers before, you already know a VM — and you probably assume a container is just a smaller one. That assumption is the single most common misconception about containers, and it will lead you to wrong conclusions about speed, cost, and security. So let's kill it up front: a container is not a lightweight VM. It's a fundamentally different thing that happens to solve some of the same problems. Understanding why they differ is what lets you reason about when each is the right tool.
The apartment-building analogy. A virtual machine is like building a separate house for every tenant — each with its own foundation, plumbing, electrical, and heating. Total isolation: what happens in one house can't touch another. But every house duplicates the entire infrastructure, so it's slow to build (boots in seconds-to-minutes) and heavy (gigabytes). A container is like an apartment in one shared building: every unit has its own locked door, its own rooms, its own furniture — real isolation for daily life — but they all share the building's foundation, water main, and power. That shared infrastructure is the host kernel. Because a container doesn't rebuild the plumbing, it's tiny (megabytes) and starts almost instantly (milliseconds).
What that shared kernel actually means. A VM virtualizes hardware and then boots a complete guest operating system on top — its own kernel, its own drivers, the works. A container skips all of that: it shares the running kernel of the host and isolates only your process and its files, using kernel features (namespaces and cgroups, which you'll meet later in the track) to give that process its own private view of the filesystem, network, and process list. From inside, it feels like it has the machine to itself. From the host's point of view, it's just a normal process wearing a disguise. That's why you can pack hundreds of containers onto a host that could hold only a handful of VMs — the density difference in the table below is the whole economic argument for containers.
The honest limit — and the common mistake. The mistake is assuming "smaller and faster" means "always better." Sharing a kernel is exactly what makes containers cheap, but it also means the isolation boundary is thinner than a VM's. For your own apps that's a fine trade. For hostile multi-tenant workloads (running untrusted strangers' code side by side) or when you genuinely need a different OS kernel, the stronger, hardware-level wall of a VM still wins — which is why the cloud often runs containers inside VMs, getting both. Keep that trade in mind as you read the comparison below: every row is really a consequence of "shares the kernel" versus "brings its own."
Both isolate, but differently. A VM virtualizes hardware and runs a whole guest OS (gigabytes, boots in seconds-to-minutes). A container shares the host kernel and isolates only your process + its files (megabytes, starts in milliseconds). That efficiency is why containers, not VMs, run modern apps.
This diagram is the whole container idea in one line, read left to right: you package your app once, build it into an image, and then run that same image as identical containers wherever you like. Each box is a stage; each arrow is "turns into".
- Your app + deps (leftmost) — your code plus its libraries and runtime, gathered together. This is the raw material; a
Dockerfiledescribes it. - docker build (arrow) — the step that follows the recipe (the Dockerfile, labeled "the recipe") and turns your app + deps into an image.
- Image — the shippable artifact: one frozen, versioned blueprint you can store, share, and copy. Building once and reusing this everywhere is what kills environment drift.
- Container (running) (rightmost) — a live instance of the image, running the same way whether it's on your laptop or in the cloud ("laptop→cloud").
In short: Build once, run anywhere. The image in the middle is the promise: because every container is stamped from the same image, "works on my machine" becomes "works everywhere".
| Virtual machine | Container | |
|---|---|---|
| Virtualizes | hardware + full OS | just the process |
| Size | gigabytes | megabytes |
| Start | seconds–minutes | milliseconds |
| Density | a few per host | hundreds per host |
| Isolation | strong (own kernel) | process-level (shared kernel) |
3 · Your first Dockerfile — line by line essential
The first time most people open a Dockerfile, it looks like a spell. A handful of SHOUTING keywords, no obvious structure, and somehow this produces a running program on any machine on earth. It feels like there must be a lot going on. There isn't. A Dockerfile is one of the most honest files you'll ever write — it's literally just the setup steps you'd type by hand to prepare a fresh computer, written down in order so a machine can replay them. Once you see that, the mystery evaporates.
The recipe analogy, taken seriously. Think of a Dockerfile as a recipe card, and the image it builds as the finished, frozen meal. A recipe starts from a base ("begin with a store-bought pie crust"), sets up your workspace ("clear a space on the counter"), brings in your ingredients ("add the filling"), and ends with the instruction for serving ("bake and serve"). The four instructions below map onto exactly that: start from a base image, set a working directory, copy your code in, and declare what runs when someone "serves" it. Docker reads the card top to bottom, one line at a time — there's no jumping around, no hidden control flow. The order is the logic.
Why "start FROM a base" is the key idea. The instinct of a beginner is to think a container has to build an operating system from nothing — that's the source of the "this must be complicated" feeling. It doesn't. You stand on the shoulders of a base image someone already assembled: python:3.12-slim is a tiny, tested Linux that already has Python installed. You're not manufacturing an OS; you're taking a known-good starting point and layering only your changes on top. That single idea — build on a base, add just your diff — is what makes containers fast to build and small to ship, and it's why the very first line of essentially every Dockerfile is FROM.
The one distinction that trips everyone. A Dockerfile has two kinds of lines: those that run while building the image (they shape the frozen blueprint) and the one that runs when a container starts from that image (CMD). Confusing "build time" with "run time" is the mistake behind half of all early Docker frustration — you'll meet it head-on in section 4. For now, just hold the frame: FROM, WORKDIR, and COPY assemble the meal; CMD is the note that says how to serve it. Four instructions get you running.
A Dockerfile is a recipe read top-to-bottom. Four instructions get you running:
FROM, WORKDIR, and COPY run while building and stack up into the image; CMD is the only line that runs when a container starts.
Read the left column top to bottom — that is the exact order Docker executes the file — then follow the arrow to what it produces.
- The three blue rows (
FROM,WORKDIR,COPY) are build-time instructions: they start from a base, set the working directory, and copy your code in — assembling the image. - The green row (
CMD) is different: it does nothing at build time and only fires when a container starts. The two coloured "build time / run time" labels below spell out that split. - The amber box on the right is the resulting Image — the base plus your app, frozen into one shippable blueprint.
In short: Three lines build the image; one line (CMD) says what to run when it starts. Order is the logic — no jumping around.
Dockerfile# app.py
# print("Hello from inside a container!")
FROM python:3.12-slim # 1) base image: a minimal Linux + Python 3.12
WORKDIR /app # 2) set (and create) the working dir inside the container
COPY app.py . # 3) copy your code from host into the image
CMD ["python", "app.py"] # 4) the command run when a container starts
A Dockerfile is the recipe for building an image (the frozen blueprint of your app + its environment). Docker reads it top to bottom, running one instruction per line. These four instructions are all you need to package a program.
FROM python:3.12-slim— the base image you build on top of: a tiny Linux that already has Python 3.12. Every Dockerfile starts with aFROM.WORKDIR /app— sets the folder inside the image where later commands run (and creates it). Think of it ascd /appthat sticks.COPY app.py .— copiesapp.pyfrom your machine into the image (the.means "into the current WORKDIR"). Your code now travels with the image.CMD ["python", "app.py"]— the default command that runs when someone starts a container from this image.FROM/WORKDIR/COPYbuild the image;CMDis what happens at run time.
Try this: The two # lines at the top just show what app.py contains (it prints a greeting). Change that greeting in your real app.py and rebuild to see the new message.
build.shdocker build -t myapp . # build image tagged "myapp" from ./Dockerfile
docker run myapp # start a container from it
# -> Hello from inside a container!
docker images # list images (see myapp + its size)
docker ps -a # list containers, running + exited
Hello from inside a container!
You have a Dockerfile — now turn it into a running container with two commands. This is the core build → run loop you'll repeat constantly. These run in your terminal (they need Docker installed).
docker build -t myapp .— reads theDockerfilein the current folder (the.) and produces an image.-t myapptags (names) itmyappso you can refer to it later.docker run myapp— starts a container (a running instance) from that image. It executes the image'sCMD, so you seeHello from inside a container!printed.docker imageslists the images you've built (name + size);docker ps -alists containers — the-ashows all of them, including ones that already finished and exited.
What the output means: The greeting prints, proving the container ran your code. Image vs container: the image is the saved blueprint on disk; a container is one live run of it — you can start many containers from one image.
Try this: Run docker run myapp a second time, then docker ps -a — you'll see two exited containers from the same image. One blueprint, many runs.
4 · CMD vs RUN vs ENTRYPOINT (the confusing three) intermediate
These three keywords cause more confused Stack Overflow questions than almost anything else in Docker. They all look like "run a command," so beginners scatter them almost at random — put RUN python app.py and wonder why the container builds fine but does nothing when it starts, or write two CMD lines and puzzle over why only the last one takes effect. The confusion is real, but it dissolves the moment you separate one question: does this happen while I'm building the image, or while a container is running?
The theatre analogy. Picture staging a play. RUN is everything that happens during rehearsal and set construction — build the scenery, hang the lights, paint the backdrop. It happens once, before any audience arrives, and the results are baked into the finished set. When you RUN pip install, you're building the set: those libraries become a permanent part of the image. CMD and ENTRYPOINT are the opening night performance — what actually happens each time the curtain goes up (each time a container starts). You rehearse once; you perform every night.
So why two performance instructions? Because there's a subtle but useful split between "what program runs" and "what arguments it gets." ENTRYPOINT is the fixed act — the executable that always runs, the thing this image fundamentally is ("this is a backup tool"). CMD is the default version of the performance, easily swapped: it either provides the whole command (when there's no ENTRYPOINT) or supplies the default arguments to the ENTRYPOINT. The practical payoff: docker run myapp python -m pytest overrides CMD to run your tests instead of the app, without touching the image — the set is unchanged, you just performed a different scene. That override-ability is exactly why you use CMD for "the default thing the app does" and reserve ENTRYPOINT for "the one program this image exists to run."
The mistake, named. The classic error is reaching for RUN when you mean CMD — e.g. ending a Dockerfile with RUN python app.py. It "works" during the build (the app runs once, at build time, and its output vanishes into a build log), so the build succeeds and you're baffled that docker run does nothing useful. The rule that prevents it: if it sets up the image, it's RUN; if it's what the app does when it starts, it's CMD. Installing something is setup (build time). Serving requests is what the app does (run time). Hold that line and these three stop fighting you.
Beginners mix these up. RUN executes at build time (installs, baked into the image). CMD is the default command at run time (overridable). ENTRYPOINT is the fixed executable; CMD becomes its default arguments.
RUN fires while building and is frozen into the image; ENTRYPOINT/CMD fire on every container start — ENTRYPOINT is the fixed program, CMD the overridable default arguments.
The picture is split into two dashed boxes: the left (blue) is what happens while building the image; the right (green) is what happens when a container starts. That single question — build or run? — is the whole distinction.
- On the left,
RUN pip installarrows into the image box: it executes once at build time and its result is baked in, so every container already has the dependencies. - On the right,
ENTRYPOINTandCMDboth fire at run time.ENTRYPOINTis the fixed program the image exists to run;CMDis the overridable default arguments (labelled "override"). - The payoff: because
CMDis overridable,docker run myapp python -m pytestswaps the arguments without rebuilding — the baked-inRUNlayer is untouched.
In short: If it sets up the image, it's RUN (build time); if it's what the app does when it starts, it's CMD/ENTRYPOINT (run time).
| Instruction | Runs when | Purpose |
|---|---|---|
| RUN | build time | install deps, set up the image |
| CMD | container start | default command (easily overridden) |
| ENTRYPOINT | container start | the fixed program; CMD = its args |
Dockerfile.run-vs-cmdFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # BUILD time -> baked in
COPY . .
CMD ["python", "app.py"] # RUN time -> the default process
# `docker run myapp python -m pytest` overrides CMD to run tests instead
Beginners mix up RUN and CMD. This Dockerfile shows the difference in context: RUN happens while building the image (once, baked in); CMD happens when a container starts (every run, and easily overridden).
COPY requirements.txt .thenRUN pip install … -r requirements.txt—RUNexecutes at build time, so the installed libraries become part of the image. You pay this cost once, not on every start.COPY . .copies the rest of your source in after installing deps (a small speed trick you'll see explained in CD2).CMD ["python", "app.py"]is the default process a container runs. It's not baked likeRUN— the comment showsdocker run myapp python -m pytestoverriding it to run tests instead.
Try this: Remember the rule: RUN = set up the image (build time); CMD = the default thing it does (run time). If you install something, it's RUN; if it's "what the app does", it's CMD.
5 · Containerize a real web app intermediate
The four-line Dockerfile in section 3 is a great teaching toy, and almost nothing you'll ship looks like it. Your real apps don't just print a line and exit — they depend on a stack of third-party libraries, and they stay running, listening on a network port for requests that arrive at unpredictable times. That's two new problems the toy never had to solve: how do the dependencies get in, and how does the outside world reach a server sealed inside a container. This section closes both gaps, and it's where a container stops being a curiosity and becomes something you'd actually deploy.
Dependencies first — and why the extra RUN line. A web app needs libraries (a framework like FastAPI, a server like uvicorn). Those aren't in the base image, so you install them into the image at build time with RUN pip install — the "build the set" step from section 4. Do this once and the libraries are baked in; every container that starts from the image already has them, with no install step at launch. This is also where a small ordering habit pays off (copy requirements.txt and install before copying your source) — a caching trick CD2 explains in full, but worth noticing now.
The part that surprises everyone: a container is sealed by default. Here's the analogy that saves people hours. A running container is like a soundproof recording booth. Inside, your web server is happily shouting "I'm listening on port 8000!" — but the booth is sealed, so from the hallway (your laptop) you hear nothing. A port mapping (-p 8000:8000) is drilling a labelled hole between one port on the outside wall and one port on the inside. Only then can a request from your browser travel through the wall to the server. Beginners expect the container to be reachable just because the app is running; it isn't, and it shouldn't be — that default isolation is a security feature, and you open exactly the doors you mean to.
The mistake this section is quietly setting you up to avoid. There's a second, sneakier wall: which network interface the server binds to inside the booth. Bind it to 127.0.0.1 (loopback) and the server is only reachable from inside the container itself — you can drill all the port holes you like and still hear silence, because the server is whispering to itself in the corner. Bind to 0.0.0.0 (all interfaces) and it speaks to the whole booth, so the mapped hole actually carries its voice out. This is the number-one first-container bug, and the warning box right after the labs hammers it home. For now, watch for --host 0.0.0.0 in the CMD below and know it's load-bearing, not decoration.
Real apps have dependencies and listen on a port. Here's the app (runnable Python logic) and the Dockerfile that ships it.
-p host:container maps one through the wall — and the server inside must bind 0.0.0.0, not 127.0.0.1, or the request arrives at nobody.
Follow the request left to right: from your browser, through the mapped port, into the server inside the container. The big outlined box is the container — a "sealed booth" that lets nothing through until you open a door.
- The browser box on the left makes a request to
localhost:8000on your machine. - The arrow labelled
-p 8000:8000is the port mapping; the dot on the container wall is the "hole" it drills — the only way in. - The green box is the server bound to
0.0.0.0:8000, so it hears anything arriving through the hole (✓). The red line below is the classic trap: bind127.0.0.1and the server only talks to itself, so the request reaches nobody.
In short: Two walls must both open — map the port (-p) and bind to 0.0.0.0. Miss either and the app is unreachable.
app_logic.py# The routing a web framework would do — pure Python so it runs anywhere.
def handle(path):
routes = {
"/": lambda: (200, {"service": "orders", "status": "ok"}),
"/healthz": lambda: (200, {"status": "healthy"}),
}
h = routes.get(path)
return h() if h else (404, {"error": "not found"})
for path in ["/", "/healthz", "/nope"]:
print(path, "->", handle(path))
/ -> (200, {'service': 'orders', 'status': 'ok'})
/healthz -> (200, {'status': 'healthy'})
/nope -> (404, {'error': 'not found'})
Before containerizing a web app, here's the app's routing logic — the part that decides what to answer for each URL path. It's written as plain Python (no web framework) so it runs anywhere and you can see the idea clearly.
routesis a dictionary mapping each URL path to a small function (lambda) that returns a(status_code, body)pair — e.g.200means OK,404means not found.routes.get(path)looks up the path. If it's found,h()calls that function; if not, we fall back to a404 not foundresponse.- The
forloop tries three paths — a real one, the health check/healthz, and a missing one — and prints what each returns.
What the output means: Three lines: / and /healthz return 200 with their data; /nope returns 404. A real framework (FastAPI) does exactly this matching for you — the Dockerfile below ships that framework version.
Try this: Add a new route like "/version": lambda: (200, {"v": "1.0"}) to the dictionary and add "/version" to the loop. This is how you'd add an endpoint to the real app.
Dockerfile.webFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # e.g. fastapi, uvicorn
COPY . .
EXPOSE 8000 # documents the port
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
This is a real, production-shaped Dockerfile: it installs dependencies, copies your app, and starts a web server that listens on a port. It's the minimal Dockerfile from earlier plus two web-specific pieces.
COPY requirements.txt .+RUN pip install --no-cache-dir -r requirements.txtinstalls your libraries (e.g.fastapi,uvicorn) at build time.--no-cache-dirskips pip's cache to keep the image smaller.EXPOSE 8000documents that the app listens on port 8000. It's a note for humans and tools — it does not by itself open the port to your machine (the-pflag at run time does that).CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]starts the web server.--host 0.0.0.0is critical (see the warning below) and--port 8000matches the EXPOSE.
Try this: Compare this to the four-line Dockerfile in section 3: same shape (FROM→WORKDIR→COPY→CMD), just with a RUN pip install for dependencies and an EXPOSE for the port.
run_web.shdocker build -t webapp .
docker run -p 8000:8000 webapp # host:8000 -> container:8000
# now http://localhost:8000 reaches the app inside the container
docker run -p 8000:8000 -e ENV=prod webapp # pass config via env vars
Now run the web app so your browser can reach it. The new idea is port mapping: a container's ports are sealed off by default, so you must explicitly connect a port on your machine to a port inside the container.
docker build -t webapp .builds and tags the imagewebapp, just like before.docker run -p 8000:8000 webapp— the-p host:containerflag maps your port 8000 to the container's port 8000. Reading it as8000:8000=outside:inside. Nowhttp://localhost:8000reaches the app.docker run -p 8000:8000 -e ENV=prod webappadds-e ENV=prod, which sets an environment variable inside the container — the standard way to pass config (which database, which mode) without changing the image.
Try this: Change the left number: -p 9000:8000. Now the app is reachable at http://localhost:9000 even though it still listens on 8000 inside. The mapping translates outside→inside.
--host 0.0.0.0, not 127.0.0.1Inside a container 127.0.0.1 means the container itself — unreachable from your machine even with -p. Bind to 0.0.0.0 (all interfaces). This is the #1 first-container bug.6 · Debugging a container from the inside advanced
Here's the failure mode that eats afternoons, and I've watched good engineers fall into it. The container starts, the app is unreachable, so they open the Dockerfile, change a line on a hunch, rebuild (wait…), rerun, still broken, change another line, rebuild (wait…)… An hour later they've made twelve edits and understand nothing. This is debugging by guessing, and it's slow precisely because each guess costs a full build-and-run cycle. The fix is a complete change of reflex: stop editing, and go look inside the container that's already running.
The analogy: a car that won't start. You wouldn't reorder new parts from the factory on a guess — you'd pop the hood on the car in front of you. Is there fuel? Is the battery connected? A container gives you the same "pop the hood" ability, and it's more powerful than debugging a normal process because the container is a sealed, reproducible box: whatever's wrong inside it is wrong the same way every time, and you can climb in and look with the app frozen in its broken state. The two tools for looking are logs (what did it say on its way down?) and exec (open a shell inside the live container and poke around by hand).
Why docker exec is the one command to internalize. docker exec -it web bash drops you into a shell inside the running container — same filesystem, same environment variables, same working directory the app sees. Now you stop reasoning about what should be true and simply check: is app.py actually at /app? Does env show the variable you expected? Does python -c "import fastapi" succeed? You're no longer guessing about the environment; you're standing in it. That's the difference between "I think the file didn't copy" and "the file isn't here — found it."
The common mistake, and why "look inside" beats it. The trap is trusting logs alone. Logs are honest but partial: they show you what the app chose to print, which means a whole category of bugs is invisible to them. The classic is binding to 127.0.0.1 from section 5 — the server logs a cheerful "listening on 8000" and looks perfectly healthy, because from its own point of view it is. Nothing in the logs reveals that it's only reachable from inside its own booth. Shell in, run the request by hand, and the truth surfaces in seconds. Reach for these tools before touching the Dockerfile — most "works locally, not in the container" bugs are a missing file, a wrong working directory, or an unset env var, and all three are obvious the moment you're inside.
Something's wrong in the container? Don't guess — shell in and look. This is your primary debugging tool: check files, env, and run the command by hand.
127.0.0.1 bind) never appears in logs — shelling in with exec and checking files, env, and the bind address surfaces them in seconds.
The big box in the middle is one running container. Two debugging tools point at it from the left — compare what each can actually see.
docker logs(top) only shows you what the app chose to print — honest but partial, so bugs it never mentions stay hidden.docker exec -it(bottom) drops you inside the container, where you can inspect the three green rows: the files at/app, the environment variables, and the address the server bound to.- The right-hand note is the payoff: a loopback (
127.0.0.1) bind is invisible in logs but obvious the moment you exec in and check the bind address — which is why "look inside" beats "read logs and guess".
In short: Reach for exec before editing the Dockerfile — most container bugs are a missing file, wrong working dir, or bad bind, all visible from inside.
debug.shdocker run -d --name web -p 8000:8000 webapp # -d = detached (background)
docker logs web # what did it print / crash with?
docker logs -f web # follow live
docker exec -it web bash # open a shell INSIDE the running container
# inside: ls -la /app ; env | grep ENV ; python -c "import fastapi"
docker exec web ls /app # run one command without a shell
docker inspect web # full JSON: mounts, network, env, state
When a container misbehaves, don't guess — look inside it. These commands are your core debugging toolkit: read its logs, and open a shell in the running container to inspect files, environment, and dependencies by hand.
docker run -d --name web -p 8000:8000 webapp—-druns it detached (in the background) and--name webgives it a friendly name so you don't need its long ID.docker logs webshows everything the app printed (and any crash message);docker logs -f webfollows it live, liketail -f.docker exec -it web bashopens an interactive shell inside the running container — the single most useful debugging command. Now you canlsthe files, checkenv, and try imports exactly as the container sees them.docker exec web ls /appruns one command without a full shell;docker inspect webdumps the container's full configuration as JSON (mounts, network, env, current state).
Try this: Most "works locally, not in the container" bugs are a missing file, a wrong working directory, or an unset env var. exec in and re-run the command by hand — you'll usually spot it in seconds.
exec"Works locally, not in the container" is almost always a missing file, wrong working dir, or unset env var. Shell in, reproduce the command manually, and you'll see it in seconds — far faster than editing the Dockerfile and rebuilding blindly.7 · Model the container lifecycle in Python advanced
You've been driving containers by hand — build, run, exec, stop. Now flip the seat: what if you were writing the tool that manages them? Every dashboard, every orchestrator, every "restart the crashed service" script has to answer one question constantly: given where this container is now, what is it allowed to do next? A container that's already removed can't be paused. A container that was never started can't be stopped. Getting these rules wrong is how ops tools issue nonsense commands and corrupt their own view of the world. This section builds the small, sturdy idea that keeps that from happening.
A container has a life, and the life has stages. When you run docker ps and see a status, you're seeing one snapshot of a journey: a container is created, then running, maybe paused and resumed, eventually stopped, and finally removed. The crucial part isn't the list of stages — it's that only certain moves between them make sense. This "set of states plus the legal moves between them" is one of the most reusable patterns in all of engineering: a finite state machine. You'll meet it again in parsers, in network protocols, in agent loops; containers are just a friendly place to build your first one.
The board-game analogy. Think of the states as squares on a board and the transitions as the only moves the rules permit. In chess you can't teleport your rook across a friendly pawn; in this game you can't jump from created straight to removed — you must pass through running and stopped first. Encoding the legal moves in one place (a TRANSITIONS map from each state to the set of states it may go to) means the rules live in data, and the code just enforces them. Add a new state later and you edit the map, not a tangle of if statements scattered across your program.
Why a guard beats trusting the caller. The tempting mistake is to skip the check — assume whoever calls your code will only ask for sensible transitions. They won't: a race condition, a stale dashboard, a retried request, and suddenly something tries to remove a running container. A tool without a guard silently corrupts its state and lies to you afterward. The lab below does the disciplined thing instead: every transition is validated against the allowed set for the current state, and an illegal jump raises loudly rather than quietly doing the wrong thing. Making the failure impossible-by-construction — rather than merely unlikely — is exactly the mindset that separates a demo script from tooling you'd trust in production.
Tooling that manages containers (dashboards, orchestrators) models their state machine. Building it makes docker ps states concrete — and it's real code you'd write for an internal ops tool.
created→running→(paused/stopped), and only a stopped container may be removed; the dashed red arrow is the jump the guard rejects.
Read it as a board where the boxes are states and the solid arrows are the only legal moves between them — start at created on the left and follow the arrows.
- The boxes are the five lifecycle states you see in
docker ps: created, running, paused, stopped, removed. - The solid blue arrows are permitted transitions — e.g.
runningcan go topausedorstopped, andpausedcan come back torunning. - The dashed red arrow (
created→removed) is an illegal jump: there's no path, so theto()guard raises rather than allowing it. Onlystoppedhas an arrow intoremoved.
In short: The TRANSITIONS map is exactly these arrows in data; the guard rejects any move without an arrow, so nonsense transitions are impossible by construction.
lifecycle.pyclass Container:
TRANSITIONS = {
"created": {"running"},
"running": {"paused", "stopped"},
"paused": {"running", "stopped"},
"stopped": {"running", "removed"},
"removed": set(),
}
def __init__(self, name): self.name, self.state, self.history = name, "created", ["created"]
def to(self, new):
if new not in self.TRANSITIONS[self.state]:
raise ValueError(f"illegal: {self.state} -> {new}")
self.state = new; self.history.append(new); return self
c = Container("web")
c.to("running").to("paused").to("running").to("stopped").to("running")
print(c.name, "history:", " -> ".join(c.history))
try:
Container("x").to("removed") # created->removed is not allowed
except ValueError as e:
print("rejected:", e)
web history: created -> running -> paused -> running -> stopped -> running
rejected: illegal: created -> removed
A container moves through a fixed set of states (created → running → paused → stopped → removed), and only certain jumps between them are legal. This is a state machine. Modeling it in Python is exactly the kind of code you'd write for an ops dashboard, and it makes the states from docker ps concrete.
TRANSITIONSis a dictionary saying, for each state, which states you're allowed to move to next. For example"running": {"paused", "stopped"}means a running container may pause or stop — but not jump straight toremoved.__init__starts every container in the"created"state and keeps ahistorylist of where it's been.to(new)is the guard: ifnewisn't in the allowed set for the current state, itraises aValueError. Otherwise it updates the state and records it. Returningselflets you chain calls likec.to("running").to("paused").- The
try/exceptat the bottom deliberately attempts an illegal jump (created → removed) to show the guard rejecting it instead of allowing a nonsense transition.
What the output means: First line prints the full legal history; the second prints rejected: illegal: created -> removed — proof the state machine blocks impossible transitions.
Try this: Add "exited" as a new state, or try c.to("removed") while c is running — the guard will reject it because only stopped containers can be removed.
8 · Professional — resource limits & cleanup professional
Here's an outage that has happened at basically every company that runs containers. Ten services share a host, humming along. One of them hits a bad input and starts leaking memory — slowly, then all at once. It doesn't just crash itself: it eats every spare byte on the machine, and now the other nine healthy services get starved and killed too. One bug, one container, and the whole host goes down. The failure wasn't the leak — leaks happen. The failure was that nothing stopped one greedy container from consuming the entire machine.
The shared-house analogy. Remember the apartment building from section 2 — containers share the host's resources. That efficiency has a dark side: shared resources can be hogged. It's like flatmates sharing one water heater. If nobody agrees on limits, the person who takes a 45-minute shower leaves everyone else in the cold. Resource limits (--memory, --cpus) are the house rule that gives each container a capped allowance, so no single tenant can drain the tank. This isn't about making any one container faster — it's about protecting the host and its neighbors from any one container's worst day.
The second, quieter outage: disk. Every image you build and every container you stop leaves something on disk. It accumulates invisibly — nobody notices the gigabytes piling up — until one day a deploy fails or the database can't write because the disk is 100% full. This is a genuine, boring, extremely common cause of production incidents, and it's entirely preventable with routine housekeeping: know how much disk your images and containers are using, and periodically reclaim the space held by dead ones. The commands below make both the capping and the cleanup concrete.
The common mistake: limits set by vibes. When people finally set a memory limit, they guess — "512MB sounds fine" — and then are baffled when a perfectly healthy container keeps getting killed. Set the ceiling below what the app actually needs at peak and the kernel will OOM-kill it (Out Of Memory) on every busy moment, turning your safety net into a self-inflicted outage. The right move, which the runnable model right after the shell lab drives home, is to measure real memory use first, find the true peak, and then set the limit comfortably above it with headroom. A limit is a guardrail, not a diet — it should catch runaways, not strangle normal work.
In production a runaway container must not starve its host. Set memory and CPU limits, and clean up dead containers/images so disks don't fill (a real outage cause).
--memory cap confines the damage to the runaway itself.
Compare the two halves across the divider: left is a host with no limits, right is the same host with a memory cap on each container.
- On the left, the red "leaking container" bar grows unbounded and the services beside it (B, C, D) all fail (✗) — one bug exhausts the host RAM and takes the neighbors down with it.
- On the right, each container has a capped allowance (
--memory=512m). The runaway hits its own ceiling and is OOM-killed alone, so the green neighbors (B, C) stay healthy (✓). - The bottom note is the tuning rule: the cap must sit above the app's real peak, or you OOM-kill healthy work.
In short: A limit isn't about speed — it's a blast-radius fence, so one container's worst day can't become the whole host's outage.
limits.shdocker run -d --name web \
--memory=512m --cpus=1.0 \ # cap resources -> one bad container can't sink the host
--restart=unless-stopped \ # auto-restart on crash/reboot
-p 8000:8000 webapp
docker stats --no-stream # live CPU/mem per container
docker system df # how much disk images/containers use
docker system prune -af # reclaim space (dangling images, stopped containers)
In production, one runaway container must not crash the whole host, and dead images must not fill the disk. These commands set resource limits and do housekeeping — real habits that prevent real outages.
--memory=512m --cpus=1.0cap how much RAM and CPU the container may use, so a single misbehaving container can't starve the machine. (The\at line ends just continues one long command across lines.)--restart=unless-stoppedtells Docker to automatically restart the container if it crashes or the host reboots — unless you stopped it on purpose.docker stats --no-streamprints a one-shot snapshot of live CPU/memory per container;docker system dfshows how much disk images and containers are using.docker system prune -afreclaims space by deleting unused (dangling) images and stopped containers.-a= all unused,-f= don't ask for confirmation.
Try this: Run docker system df before and after docker system prune -af to see the disk you get back. On a busy machine this can free gigabytes.
oom.pydef will_oom(peak_mb, limit_mb):
return peak_mb > limit_mb
for peak, limit in [(300, 512), (700, 512)]:
verdict = "OOM-KILLED" if will_oom(peak, limit) else "ok"
print(f"peak {peak}MB vs limit {limit}MB -> {verdict}")
print("lesson: profile real memory use, then set the limit ABOVE peak with headroom")
peak 300MB vs limit 512MB -> ok
peak 700MB vs limit 512MB -> OOM-KILLED
lesson: profile real memory use, then set the limit ABOVE peak with headroom
This models what happens when a container needs more memory than its limit allows: the kernel OOM-kills it (Out Of Memory). The code is trivial on purpose — the point is the lesson about how to choose a safe limit.
will_oom(peak_mb, limit_mb)just returns whether the app's peak memory use exceeds the limit you set. If it does, Docker kills the container to protect the host.- The loop tries two cases: peak 300MB under a 512MB limit (fine), and peak 700MB under the same 512MB limit (over → killed).
What the output means: peak 300MB … -> ok and peak 700MB … -> OOM-KILLED. The takeaway line says it all: measure real memory use, then set the limit above the peak with headroom — too tight and healthy containers die.
Try this: Change the pairs to (500, 512) — barely under. It says ok, but in real life a spike would kill it. That's why you leave headroom above the observed peak.
9 · Tech-lead — a base-image standard + policy check tech-lead
Scale changes the problem entirely. One engineer writing one good Dockerfile is easy. Fifty engineers writing fifty Dockerfiles is a different beast: one pins python:latest (which silently changes under everyone next Tuesday), one forgets a USER line so the container runs as root, one copies a .env file and bakes a live secret into a shareable image. Individually, each is an honest slip. Collectively, they're how a fleet becomes a bloated, insecure, unreproducible mess — and no amount of "please remember the rules" in a wiki fixes it, because humans under deadline forget. This section is about the tech-lead move: making the right thing automatic instead of remembered.
Why one approved base image is such leverage. Picture a restaurant kitchen. You could let every cook buy their own ingredients from whichever shop they like — and get wildly inconsistent dishes and a few cases of food poisoning. Real kitchens don't: they standardize on approved suppliers so every plate starts from the same trusted base. An approved base image is that supplier. When every service builds FROM the same vetted, slim, patched image, you fix a security hole or shave off bloat once and the whole fleet inherits it. Divergence is the enemy of operability; a shared base is how a team of fifty stays as consistent as a team of one.
From "please remember" to "the machine checks." A standard nobody enforces is just a suggestion, and suggestions decay. The move that makes it real is to encode the policy as a lint — an automated check that reads a Dockerfile and flags every violation — and wire it into CI so it runs on every change. Now the rule isn't a paragraph a reviewer might skim past; it's a gate. A Dockerfile that pins to :latest, or omits USER, or copies .env, simply doesn't pass, and it never reaches production. The engineer gets the feedback in seconds, phrased as a fixable reason, not a scolding in code review three days later.
The common mistake: enforcing with a boolean. A tempting first version of a lint just returns pass/fail — and it's nearly useless, because a red "FAILED" with no reason sends the engineer back to guessing. The lab below does the thing good tooling does: it returns the list of reasons, each a human-readable sentence ("base image not pinned," "runs as root"), so the fix is obvious. That's the whole philosophy of a lead-built guardrail: don't just block bad work, teach the fix in the same breath. Encode the standard once, phrase the failures kindly, and let CI carry the standard so you don't have to.
A lead defines an approved base image and a policy every service must pass, so the fleet is consistent, small, and safe. Encode it as a lint that runs in CI.
Follow one Dockerfile left to right as it hits the CI gate and branches into one of two outcomes.
- The left box is a Dockerfile from any engineer; it flows into the CI lint in the middle, which runs one check per team rule.
- The upper red branch is a violation: the lint blocks it and returns the specific reasons (
:latest, missingUSER, copied.env, no--no-cache-dir), so the fix is obvious. - The lower green branch is a clean file that passes the gate and ships — slim, non-root, pinned. Returning reasons rather than a bare pass/fail is what makes the block teach the fix.
In short: Encode the standard once as a lint in CI; every service passes through the same gate, so the fleet stays consistent without anyone re-deriving the rules.
dockerfile_lint.pydef lint_dockerfile(text):
lines = [l.strip() for l in text.strip().splitlines() if l.strip() and not l.startswith("#")]
issues = []
froms = [l for l in lines if l.startswith("FROM ")]
if not froms:
issues.append("no FROM")
elif any(f.split(":")[-1] in ("latest", froms[0].split()[1]) and ":" not in f for f in froms):
pass
if any(l.startswith("FROM") and (l.endswith(":latest") or ":" not in l.split()[1]) for l in froms):
issues.append("base image not pinned to a version (avoid :latest)")
if not any(l.startswith("USER ") for l in lines):
issues.append("no USER -> runs as root (security risk)")
if any(("COPY .env" in l or "ADD .env" in l) for l in lines):
issues.append("copies .env -> secret baked into the image")
if not any("--no-cache-dir" in l for l in lines if "pip install" in l) and any("pip install" in l for l in lines):
issues.append("pip without --no-cache-dir -> larger image")
return issues
bad = "FROM python:latest\nCOPY .env .\nRUN pip install -r requirements.txt\nCMD [\"python\",\"app.py\"]"
print("bad ->", lint_dockerfile(bad))
good = "FROM python:3.12-slim\nRUN pip install --no-cache-dir -r requirements.txt\nRUN useradd app\nUSER app\nCMD [\"python\",\"app.py\"]"
print("good ->", lint_dockerfile(good) or "clean")
bad -> ['base image not pinned to a version (avoid :latest)', 'no USER -> runs as root (security risk)', 'copies .env -> secret baked into the image', 'pip without --no-cache-dir -> larger image']
good -> clean
A tech lead encodes the team's Docker rules as an automated lint — a check that scans a Dockerfile and flags policy violations, so every service ends up small, secure, and consistent without each engineer remembering the rules. This is that check.
- The first line splits the Dockerfile into non-empty, non-comment
linesso the checks can scan them. - It flags a base image not pinned to a version (using
:latestor no tag is risky — the image can change under you); a missingUSERline (so the container would run as root, a security risk); copying.env(which would bake secrets into the image); andpip installwithout--no-cache-dir(which bloats the image). - It returns a list of
issues— empty means the Dockerfile passed. - The two examples at the bottom run the linter on a deliberately
badDockerfile and agoodone to show both outcomes.
What the output means: bad prints a list of four problems; good prints clean (issues or "clean" falls back to the word when the list is empty). Dropped into CI, this rejects a bad Dockerfile before it ships.
Try this: Remove the USER app line from the good string and re-run — the linter now flags "runs as root". That is your policy catching a regression automatically.
Exercise CD1.1 — Containerize, debug, harden
Context: Putting the whole chapter together is what a first real containerization task looks like: package an app, watch it break, and fix it like an operator. Deliberately breaking it teaches you the debugging reflex faster than a working container ever could.
Your task: Containerize a small web app (slim base, non-root, port-mapped), deliberately break it, then use docker logs/exec to diagnose and fix — and harden it with a memory limit and a clean lint.
Requirements:
- Build a slim, non-root, port-mapped image of a small web app
- Introduce a real bug — a wrong
WORKDIRor a bind to127.0.0.1 - Diagnose it with
docker logsanddocker exec, then fix it - Set a memory limit on the container with
--memory - Run
lint_dockerfileover your Dockerfile until it reports clean
💡 Hint: Reproduce the failing command by hand inside the running container with exec — the loopback bind is invisible in logs but obvious once you're inside.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Packaging an app so it runs the same everywhere starts with a single recipe file. The first Dockerfile you ever write is four lines, and every image you build afterward is a variation on them.
Your task: Write a minimal Dockerfile that packages a single-file Python program app.py (which just prints a greeting), then build and run it.
Requirements:
- Start from a slim Python base with
FROM python:3.12-slim - Set a working directory inside the image with
WORKDIR - Copy
app.pyinto the image withCOPY - Declare the run-time default with
CMD ["python", "app.py"] - Show the
docker build -t myapp .thendocker run myapploop that prints the greeting
💡 Hint: Docker reads the file top to bottom; the build-time instructions set up the image and CMD is the one thing that runs when a container starts.
Show solution
Use a slim Python base, set a working directory, copy the script in, and declare the default command. Every Dockerfile starts with FROM; CMD is what runs when a container starts.
FROM python:3.12-slim # minimal Linux + Python 3.12
WORKDIR /app # set/create the working dir inside the image
COPY app.py . # copy your code from host into the image
CMD ["python", "app.py"] # default command when a container starts
Build and run it with the core build to run loop:
docker build -t myapp . # build image tagged "myapp"
docker run myapp # start a container -> prints the greeting
Context: Real apps aren't one-shot scripts — they install dependencies and listen on a port for requests. Getting a web server reachable from your browser is the difference between a toy container and a service.
Your task: Write a Dockerfile for a web app that installs its dependencies and starts a uvicorn server on port 8000, then give the exact docker run command that reaches it at http://localhost:8000.
Requirements:
- Install dependencies with
RUN pip install --no-cache-dir -r requirements.txtbefore copying the code - Document the listening port with
EXPOSE 8000 - Bind the server to
0.0.0.0in theCMD, never127.0.0.1 - Publish the port at run time with
docker run -p 8000:8000(host:container) - Explain that
EXPOSEalone does not open the port — the-pflag does
💡 Hint: Inside a container 127.0.0.1 means the container itself; bind to all interfaces or the app is unreachable from your host even with -p.
Show solution
Add a RUN pip install for dependencies (cache-free), an EXPOSE to document the port, and a CMD that binds to 0.0.0.0 (not 127.0.0.1, or the container is unreachable from your host).
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # e.g. fastapi, uvicorn
COPY . .
EXPOSE 8000 # documents the port
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Run it with a port map (host:container). EXPOSE alone does not open the port — the -p flag does:
docker build -t webapp .
docker run -p 8000:8000 webapp # host:8000 -> container:8000
Context: When a container starts but the app is unreachable, editing the Dockerfile and rebuilding blindly wastes time. The fast path is to shell into the running container and reproduce the failure by hand — the lesson says 90% of container bugs are found this way.
Your task: A container built from webapp starts but the app is unreachable. Give the sequence of Docker commands that diagnose it from the inside.
Requirements:
- Run it detached and named with
docker run -d --name web -p 8000:8000 webapp - Read its output with
docker logs web(and-fto follow live) - Open a shell in the running container with
docker exec -it web bash - Inside, check the files, the
env, and re-run the command by hand - Use
docker inspect webfor the full JSON (mounts, network, env, state)
💡 Hint: Most 'works locally, not in the container' bugs are a missing file, a wrong working directory, or an unset env var — a bind to loopback is invisible in logs but obvious once you exec in.
Show solution
Run it detached with a name, read its logs, then shell in and reproduce the command by hand. Most failures are a missing file, wrong working directory, or an unset env var.
docker run -d --name web -p 8000:8000 webapp # -d = detached (background)
docker logs web # what did it print / crash with?
docker logs -f web # follow live
docker exec -it web bash # open a shell INSIDE the running container
# inside: ls -la /app ; env | grep ENV ; python -c "import fastapi"
docker exec web ls /app # run one command without a full shell
docker inspect web # full JSON: mounts, network, env, state
The --host 127.0.0.1 bug is invisible from logs but obvious once you exec in and see the server is bound only to loopback — rebind to 0.0.0.0.
Context: Tooling that manages containers — dashboards, orchestrators — models their allowed states as a state machine. Building one makes the states you see in docker ps concrete and is exactly the code you'd write for an internal ops tool.
Your task: Model the container lifecycle as a state machine in Python and reject illegal jumps: created→running, running→paused/stopped, paused→running/stopped, stopped→running/removed.
Requirements:
- Store the allowed next-states per state in a
TRANSITIONSmap - Every container starts in the
createdstate - A transition to a state not in the current state's allowed set raises
ValueError - Keep a
historylist and returnselfso calls can be chained - Demonstrate that an illegal jump such as created→removed is rejected
💡 Hint: Only a stopped container may be removed; guard each move against the allowed set for the current state before changing it.
Show solution
Store the allowed next-states per state, guard every transition, and keep a history. Returning self lets you chain calls.
class Container:
TRANSITIONS = {
"created": {"running"},
"running": {"paused", "stopped"},
"paused": {"running", "stopped"},
"stopped": {"running", "removed"},
"removed": set(),
}
def __init__(self, name):
self.name, self.state, self.history = name, "created", ["created"]
def to(self, new):
if new not in self.TRANSITIONS[self.state]:
raise ValueError(f"illegal: {self.state} -> {new}")
self.state = new
self.history.append(new)
return self
c = Container("web")
c.to("running").to("paused").to("running").to("stopped")
print(c.name, "->", " -> ".join(c.history))
try:
Container("x").to("removed") # created->removed is illegal
except ValueError as e:
print("rejected:", e)
The guard blocks nonsense transitions — only a stopped container may be removed, matching the states you see in docker ps.
Context: In production one runaway container must not starve its host, and dead containers and images must not fill the disk — both are real outage causes. Knowing when the kernel will OOM-kill a container tells you how to set a safe memory limit.
Your task: Run a container with resource limits so one bad process can't sink the host, then model in Python whether a container is OOM-killed given its peak memory versus its limit.
Requirements:
- Cap RAM and CPU with
--memoryand--cpusondocker run - Add
--restart=unless-stoppedso it auto-restarts on crash or reboot - Show housekeeping:
docker stats,docker system df,docker system prune -af - Model the OOM rule as peak memory exceeding the limit
- State the lesson: set the limit above the observed peak with headroom, or healthy containers die
💡 Hint: The kill decision is just peak > limit; the real skill is profiling actual memory use first, then leaving room above it.
Show solution
Cap memory and CPU, add a restart policy, and do housekeeping. Then a trivial model captures the OOM rule: peak above limit means the kernel kills it.
docker run -d --name web \
--memory=512m --cpus=1.0 \ # cap resources
--restart=unless-stopped \ # auto-restart on crash/reboot
-p 8000:8000 webapp
docker stats --no-stream # live CPU/mem per container
docker system df # disk used by images/containers
docker system prune -af # reclaim space (dangling images, stopped containers)
def will_oom(peak_mb, limit_mb):
return peak_mb > limit_mb
for peak, limit in [(300, 512), (700, 512)]:
verdict = "OOM-KILLED" if will_oom(peak, limit) else "ok"
print(f"peak {peak}MB vs limit {limit}MB -> {verdict}")
Lesson: profile real memory use, then set the limit above the observed peak with headroom — too tight and healthy containers die.
Context: A tech lead defines one approved standard so the whole fleet stays small, non-root, and safe without every engineer re-deriving the rules. Encoding that standard as a lint lets CI reject a bad Dockerfile before it ships.
Your task: As a tech lead, write a Python lint that enforces the team's Dockerfile policy in CI: flag an unpinned base image, a missing USER, a copied .env, and pip install without --no-cache-dir.
Requirements:
- Split the file into non-empty, non-comment lines before scanning
- Flag a base image on
:latestor with no tag as unpinned - Flag a missing
USERline (the container would run as root) - Flag a
COPY .env/ADD .env(a secret baked into the image) - Flag
pip installwithout--no-cache-dir(image bloat) - Return a list of human-readable issues — empty means the file passed the gate
💡 Hint: Return the reasons, not just a boolean; an empty issues list falling back to the word 'clean' is what makes the pass/fail readable in a CI log.
Show solution
Split the file into meaningful lines, then run one check per rule, appending a human-readable reason. An empty issue list means the Dockerfile passed the gate.
def lint_dockerfile(text):
lines = [l.strip() for l in text.strip().splitlines()
if l.strip() and not l.startswith("#")]
issues = []
froms = [l for l in lines if l.startswith("FROM ")]
if not froms:
issues.append("no FROM")
if any(l.endswith(":latest") or ":" not in l.split()[1] for l in froms):
issues.append("base image not pinned to a version (avoid :latest)")
if not any(l.startswith("USER ") for l in lines):
issues.append("no USER -> runs as root (security risk)")
if any(("COPY .env" in l or "ADD .env" in l) for l in lines):
issues.append("copies .env -> secret baked into the image")
if any("pip install" in l for l in lines) and \
not any("--no-cache-dir" in l for l in lines if "pip install" in l):
issues.append("pip without --no-cache-dir -> larger image")
return issues
bad = "FROM python:latest\nCOPY .env .\nRUN pip install -r requirements.txt"
good = "FROM python:3.12-slim\nRUN pip install --no-cache-dir -r requirements.txt\nRUN useradd app\nUSER app"
print("bad ->", lint_dockerfile(bad))
print("good ->", lint_dockerfile(good) or "clean")
Dropped into CI, this rejects a policy-violating Dockerfile before it ships — one approved standard, enforced, instead of every engineer re-deriving the rules.
✓ Checkpoint — you can move on when you can…
- Explain env drift, container vs VM, and when to still use a VM.
- Write a Dockerfile; distinguish RUN/CMD/ENTRYPOINT.
- Containerize a web app and debug it with logs/exec.
- Set resource limits and enforce a base-image standard.
Knowledge check check yourself
The chapter contrasts containers with virtual machines. What is the core technical difference, and why does it make containers the default for apps?
Show answer
Distinguish RUN, CMD, and ENTRYPOINT in a Dockerfile.