PYTHON

Production-ready Python Docker Containers with uv

Starting with 0.3.0, Astral’s uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python’s finest workflow tool for my (non-scientific) use cases. Here’s how I build production-ready containers, as fast as possible.

I’m keeping this post up-to-date with my own production use of uv – see History.

Currently, this post assumes you’re running at least uv 0.4.4.

As a reminder, in production, you want the following properties from your Docker workflow:

  1. Multi-stage builds, so you don’t ship your build tools.

  2. Judicious layering, for fast builds. Layers should be added in the inverse order they are likely to change so they can be cached for as long as possible.

    This also means that dependency installations (what’s in uv.lock) and application installations (what you wrote) should be strictly separate. If you’re doing something remotely akin to continuous deployment, your code is more likely to change than your dependencies.

  3. Bonus: build-cache mounts, so, for example, you don’t have to rebuild all wheels whenever your dependency layer needs to be recreated because one package needs an update.

  4. Bonus: byte-compile your Python files for faster container startup times.


What I like to do is to build a virtual environment with my application in the /app directory and then copy it wholesale into the runtime container. This has many upsides, including using the same base containers for different Python versions and virtualenvs coming with standard UNIX directories like bin or lib, making them natural application containers.

In case you wonder why I use virtual environments inside of Docker, I wrote it up for you.

This workflow used to be tricky before uv 0.4.4 (readers of previous versions of this article will remember), but then it introduced the UV_PROJECT_ENVIRONMENT environment variable and made it a supported workflow.

So let’s build a web app container together. While I wouldn’t use uWSGI in production anymore as it’s a largely dormant pile of C that has been declared to be in “maintenance mode” by its maintainers, I’ll use it here because it adds build complexities in the form of extra dependencies that you’re likely to encounter in real life, too:

# syntax=docker/dockerfile:1.9
FROM ubuntu:noble AS build

# The following does not work in Podman unless you build in Docker
# compatibility mode: <https://github.com/containers/podman/issues/8477>
# You can manually prepend every RUN script with `set -ex` too.
SHELL ["sh", "-exc"]

### Start Build Prep.
### This should be a separate build container for better reuse.

RUN <<EOT
apt-get update -qy
apt-get install -qyy \
    -o APT::Install-Recommends=false \
    -o APT::Install-Suggests=false \
    build-essential \
    ca-certificates \
    python3-setuptools \
    python3.12-dev
EOT

# Security-conscious organizations should package/review uv themselves.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# - Silence uv complaining about not being able to use hard links,
# - tell uv to byte-compile packages for faster application startups,
# - prevent uv from accidentally downloading isolated Python builds,
# - pick a Python,
# - and finally declare `/app` as the target for `uv sync`.
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=never \
    UV_PYTHON=python3.12 \
    UV_PROJECT_ENVIRONMENT=/app

### End Build Prep -- this is where your Dockerfile should start.

# Since there's no point in shipping lock files, we move them
# into a directory that is NOT copied into the runtime image.
# The trailing slash makes COPY create `/_lock/` automagically.
COPY pyproject.toml /_lock/
COPY uv.lock /_lock/

# Synchronize DEPENDENCIES without the application itself.
# This layer is cached until uv.lock or pyproject.toml change.
# You can create `/app` using `uv venv` in a separate `RUN`
# step to have it cached, but with uv it's so fast, it's not worth
# it, so we let `uv sync` create it for us automagically.
RUN --mount=type=cache,target=/root/.cache <<EOT
cd /_lock
uv sync \
    --locked \
    --no-dev \
    --no-install-project
EOT

# Now install the APPLICATION from `/src` without any dependencies.
# `/src` will NOT be copied into the runtime container.
# LEAVE THIS OUT if your application is NOT a proper Python package.
# As of uv 0.4.11, you can also use
# `cd /src && uv sync --locked --no-dev --no-editable` instead.
COPY . /src
RUN --mount=type=cache,target=/root/.cache \
    uv pip install \
        --python=$UV_PROJECT_ENVIRONMENT \
        --no-deps \
        /src


##########################################################################

FROM ubuntu:noble
SHELL ["sh", "-exc"]

# Optional: add the application virtualenv to search path.
ENV PATH=/app/bin:$PATH

# Don't run your app as root.
RUN <<EOT
groupadd -r app
useradd -r -d /app -g app -N app
EOT

