Новый разработчик приходит в проект в понедельник, а к четвергу всё ещё возится с окружением. У него на машине Windows, у тимлида macOS на процессоре M-серии, на сервере тестирования Ubuntu, а полный набор тестов проходит только на двух машинах из пяти. Виртуальные окружения закрепляют версии Python-пакетов, но даже не пытаются унифицировать сам интерпретатор, библиотеки операционной системы и сервисы, стоящие рядом. Docker даёт всей команде одно окружение до байта, зато требует дисциплины с кэшем и знания подводных камней. Ниже разбор того, где виртуальных окружений хватает, где контейнеры становятся обязательными и как совмещать оба подхода, чтобы не потерять скорость локальной работы.
Где виртуальные окружения ещё обеспечивают воспроизводимость Python-проекта
Модуль venv создаёт рядом с проектом папку с изолированным набором пакетов. Схема работает в любой среде и изучается с первого урока:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.lock
На Windows активация выглядит иначе, скрипт .venv\Scripts\activate. Внутри оказывается изолированный site-packages, и каждый проект получает собственный набор пакетов, не пересекающийся с соседями. Вместе с lock-файлом, где прописаны версии и хеши архивов, состав пакетов воспроизводится на чистой машине точно. Так годами живут большинство одиночных и небольших проектов без всякой контейнеризации.
Но venv изолирует пакеты, а не платформу. Окружение жёстко привязано к интерпретатору, который его создал: после команды python3.11 -m venv .venv внутри будет 3.11 конкретной машины, и инструмент даже не пытается скачать другую версию. Системные библиотеки уровня операционной системы не изолируются вовсе: драйверу psycopg2 нужна libpq, пакету lxml нужны заголовки libxml2, а на Windows для сборки C-расширений требуются инструменты Visual Studio Build Tools. Если проект живёт на чистых Python-пакетах и одной операционной системе, venv закрывает всю задачу. Как только появляются нативные расширения или разнородный парк машин, в покрытии открываются дыры.
Отдельная беда командных проектов это сервисы рядом с кодом. У одного участника локальный PostgreSQL с расширением pg_trgm, у второго контейнер со старой версией той же базы, у третьего сервис вообще не поднят, и он честно ходит в тестовую среду. Виртуальное окружение не описывает ни один из этих сценариев, поэтому схема окружения держится в голове старожилов команды, а не в файлах проекта.
Интерпретатор каждого разработчика как скрытая зависимость всего проекта
Даже при образцовой дисциплине каждый участник команды получает собственный интерпретатор. Один поставил 3.11.4 из пакетов дистрибутива, второй 3.11.9 с официального сайта, третий 3.12.2, потому что свежее. Менеджеры версий частично лечат ситуацию: pyenv, asdf или команда uv python install читают закреплённую версию из файла проекта и ставят нужную сборку. Однако файл ещё нужно завести, поддерживать и следить, чтобы он не остался упомянутым только в устной инструкции.
Разные минорные версии ведут себя коварно. В Python 3.12 удалили давно осуждавшийся пакет distutils, поэтому скрипт сборки с импортом from distutils.core import setup падает на новых машинах даже внутри тщательно собранного venv. Версия модуля sqlite3 зависит от сборки SQLite, вшитой в интерпретатор: на одном ноутбуке свежая 3.39 с оконными функциями, на другой машине старая, и запрос, работавший локально, разваливается в конвейере сборки. Колёса популярных пакетов собираются под glibc, так что на Alpine с musl тот же pip вместо готового бинарника начинает компилировать numpy из исходников и требует полноценный набор сборочных инструментов.
В конвейере сборки картина повторяется. Свежий раннер поднимается с той версией интерпретатора, которую прописали в шаге подготовки, а если шаг ставит просто python3, версия зависит от обновлений образа раннера и меняется без ведома команды. Решение то же, файл с закреплённой версией в корне репозитория:
3.12.7
Менеджеры версий читают такой файл автоматически: uv скачает нужную сборку по команде uv python install, pyenv переключит версию при входе в каталог. Это закрывает вопрос интерпретатора, но не системных библиотек: libpq и zlib остаются на совести конкретной операционной системы.
Классическое оправдание "у меня всё работает" растёт именно отсюда. Разница минорных версий интерпретатора и системных библиотек не видна в файлах требований, не проявляется на локальной машине и бьёт в самый неудобный момент: при онбординге новичка, перед релизом или на свежем CI-раннере. Виртуальное окружение, даже самое чистое, не может пообещать ничего из того, что лежит за его границами.
Dockerfile как единое описание окружения для всех участников и конвейера CI
Контейнеризация переносит описание окружения из вики и устных традиций в файл под контролем версий. Минимальный вариант для веб-проекта выглядит так:
FROM python:3.12.7-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev
WORKDIR /app
COPY requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock
COPY . .
CMD ["pytest", "-q"]
Тег базового образа несёт сразу три сведения: точную версию интерпретатора, поколение дистрибутива и минимальную основу без лишних пакетов. Полный образ python:3.12 весит около гигабайта, вариант slim около 120 МБ, а для абсолютной воспроизводимости тег заменяют дайджестом sha256, закрепляющим байты образа до последнего. Порядок слоёв здесь не красота диффа, а работа кэша: requirements.lock меняется редко, поэтому тяжёлый шаг установки зависимостей не пересчитывается, пока выше изменяется код.
Вторая половина гигиены это файл .dockerignore, без которого в образ уезжают папка .venv, локальные кэши и настройки с секретами. Проектам с C-расширениями помогает многоступенчатая сборка: на первой ступени стоят компилятор и заголовки, в финальный образ переезжают только собранные колёса и код. Дальше один и тот же образ собирается на ноутбуке разработчика, на раннере CI и на сервере, и фраза "у меня всё работает" теряет юридическую силу: окружение теперь описано файлом, который ревьюят точно так же, как исходники.
Для проектов с компиляцией расширений сборку делят на ступени:
FROM python:3.12.7-slim-bookworm AS builder
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.lock ./
RUN pip wheel --wheel-dir /wheels -r requirements.lock
FROM python:3.12.7-slim-bookworm
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*
Первая ступень ставит компилятор и заголовки, собирает колёса всех зависимостей и после завершения выбрасывается. Вторая начинается с чистого slim-образа и копирует внутрь только готовые бинарники, поэтому финальный образ остаётся компактным, а инструменты сборки не попадают в продакшен вместе с кодом.
Реальная плата за контейнеры в повседневной работе со медленными маунтами и кэшем
У контейнеров есть цена, и ощущается она не в отчётах, а в ежедневном цикле правка плюс тест. Файловые маунты на macOS и Windows идут через слой виртуализации: сборка тысячи файлов тестов на таком маунте идёт заметно дольше, чем на родном диске. Тяжёлые операции выносят в кэши внутри контейнера, а виртуальное окружение убирают с маунта целиком, иначе импорт пакетов начинает подвисать по секунде на каждый.
Пересборка образа тоже имеет свои тонкости. Правильно разложенный кэш обновляет только изменившиеся слои, зато полная пересборка с нуля на домашнем канале занимает минуты, а не секунды. Диск постепенно съедают вчерашние образы, поэтому командой docker system prune приходится пользоваться как привычкой. Отдельный подводный камень это архитектура процессора: Docker на ноутбуках с чипами Apple по умолчанию собирает arm64-образы, а классический сервер x86-64 такой образ просто не запустит. Сборка с флагом --platform linux/amd64 или мультиархитектурный манифест решают задачу, но эмуляция работает заметно медленнее нативной, поэтому кросс-сборку лучше оставить конвейеру CI, а не локальной машине.
Сборка ускоряется кэшем пакетов BuildKit: строка с монтированием кэша разрешает pip переиспользовать скачанные архивы даже тогда, когда слой приходится пересчитывать:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock
На проекте с десятками зависимостей холодная сборка с таким кэшем занимает заметно меньше времени, чем полный путь с загрузкой каждого архива заново.
Наконец, контейнер сам по себе не отменяет lock-файл. Образ, который собирает команды pip install поверх свободного списка зависимостей, снова утянет самые свежие, а не проверенные версии. Контракт окружения состоит из трёх частей: закреплённый базовый образ, lock-файл с хешами и манифест сервисов. Выпадение одной части разрушает всю конструкцию.
Гибридная схема с venv для быстрых правок и контейнерами для контрольных прогонов
Полная жизнь внутри контейнера замедляет внутренний цикл: разработчик хочет, чтобы тест одного файла стартовал мгновенно, а не после синхронизации маунтов и томов. На практике команды чаще приходят к гибриду. Сервисы, которые тяжело поднимать локально, описываются в compose-файле:
services:
db:
image: postgres:16.4
environment:
POSTGRES_PASSWORD: test
ports:
- "5432:5432"
cache:
image: redis:7.4
PostgreSQL и Redis поднимаются контейнерами, а сами тесты гоняются локально в venv той версии Python, которая закреплена в файле проекта. Строгое правило такое: локальный интерпретатор совпадает с образом по минорной версии, а полный набор тестов в конвейере запускается строго внутри контейнера. Тогда расхождения сред вылезают не на пользователе, а в пайплайне, где их подхватывают логи и артефакты сборки.
Ежедневный цикл при таком раскладе помещается в три команды:
docker compose up -d db cache
uv venv && uv sync
pytest tests/test_orders.py -q
Первая поднимает сервисы в фоне, вторая пересобирает локальное окружение по lock-файлу, третья гоняет нужный модуль тестов. Полная проверка перед пул-реквестом проходит уже в контейнере, где вспомогательные сервисы описаны тем же compose-файлом.
Слабое место гибрида в медленном расползании локального venv и образа. Системные зависимости обновляются в контейнере, версии ползут, и окружение тихо перестаёт совпадать с локальным. Рабочее лекарство это плановый полный локальный прогон в контейнере, лучше всего еженедельный, плюс такой же прогон в CI на каждый пул-реквест. Файл devcontainer с описанием среды в репозитории превращает такую настройку в документированный стандарт, а не в груду настроек с ноутбука тимлида.
Порядок контейнеризации живого проекта без остановки разработки
Контейнеризация старого проекта с историей занимает пару вечеров, если не пытаться переделать всё за один заход. Последовательность, которая сохраняет работоспособность команды:
- закрепить версию интерпретатора в файле проекта и обновить под неё машины всех участников;
- перенести зависимости в lock-файл с хешами и добиться зелёных тестов на чистой машине тимлида;
- написать Dockerfile с закреплённым базовым образом и правильным порядком слоёв, добавить .dockerignore;
- описать вспомогательные сервисы в compose-файле и переключить на них локальные тесты;
- добавить в конвейер шаг сборки образа и запуска набора тестов внутри контейнера;
- закрепить команды настройки среды в документации проекта, чтобы онбординг занимал полчаса.
Одиночному разработчику на одной машине с чистым Python-проектом можно продолжать жить в venv, это честный выбор. Но как только появляется третий участник, непохожая операционная система, нативное расширение или сервис рядом, окружение обязано стать явным контрактом. Контейнер и есть такой контракт, записанный в исполняемой форме: файл лежит под контролем версий, проходит ревью, собирается одинаково на любой машине. Дисциплина закрепляет интерпретатор, зависимости и системные библиотеки, инструмент лишь принуждает к дисциплине. Виртуальное окружение отвечает на вопрос, какие пакеты ставить, а контейнер отвечает на более важный: где, на каком интерпретаторе и рядом с какими библиотеками код будет реально работать.