AI EngineeringZero to ProductionHome·About·Contact
Containers & Deployment · Chapter CD1

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.

⏱️ ~3.5 hours🧪 12 labs🎯 Beginner→Tech-lead
🌱 Start here — from zero Containers, from scratch — "but it works on my machine!" — this chapter takes you from that pain to running containers like an operator.

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):

TermWhat it actually means
containeran isolated, packaged app + dependencies, running as a process.
imagethe built blueprint; a container is a running instance of it.
Dockerfilethe recipe that builds an image, step by step.
registrywhere images are stored/shared (Docker Hub, GHCR, ECR).
port mappingexposing 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 --version to 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 essentialexpert 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.
▶ Runnable companionCode saved under 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · quantify 'environment drift' (runs)
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
▶ How this works

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.

  1. dev and prod are two dictionaries — each is a name→value list describing an environment (e.g. "python": "3.12").
  2. env_diff collects 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.
  3. The for loop prints each mismatch as key 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.

Your app + deps code+libs+runtime docker build the recipe Image shippable artifact Container (running) laptop→cloud
🗺️ How to read this diagram

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 Dockerfile describes 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 machineContainer
Virtualizeshardware + full OSjust the process
Sizegigabytesmegabytes
Startseconds–minutesmilliseconds
Densitya few per hosthundreds per host
Isolationstrong (own kernel)process-level (shared kernel)
When you still want a VMContainers share the host kernel, so for hostile multi-tenant workloads or a different OS kernel (Windows containers on Linux) you still use VMs — often VMs running containers. For your apps, containers are the default.

3 · Your first Dockerfile — line by line essential

A Dockerfile is a recipe read top-to-bottom. Four instructions get you running:

config · a minimal Dockerfile, annotated
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
▶ How this works

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.

  1. 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 a FROM.
  2. WORKDIR /app — sets the folder inside the image where later commands run (and creates it). Think of it as cd /app that sticks.
  3. COPY app.py . — copies app.py from your machine into the image (the . means "into the current WORKDIR"). Your code now travels with the image.
  4. CMD ["python", "app.py"] — the default command that runs when someone starts a container from this image. FROM/WORKDIR/COPY build the image; CMD is 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.

shell · build then run
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!
▶ How this works

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).

  1. docker build -t myapp . — reads the Dockerfile in the current folder (the .) and produces an image. -t myapp tags (names) it myapp so you can refer to it later.
  2. docker run myapp — starts a container (a running instance) from that image. It executes the image's CMD, so you see Hello from inside a container! printed.
  3. docker images lists the images you've built (name + size); docker ps -a lists containers — the -a shows 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.

InstructionRuns whenPurpose
RUNbuild timeinstall deps, set up the image
CMDcontainer startdefault command (easily overridden)
ENTRYPOINTcontainer startthe fixed program; CMD = its args
config · RUN vs CMD in context
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
▶ How this works

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).

  1. COPY requirements.txt . then RUN pip install … -r requirements.txtRUN executes at build time, so the installed libraries become part of the image. You pay this cost once, not on every start.
  2. COPY . . copies the rest of your source in after installing deps (a small speed trick you'll see explained in CD2).
  3. CMD ["python", "app.py"] is the default process a container runs. It's not baked like RUN — the comment shows docker run myapp python -m pytest overriding 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.

Python · the app's routing logic (runs, framework-free)
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'})
▶ How this works

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.

  1. routes is a dictionary mapping each URL path to a small function (lambda) that returns a (status_code, body) pair — e.g. 200 means OK, 404 means not found.
  2. routes.get(path) looks up the path. If it's found, h() calls that function; if not, we fall back to a 404 not found response.
  3. The for loop 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.

config · Dockerfile for the web 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"]
▶ How this works

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.

  1. COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt installs your libraries (e.g. fastapi, uvicorn) at build time. --no-cache-dir skips pip's cache to keep the image smaller.
  2. EXPOSE 8000 documents 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 -p flag at run time does that).
  3. CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] starts the web server. --host 0.0.0.0 is critical (see the warning below) and --port 8000 matches the EXPOSE.

Try this: Compare this to the four-line Dockerfile in section 3: same shape (FROMWORKDIRCOPYCMD), just with a RUN pip install for dependencies and an EXPOSE for the port.

shell · run with a port map
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
▶ How this works

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.

  1. docker build -t webapp . builds and tags the image webapp, just like before.
  2. docker run -p 8000:8000 webapp — the -p host:container flag maps your port 8000 to the container's port 8000. Reading it as 8000:8000 = outside:inside. Now http://localhost:8000 reaches the app.
  3. docker run -p 8000:8000 -e ENV=prod webapp adds -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.

