Blazing fast Python Docker builds with Poetry 🏃

How we can turn slow and tedious Docker builds into seamless operations

Riccardo Albertazzi
8 min readApr 28, 2023
A view of Annapurna from its south face. Legend says only people who know how to optimize Docker builds can reach the top.

Building Docker images for your project typically involves installing its dependencies in a reproducible and deterministic way. In the Python community Poetry is one of the most affirmed tools to achieve that. However, a non-optimal usage of Poetry in your Docker builds can result in poor performance and long builds, which in the end hinder developer productivity.

This article assumes that you are already familiar with both Poetry and Docker — in particular how Docker layer caching works— and that you are looking for a way to optimize your builds. I structured the article from naive to more optimized solutions to let the reader understand the impact of each optimization. Enough about introduction, let’s see some Dockerfiles! 💪

0. Project structure

Let’s use a toy project to reason about. I randomly named it annapurna as one the best mountains I’ve ever seen ⛰ A minimal Poetry project would contain the pyproject.toml , the associated poetry.lock, your code and a Dockerfile .

.
├── Dockerfile
├── README.md
├── annapurna
│ ├── __init__.py
│ └── main.py
├── poetry.lock
└── pyproject.toml

For simplicity I just installed the famous fastapi web server through poetry add fastapi and a couple linters that I typically use in my projects:

[tool.poetry]
name = "annapurna"
version = "1.0.0"
description = ""
authors = ["Riccardo Albertazzi <my@email.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"

fastapi = "^0.95.1"


[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
mypy = "^1.2.0"
ruff = "^0.0.263"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

1. The naive approach 😐

What our Docker build needs to do is having Python and Poetry installed, getting our code, installing the dependencies and setting the project’s entrypoint. This is exactly what we are doing in here:

FROM python:3.11-buster

RUN pip install poetry

COPY . .

RUN poetry install

ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]

This simple Dockerfile does the job, and with a simple docker build . you’ll already get a working image. This is in fact the typical Dockerfile you see in tutorials and open-source projects, as it’s pretty easy to understand. But as your project grows it will lead you under a path of tedious builds and huge Docker images — my resulting Docker image is in fact 1.1GB! All the optimizations we’ll see go in the direction of exploiting caching and reducing the final image size.

2. Warming up 🚶

Let’s start with a few improvements to warm up:

  • Pin the poetryversion. I suggest doing it as Poetry can contain breaking changes from one minor version to other, and you don’t want your builds to suddenly break when a new version is released. You should clearly pin it to the same version you are using locally.
  • Just COPY the data that you need, and nothing else. This will avoid for instance a useless copy of my local virtual environment (located at .venv ). Poetry will complain if a README.md is not found (I don’t really share this choice) and as such I create an empty one. I could have copied the local one but this would effectively prevent Docker layer caching every time I modify it.
  • Avoid installing development dependencies with poetry install --without dev , as you won’t need linters and tests suites in your production environment.
FROM python:3.11-buster

RUN pip install poetry==1.4.2

WORKDIR /app

COPY pyproject.toml poetry.lock ./
COPY annapurna ./annapurna
RUN touch README.md

RUN poetry install --without dev

ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]

This already brings us down from 1.1 GB to 959 MB. It ain’t much, but it’s honest work.

3. Cleaning Poetry cache 🧹

By default, Poetry caches downloaded packages so that they can be re-used for future installation commands. We clearly don’t care about this in a Docker build (do we?) and as such we can remove this duplicate storage.

  • Poetry also supports a --no-cache option, so why am I not using it? We’ll see it later ;)
  • When removing the cache folder make sure this is done in the same RUN command. If it’s done in a separate RUN command the cache will still be part of the previous Docker layer (the one containing poetry install ), effectively rendering your optimization useless.

While doing this I’m also setting a few Poetry environment variables to further strengthen the determinism of my build. The most controversial one is POETRY_VIRTUALENVS_CREATE=1. What’s the point why would I want to create a virtual environment inside a Docker container? I honestly prefer this solution over who disables this flag, as it makes sure that my environment will be as isolated as possible and above all that my installation will not mess up with the system Python or, even worse, with Poetry itself.

FROM python:3.11-buster

RUN pip install poetry==1.4.2

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
COPY annapurna ./annapurna
RUN touch README.md

RUN poetry install --without dev && rm -rf $POETRY_CACHE_DIR

ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]

4. Installing dependencies before copying code 👏

So far so good, but our Docker build still suffers from a very painful point: every time we modify our code we’ll have to re-install our dependencies! That’s because we COPY our code (which is needed by Poetry to install the project) before the RUN poetry install instruction. Because of how Docker layer caching works, every time the COPY layer is invalidated we’ll also rebuild the successive ones. As your project grows this can get very tedious and result in very long builds even if you are just changing a single line of code.

