4.5.11 · D4Software Engineering

Exercises — Docker — images, containers, Dockerfile, docker-compose

2,771 words13 min readBack to topic

Before we start, one promise: every symbol is earned before use. If a command flag or term appears, it was defined in the parent note or is explained right here.


L1 — Recognition

At this level you only need to name and match things. No building yet.

Exercise 1.1 — Match the object to its job

Match each of the four Docker objects to its one-line job:

Object Candidate jobs
Image (a) the running, living instance
Container (b) the frozen read-only recipe/snapshot
Dockerfile (c) the multi-service party menu (YAML)
docker-compose (d) the plain-text build script
Recall Solution 1.1
  • Image → (b) the frozen read-only recipe/snapshot.
  • Container → (a) the running, living instance.
  • Dockerfile → (d) the plain-text build script.
  • docker-compose → (c) the multi-service party menu (YAML).

Why: recall the mnemonic "I Can't Do Compose" = Image, Container, Dockerfile, Compose. The image is the class, the container is the object built from it.

Exercise 1.2 — Read the port mapping

You run docker run -d -p 8080:8000 myapp. A user's browser must connect to which port on the host machine, and that traffic arrives at which port inside the container?

Recall Solution 1.2

The flag is -p HOST:CONTAINER. So -p 8080:8000 means:

  • Browser knocks on host port 8080 (the door outside).
  • Docker forwards it to container port 8000 (the room inside).

Mnemonic: "Host is the Hello, Container is the Code." Host is always on the left.

Exercise 1.3 — Which layer is writable?

In a running container built from nginx:1.25, which single layer can be written to, and what happens to what you wrote when the container is deleted?

Recall Solution 1.3

Only the thin top writable layer (added by Docker on top of the read-only image layers) can be written to. When the container is deleted, that writable layer — and everything you wrote into it — is destroyed. To keep data you must use a volume.


L2 — Application

Now you use the tools: write commands and small Dockerfile snippets.

Exercise 2.1 — Build then run

Write the two commands that (a) build an image named web with tag 2.0 from the Dockerfile in the current directory, and (b) run it detached, mapping host port 3000 to container port 80.

Recall Solution 2.1
docker build -t web:2.0 .
docker run -d -p 3000:80 web:2.0
  • -t web:2.0 tags the image name:tag.
  • . is the build context (current directory, where the Dockerfile lives).
  • -d = detached (runs in background).
  • -p 3000:80 = host 3000 → container 80.

Exercise 2.2 — Finish the cache-friendly Dockerfile

Fill in the two missing lines so a Node app installs dependencies in a cached layer and only re-copies source on code edits. package.json is the manifest; npm install installs deps.

FROM node:20-slim
WORKDIR /app
______(A)______           # copy manifest only
______(B)______           # install deps (cached layer)
COPY . .                  # copy the rest of the source
CMD ["node", "server.js"]
Recall Solution 2.2
COPY package.json .       # (A) copy manifest only
RUN npm install           # (B) install deps -> cached layer

Why this order: the RUN npm install layer's inputs are only package.json. As long as that file is unchanged, Docker reuses the cached layer and skips the (slow) install. COPY . . sits below it, so editing your source code only invalidates that last copy — not the install.

Exercise 2.3 — Open a shell inside a running container

A container is running with id a1b2c3. Write the command to open an interactive bash shell inside it, then the command to view its logs.

Recall Solution 2.3
docker exec -it a1b2c3 bash    # -i interactive, -t tty -> a usable shell
docker logs a1b2c3            # prints the container's stdout/stderr

exec runs a new command in an already running container; -it gives you a live terminal.


L3 — Analysis

Now you reason about behaviour: predict outputs, count rebuilds, explain why.

Exercise 3.1 — Count the rebuilt layers

Given this Dockerfile (7 layer-producing instructions), you edit only app.py (which is copied by COPY . .) and rebuild. How many layers rebuild, and which is the first one to be invalidated?

FROM python:3.12-slim     # layer 1
WORKDIR /app              # layer 2
COPY requirements.txt .   # layer 3
RUN pip install -r requirements.txt   # layer 4
COPY . .                  # layer 5  (app.py lives here)
EXPOSE 8000               # layer 6
CMD ["python","app.py"]   # layer 7
Recall Solution 3.1

Editing app.py changes the inputs to layer 5 (COPY . .). Layers 1–4 are unchanged, so they are pulled from cache. Layer 5 rebuilds, and because every layer below an invalidated layer must also rebuild, layers 6 and 7 rebuild too.

  • First invalidated layer: layer 5.
  • Layers rebuilt: 5, 6, 7 → 3 layers.
  • Crucially, the slow pip install (layer 4) is not re-run. That's the whole point of the ordering. See the figure below.
Figure — Docker — images, containers, Dockerfile, docker-compose

Exercise 3.2 — Where did my data go?

You run a Postgres container, insert 100 rows, then docker rm the container and start a fresh one from the same postgres:16 image. The 100 rows are gone. Explain precisely why, and give the one-word fix.

Recall Solution 3.2

The rows were written into the container's thin writable layer. That layer is part of the container, not the image, and it is destroyed when the container is removed. The new container starts from the pristine read-only image layers again — empty. Fix in one word: a volume. Mount a named volume at Postgres's data directory:

volumes:
  - pgdata:/var/lib/postgresql/data

The volume lives outside the container lifecycle, so data survives docker rm.