ENTRYPOINT ["/docker-entrypoint.sh"]
# See <https://hynek.me/articles/docker-signals/>.
STOPSIGNAL SIGINT

# Note how the runtime dependencies differ from build-time ones.
# Notably, there is no uv either!
RUN <<EOT
apt-get update -qy
apt-get install -qyy \
    -o APT::Install-Recommends=false \
    -o APT::Install-Suggests=false \
    python3.12 \
    libpython3.12 \
    libpcre3 \
    libxml2

apt-get clean
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
EOT

COPY docker-entrypoint.sh /
COPY uwsgi.ini /app/etc/uwsgi.ini

# Copy the pre-built `/app` directory to the runtime container
# and change the ownership to user app and group app in one step.
COPY --from=build --chown=app:app /app /app

# If your application is NOT a proper Python package that got
# pip-installed above, you need to copy your application into
# the container HERE:
# COPY . /app/whereever-your-entrypoint-finds-it

USER app
WORKDIR /app

# Strictly optional, but I like it for introspection of what I've built
# and run a smoke test that the application can, in fact, be imported.
RUN <<EOT
python -V
python -Im site
python -Ic 'import the_app'
EOT

Sidenote on uv sync --frozen vs uv sync --locked: There’s a bit of confusion what these options do – including yours truly. When using uv sync and uv run, it looks like uv is trying to update them and those two options prevent that – the former by not doing it and the latter by failing if the lock file is out of date.

What it actually does is checking whether the lock file is still up to date with regards to the environment as defined by pyproject.toml. So for example, if you’ve added/removed dependencies – or their pins.

Therefore, I think that --locked is more appropriate for deployment pipelines.

Local development

On my local side, I use Direnv with this in my .envrc:

uv sync
source .venv/bin/activate

It will ensure that whenever I enter the project directory, .venv exists and has my development dependencies, up to date to the lock file. Then it activates the virtualenv, because I like to be able to run my project’s CLI commands like regular commands.

Don’t ask me about in-dev containers – I don’t know, and I don’t care. The moment I’m forced to use Docker containers in development, I’m switching to goat farming.

Two build stages?

Since it’s been suggested to me, I have experimented with have two build stages: one that builds the virtual environment with the dependencies under /app, and one that copies /app from there and installs the application into it, which in turn is copied into the runtime image.

This should mean a much faster build when only the source code changes, but I haven’t found significant-enough improvements in a typical web application – where all of /all is significantly under 100 MB – to justify making the example more complex.

If you have huge dependencies, it might be worth trying it out, though.

A note on “unpackaged” applications

I strongly believe that a Python application should be properly packaged to enjoy the many upsides like resource management using importlib, proper executable scripts using project.scripts instead of a slapped-on scripts folder, enjoying the upsides of the src layout, and the general guardrails of a documented and well-understood structure.

I do understand why Astral added support for unpackaged apps in uv 0.4.0: if you want to go big, you have to cover all workflows, even the sloppy ones. And I do understand that people are resistant to learn another concept once things finally work – although I don’t quite understand the vehemence of the resistance. To be clear: I don’t want them to remove the feature – I just don’t think it’s something to celebrate.

Anyways, if your application isn’t packaged for whatever reason: I have added inline notes how to adjust the Dockerfile. In a nutshell, instead of installing your application in the build step, you COPY it into the runtime container after COPYing /app over. Be careful to not overwrite /app.


P.S. If you need more help with Docker, my friend Itamar Turner-Trauring has great resources for you.

History

  • 2024-09-24: Added note on uv sync --frozen vs uv sync --locked and changed Dockerfile to --locked.
  • 2024-09-04: Adapted to 0.4.4’s UV_PROJECT_ENVIRONMENT feature.

This post was made possible by the donations from people and corporations who appreciate my public work.

Want more content like this? Here’s my free, low-volume, non-creepy Hynek Did Something newsletter!
It allows me to share my content directly with you and add extra context:

Hynek Schlawack

Code Bohemian in ❤️ with Python 🐍, Go 🐹, and DevOps 🔧.
blogger 📝,
speaker 📢,
PSF fellow 🏆,
big city beach bum 🏄🏻,
substance over flash 🧠.

Is my content helpful and/or enjoyable to you?
Please consider supporting me!
Every bit helps to motivate me in creating more.



Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button