Сборка Python проекта с uv и Docker

от автора

Привет, Хабр! Меня зовут Денис Савран. Я старший разработчик направления серверной разработки на интерпретируемых языках и работаю в компании «Криптонит». В этой статье я хочу поделиться опытом сборки проектов на Python с использованием самых современных инструментов.

Прочитав эту статью, вы узнаете:

  1. Как сократить количество инструментов локальной разработки.

  2. Как оптимально собрать образ Docker.

  3. Как проверить код проекта хуками pre-commit и запустить тесты в GitLab CI.

Думаю, что вас, как и многих разработчиков, не устраивает текущая ситуация в Python сообществе: есть большое количество инструментов, которые решают похожие задачи, но делают это по-разному. Из-за огромного числа вариантов нужно прилагать дополнительные усилия, чтобы выбрать тот, который лучше всего подходит для решения предстоящих задач. Каждый инструмент нужно отдельно установить и настроить, после чего запомнить его основные команды.

При этом нет гарантии, что предварительный анализ поможет сделать правильный выбор, так как в интернете уже достаточно много устаревших и вредных советов.

Нужно также учитывать, что со всеми этими инструментами должен ознакомиться каждый член Python-команды. Дополнительная сложность возникает, когда разработка ведётся на разных операционных системах.

Рассмотрим возможные цепочки принятия решений при поиске способа установки Python-утилиты командной строки новичком:

  1. Можно установить pip, затем выполнить pip install foo, но в случае Linux дистрибутива это может поломать какой-нибудь системный пакет. В 2023 году в pip появился механизм защиты от подобных проблем (v23.0). Если он не используется в вашей ОС, то вы по-прежнему можете всё сломать. Также его можно принудительно отключить через опцию --break-system-packages. На Stack Overflow есть даже ответ с предложением добавления этой опции в глобальный конфиг.

  2. Можно попробовать установить пакет через pip, но уже с опцией --user. У этого варианта тоже есть потенциальные проблемы, если установить таким способом несколько пакетов. Зависимости одного пакета могут конфликтовать с зависимостями другого пакета.

  3. Представим, что новичок сразу решит установить пакет в отдельном виртуальном окружении, но и тут не всё так просто. В Python есть virtualenv и venv. Что ему стоит выбрать? При поиске ответа на вопрос можно встретить virtualenvwrapper, pyenv, pipenv, и т. д. Можно пожелать ему терпения.

  4. Если ему повезёт, и он найдёт pipx, то сможет сразу установить свой пакет в отдельном виртуальном окружении, но только при условии, что у него установлена необходимая версия интерпретатора Python. Если это не так, то придётся искать способ установки интерпретатора нужной версии. В 2024 году в pipx появилась возможность установки отсутствующей версии интерпретатора через опцию --fetch-missing-python(v1.5.0). Однако вряд ли вы её легко заметите, так как после неудачной команды установки пакета с опцией --python вам про неё не сообщат.

По-моему, я перечислил достаточное количество проблем, с которыми не хотелось бы сталкиваться при разработке.

Дальше речь пойдёт о перспективном инструменте, который появился только в этом году, но уже решает множество проблем.

Пакетный менеджер uv

В феврале 2024 года появился новый пакетный менеджер uv от создателей Ruff.

Мы заметили его где-то полгода назад при просмотре репозиториев организации astral-sh на GitHub. В тот момент в нём ещё не было возможности создания кроссплатформенного файла жёсткой фиксации зависимостей проекта (lock-файла) и удобной установки проекта в режиме «non-editable», но я всё равно поставил ему звезду, чтобы следить за обновлениями, так как очень понравилась задумка.

uv интересен тем, что решает сразу несколько задач:

  • установка разных версий Python;

  • установка и запуск Python утилит командной строки;

  • создание виртуального окружения Python и установка зависимостей;

  • сборка Python проекта.

Также приятно, что uv очень быстро работает.

Для полного счастья uv пока не хватает получения списка устаревших зависимостей и официальной интеграции с IDE.

Если вернуться к установке Python-утилиты из предыдущего раздела, то с uv хватило бы одной команды:

uv tool install foo

Эта команда при необходимости установит интерпретатор Python, создаст виртуальное окружение, установит в него пакет и добавит символическую ссылку в локальную директорию пользователя с исполняемыми файлами.

Рассмотрим пример использования uv в небольшом проекте на Python с фреймворком gRPC.

Дерево директорий и файлов проекта выглядит следующим образом:

