Running pip-tools in docker

There are a number of great CLI tools that help us to manage our packages, such as pip-tools or pipenv for python and npm for nodejs, that provide some useful functionality, including the ability to snapshot (aka “pin” or “lock”) the exact versions of packages installed, with versions and hashes of the code, from a high level specification of the requirements.

Best practice is to save the “lock” file that stores these versions in a version control system (e.g. git) with the code, so that the runtime environment of the code is deterministically reproducible.

When running your application in docker, this becomes non-trivial, because one can not export files while building a docker image (i.e. there is no inverse of the COPY or ADD instructions). It has to be extracted after the images is build and run as a container, which can be a little complicated to work into a development or CI/CD workflow.

pip-tools in Docker

The following illustrates a minimal example setup for running pip-tools (pip-compile, pip-sync) within Docker, while writing the file back to the host system, where it can be managed in your version control system e.g. committed with git

Dockerfile:

FROM python:3.12-slim-bookworm AS base

ENV PYTHONUNBUFFERED=1

WORKDIR /opt/requirements

RUN pip install --upgrade --no-cache-dir \
    pip \
    pip-tools

FROM base AS compile_requirements

# Import settings (e.g. [tool.pip-tools])
COPY pyproject.toml .
# Import input files
COPY requirements.txt requirements.in ./
# Pin dependencies
ARG PIP_COMPILE_ARGS=''
ARG PIP_COMPILE_OUTPUT_FILE='requirements.txt'
ENV CUSTOM_COMPILE_COMMAND='./pip-compile-wrapper.sh'
RUN pip-compile ${PIP_COMPILE_ARGS} --output-file=${PIP_COMPILE_OUTPUT_FILE}

FROM base

WORKDIR /opt/app

COPY requirements.txt ./

RUN pip-sync ${REQUIREMENTS_FILES} --pip-args '--no-cache-dir --no-deps'

pyproject.toml:

[tool.pip-tools]
generate-hashes = true

pip-compile-wrapper.sh:

#!/bin/sh

set -eu

PIP_COMPILE_OUTPUT_FILE=${PIP_COMPILE_OUTPUT_FILE:-"requirements.txt"}

docker build -t compile-tag \
    --target compile_requirements \
    --build-arg PIP_COMPILE_ARGS \
    --build-arg PIP_COMPILE_OUTPUT_FILE \
    .
id=$(docker create compile-tag)
docker cp $id:/opt/requirements/${PIP_COMPILE_OUTPUT_FILE} ${PIP_COMPILE_OUTPUT_FILE}
docker rm -v $id

with this setup one can manage the contexts of the requirements.txt file like this:

./pip-compile-wrapper.sh
# or to bump package versions
PIP_COMPILE_ARGS='--upgrade' ./pip-compile-wrapper.sh
# and then build your image with the updated requirements
docker build .

Alternative implementation

One can build the compile_requirements stage, specify pip-compile as the entrypoint, and mount the local directory in a docker run command. However this approach can lead to a number of challenges with file ownership on the host potentially being different that inside the container. You can end up with the files being owned by another user (e.g. root) when it’s written from to the mounted location. These ownership issues can be overcome, but using docker cp to copy the requirements.txt file out of a container handles all of this file ownership issue for us