The solution here is to provide Poetry with the minimal information needed to build the virtual environment and only later COPY our codebase. We can achieve this with the --no-root option, which instructs Poetry to avoid installing the current project into the virtual environment.

FROM python:3.11-buster

RUN pip install poetry==1.4.2

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN touch README.md

RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR

COPY annapurna ./annapurna

RUN poetry install --without dev

ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]

You can now try to modify the application code, and you’ll see that just the last 3 layers will be re-computed. Builds just got crazy fast! 🚀

  • The additional RUN poetry install --without dev instruction is needed to install your project in the virtual environment. This can be useful for example for installing any custom script. Depending on your project you may not even need this step. Anyways, this layer execution will be super fast since the project dependencies have already been installed.

5. Using Docker multi-stage builds 🏃‍♀

Up to now builds are fast, but we still end up with big Docker images. We can win this fight by calling multi-stage builds into the game. The optimization is achieved by using the right base image for the right job:

  • Python busteris a big image that comes with development dependencies, and we will use it to install a virtual environment.
  • Python slim-busteris a smaller image that comes with the minimal dependencies to just run Python, and we will use it to run our application.

Thanks to multi-stage builds we can pass information from one stage to the other, in particular the virtual environment being built. Note how:

  • Poetry isn’t even installed in the runtime stage. Poetry is in fact an unnecessary dependency for running your Python application once your virtual environment is built. We just need to play with environment variables (such as the VIRTUAL_ENV variable) to let Python recognize the right virtual environment.
  • For simplicity I removed the second installation step (RUN poetry install --without dev ) as I don’t need it for my toy project, although one could still add it in the runtime image in a single instruction: RUN pip install poetry && poetry install --without dev && pip uninstall poetry .

Once Dockerfiles get more complex I also suggest using Buildkit, the new build backend plugged into the Docker CLI. If you are looking for fast and secure builds, that’s the tool to use.

DOCKER_BUILDKIT=1 docker build --target=runtime .
# The builder image, used to build the virtual environment
FROM python:3.11-buster as builder

RUN pip install poetry==1.4.2

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN touch README.md

RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR

# The runtime image, used to just run the code provided its virtual environment
FROM python:3.11-slim-buster as runtime

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

COPY annapurna ./annapurna

ENTRYPOINT ["python", "-m", "annapurna.main"]

The result? Our runtime image just got 6x smaller! Six times! From > 1.1 GB to 170 MB.

6. Buildkit Cache Mounts ⛰

We already got a small Docker image and fast builds when code changes, what could we get more? Well… we can also get fast builds when dependencies change 😎

This final trick is not known to many as it’s rather newer compared to the other features I presented. It leverages Buildkit cache mounts, which basically instruct Buildkit to mount and manage a folder for caching reasons. The interesting thing is that such cache will persist across builds!

By plugging this feature with Poetry cache (now you understand why I did want to keep caching?) we basically get a dependency cache that is re-used every time we build our project. The result we obtain is a fast dependency build phase when building the same image multiple times on the same environment.

Note how the Poetry cache is not cleared after installation, as this would prevent to store and re-use the cache across builds. This is fine, as Buildkit will not persist the managed cache in the built image (plus, it’s not even our runtime image).

FROM python:3.11-buster as builder

RUN pip install poetry==1.4.2

ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache

WORKDIR /app

COPY pyproject.toml poetry.lock ./
RUN touch README.md

RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev --no-root

FROM python:3.11-slim-buster as runtime

ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"

COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}

COPY annapurna ./annapurna

ENTRYPOINT ["python", "-m", "annapurna.main"]

The con of this optimization? Cache mounts are not very CI friendly at the moment, as Buildkit doesn’t allow you controlling the storage location of the cache. It’s unsuprising that this is the most voted open GitHub issue on the Buildkit repo 😄

Summary

We saw how we can bring a simple but awful Dockerfile that produces >1 GB images in minutes to an optimized version that produces images of a couple hundred MBs in a few seconds. All the optimizations mainly leverage some Docker build mantras:

  • Keep layers small, minimizing the amount of stuff you copy and install in it
  • Exploit Docker layer caching and reduce cache misses as much as possible
  • Slow-changing things (project dependencies) must be built before fast-changing things (application code)
  • Use Docker multi-stage builds to make your runtime image as slim as possible

This is how you can put them in practice in Python projects managed by Poetry, but the same principles can be applied to other dependency managers (such as PDM) and other languages.

I hope you’ll shed some tears of joy watching your builds become fast and small, and if you know some additional Docker tricks please leave them in the comments! 👋

--

--

Riccardo Albertazzi

Python, Computer Vision and Deep Learning enthusiast. I love playing piano and travelling around the most exotic places in the world.