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
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.
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
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
A Dockerfile is a recipe read top-to-bottom. Four instructions get you running:
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
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.
| 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
Real apps have dependencies and listen on a port. Here's the app (runnable Python logic) and the Dockerfile that ships it.
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
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.
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
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.
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
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).
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
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.
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.