PYTH-260 · Lesson 6 of 12 · 22 min

Containers with Docker

▶ Video coming soon A video version of this lesson is on the way.

A wheel and a lock file pin down Ridgeline's Python packages, but a running program depends on more than Python packages. It needs a particular Python interpreter, operating system libraries such as the C library and SSL certificates, environment settings, and a command to start it. The wholesale ordering site from NET-120 runs on a cloud service that starts copies of the application on demand, on machines Ridgeline never sees. To make that work, the whole runtime has to travel with the code. Containers are the standard way to do that.

This lesson explains what containers and images are, how a Dockerfile builds an image layer by layer, how to order instructions so builds are fast, how multi-stage builds keep images small, how to run containers safely as a non-root user, how images are named and stored in registries, and how Docker Compose runs the application with its database on a laptop.

Containers and images

A container is an isolated process, or group of processes, running on a shared operating system kernel, with its own view of the filesystem, network and process list. To the program inside, it looks like a small computer of its own; to the host, it is just a process with limits. Because containers share the host's kernel instead of running a whole guest operating system, they start in a fraction of a second and use far less memory than virtual machines. Linux provides the isolation through namespaces, which give each container its own view of resources, and control groups, which limit how much CPU and memory it may use.

An image is the template a container is started from: a read-only filesystem with everything the program needs, plus metadata such as the default command, the user to run as and the environment variables. An image is to a container what a class is to an object in Python: one image, many running containers. Images are built once, stored in a registry, and pulled to wherever they need to run, so the image tested in staging is byte for byte the image that runs in production.

Docker, released in 2013, made containers popular. Its image format and runtime were later standardized by the Open Container Initiative (OCI), so images built with Docker also run under Podman, containerd, Kubernetes and every major cloud's container services. This lesson uses Docker's commands, which are the most widely known.

Layers

An image is a stack of layers. Each layer records the files added, changed or deleted by one build instruction. When a container starts, the layers are combined into a single view of the filesystem, with a thin writable layer on top for anything the running program writes. Layers are identified by a hash of their contents, so two images built on the same base share the base layers on disk and in the registry, and pushing or pulling an image transfers only the layers the other side does not already have.

A stack of six horizontal bars, bottom to top: Debian slim base, Python 3.12, virtual environment created, dependencies installed from requirements.lock, ridgeline wheel installed, and a thin writable container layer on top. Arrows on the right mark the lower layers as rarely changing and cached, and the wheel layer as changing on every release.
An image is a stack of layers; put rarely changing layers at the bottom so most builds reuse them

Writing a Dockerfile

A Dockerfile is a text file of instructions that docker build runs from top to bottom. Each instruction that changes the filesystem creates a layer. The common instructions are FROM (the base image), WORKDIR (the working folder), COPY (copy files from the build context into the image), RUN (run a command during the build), ENV (set environment variables), USER (switch to a non-root user), EXPOSE (document the port the program listens on), and CMD (the default command when a container starts). Here is the Dockerfile Jordan writes for the wholesale site:

Dockerfile for the Ridgeline wholesale web app (a multi-stage build)
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:/usr/local/bin:/usr/bin:/bin"
COPY requirements.lock .
RUN pip install --no-cache-dir --require-hashes -r requirements.lock
COPY dist/ridgeline_tools-0.6.0-py3-none-any.whl /tmp/
RUN pip install --no-cache-dir --no-deps /tmp/ridgeline_tools-0.6.0-py3-none-any.whl

FROM python:3.12-slim
RUN useradd --create-home --uid 10001 ridgeline
COPY --from=build /venv /venv
ENV PATH="/venv/bin:/usr/local/bin:/usr/bin:/bin" PYTHONUNBUFFERED=1
USER ridgeline
WORKDIR /home/ridgeline
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "ridgeline.web:app"]

It has two stages, explained below. In the first, named build, it starts from the official slim Python 3.12 image, creates a virtual environment, installs the exact dependencies from the hashed lock file (--require-hashes makes pip refuse anything not listed), then installs the ridgeline-tools wheel built in CI without letting pip fetch anything else (--no-deps). The second stage starts again from the slim image, creates an ordinary user, copies in only the finished virtual environment, switches to that user, and declares how to start the web server and how to check its health.

Ordering instructions for fast builds

Docker caches each layer. When you rebuild, it reuses a cached layer if the instruction and everything it depends on are unchanged, and rebuilds that layer and every layer after it otherwise. The order of instructions therefore decides how much work each rebuild does. The rule of thumb is to put things that change rarely at the top and things that change often at the bottom.

That is why the Dockerfile copies requirements.lock and installs dependencies before copying the wheel. Dependencies change every few weeks; the wheel changes with every release. With this order a typical release rebuilds only the last layer of the first stage, in seconds. If the wheel were copied first, every release would reinstall every dependency.

Why order matters: in the bad order, every release reinstalls all dependencies
def rebuilt_layers(instructions, changed_files):
    """Simulate Docker's layer cache: the first instruction that touches a changed file,
    and every instruction after it, must be rebuilt."""
    for i, (instruction, files) in enumerate(instructions):
        if set(files) & set(changed_files):
            return [ins for ins, _ in instructions[i:]]
    return []

good_order = [
    ("FROM python:3.12-slim", []),
    ("COPY requirements.lock .", ["requirements.lock"]),
    ("RUN pip install -r requirements.lock", ["requirements.lock"]),
    ("COPY ridgeline wheel", ["ridgeline.whl"]),
    ("RUN pip install ridgeline wheel", ["ridgeline.whl"]),
]
bad_order = [good_order[0], good_order[3], good_order[1], good_order[2], good_order[4]]

for name, order in [("good order", good_order), ("bad order", bad_order)]:
    redo = rebuilt_layers(order, ["ridgeline.whl"])
    print(f"{name}: new release rebuilds {len(redo)} layer(s): {redo}")
Output
good order: new release rebuilds 2 layer(s): ['COPY ridgeline wheel', 'RUN pip install ridgeline wheel']
bad order: new release rebuilds 4 layer(s): ['COPY ridgeline wheel', 'COPY requirements.lock .', 'RUN pip install -r requirements.lock', 'RUN pip install ridgeline wheel']

The build context matters too. docker build sends the folder you name, usually the current folder, to the builder, and COPY can only copy from it. A .dockerignore file, with the same syntax as .gitignore, keeps out files that should never reach an image: the .git folder, virtual environments, caches, test data and, above all, files holding secrets such as .env. Leaving them out also keeps unrelated changes from invalidating the cache.

A .dockerignore keeps secrets, history and clutter out of the build context
# .dockerignore
.git
.venv
__pycache__/
*.pyc
.pytest_cache/
.env
tests/
docs/

Multi-stage builds and small images

Building software needs tools that running it does not: compilers, build front ends, header files, caches. A multi-stage build uses one stage to build and a final stage that copies in only the results, so the shipped image contains none of the build tools. Smaller images download and start faster, cost less to store, and, most importantly, have fewer packages that could contain vulnerabilities.

Base image choice matters as much. The full python:3.12 image is built on Debian with many development tools and is close to a gigabyte. python:3.12-slim is a minimal Debian with Python, a little over a hundred megabytes. Alpine-based images are smaller still, but use a different C library (musl rather than glibc), which means many Python wheels do not fit and packages must be compiled, so slim is the usual choice for Python. Distroless images go further and remove the shell entirely; they are excellent for production but harder to debug.

Approximate image sizes for three ways of building the same app (illustrative figures)
layers_mb = {
    "single stage, python:3.12 base": {"base": 1020, "build tools": 0, "dependencies": 38, "app": 1},
    "single stage, slim base + compiler": {"base": 125, "build tools": 210, "dependencies": 38, "app": 1},
    "multi-stage, slim final image": {"base": 125, "build tools": 0, "dependencies": 38, "app": 1},
}
for name, parts in layers_mb.items():
    print(f"{name:<36} {sum(parts.values()):>5} MB")
Output
single stage, python:3.12 base        1059 MB
single stage, slim base + compiler     374 MB
multi-stage, slim final image          164 MB