. ├── .venv/                   # Директория с виртуальным окружением Python. Игнорируется в Git. ├── etc/                     # Директория c конфигами. │   └── alembic.ini ├── src/                     # Директория с исходным кодом. │   └── my_project/          # Python модуль проекта. │       ├── grpc/ │       ├── migrations/ │       ├── models/ │       ├── scripts/ │       └── __init__.py ├── tests/                   # Директория с тестами. ├── .dockerignore ├── .env                     # Игнорируется в Git. ├── .envrc                   # Игнорируется в Git. ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── compose.yaml ├── docker-entrypoint.sh ├── Dockerfile ├── pyproject.toml ├── README.md ├── uv.lock └── VERSION

Представленная структура поддерживается большинством пакетных менеджеров и систем сборки без необходимости дополнительной настройки. Хранение конфигов в директории etc и исходного кода — в src упрощает последующее копирование файлов в образ Docker.

Ниже приведён пример файла pyproject.toml c пояснениями в комментариях:

pyproject.toml

[project] name = "my_project" # Мы храним версию в файле `VERSION`. # Это позволяет унифицировать логику версионирования разных проектов (например, проектов без файла `pyproject.toml`) и # чаще переиспользовать Docker-слои, так как не каждое обновление версии сопровождается обновлением зависимостей Python. version = "0.0.0" authors = [     { name = "Ivan Petrov", email = "ipetrov@example.com" }, ] # https://docs.astral.sh/uv/reference/resolver-internals/#requires-python requires-python = ">=3.12" # https://docs.astral.sh/uv/concepts/dependencies/#project-dependencies dependencies = [     "psycopg2==2.9.*",     "sqlalchemy==2.0.*",     "alembic==1.13.*",     "grpcio==1.66.*", ]  # https://docs.astral.sh/uv/configuration/ # https://docs.astral.sh/uv/reference/settings/ [tool.uv] # https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies dev-dependencies = [     "grpcio-tools==1.66.*",     "pytest==8.3.*", ]  # Здесь перечислены утилиты командной строки, которые станут доступны после установки проекта. [project.scripts] run_server = "my_project.scripts.run_server:cli" do_something = "my_project.scripts.do_something:cli"  # https://docs.astral.sh/uv/concepts/projects/#build-systems [build-system] requires = ["hatchling"] build-backend = "hatchling.build"

Теперь можно создать виртуальное окружение, установить в него все зависимости и закрепить их в lock-файле следующей командой:

uv sync

Для дополнительного удобства локальной разработки мы используем расширение шелла direnv. Оно позволяет автоматически создавать, обновлять и активировать виртуальное окружение, а также экспортировать переменные окружения при входе в директорию проекта.

Ниже приведён пример файла .envrc для direnv:

.envrc

# https://direnv.net/man/direnv-stdlib.1.html dotenv_if_exists uv sync --frozen source .venv/bin/activate # https://github.com/direnv/direnv/wiki/PS1 unset PS1

Итак, мы локально установили проект на Python. Теперь давайте рассмотрим создание образа для конечного пользователя с помощью Docker.

Dockerfile с двухэтапной сборкой

При создании Docker-образа нужно учитывать следующие важные моменты:

  • для получения компактного образа без лишних зависимостей нужно использовать многоэтапную (multi-stage) сборку;

  • для переиспользования слоёв инструкции должны быть упорядочены от редко меняющихся к часто меняющимся;

  • для быстрого запуска приложения файлы Python нужно предварительно компилировать в байт-код.

Можно выделить ещё один довольно важный момент касательно переиспользования Docker-слоёв: права доступа у файлов и директорий на ПК должны совпадать с теми, что используются на сервере сборки Docker-образов. Если они будут отличаться, то при локальной сборке образа c опцией --cache-from все слои инструкций COPY будут создаваться повторно.

Ниже приведён пример Dockerfile с пояснениями в комментариях:

Dockerfile

