Привет, Хабр! Меня зовут Денис Савран. Я старший разработчик направления серверной разработки на интерпретируемых языках и работаю в компании «Криптонит». В этой статье я хочу поделиться опытом сборки проектов на Python с использованием самых современных инструментов.
Прочитав эту статью, вы узнаете:
Как сократить количество инструментов локальной разработки.
Как оптимально собрать образ Docker.
Как проверить код проекта хуками pre-commit и запустить тесты в GitLab CI.
Думаю, что вас, как и многих разработчиков, не устраивает текущая ситуация в Python сообществе: есть большое количество инструментов, которые решают похожие задачи, но делают это по-разному. Из-за огромного числа вариантов нужно прилагать дополнительные усилия, чтобы выбрать тот, который лучше всего подходит для решения предстоящих задач. Каждый инструмент нужно отдельно установить и настроить, после чего запомнить его основные команды.
При этом нет гарантии, что предварительный анализ поможет сделать правильный выбор, так как в интернете уже достаточно много устаревших и вредных советов.
Нужно также учитывать, что со всеми этими инструментами должен ознакомиться каждый член Python-команды. Дополнительная сложность возникает, когда разработка ведётся на разных операционных системах.
Рассмотрим возможные цепочки принятия решений при поиске способа установки Python-утилиты командной строки новичком:
Можно установить pip, затем выполнить
pip install foo
, но в случае Linux дистрибутива это может поломать какой-нибудь системный пакет. В 2023 году в pip появился механизм защиты от подобных проблем (v23.0). Если он не используется в вашей ОС, то вы по-прежнему можете всё сломать. Также его можно принудительно отключить через опцию--break-system-packages
. На Stack Overflow есть даже ответ с предложением добавления этой опции в глобальный конфиг.Можно попробовать установить пакет через pip, но уже с опцией
--user
. У этого варианта тоже есть потенциальные проблемы, если установить таким способом несколько пакетов. Зависимости одного пакета могут конфликтовать с зависимостями другого пакета.Представим, что новичок сразу решит установить пакет в отдельном виртуальном окружении, но и тут не всё так просто. В Python есть virtualenv и venv. Что ему стоит выбрать? При поиске ответа на вопрос можно встретить virtualenvwrapper, pyenv, pipenv, и т. д. Можно пожелать ему терпения.
Если ему повезёт, и он найдёт 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/concepts/dependencies/#development-dependencies
[dependency-groups]
dev = [
"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-инструментами в лучшую сторону и не подведёт своих пользователей!
Комментарии (10)
Ryav
28.10.2024 14:28[tool.uv] # https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies dev-dependencies = [ "grpcio-tools==1.66.*", "pytest==8.3.*", ]
Такая форма записи является устаревшей после принятия PEP735, в uv это уже учитывается.
denis-savran Автор
28.10.2024 14:28Спасибо, обновил.
Поддержка секцииdependency-groups
была добавлена в uv четыре дня назад в версии 0.4.27.
NikolasLav
28.10.2024 14:28Если сравнивать с пакетом "poetry" то можете ли уточнить отличия?
Благодарю)
denaspireone
28.10.2024 14:28скорость, uv быстрее будет, см. https://github.com/astral-sh/uv/blob/main/BENCHMARKS.md
nightblure
28.10.2024 14:28нейминги некоторых секций в файле pyproject.toml не соответствуют PEP-621
значительно медленнее uv
не умеет запускать скрипты from-scratch (см. https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies)
denis-savran Автор
28.10.2024 14:28Poetry - это инструмент только для работы с Python проектом, позволяющий управлять его зависимостями, собирать и публиковать пакет. Для решения всех задач по разработке этого недостаточно, нужны вспомогательные инструменты.
Со своей основной задачей Poetry справляется хорошо. uv может делать практически всё, что и Poetry, но в несколько раз быстрее.
Отличия uv, которые были замечены во время миграции:
1) Нет команды для создания шелла с активированным виртуальным окружением. Этот функционал планируют удалить из основного пакета Poetry в версии 2.0.
2) Разная логика фиксации зависимостей через тильду (~
) (Poetry, uv). Можно случайно обновить какую-нибудь зависимость до несовместимой версии.
3) Разные имена и структура секций вpyproject.toml
.
netmaniac
28.10.2024 14:28При:
https://github.com/hadolint/hadolint/wiki/DL3041
apt-get install --quiet --no-install-recommends --assume-yes \ build-essential \ libpq-dev \
без версий пакетов они часто будут меняться при каждой сборке образаdenis-savran Автор
28.10.2024 14:28В текущем варианте нет повторной установки пакетов при каждой сборке образа, так как в большинстве случаев команда инструкции
RUN
не меняется.
Не вижу особого смысла в фиксировании версии пакетаbuild-essential
, так как логика приложения не зависит от него напрямую.
Версия фиксируется только у последнего пакета. Если обновить значение переменнойpython_version
, то произойдёт инвалидация кэша.
Также кэш будет инвалидироваться при обновлении базового образаubuntu
. Для автоматической загрузки актуальных версий образов можно использовать опцию--pull
(docker buildx build).
Подробнее можно почитать тут.
ko_0n
Говорить про новичков, а потом кидать dockefile с multi-stage, затем pre-commit hooks и тесты в gitlab-ci - сильно!
Хорошая статья