Images and Containers
Why this matters
Every Docker workflow is "build (or pull) an image, then run one or more containers from it." Understanding what an image actually is — layers, not a monolithic blob — explains why builds are fast on the second try, why disk usage doesn't multiply per container, and why "it works on my machine" bugs shrink dramatically once you're always running from the same image.
Concepts
An image is a read-only template
An image is a read-only template: a stack of filesystem layers plus metadata (the default command to run, exposed ports, environment variables). It doesn't run; it's the blueprint you run from. Nothing you do to a running container changes the image it came from.
A container is a running instance of an image
A container is a running (or stopped) instance created from an image,
with one thin writable layer added on top. Many containers can be
created from the same image at once — think of the image as a class and
each container as an instance of it. In Linux terms: the image is a
read-only base filesystem, and the container is that filesystem plus a
private overlay of changes (Docker's storage drivers implement this with
overlayfs, the same read-only-plus-read-write stacking you may have
seen with mount -t overlay).
Layers come from Dockerfile instructions
Each instruction in a Dockerfile that changes the filesystem (RUN,
COPY, ADD) produces a new, cached, content-addressed layer. Layers
are shared across images: if two images both start FROM python:3.12-slim,
they share that base's layers on disk instead of duplicating them. This
is why docker pull for an image that shares a base with one you already
have is fast — it only downloads the layers you don't already have.
Container A Container B
┌──────────────┐ ┌──────────────┐
│ writable ▲ │ │ writable ▲ │ ← per-container, thin, R/W
│ layer (R/W) │ │ layer (R/W) │
└──────┬───────┘ └──────┬───────┘
└───────────┬──────────────┘
┌───────────▼──────────────┐
│ CMD ["python","app.py"] │ (image metadata)
│ COPY app.py . │ ← layer ┐
│ RUN pip install ... │ ← layer │ read-only
│ COPY requirements.txt . │ ← layer │ image layers,
│ FROM python:3.12-slim │ ← layers ┘ SHARED on disk
└──────────────────────────┘
Both containers reuse the same read-only layers; only their
writable top layer differs.
In Docker Desktop: the Images tab lists every local image and its size, the GUI equivalent of
docker images. Click an image to see its layers and the command that created each — the same informationdocker historyprints.
The container lifecycle maps to process states
The lifecycle states line up with process states you already know from
ps:
docker create— makes a container (writable layer + config) without starting it. Rarely used directly.docker run— create, then start, then (by default) attach your terminal to its stdout/stderr. It'screate+start+attach.- Running — PID 1 inside the container is executing.
- Exited — PID 1 has terminated; the writable layer and metadata
still exist on disk until you
docker rmit. docker stop— sendsSIGTERM, waits a grace period, thenSIGKILLif needed (the same signal-then-timeout-then-kill pattern you'd script withkillandsleep).docker rm— deletes a stopped container's writable layer and metadata permanently.
image ──create──► [Created] ──start──► [Running] ──stop──► [Exited]
▲ │ │
│ │ start │ rm
└────────────────────┘ ▼
(docker run = create + start + attach) (gone)
In Docker Desktop: the Containers tab shows each container's state (
Running,Exited) live. The Start/Stop/Delete buttons on each row are the GUI equivalents ofdocker start,docker stop, anddocker rm.
exec and logs let you look inside
docker exec starts a new process inside an already-running
container's namespaces — useful for poking around a live container
(closer to nsenter than SSH). docker logs replays the
stdout/stderr that the container's PID 1 has produced, buffered by the
Docker daemon — which is why well-behaved containers log to
stdout/stderr rather than to a file, the same convention you'd follow for
a daemon on bare Linux.
In Docker Desktop: click a running container and use its Logs sub-tab (equivalent to
docker logs), its Terminal or Exec sub-tab (equivalent todocker exec -it), and its Inspect sub-tab (equivalent todocker inspect).
Command reference
| Command | What it does | Example |
|---|---|---|
docker pull <image>[:tag] |
Downloads an image (and any missing layers) from a registry | docker pull python:3.12-slim |
docker images |
Lists locally stored images | docker images |
docker run [flags] <image> [cmd] |
Creates and starts a container from an image | docker run -d --name web -p 8000:8000 webapp |
docker ps |
Lists running containers | docker ps |
docker ps -a |
Lists all containers, running or stopped | docker ps -a |
docker logs [-f] <container> |
Shows a container's stdout/stderr; -f follows/streams it |
docker logs -f web |
docker exec [-it] <container> <cmd> |
Runs an additional command inside a running container | docker exec -it web bash |
docker stop <container> |
Sends SIGTERM, waits, then SIGKILL if needed |
docker stop web |
docker start <container> |
Restarts a previously stopped (not removed) container | docker start web |
docker rm [-f] <container> |
Removes a stopped container; -f force-stops first |
docker rm -f web |
docker inspect <container|image> |
Dumps full JSON metadata: config, mounts, network, state | docker inspect web |
docker image rm <image> |
Deletes a local image (fails if a container still references it) | docker image rm webapp |
docker history <image> |
Lists an image's layers with the command that created each | docker history webapp |
docker system df |
Shows disk usage for images, containers, volumes, build cache | docker system df |
Flag breakdown for docker run -d --name web -p 8000:8000 webapp:
-d(detached) — run in the background and return your prompt, instead of attaching to the container's output.--name web— give the container a fixed, memorable name so you can refer to it aswebin later commands instead of a random ID/name.-p 8000:8000— publish a port, inhost:containerorder: traffic to port8000on your host is forwarded to port8000inside the container. (Ports get a full treatment in module 05.)webapp— the image to create the container from.
Two more that take flags:
docker logs -f web—-f(follow) streams new log lines as they arrive instead of printing what exists and exiting.docker exec -it web bash—-ikeeps STDIN open and-tallocates a TTY, together giving you an interactive shell insideweb.
Hands-on exercises
Several exercises use a tiny self-contained example app. You'll create it inline in exercise 2 — there is nothing to download.
(WSL2 Ubuntu terminal) Pull a small base image and inspect its layers:
docker pull python:3.12-slim docker history python:3.12-slimExpect a multi-row table, each row one layer, with a
CREATED BYcommand and a size — this is the base image your example app will be builtFROM.(WSL2 Ubuntu terminal) Create the example app from scratch, then build its image. Everything here is written by you — no external folder needed:
mkdir -p ~/learn-docker/webapp && cd ~/learn-docker/webapp cat > app.py <<'EOF' from flask import Flask app = Flask(__name__) @app.get("/health") def health(): return {"status": "ok"} @app.get("/") def index(): return "Hello from the example app\n" if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) EOF cat > requirements.txt <<'EOF' flask==3.0.3 EOF cat > Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"] EOF docker build -t webapp .Flag breakdown for
docker build -t webapp .:-t webapptags the resulting image with the namewebapp; the.is the build context — the current directory, whose files are sent to the daemon. Expect several build steps ending in a line naming the imagewebapp:latest.(WSL2 Ubuntu terminal) Run it detached and confirm it's live:
docker run -d --name web -p 8000:8000 webapp docker ps curl -s http://localhost:8000/healthExpect
docker psto showwebasUp, and thecurlto print{"status":"ok"}.(Docker Desktop GUI, then CLI) Read logs and open a shell from the GUI. In Docker Desktop's Containers tab, click
web. On its Logs sub-tab you'll see Flask's startup lines (including a line about serving on0.0.0.0:8000). Now open the container's Terminal (or Exec) sub-tab and runcat /etc/os-release— you're inside the Debian-basedpython:3.12-slimfilesystem, not your Ubuntu host. Cross-check both from the CLI:docker logs web docker exec -it web bash -c "cat /etc/os-release"Expect the CLI logs and the GUI Logs sub-tab to match exactly — same engine, two views.
(WSL2 Ubuntu terminal) Run a second container from the same image to see "class vs. instance" directly:
docker run -d --name web2 -p 8001:8000 webapp docker ps curl -s http://localhost:8001/healthFlag note:
-p 8001:8000maps host port8001to the same container port8000, so the two containers don't collide on the host. Expect bothwebandweb2listed as separate running containers from one image, each serving on its own published port. You'll also see both in the Containers tab.(WSL2 Ubuntu terminal) Stop, then restart (don't remove) a container and confirm state:
docker stop web docker ps -a docker start web docker psExpect
webto showExitedafterstopandUpagain afterstart—stop/startdoesn't destroy the container, just pauses its process.(WSL2 Ubuntu terminal) Confirm an exited container's filesystem changes persist until removal. Write a file, stop, restart, re-read:
docker exec web sh -c "echo hello > /tmp/note.txt" docker stop web && docker start web docker exec web cat /tmp/note.txtExpect
helloto print — the writable layer survived stop/start. It would not survivedocker rm+ a freshdocker run, since that creates a brand-new writable layer.(WSL2 Ubuntu terminal) Clean up the extra containers and check image disk usage:
docker rm -f web web2 docker system dfExpect the containers gone from
docker ps -a, anddocker system dfstill showing thewebappandpython:3.12-slimimages taking disk space (images survive container removal). The Images tab confirms both are still present.Diagnose and fix: container exits immediately. Try this deliberately broken run:
docker run -d --name broken webapp python -c "print('hi')" docker ps -a docker logs brokenExpect
brokento showExited (0)almost immediately — you overrode the image'sCMD(which starts the long-running Flask server) with a one-shotpython -ccommand that finishes instantly and exits, so the container has nothing left to run. This is one of the most common real-world "why did my container die" causes: the foreground process finished. You can watch it flashExitedin the Containers tab too. Fix it by running without the override so the image's originalCMDtakes over:docker rm broken docker run -d --name fixed -p 8002:8000 webapp docker psExpect
fixedto stayUp.(WSL2 Ubuntu terminal) Clean up everything from this module:
docker rm -f fixed docker image rm webapp docker ps -a docker imagesExpect no leftover containers from this exercise, and
webappgone fromdocker images(the pulledpython:3.12-slimbase may remain shared/cached — that's expected and fine). You can do the deletion in the GUI instead: deletefixedfrom the Containers tab, then delete thewebappimage from the Images tab, and the CLI will reflect the same result.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Build the small Flask example image this module uses, then design an experiment that proves the "one image, many independent writable layers" claim rather than just reading it. Run two containers from the same image, make a distinct change inside each one's filesystem (write a different file, or different contents to the same path), and demonstrate that neither container can see the other's change and that neither change is present in the image itself. Then show that one of those changes survives a stop/start but is destroyed by a remove-and-recreate. Connect this back to module 01: explain why the writable layer is per-container in the same way each container's PID and network namespace was per-container there.
Stuck? One hint
Use docker exec to write into each running container and to read back from the other, and remember that a fresh docker run after docker rm starts a brand-new writable layer while docker stop/docker start reuses the existing one.
Common mistakes & troubleshooting
- "My container exited immediately and I don't know why." Almost
always: the main process finished or crashed. Run
docker logs <name>first, always — or open the container's Logs sub-tab in Docker Desktop. It's the single highest-value diagnostic in Docker. If logs are empty, check whether you accidentally overrodeCMDwith something that exits instantly (exercise 9). - Confusing
docker stop(pauses, keeps container) withdocker rm(permanently deletes it).stopis reversible withstart;rmis not — the writable layer is gone. - Trying to
docker image rman image still used by a container. Docker refuses; you mustdocker rmthe container(s) first, or usedocker image rm -f. - Expecting
docker exec -it <name> bashto work on every image. Minimal base images (e.g.scratch,distroless) don't ship a shell at all — you'll get "exec: bash: not found." Trysh, or accept that some hardened images aren't meant to be shelled into (module 07). - Thinking edits inside a running container survive a rebuild. They don't — a rebuilt image is a fresh set of layers; a container run from it starts with a brand-new writable layer with none of your live edits. Persistent data needs a volume (module 04).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- What's the relationship between an image and a container — which one is read-only and which has a writable layer?
- Why does pulling a second image that shares a base image with one you already have go faster than pulling both from scratch?
- What's the first command you should run when a container has exited unexpectedly, and where's the GUI equivalent?
- What's the difference between
docker stopanddocker rm, in terms of what's recoverable? - Why did the
brokencontainer in exercise 9 exit immediately even though the image itself runs a working web server by default? - What does
docker execactually do, in terms of namespaces, and how is that different from whatdocker rundoes? - If you write a file inside a running container's filesystem and then
docker stop+docker startit, is the file still there? What if you insteaddocker rmit anddocker runa new container?
Show answers
- The image is the read-only template (stacked layers); the container is a running/stopped instance with one thin writable layer added on top of that image's layers.
- Docker only downloads the layers it doesn't already have locally —
shared base layers (a common
FROMimage) are reused, not re-downloaded. docker logs <container>— it shows whatever the main process printed before exiting. In Docker Desktop, the same output is under the container's Logs sub-tab.docker stoppauses the container's process but keeps its writable layer and metadata, sodocker startresumes it;docker rmpermanently deletes the writable layer and metadata.- Providing a command after the image name in
docker runoverrides the image's defaultCMDentirely —python -c "print('hi')"replaced the server command, finished instantly, so the container had nothing left running. docker execstarts a brand-new process inside the namespaces of an already-running container;docker runcreates a whole new container (new writable layer, new namespaces) from an image and starts its main process.- Yes, the file survives
stop/start— the writable layer isn't touched. No, it does not surviverm+ a freshrun— that creates an entirely new writable layer with none of the previous changes.
Further reading & sources
- Docker: Images and layers (storage drivers) - the authoritative explanation of stacked read-only layers plus the writable container layer.
- Docker: The container lifecycle / docker run reference - full reference for the create/start/stop states and every
docker runflag used here. - Docker: docker exec reference - details on running an additional process inside a live container's namespaces.
- Docker: docker logs reference - how the daemon buffers stdout/stderr and why containers should log there.
- Docker: Overlay filesystem driver overview - ties image layering back to the
overlayfsmechanism you met on the Linux track.
Next
Continue to
03-dockerfile-deep-dive to learn
how the layers you just poked at with docker history actually get
authored.