Containers solved "it works on my machine" by shipping the machine along with the code: your application, its runtime and its libraries in one immutable image that runs the same on a laptop, in CI and in production. Everything later in this track, from CI/CD to Kubernetes, moves images around. This module teaches you to build images that are small, fast to rebuild and safe to run.
- Explain what a container is in terms of processes, namespaces and images, and how it differs from a virtual machine
- Write a Dockerfile that uses the layer cache well and produces a small image with a multi-stage build
- Run, inspect and debug containers: ports, environment, volumes, logs and
exec - Describe a multi-service development environment with Docker Compose
- Apply the basic hardening rules: non-root user, minimal base image, pinned versions, no secrets in layers
What a container actually is
A container is not a small virtual machine. It is an ordinary Linux process that the kernel has isolated. Namespaces give it its own view of the process list, network interfaces, hostname and filesystem; cgroups cap how much CPU and memory it may use. There is no guest operating system and no boot, which is why a container starts in milliseconds and a host can run hundreds.
An image is the read-only template: a filesystem plus metadata such as the default command. A container is a running (or stopped) instance of an image with a thin writable layer on top. Delete the container and that layer is gone, so anything worth keeping must live in a volume or an external service. Images are stored in and pulled from a registry.
| Virtual machine | Container | |
|---|---|---|
| Isolation | Own kernel, virtual hardware | Shares the host kernel |
| Start time | Tens of seconds | Well under a second |
| Size | Gigabytes | Megabytes to a few hundred |
| Boundary strength | Strong | Weaker: a kernel bug affects everyone |
Because containers share the host kernel, Linux containers need a Linux kernel. Docker Desktop on macOS and Windows quietly runs a small Linux VM for you.
Running and inspecting containers
docker run --rm -it ubuntu:24.04 bash # interactive shell, removed on exit
docker run -d --name web -p 8080:80 nginx:1.27 # detached; host 8080 -> container 80
docker ps # running containers (-a includes stopped)
docker logs -f web # follow stdout/stderr
docker exec -it web sh # a shell inside the running container
docker inspect web # full JSON: mounts, network, env, state
docker stop web && docker rm web # SIGTERM, then removeThree flags carry most real-world configuration. -p HOST:CONTAINER publishes a port. -e NAME=value sets an environment variable, which is how twelve-factor apps receive configuration. -v mounts storage: a named volume (-v pgdata:/var/lib/postgresql/data) is managed by Docker and survives the container, while a bind mount (-v "$PWD":/app) maps a host directory in, which is handy in development.
docker stop sends SIGTERM, waits ten seconds by default, then sends SIGKILL, exactly the sequence from the Linux module. A container that always takes ten seconds to stop is one whose main process is not handling SIGTERM.
Dockerfiles and the layer cache
A Dockerfile is the recipe for an image. Each instruction produces a layer, and layers are cached: if an instruction and everything it depends on are unchanged, Docker reuses the previous result. The moment one layer changes, every layer after it is rebuilt. So the ordering rule is: put what changes least first.
FROM python:3.12-slim
WORKDIR /app
# 1. dependencies change rarely: copy only the manifest, then install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. source code changes constantly: copy it last
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]With that order, editing a source file invalidates only the final COPY, and the slow dependency install is served from cache. Copying everything before pip install would reinstall every dependency on every build.
- A
.dockerignorefile keeps.git,node_modules, virtualenvs and local.envfiles out of the build context, which speeds up builds and prevents secrets from leaking into the image. CMDin exec form (a JSON array) runs your program as PID 1 so it receives signals. The shell form (CMD gunicorn ...) wraps it in/bin/sh -c, and the shell may not forwardSIGTERM.ENTRYPOINTfixes the executable andCMDsupplies default arguments thatdocker run IMAGE argscan override.EXPOSEis documentation only. Publishing a port still needs-p.- Chain related commands in one
RUNand clean up in the same instruction; a file deleted in a later layer still takes up space in the earlier one.
docker build -t myapp:1.4.0 . # build from ./Dockerfile with context .
docker history myapp:1.4.0 # size of each layer
docker images # local images and their sizesMulti-stage builds
Compilers, build tools and dev dependencies are needed to build your app but not to run it. A multi-stage build uses one stage with the full toolchain to produce the artifact, then copies only that artifact into a clean, minimal final image. Everything in the earlier stages is discarded.
# ---- build stage: full Go toolchain ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# ---- final stage: just the binary ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]The build stage is hundreds of megabytes; the final image is the size of the binary plus a few megabytes. A smaller image pulls faster, starts faster on a new node, and above all contains less software that can have vulnerabilities. The same pattern works for Node (build the bundle, serve it from a slim image), Java (build with a JDK, run on a JRE) and Python (build wheels, install them into a slim image).
| Base image | Character |
|---|---|
ubuntu, debian | Full distribution. Easy to debug, large, many packages to patch. |
*-slim | Trimmed Debian. A good default for interpreted languages. |
alpine | Very small, but uses musl libc, which occasionally breaks native Python or Node modules. |
distroless / scratch | No shell or package manager at all. Smallest attack surface, harder to debug. |
Compose for multi-container development
Real applications need a database, a cache, perhaps a queue. Docker Compose describes the whole set in one compose.yaml and starts it with one command. Every service joins a private network where it can reach the others by service name, so the app connects to the hostname db, not to localhost.
services:
app:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://app:devpassword@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
volumes:
pgdata:docker compose up -d --build # build, create and start everything
docker compose ps # status, including health
docker compose logs -f app # follow one service
docker compose exec db psql -U app # run a command in a service
docker compose down # stop and remove (add -v to delete volumes too)Plain depends_on only orders start-up; it does not wait for the database to accept connections. The healthcheck plus condition: service_healthy does. Compose is a development and single-host tool. In production the same images run under an orchestrator, which is the Kubernetes module.
Small and secure by default
- Do not run as root. By default the process in a container is root, and a container escape then lands as root on the host. Add a
USERinstruction. - Pin the base image.
FROM python:latestmeans tomorrow's build may differ from today's. Pin a specific version, and for full reproducibility pin the digest (image@sha256:...). - Never bake secrets in.
ENV,ARGandCOPYall persist in image layers and show up indocker history. Inject secrets at runtime, or use BuildKit'sRUN --mount=type=secretfor build-time credentials. - Keep it minimal and rebuild often. Fewer packages means fewer CVEs. Rebuilding regularly picks up base-image patches; scanning is covered in the supply chain module.
- One concern per container. Logs go to stdout and stderr, where
docker logsand every orchestrator collect them.
Mounting /var/run/docker.sock into a container, or running with --privileged, hands that container control of the host. Treat both as equivalent to giving it root on the machine.