shell · the debugging toolkit
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
▶ How this works

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.

  1. docker run -d --name web -p 8000:8000 webapp-d runs it detached (in the background) and --name web gives it a friendly name so you don't need its long ID.
  2. docker logs web shows everything the app printed (and any crash message); docker logs -f web follows it live, like tail -f.
  3. docker exec -it web bash opens an interactive shell inside the running container — the single most useful debugging command. Now you can ls the files, check env, and try imports exactly as the container sees them.
  4. docker exec web ls /app runs one command without a full shell; docker inspect web dumps 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.

90% of container bugs are found with 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.

Python · a validated lifecycle state machine (runs)
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
▶ How this works

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.

  1. TRANSITIONS is 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 to removed.
  2. __init__ starts every container in the "created" state and keeps a history list of where it's been.
  3. to(new) is the guard: if new isn't in the allowed set for the current state, it raises a ValueError. Otherwise it updates the state and records it. Returning self lets you chain calls like c.to("running").to("paused").
  4. The try/except at 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).

shell · limits + housekeeping
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)
▶ How this works

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.

  1. --memory=512m --cpus=1.0 cap 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.)
  2. --restart=unless-stopped tells Docker to automatically restart the container if it crashes or the host reboots — unless you stopped it on purpose.
  3. docker stats --no-stream prints a one-shot snapshot of live CPU/memory per container; docker system df shows how much disk images and containers are using.
  4. docker system prune -af reclaims 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.

Python · would this container be OOM-killed? (runs)
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
▶ How this works

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.

  1. 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.
  2. 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.

Python · lint a Dockerfile against team policy (runs)
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
▶ How this works

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.

  1. The first line splits the Dockerfile into non-empty, non-comment lines so the checks can scan them.
  2. It flags a base image not pinned to a version (using :latest or no tag is risky — the image can change under you); a missing USER line (so the container would run as root, a security risk); copying .env (which would bake secrets into the image); and pip install without --no-cache-dir (which bloats the image).
  3. It returns a list of issues — empty means the Dockerfile passed.
  4. The two examples at the bottom run the linter on a deliberately bad Dockerfile and a good one 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.

Standards scale a teamOne approved base + a CI lint means every service is small, non-root, reproducible — without each engineer re-deriving it. That's the CD2 best-practices checklist, enforced. Setting it is the tech-lead move.

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 WORKDIR or a bind to 127.0.0.1
  • Diagnose it with docker logs and docker exec, then fix it
  • Set a memory limit on the container with --memory
  • Run lint_dockerfile over 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.

Exercise 1 · Your first DockerfileBeginner

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.py into the image with COPY
  • Declare the run-time default with CMD ["python", "app.py"]
  • Show the docker build -t myapp . then docker run myapp loop 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
Exercise 2 · Run a web app with a port mapIntermediate

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.txt before copying the code
  • Document the listening port with EXPOSE 8000
  • Bind the server to 0.0.0.0 in the CMD, never 127.0.0.1
  • Publish the port at run time with docker run -p 8000:8000 (host:container)
  • Explain that EXPOSE alone does not open the port — the -p flag 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
Exercise 3 · Debug a container from the insideAdvanced

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 -f to 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 web for 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.

Exercise 4 · Model the container lifecycleExpert

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 TRANSITIONS map
  • Every container starts in the created state
  • A transition to a state not in the current state's allowed set raises ValueError
  • Keep a history list and return self so 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.

Exercise 5 · Resource limits & the OOM ruleProfessional

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 --memory and --cpus on docker run
  • Add --restart=unless-stopped so 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.

Exercise 6 · A CI Dockerfile policy lintIndustry scenario

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 :latest or with no tag as unpinned
  • Flag a missing USER line (the container would run as root)
  • Flag a COPY .env/ADD .env (a secret baked into the image)
  • Flag pip install without --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

✓ Knowledge check

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
A VM virtualizes hardware and runs a whole guest OS (gigabytes, boots in seconds-to-minutes), while a container shares the host kernel and isolates only your process and its files (megabytes, starts in milliseconds). That efficiency -- far higher density and near-instant start -- is why containers run modern apps; you still use VMs for hostile multi-tenant or a different OS kernel.
✓ Knowledge check

Distinguish RUN, CMD, and ENTRYPOINT in a Dockerfile.

Show answer
RUN executes at build time and its result is baked into the image (e.g. installing dependencies). CMD is the default command run when a container starts and is easily overridden. ENTRYPOINT is the fixed executable, with CMD supplying its default arguments.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in