# syntax=docker/dockerfile:1 # Сборочный этап. # В качестве базового образа используем Ubuntu, так как в основном разработка у нас ведётся на этой ОС. # При этом ничто не мешает использовать официальные образы Python от Docker. FROM ubuntu:noble AS build  ARG python_version=3.12  # Переопределяем стандартную команду запуска шелла для выполнения команд в форме "shell". # https://docs.docker.com/reference/dockerfile/#shell-and-exec-form # Опция `-e` включает мгновенный выход после ошибки для любой непроверенной команды. #   Команда считается проверенной, если она используется в условии оператора ветвления (например, `if`) #   или является левым операндом `&&` либо `||` оператора. # Опция `-x` включает печать каждой команды в поток stderr перед её выполнением. Она очень полезна при отладке. # https://manpages.ubuntu.com/manpages/noble/en/man1/sh.1.html SHELL ["/bin/sh", "-exc"]  # Устанавливаем системные пакеты для сборки проекта. # Используем команду `apt-get`, а не `apt`, так как у последней нестабильный интерфейс. # `libpq-dev` — это зависимость `psycopg2` — пакета Python для работы с БД, который будет компилироваться при установке. RUN <<EOF apt-get update --quiet apt-get install --quiet --no-install-recommends --assume-yes \   build-essential \   libpq-dev \   "python$python_version-dev" EOF  # Копируем утилиту `uv` из официального Docker-образа. # https://github.com/astral-sh/uv/pkgs/container/uv # опция `--link` позволяет переиспользовать слой, даже если предыдущие слои изменились. # https://docs.docker.com/reference/dockerfile/#copy---link COPY --link --from=ghcr.io/astral-sh/uv:0.4 /uv /usr/local/bin/uv  # Задаём переменные окружения. # UV_PYTHON — фиксирует версию Python. # UV_PYTHON_DOWNLOADS — отключает автоматическую загрузку отсутствующих версий Python. # UV_PROJECT_ENVIRONMENT — указывает путь к виртуальному окружению Python. # UV_LINK_MODE — меняет способ установки пакетов из глобального кэша. #   Вместо создания жёстких ссылок, файлы пакета копируются в директорию  виртуального окружения `site-packages`. #   Это необходимо для будущего копирования изолированной `/app` директории из  стадии `build` в финальный Docker-образ. # UV_COMPILE_BYTECODE — включает компиляцию файлов Python в байт-код после установки. # https://docs.astral.sh/uv/configuration/environment/ # PYTHONOPTIMIZE — убирает инструкции `assert` и код, зависящий от значения  константы `__debug__`, #   при компиляции файлов Python в байт-код. # https://docs.python.org/3/using/cmdline.html#environment-variables ENV UV_PYTHON="python$python_version" \   UV_PYTHON_DOWNLOADS=never \   UV_PROJECT_ENVIRONMENT=/app \   UV_LINK_MODE=copy \   UV_COMPILE_BYTECODE=1 \   PYTHONOPTIMIZE=1  # Копируем файлы, необходимые для установки зависимостей без кода проекта, так как обычно зависимости меняются реже кода. COPY pyproject.toml uv.lock /_project/  # Для быстрой локальной установки зависимостей монтируем кэш-директорию, в которой будет храниться глобальный кэш uv. # Первый вызов `uv sync` создаёт виртуальное окружение и устанавливает зависимости без текущего проекта. # Опция `--frozen` запрещает обновлять `uv.lock` файл. RUN --mount=type=cache,destination=/root/.cache/uv <<EOF cd /_project uv sync \   --no-dev \   --no-install-project \   --frozen EOF  # Переключаемся на интерпретатор из виртуального окружения. ENV UV_PYTHON=$UV_PROJECT_ENVIRONMENT  COPY VERSION /_project/ COPY src/ /_project/src  # Устанавливаем текущий проект. # Опция `--no-editable` отключает установку проекта в  режиме "editable". #   Код проекта копируется в директорию виртуального окружения `site-packages`. RUN --mount=type=cache,destination=/root/.cache/uv <<EOF cd /_project sed -Ei "s/^(version = \")0\.0\.0(\")$/\1$(cat VERSION)\2/" pyproject.toml uv sync \   --no-dev \   --no-editable \   --frozen EOF  # Финальный этап. FROM ubuntu:noble AS final  # Два следующих аргумента позволяют изменить UID и GID пользователя Docker-контейнера. ARG user_id=1000 ARG group_id=1000 ARG python_version=3.12  ENTRYPOINT ["/docker-entrypoint.sh"] # Для приложений на Python лучше использовать сигнал SIGINT, так как не все фреймворки (например, gRPC) корректно обрабатывают сигнал SIGTERM. STOPSIGNAL SIGINT EXPOSE 8080/tcp  SHELL ["/bin/sh", "-exc"]  # Создаём группу и пользователя с нужными ID. # Если значение ID больше нуля (исключаем "root" ID) и в системе уже есть пользователь или группа с указанным ID, # пересоздаём пользователя или группу с именем "app". RUN <<EOF [ $user_id -gt 0 ] && user="$(id --name --user $user_id 2> /dev/null)" && userdel "$user"  if [ $group_id -gt 0 ]; then   group="$(id --name --group $group_id 2> /dev/null)" && groupdel "$group"   groupadd --gid $group_id app fi  [ $user_id -gt 0 ] && useradd --uid $user_id --gid $group_id --home-dir /app app EOF  # Устанавливаем системные пакеты для запуска проекта. # Обратите внимание, что в именах пакетов нет суффиксов "dev". RUN <<EOF apt-get update --quiet apt-get install --quiet --no-install-recommends --assume-yes \   libpq5 \   "python$python_version" rm -rf /var/lib/apt/lists/* EOF  # Задаём переменные окружения. # PATH — добавляет директорию виртуального окружения `bin` в начало списка директорий с исполняемыми файлами. #   Это позволяет запускать Python-утилиты из любой директории контейнера без указания полного пути к файлу. # PYTHONOPTIMIZE — указывает интерпретатору Python, что нужно использовать ранее скомпилированные файлы из  директории `__pycache__` с  суффиксом `opt-1` в имени. # PYTHONFAULTHANDLER — устанавливает обработчики ошибок для дополнительных сигналов. # PYTHONUNBUFFERED — отключает буферизацию для потоков stdout и stderr. # https://docs.python.org/3/using/cmdline.html#environment-variables ENV PATH=/app/bin:$PATH \   PYTHONOPTIMIZE=1 \   PYTHONFAULTHANDLER=1 \   PYTHONUNBUFFERED=1  COPY docker-entrypoint.sh /  COPY --chown=$user_id:$group_id /etc /app/etc # Копируем директорию с виртуальным окружением из предыдущего этапа. COPY --link --chown=$user_id:$group_id --from=build /app /app  USER $user_id:$group_id WORKDIR /app  # Выводим информацию о текущем окружении и проверяем работоспособность импорта модуля проекта. RUN <<EOF python --version python -I -m site python -I -c 'import my_project' EOF

