Portable Python scripts with Docker and Make
October 21, 2022
Sometimes you need to share a script you quickly whipped up for some
one-off task, when your coworker has a very similar task where they
could use what you have with minor modifications. Getting the script
working on their machine is not always trivial: You may have different
Python versions and you most definitely have different
globally-installed dependencies. The first problem can be solved with
pyenv
, the latter with virtual environments, but getting up
and running can still be a hassle that feels like an unnecessary detour.
And it is. It's the 2020s, we may not have flying cars but we have
Docker.
Here I show how to create a setup that is as easy to get up and running as (1) cloning the repository, (2) running the script. That's it, assuming you have Docker and usual Unix/Linux command-line tools at your fingertips.
You can check out the full example at Github.
All this may seem like a bit too much overhead for a one-off script.
But if you make a cookiecutter
template out of it and use
that every time you start a new one-off script, that overhead is
negligible.
Step 1: Create the Dockerfile
The main idea here is to create a Docker image with the dependencies of the script, but exclude the actual script. We'll mount that as a volume when running the container. The advantage of this approach is that we can iteratively improve the script without the overhead of building the Docker image unless the dependencies change.
For the package management we could use plain pip
, or
poetry
, or conda
, but I've chosen
pipenv
in this example, purely because that's the tool I've
been mostly working with lately.
There is no CMD
or ENTRYPOINT
in this
Dockerfile
. We'll specify the exact Python command in the
docker run
command. That will make it easy to pass along
command-line parameters.
FROM python:3.10-slim
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN pip install pipenv && apt-get update && apt-get install -y --no-install-recommends gcc
COPY Pipfile Pipfile.lock .
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install
WORKDIR /src
Step 2: Create the Makefile
How can we avoid rebuilding the image unless necessary? There's a
classic tool for that, make
. The make
tool
needs a Makefile
, which describes a dependency graph of
files that depend on the other files, plus instructions on how to
produce those other files.
There is one problem, however: Docker images are not created as files
in the working directory, but as images in the local Docker repository,
so make
cannot by default know if the image is older than
the files it depends on. The trick to solve this is to have a file with
a last-modified time of last Docker build. I've chosen to call that file
.build
here. We should also put that file in the
.gitignore
.
# Build with `make image_name=<image-name> .build
.build: Dockerfile Pipfile Pipfile.lock
@echo 'Building the Docker image...'
$(image_name)
docker build . -f Dockerfile -t @touch .build
Step 3: Create the run script
Now we can bring it all together with a tiny shell script (which I
named simply run
):
#!/bin/bash
set -e
DOCKER_IMAGE_NAME=dockerized-python-example
make -s image_name=$DOCKER_IMAGE_NAME .build
docker run --rm -i -v "$(pwd)/src":/src -t $DOCKER_IMAGE_NAME python main.py "$@"
The script first runs make
in the silent mode, and
make
builds the image if either the .build
file is missing or has a last-modified timestamp older than any of its
three dependencies.
Then the script runs the Python script in the Docker container using
that image. We mount the src/
directory, where I'm placing
the actual Python script as main.py
, and pass all the
command-line arguments onwards to it.
Step 4: The rest
Now that we've created Dockerfile
,
Makefile
, and run
script, we only have to add
a couple of necessary files before we can try this setup.
We need the src/
directory with a main.py
script -- That will be the script we wanted to make portable.
Because we chose to use pipenv
for dependencies, we
should create a Pipfile
and Pipfile.lock
. This
can be done easily by just running pipenv install
in the
project directory, assuming you have it installed system-wide.
In the end, the overall file structure should look like this:
project
+- src
| +- main.py
+- Dockerfile
+- Makefile
+- Pipfile
+- Pipfile.lock
+- run