Running containers safely

By default, the process inside a container runs as root. Container isolation is strong but not perfect, and a root process that escapes, or that is tricked into writing where it should not, can do far more damage than an ordinary user. The USER instruction switches to an unprivileged account, here ridgeline with user ID 10001, and every serious production image should do the same. Other good habits:

  • Run one main process per container. The web server is one container; the database is another; the morning report job is a third, started on a schedule.
  • Keep containers stateless and disposable. Anything worth keeping, such as uploaded files or the database, lives outside the container in a volume or a managed service, because a container can be replaced at any moment.
  • Log to standard output and standard error, not to files inside the container. The platform collects the streams (lesson 10). PYTHONUNBUFFERED=1 makes Python write output immediately instead of buffering it.
  • Never bake secrets into images. Anything in an image can be read by anyone who can pull it, even if a later layer deletes the file, because the earlier layer still contains it. Secrets are supplied at run time (lesson 7).
  • Scan images for known vulnerabilities in CI with a scanner such as Trivy or Grype, and rebuild regularly so base image security fixes are picked up.
Building and running the image locally; the process runs as the ridgeline user
> docker build -t ridgeline-web:0.6.0 .
> docker run --rm -p 8000:8000 --env-file .env.local ridgeline-web:0.6.0
> docker ps
CONTAINER ID   IMAGE                 STATUS                    PORTS
3f2b9c1d8e77   ridgeline-web:0.6.0   Up 12 seconds (healthy)   0.0.0.0:8000->8000/tcp
> docker exec -it 3f2b9c1d8e77 whoami
ridgeline

Naming images: tags and digests

A full image reference looks like registry/repository:tag, for example ghcr.io/ridgeline-roasters/ridgeline-web:0.6.0. The registry is the server that stores images: Docker Hub, GitHub Container Registry, or a cloud provider's registry. The repository is the image's name. The tag is a human-friendly label, usually a version. Tags can be moved: someone can push a new image under the same tag, and latest is just a tag that by convention points at the newest build.

For deployments, that mobility is a problem: deploying ridgeline-web:latest today and tomorrow might run different code. Every image also has a digest, a sha256 hash of its contents, written after an @ sign. A digest can never point at different contents. Ridgeline's pipeline tags each image with its version and the git commit, but deploys by digest, so the staging test and the production release are provably the same image.

Taking an image reference apart: registry, repository, tag and digest
def parse_image(ref):
    """Split registry/repository:tag@digest into parts (simplified rules)."""
    digest = None
    if "@" in ref:
        ref, digest = ref.split("@", 1)
    name, tag = ref, None
    last = ref.rsplit("/", 1)[-1]
    if ":" in last:
        name, tag = ref.rsplit(":", 1)
    first = name.split("/", 1)[0]
    if "/" in name and ("." in first or ":" in first or first == "localhost"):
        registry, repo = name.split("/", 1)
    else:
        registry, repo = "docker.io", name
    if tag is None and digest is None:
        tag = "latest"
    return registry, repo, tag, digest

for ref in ["python:3.12-slim", "ghcr.io/ridgeline-roasters/ridgeline-web:0.6.0",
            "localhost:5000/ridgeline-web", "ghcr.io/ridgeline-roasters/ridgeline-web@sha256:4f2a9c01"]:
    print(parse_image(ref))
Output
('docker.io', 'python', '3.12-slim', None)
('ghcr.io', 'ridgeline-roasters/ridgeline-web', '0.6.0', None)
('localhost:5000', 'ridgeline-web', 'latest', None)
('ghcr.io', 'ridgeline-roasters/ridgeline-web', None, 'sha256:4f2a9c01')

Docker Compose for local development

The wholesale site needs PostgreSQL as well as the web app. Docker Compose describes several containers that run together in one YAML file, compose.yaml, and starts them with docker compose up. Jordan's file runs the web image and a PostgreSQL container on a private network, with the database's data in a named volume that survives restarts. A new developer can run the whole system with one command instead of installing a database by hand, and the development setup stays close to production, as the twelve-factor guidelines recommend.