Возможно, у некоторых читателей возник вопрос по поводу создания виртуального окружения в Docker-образе. Зачем что-то усложнять, если можно взять Docker-образ python:3.12 в качестве базового и установить все зависимости в директории системного интерпретатора?

Ниже перечислены преимущества виртуального окружения:

  • позволяет использовать разные базовые образы, так как нет конфликта с системными пакетами;

  • структура директорий в виртуальном окружении контейнера схожа со структурой директорий при локальной разработке, а единообразие упрощает восприятие и поддержку проектов.

Создаём Docker-образ:

docker buildx build --tag my_image:latest .

В этом разделе мы создали Docker-образ, но перед тем, как отправить его клиенту, хотелось бы дополнительно убедиться, что код соответствует принятым стандартам, и тесты успешно проходят. Рассмотрим, как это можно сделать на примере GitLab CI.

Проверка кода проекта хуками pre-commit и запуск тестов в GitLab CI

Для осуществления целей нам понадобится немного другой Docker-образ:

ci.Dockerfile

# syntax=docker/dockerfile:1 FROM ubuntu:noble AS final  ARG python_version=3.12  SHELL ["/bin/sh", "-exc"]  # Устанавливаем системные пакеты для сборки проекта и фреймворка pre-commit. RUN <<EOF apt-get update --quiet apt-get install --quiet --no-install-recommends --assume-yes \   build-essential \   libpq-dev \   git \   ca-certificates \   "python$python_version-dev" EOF  COPY --link --from=ghcr.io/astral-sh/uv:0.4 /uv /usr/local/bin/uv  # Добавляем  параметр `safe.directory` в глобальный Git-конфиг для предотвращения ошибки c "unsafe repository". RUN git config --global --add safe.directory '*'  ENV UV_PYTHON="python$python_version" \   UV_PYTHON_DOWNLOADS=never \   UV_PROJECT_ENVIRONMENT=/app  # Устанавливаем pre-commit. # Заметьте, что у следующей команды нет опции `--mount`. Это приводит к хранению кэша uv в образе. # Для команды установки хуков pre-commit тоже не нужно добавлять  опцию `--mount`, чтобы не потерять кэш pre-commit. # На текущий момент монтируемый кэш не экспортируется: https://github.com/moby/buildkit/issues/1512. RUN <<EOF uv tool run --compile-bytecode pre-commit --version EOF  COPY .pre-commit-config.yaml /_project/  # Создаём пустой Git-репозиторий, чтобы установить хуки pre-commit без копирования директории проекта. RUN <<EOF cd /_project git init uv tool run pre-commit install-hooks EOF  COPY pyproject.toml uv.lock /_project/  RUN <<EOF cd /_project uv sync \   --no-install-project \   --frozen \   --compile-bytecode EOF  ENV PATH=/app/bin:$PATH \   UV_PYTHON=$UV_PROJECT_ENVIRONMENT  WORKDIR /_project

