Python, Poetry and Docker.

Building docker images for Python projects that use Poetry is surprisingly unclear, considering to it should be the default way to build and deploy Python projects. This is my attempt at clearing things up and providing a simple default way to build images.

Play Nice!

Layers of a cake

First off we’ll be using a multistage build for our docker image, that will allow us to keep the final image nice and light. We’ll have a base layer, a builder layer, and a runtime layer. The base layer will contain all the shared dependencies for the builder and runtime layers

FROM python:3.11-alpine as base

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

RUN apk update && \
    apk add libpq

Next the builder layer is where we install poetry and all it’s build dependencies.

FROM base as builder

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

RUN apk update && \
    apk add musl-dev build-base gcc gfortran openblas-dev

WORKDIR /app

# Install Poetry
RUN pip install poetry==1.6.1

# Install the app
COPY pyproject.toml poetry.lock ./
RUN if [ $DEV ]; then \
      poetry install --with dev --no-root && rm -rf $POETRY_CACHE_DIR; \
    else \
      poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR; \
    fi

This installs all the dependencies to build my project, installs poetry, copies across my pyproject.toml and poetry.lock and installs it all.

Finally we can setup our final runtime layer.

FROM base as runtime

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

COPY src ./src

WORKDIR /app/src

ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]

Which simply involves coping across the VIRTUAL_ENV from the builder image, copy our source code into the image and run the application. Notice how we don’t need to install any build time dependencies into our final image. It’s just the python base image, some runtime dependencies, our source code and the virtual_env.

Hold on, I Dev in Docker.

Some wierdo’s like to run there development environments in the docker container. Our stripped down runtime image is no place to be writing code but the builder stage is perfect.

First we are going to want to add docker-compose.yml to easily run our dev server.

---
version: "3.7"

services:
  app:
    build:
      context: .
      target: builder
      args:
        DEV: true
    command: uvicorn src.app.main:app --reload --host 0.0.0.0
    ports:
      - 8000:8000
    volumes:
      - ./:/app
      - /app/.venv

Next you’ll want to configure PyCharm to use the Python interpreter from the Docker Image. But that is beyond to scope of me caring and is left as an excercise for the reader.

Conclusion

Poetry and Docker actually play very nicely together if you know how, I find it strange that the Poetry documentation is not clearer on how to combine them.

Using a builder stage install your virtual env and simply coping that across to a pretty bare python runtime image is the perfect way to keep your final image lean and production ready without having to maintain multiply Dockerfile’s.

Final Dockerfile

FROM python:3.11-alpine as base

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

RUN apk update && \
    apk add libpq


FROM base as builder

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

RUN apk update && \
    apk add musl-dev build-base gcc gfortran openblas-dev

WORKDIR /app

# Install Poetry
RUN pip install poetry==1.6.1

# Install the app
COPY pyproject.toml poetry.lock ./
RUN if [ $DEV ]; then \
      poetry install --with dev --no-root && rm -rf $POETRY_CACHE_DIR; \
    else \
      poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR; \
    fi


FROM base as runtime

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

COPY src ./src

WORKDIR /app/src

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