Exercise 3.3 — RUN vs CMD timing

In the Dockerfile above, RUN pip install ... runs at build time and CMD ["python","app.py"] runs at container start time. If you moved the pip install into CMD instead, what breaks and why?

Recall Solution 3.3

RUN executes while building the image — its result is baked into a layer, done once. CMD executes every time a container starts and defines the main process. If you put pip install in CMD:

  1. It re-installs dependencies on every container start, wasting time and needing network access at runtime.
  2. CMD can only be one command (the main process), so you'd lose the ability to actually run python app.py — the install would become the main process and the container would exit once it finished.

Rule: build-time setup → RUN; the single long-lived process → CMD.


L4 — Synthesis

Now you design: assemble multiple pieces into a working whole.

Exercise 4.1 — Compose a web + db stack

Write a minimal docker-compose.yml with two services: web (built from the local Dockerfile, host port 5000 → container port 8000) and db (image postgres:16, data persisted in a named volume dbdata). web must start after db, and must reach the database using the hostname db.

Recall Solution 4.1
services:
  web:
    build: .
    ports:
      - "5000:8000"
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
  db:
    image: postgres:16
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:

Why each piece:

  • depends_on: [db] → guarantees start order (db first).
  • web reaches db by the name db because Compose builds a shared network and registers each service name as a DNS hostname — no IP addresses needed. See Environment Variables and 12-Factor App for why DATABASE_URL is an env var, not hardcoded.
  • The named volume dbdata stores Postgres data outside the container so docker compose down doesn't wipe it — see Volumes and Persistent Storage.

Exercise 4.2 — ENTRYPOINT + CMD for overridable args

Design the last two Dockerfile lines so the container always runs python app.py, but the user can override the port argument at docker run time (default --port 8000).

Recall Solution 4.2
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]

Why: ENTRYPOINT is the fixed executable — it can't be swapped away by a normal docker run. CMD supplies default arguments that are overridable. So:

  • docker run myapp → runs python app.py --port 8000.
  • docker run myapp --port 9000 → the docker run args replace CMD, giving python app.py --port 9000.

This is the pattern for CLI-style images.


L5 — Mastery

Now you reason across the whole system, including tradeoffs and edge cases.

Exercise 5.1 — Why a container is not a VM (defend it)

A teammate says "a container is just a tiny VM." Give two precise technical reasons this is wrong, naming the Linux mechanisms involved.

Recall Solution 5.1
  1. Shared kernel, not a guest OS. A VM virtualizes entire hardware and boots a full guest operating system on a hypervisor (gigabytes, slow). A container is just an isolated process that shares the host's Linux kernel — no guest OS. That's why it boots in milliseconds and weighs megabytes.
  2. Isolation is done by kernel features, not virtualized hardware. Containers use Linux *namespaces* to isolate what a process can see (its own PIDs, network, mounts) and cgroups to limit what it can use (CPU, memory). A VM instead relies on the hypervisor emulating hardware.

Deeper contrast in Virtual Machines vs Containers.

Exercise 5.2 — Predict the build-cache behaviour across a team

Two developers, Amy and Ben, build the same Dockerfile (from Ex 3.1) on their own laptops. Amy edits requirements.txt; Ben edits only app.py. For each, state which layers rebuild and why the outcomes differ.

Recall Solution 5.2
  • Amy (edits requirements.txt) invalidates layer 3 (COPY requirements.txt .). Everything below rebuilds: layers 3, 4, 5, 6, 7 = 5 layers, including the slow pip install.
  • Ben (edits app.py) invalidates layer 5 (COPY . .). Rebuilds layers 5, 6, 7 = 3 layers; the pip install (layer 4) stays cached.

Why they differ: cache invalidation depends on which layer's inputs changed and how high it sits. Changing a manifest (high layer) is expensive because more layers sit below it; changing code (low layer) is cheap. This is exactly why the Dockerfile is ordered least-changing → most-changing. See the figure in Ex 3.1.

Exercise 5.3 — Design decision: named volume vs bind mount

You want (a) a database whose files Docker manages and that survives container deletion, and (b) a live-reloading dev setup where editing code on your laptop instantly updates the container. Which storage type for each, and why?

Recall Solution 5.3
  • (a) Database → named volume (e.g. dbdata:/var/lib/postgresql/data). Docker manages its location on disk; it survives docker rm / docker compose down. You don't care about its exact host path — you just want durability.
  • (b) Live dev → bind mount (e.g. ./src:/app/src). A bind mount maps a specific host directory into the container, so edits on your laptop appear inside the container immediately (great for hot-reload). The tradeoff: it's tied to that exact host path, so it's less portable.

Rule of thumb: managed durable data → named volume; live host files → bind mount. More in Volumes and Persistent Storage.


Recall One-screen self-check (cover the answers)

Image vs container — which is immutable and shared? ::: The image (read-only, shared layers). The container adds a writable layer. -p 8080:8000 — which number is the host? ::: 8080, the left one. Host is the Hello. Editing one line of code rebuilds which layers in the Ex 3.1 Dockerfile? ::: Layer 5 (COPY . .) and everything below it: 5, 6, 7. RUN runs at ___ time; CMD runs at ___ time. ::: Build time; container start time. Two containers reach each other by service name because Compose provides ___. ::: A shared network with each service registered as a DNS hostname. Data survives docker rm only if you use a ___. ::: Volume (named volume or bind mount).