В предыдущем Dockerfile нет копирования директории проекта. Контейнер будет получать доступ к коду через Docker-том (Docker volume) со сборками, который автоматически монтируется в GitLab CI. Это позволяет экономить место в Docker-реестре (Docker registry), так как мы не создаём дополнительный слой с кодом проекта.

Далее приведены примеры трёх GitLab CI job:

  1. build_docker_image-ci — собирает Docker-образ и загружает его в Docker-реестр.

build_docker_image-ci:   image: docker   variables:     # https://docs.gitlab.com/runner/configuration/feature-flags.html     FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR: 1     DOCKER_IMAGE: $CI_PROJECT_PATH/ci   script:     # Аутентифицируемся в Docker-реестре.     - echo -n $DOCKER_PASS | docker login --username $DOCKER_USER --password-stdin $DOCKER_REGISTRY     # Создаём нового сборщика.     - docker buildx create --name my_builder --driver docker-container --bootstrap --use     # Создаём Docker-образ и загружаем его в реестр.     - |       docker buildx build \         --file ci.Dockerfile \         --cache-from type=registry,ref=$DOCKER_REGISTRY/$DOCKER_IMAGE:cache \         --cache-to type=registry,ref=$DOCKER_REGISTRY/$DOCKER_IMAGE:cache,mode=max \         --pull \         --tag $DOCKER_REGISTRY/$DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA \         --tag $DOCKER_REGISTRY/$DOCKER_IMAGE:latest \         --push \         .

Дополнительно нужно добавить периодическую задачу для удаления старых CI образов из реестра. Например, удалять все образы старше одного дня, кроме образов с тегами latest и cache.

  2. run_pre_commit_hooks — запускает pre-commit хуки.

run_pre_commit_hooks:   image: $DOCKER_REGISTRY/$CI_PROJECT_PATH/ci:$CI_COMMIT_SHORT_SHA   script:     - uv tool run pre-commit run --all-files

  3. run_tests — запускает тесты.

run_tests:   services:     - name: postgres:15       variables:         POSTGRES_USER: my_user         POSTGRES_PASSWORD: my_password   image: $DOCKER_REGISTRY/$CI_PROJECT_PATH/ci:$CI_COMMIT_SHORT_SHA   variables:     # https://docs.gitlab.com/ee/ci/services/#connecting-services     FF_NETWORK_PER_BUILD: 1   script:     # uv автоматически установит проект в режиме "editable".     - uv run --frozen pytest

Создание отдельного Docker-образа для GitLab CI позволяет упростить и ускорить задачи проверки кода. В этом варианте не нужно использовать GitLab CI кэширование и запускать дочерний Docker-контейнер из основного Docker-контейнера.

Вариант с Docker в Docker (Docker-in-Docker) мог бы выглядеть примерно так:

my_job:   ...   image: docker   script:     - |       docker buildx build \         --file ci.Dockerfile \         --tag $DOCKER_IMAGE \         .     - container_id=$(docker ps --filter "label=com.gitlab.gitlab-runner.job.id=$CI_JOB_ID" --filter "label=com.gitlab.gitlab-runner.type=build" --quiet)     - volume_name=$(docker inspect --format '{{ range .Mounts }}{{ if eq .Destination "/builds" }}{{ .Name }}{{end}}{{end}}' $container_id)     - network_name=$(docker inspect --format '{{ range $network_name, $_ := .NetworkSettings.Networks }}{{ $network_name }}{{ end }}' $container_id)     - |       docker run \         --mount type=volume,source=$volume_name,destination=/builds \         --network $network_name \         --workdir $CI_PROJECT_DIR \         $DOCKER_IMAGE \         my_command

Однако по моему опыту людям обычно сложнее понять что-то подобное.

Заключение

Мы рассмотрели один из вариантов использования связки uv и Docker для удобной локальной разработки и создания Docker-образа конечного продукта. В нашем случае один инструмент заменил сразу четыре: pip, pyenv, pipx и Poetry, а многоэтапная сборка позволила уменьшить размер Docker-образа в три раза в одном из проектов. Надеюсь, что в будущем пакетный менеджер uv продолжит развиваться, изменит ситуацию с Python-инструментами в лучшую сторону и не подведёт своих пользователей!


ссылка на оригинал статьи https://habr.com/ru/articles/853548/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *