Guide

You inherit a FastAPI service. docker images reports 1.21 GB for its image. This page shrinks it. The method works for any image: measure first, then fix the biggest cost, then measure again.

Walking through the original

dockerfile
FROM python:3.12
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
CMD uvicorn main:app --host 0.0.0.0 --port 8080

Investigate before you change anything. docker history <image> lists the size of each layer. The open-source tool dive lets you browse what is inside each one. A typical breakdown looks like this. The sizes are approximate.

LayerApprox. sizeProblem
python:3.12 base~1,000 MBFull Debian with compilers and headers
COPY . .~60 MB.git, a local .venv, test fixtures
apt-get install~100 MBBuild tools the app does not need at runtime
pip install~90 MBDependencies plus pip's download cache

Step 1: add a .dockerignore

Exclude .git, .venv, __pycache__, tests/, *.md and .env*. That saves roughly 60 MB and stops the cache from breaking on every commit.

Step 2: multi-stage build on -slim

Compile in a builder stage that has build-essential. Install into a virtualenv, then copy only the virtualenv into a fresh slim stage. Swapping psycopg2 for psycopg[binary] may remove the need for libpq-dev and a compiler, because the binary package ships a prebuilt wheel.

dockerfile
FROM python:3.12-slim AS build
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.12-slim
ENV PATH="/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY --from=build /venv /venv
WORKDIR /app
COPY src/ ./src/
RUN useradd --uid 10001 --no-create-home app
USER 10001
EXPOSE 8080
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]

Step 3: add it up

python:3.12-slim base               ~125 MB
/venv (fastapi, uvicorn, pydantic,
       psycopg[binary], httpx)      ~ 20-30 MB
src/                                ~  1 MB
-------------------------------------------
total                               ~145-155 MB

That is roughly an 8x reduction. To go further, drop dependencies the code does not import. Teams often find pandas or boto3 pulled in for one helper. You can also move to distroless/python3-debian12 and copy the virtualenv in. The venv's Python version must match the distroless interpreter.

A heavy scientific stack is different. NumPy, SciPy and PyTorch wheels can be hundreds of MB to several GB alone, and no Dockerfile trick removes that.

Common mistakes

  • Shrinking without measuring. Guessing often fixes the wrong layer.
  • Leaving build tools in the runtime image. The compiler is needed once, so keep it in the build stage.
  • Trimming the base but not the dependencies. Unused packages can be most of the remaining size.

Check yourself

  1. Which layer is largest in the original, and why? Answer: The python:3.12 base, about 1,000 MB, because the full Debian image includes compilers and headers.
  2. Why does a smaller image also help shutdown? Answer: The exec-form CMD lets SIGTERM reach uvicorn, so the service shuts down gracefully instead of being killed.

Key takeaways

  • Measure each layer with docker history and dive before you change anything.
  • Use a .dockerignore, a multi-stage build and a slim base. Together they cut this image by about 8x.
  • Remove unused dependencies, since they are often the last large cost.
  • Smaller images also pull faster, so scaling and node replacement are faster.

Further learning

The next step is to see how Kubernetes schedules these containers and restarts them: Kubernetes core.