Multi-Stage Docker Builds for Python Projects using uv
Tags: python, docker, webdev
Published: 2024-02-21 00:20:09

Charlie Marsh and the brillant guys at Astral have helped the python ecosystem a lot with ruff and now they have released a new tool: [uv](https://github.com/astral-sh/uv).

It is intended as a drop-in replacement of pip. At the moment, it supports dependency resolution and installation. Compared to pip, it is a lot faster. I have observed up to 10-15x faster dependency installations (with no caching) for medium-sized projects.

## Using uv to speed up Docker builds

Another nice benefit is that his can speed up your Docker build. You can even use uv without having to install it into your final Docker image using a multi-stage build.

## Multi-stage builds

A typical multi-stage build for a Python projects works like this:

- We use a build image that has all the necessary tools to build certain python packages.
- Our final image is a slimmed down version of the build image that contains no build dependencies. We copy over the compiled dependencies from the build image to our final image.

## Example: Azure Function App

Let's look at this example of a Dockerfile for an Azure Function App.

```Dockerfile
# syntax = docker/dockerfile:1.0-experimental

# Build image to compile all packages
FROM python:3.11 as build

ENV VIRTUAL_ENV=/home/packages/.venv
ADD https://astral.sh/uv/install.sh /install.sh
RUN chmod -R 655 /install.sh && /install.sh && rm /install.sh

COPY ./requirements.txt .
RUN /root/.cargo/bin/uv venv /home/packages/.venv
RUN /root/.cargo/bin/uv pip install --no-cache -r requirements.txt

# Run image for the function app code
FROM mcr.microsoft.com/azure-functions/python:4-python3.11

ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
    AzureWebJobsFeatureFlags=EnableWorkerIndexing \
    PATH="/home/packages/.venv/bin:$PATH" \
    VIRTUAL_ENV=/home/packages/.venv

COPY --from=build /home/packages/.venv /home/packages/.venv
COPY . /home/site/wwwroot
```

(Thank you to [@ryxcommar](https://twitter.com/ryxcommar) for the Dockerfile commands that [install uv from his blog article](https://ryxcommar.com/2024/02/15/how-to-cut-your-python-docker-builds-in-half-with-uv/).)

Let's break this down assuming you have your Function App files in the directory for which you are calling `docker buildx build .`:

- First, we use the `python:3.11` image as our build image. It is close enough to our base image for the final image.
- We install uv and create a virtual environment at `/home/packages/.venv` using `uv venv /home/packages/.venv`.
- We install our dependencies from the `requirements.txt` file that we copied over.

This is it for the build image. Now, we need a final image:

- Our final image is based on the image necessary for the Python Function App `mcr.microsoft.com/azure-functions/python:4-python3.11`.
- Then, we set some environment variables. Here, the important ones are the `PATH` and the `VIRTUAL_ENV` to make sure our virtual environment is picked up by the system.
- Then, we copy over the complete virtual environment from `/home/packages/.venv` in the build image to our final image.
- Finally, we copy all the files necessary for the Function App into our image.

## Example: Python app

A more general example for a Dockerfile of a Python app could be this:

```Dockerfile
# syntax = docker/dockerfile:1.0-experimental

# Base image
FROM python:3.12 as build

RUN apt-get update && apt-get install -y build-essential curl
ENV VIRTUAL_ENV=/opt/venv \
    PATH="/opt/venv/bin:$PATH"

ADD https://astral.sh/uv/install.sh /install.sh
RUN chmod -R 655 /install.sh && /install.sh && rm /install.sh
COPY ./requirements.txt .
RUN /root/.cargo/bin/uv venv /opt/venv && \
    /root/.cargo/bin/uv pip install --no-cache -r requirements.txt

# App image
FROM python:3.12-slim-bookworm
COPY --from=build /opt/venv /opt/venv

# Activate the virtualenv in the container
# See here for more information:
# https://pythonspeed.com/articles/multi-stage-docker-python/
ENV PATH="/opt/venv/bin:$PATH"
```

In this example, we use the `python:3.12` image as the build image and then the slimmed down version `python:3.12-slim-bookworm` for the final app image.

You will need to add your `ENTRYPOINT` to make this work but the dependencies will be installed and calling `python` will use your virtual environment.

If you want to learn more about multi-stage docker builds, I can highly recommend [this article about multi-stage docker builds for python](https://pythonspeed.com/articles/multi-stage-docker-python/) and also [this post about using virtual environments in dockerfiles](https://pythonspeed.com/articles/activate-virtualenv-dockerfile/) from Itamar Turner-Trauring.