compose.yaml: the web app and its database for local development (the password is a development-only value)
services:
  web:
    build: .
    ports: ["8000:8000"]
    environment:
      RIDGELINE_DATABASE_URL: postgresql://ridgeline:devpassword@db:5432/ridgeline
      RIDGELINE_DEBUG: "1"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: ridgeline
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: ridgeline
    volumes: ["dbdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "ridgeline"]
volumes:
  dbdata:

Try it yourself

Try it yourself

Lint a Dockerfile

Write dockerfile_problems(text) that returns a list of problems found in a Dockerfile string, in this order: "no USER instruction" if no line starts with USER; "unpinned base image" for each FROM line whose image has no tag or uses the tag latest (ignore any "AS name" part); "secret copied into image" for each COPY or ADD line that mentions ".env". Instructions are case-insensitive.

def dockerfile_problems(text):
    return []

Try it yourself

Which layers rebuild?

Write rebuilt(instructions, changed) where instructions is a list of (instruction_text, files_it_uses) pairs in Dockerfile order and changed is a list of changed files. Return how many instructions must run again: the first instruction that uses a changed file and every instruction after it. Return 0 if nothing uses a changed file.

def rebuilt(instructions, changed):
    return len(instructions)

Try it yourself

Deploy by digest

Write deployable(ref) that returns True only for image references that are safe to deploy: they must include a digest written as "@sha256:" followed by at least 12 lower-case hexadecimal characters. Tags alone, including latest, are not enough.

def deployable(ref):
    return ":" in ref

Common mistakes

  • MistakeCopying the whole project before installing dependencies.

    InsteadCopy the lock file and install dependencies first, then copy the code, so most builds reuse the dependency layer.

  • MistakeRunning the application as root inside the container.

    InsteadCreate an unprivileged user and switch to it with USER.

  • MistakeCopying .env or other secrets into an image, even if a later step deletes them.

    InsteadExclude them with .dockerignore and supply secrets at run time.

  • MistakeDeploying the latest tag.

    InsteadDeploy a specific version, ideally by digest, so staging and production run the same image.

  • MistakeWriting logs to files inside the container.

    InsteadWrite to standard output and let the platform collect the stream.

Summary and key terms

  • A container is an isolated process sharing the host kernel; an image is the read-only template it starts from.
  • Images are stacks of content-addressed layers, shared between images and cached between builds.
  • A Dockerfile builds an image with FROM, COPY, RUN, ENV, USER, EXPOSE and CMD; order rarely changing steps first.
  • Multi-stage builds and slim base images keep build tools out of the shipped image.
  • Run as a non-root user, one process per container, stateless, logging to standard output, with no secrets baked in.
  • Images are named registry/repository:tag; tags can move, digests cannot, so deploy by digest.
  • Docker Compose runs the app with its database locally from one compose.yaml file.
container
An isolated process with its own view of the filesystem, network and processes, sharing the host kernel.
image
A read-only template of a filesystem plus metadata, from which containers are started.
layer
One step's filesystem changes in an image, identified by a hash of its contents.
Dockerfile
A file of instructions for building an image.
multi-stage build
A Dockerfile with separate build and final stages, so only results reach the final image.
registry
A server that stores and serves container images.
digest
A sha256 hash that identifies an image's exact contents.
Docker Compose
A tool that runs several related containers from one YAML file.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Why does the Dockerfile copy requirements.lock before the wheel?

    Show answer

    Dependencies change less often than the app, so most rebuilds reuse the cached dependency layer.

  2. A secret file was copied into one layer and deleted in the next. Is the image safe to share?

    Show answer

    No: the earlier layer still contains the file, and anyone with the image can extract it.

  3. What is the difference between ridgeline-web:0.6.0 and ridgeline-web@sha256:...?

    Show answer

    The tag can be moved to a different image; the digest always names the same contents.

  4. Why is python:3.12-slim usually preferred to an Alpine image for Python apps?

    Show answer

    Alpine uses musl instead of glibc, so many prebuilt wheels do not fit and packages must be compiled.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz