RE:NODE

Приложения12 мин чтения

Python: requirements и virtualenv, pip-tools, uv и закрепление версий

Как сделать установку Python воспроизводимой: virtualenv, закреплённый requirements.txt, pip-tools и lock-файлы uv, установка, которая не ломается на сервере.

0 прочтений

Цель узкая и заслуживает того, чтобы назвать её прямо: один и тот же набор файлов должен давать один и тот же набор установленных пакетов на вашем ноутбуке, в CI и на сервере, сегодня и через четыре месяца. Python доводит вас до этого двумя механизмами - виртуальным окружением, которое изолирует пакеты одного приложения от всего остального, и файлом requirements, который точно говорит, какие версии в него попадают. Воспроизводимость почти всегда ломается на второй половине: файл закрепляет ваши собственные зависимости, но не их зависимости, и обновление, которое вы не просили, приходит при следующем перезапуске.

В этой статье - что такое virtualenv на самом деле и как он ломается, как написать файл requirements, который держится, какие инструменты lock-файлов стоит использовать и что идёт не так, когда установка выполняется на маленьком сервере, а не на вашей машине.

Что такое virtualenv на самом деле#

Виртуальное окружение - это каталог с копией механизма пакетов Python и собственным site-packages. Создаётся оно встроенными средствами:

bash
$ python -m venv .venv$ .venv/bin/python -m pip install --upgrade pip$ .venv/bin/pip install -r requirements.txt

Магии здесь меньше, чем принято думать. .venv/bin/python - это символическая ссылка или небольшая копия, указывающая на интерпретатор, который его создал. .venv/pyvenv.cfg записывает, что это за интерпретатор и видны ли системные пакеты. Активация - source .venv/bin/activate - тоже не делает ничего хитрого: она ставит .venv/bin в начало вашего PATH и задаёт VIRTUAL_ENV. Вот и весь фокус.

Поэтому на сервере лучше привычка вызывать .venv/bin/python и .venv/bin/pip по полным путям. В стартовой команде нет сеанса оболочки, где можно активировать окружение, нет риска, что скрипт забыл активацию, и нет путаницы в том, какой интерпретатор запущен. Всё, что вы написали бы как python manage.py migrate, становится .venv/bin/python manage.py migrate.

Три свойства venv важны в продакшене:

  • Он не переносится. Консольные скрипты в .venv/bin содержат строку shebang с абсолютным путём, а pyvenv.cfg хранит абсолютный путь. Переименуйте каталог над ним или перенесите приложение, и окружение перестанет работать. Пересоздайте его, а не пытайтесь править пути.
  • Он привязан к одной версии интерпретатора. Пакеты лежат в .venv/lib/python3.12/site-packages. Если базовый образ переходит с 3.12 на 3.13, окружение за ним не следует, и вы получаете ModuleNotFoundError для того, что явно лежит на диске.
  • Ему не место в git. Добавьте .venv/ в .gitignore. Закоммиченное окружение - это сотни мегабайт бинарников под конкретную платформу, которые больше нигде не запустятся.

В современных Debian и Ubuntu вы также встретите error: externally-managed-environment, когда попытаетесь выполнить pip install в системный Python. Это сообщение - дистрибутив защищает свои пакеты, и правильная реакция - создать venv. --break-system-packages названо точно.

requirements.txt: что закреплять и что ломается#

Файл requirements - это список спецификаторов, по одному на строку. Спецификаторы, которыми вы будете пользоваться:

СпецификаторЗначитИспользовать для
django==5.0.6Ровно эта версияВсего, что на сервере
django~=5.0.6>=5.0.6, <5.1.0Библиотек, которые вы сопровождаете
django>=5.0Эта или новееНичего из того, что вы разворачиваете
djangoТо, что разрешится сегодняНичего и никогда

Сбой - не тот, который очевиден. Большинство людей действительно закрепляют прямые зависимости. Не закрепляют зависимости этих зависимостей, и вот тут сидит сюрприз: вы закрепили веб-фреймворк, фреймворк зависит от библиотеки шаблонов со свободным диапазоном, эта библиотека выпускает релиз с изменением поведения, и при следующем деплое он подхватывается, потому что ничто не говорило иначе.

Обычный первый ответ - pip freeze > requirements.txt, и он действительно фиксирует всё установленное. У него три известные проблемы. Он записывает состояние вашей машины, а не ваши намерения, так что позже никто не поймёт, какие пакеты вы выбрали, а какие пришли за компанию. Он захватывает редактируемые установки и локальные пути, которые на другой машине ничего не значат. И он охотно записывает всё, во что дрейфовало ваше окружение, включая пакет, который вы однажды поставили, чтобы что-то попробовать.

Шаблон, который это решает, - два файла. requirements.in содержит несколько пакетов, которые вы выбрали, можно свободно. Инструмент один раз разрешает его и записывает requirements.txt, содержащий каждый пакет из дерева с точной версией. Вы правите первый, коммитите оба, а сервер устанавливает только второй.

requirements.in
django~=5.0gunicornpsycopg[binary]whitenoise
bash
$ pip-compile requirements.in -o requirements.txt
requirements.txt (generated)
asgiref==3.8.1    # via djangodjango==5.0.6    # via -r requirements.ingunicorn==22.0.0    # via -r requirements.inpackaging==24.1    # via gunicorn

Комментарии # via - причина, по которой этот формат стоит лишнего файла: через полгода вы видите, какой из ваших выборов притащил пакет, а удаление строки из файла .in убирает её и всё, что она принесла.

Lock-файлы: pip-tools, uv, Poetry и Pipenv#

Эту работу делают четыре инструмента. Они различаются тем, сколько всего остального берут на себя.

ИнструментВходLock-файлУстановка на сервере
pip-toolsrequirements.inrequirements.txtpip install -r requirements.txt
uvrequirements.in или pyproject.tomlrequirements.txt или uv.lockuv pip sync или uv sync
Poetrypyproject.tomlpoetry.lockpoetry install --only main
PipenvPipfilePipfile.lockpipenv install --deploy

pip-tools - самый маленький шаг от того места, где большинство проектов уже находится. Он выдаёт обычный файл requirements, который установит любой pip, так что серверу не нужно знать о существовании инструмента. pip-sync requirements.txt идёт дальше, чем pip install, потому что ещё и удаляет из окружения всё, чего нет в файле, - это разница между «нужные мне пакеты есть» и «окружение совпадает с файлом».

uv делает ту же работу на порядок быстрее и может заменить и venv, и pip. uv venv создаёт окружение, uv pip compile requirements.in -o requirements.txt разрешает зависимости, uv pip sync requirements.txt устанавливает. Есть и режим проекта на основе pyproject.toml и uv.lock, где uv sync создаёт окружение и ставит зафиксированный набор одной командой. Скорость даёт глобальный кэш и резолвер, написанный на Rust; на медленном сервере разница между установкой за тридцать секунд и за три меняет вашу готовность переустанавливать при каждом перезапуске.

Poetry управляет зависимостями, окружениями и упаковкой вместе, со своим резолвером и pyproject.toml как источником истины. Он хорошо подходит для библиотеки и вполне подходит для приложения, с одной оговоркой для деплоя: теперь серверу нужно установить Poetry, прежде чем он сможет установить что-либо ещё. Экспорт в обычный файл requirements возможен - в последних версиях через плагин - и это обычный способ сохранить сервер простым.

Pipenv делает то же самое с Pipfile и Pipfile.lock. Он по-прежнему поддерживается и по-прежнему работает; просто в новых проектах встречается реже, чем раньше.

Если у вас нет предпочтений, используйте uv с файлом .in и скомпилированным requirements.txt. Вы получаете lock-файл, серверу не нужно ничего, кроме pip, если вы когда-нибудь захотите отказаться от инструмента, а установка достаточно быстрая, чтобы запускать её при каждом деплое.

requirements.inпакеты, что вы выбралиpip-compile или uvразрешает один разrequirements.txtточные версии, хешиНоутбукCIСервер
Одно разрешение, три одинаковые установки

Хеши и когда они того стоят#

Закреплённая версия говорит, какой релиз ставить. Хеш говорит, какие именно байты. С --generate-hashes скомпилированный файл несёт дайджест для каждого артефакта, и pip отказывается ставить всё, что не совпадает:

bash
$ pip-compile --generate-hashes requirements.in -o requirements.txt$ pip install --require-hashes -r requirements.txt

Это защищает от индекса, который отдаёт не то, что вы тестировали: скомпрометированное зеркало, повторно загруженный артефакт, прокси посередине. Цена реальна: режим хешей требует, чтобы каждая зависимость была закреплена с хешем, так что потом ничего нельзя поставить наспех, а файл становится длинным и шумным при ревью. Для всего, что обращается с деньгами или учётными данными, это стоит того, а для хобби-проекта можно пропустить. В любом случае закрепляйте версии.

Раз уж вы этим занимаетесь, pip-audit проверяет окружение или файл requirements по базе уязвимостей Python и сообщает, у каких закреплённых версий есть известные уязвимости. Это лучшее применение пяти минут, чем слепое обновление всего подряд.

Установка на сервере#

Установка принадлежит шагу деплоя, а на большинстве панелей шаг деплоя - это стартовая команда:

bash
$ .venv/bin/pip install --no-cache-dir -r requirements.txt \  && .venv/bin/gunicorn myproject.wsgi:application --bind 0.0.0.0:8000

pip пропускает всё, что уже удовлетворено, поэтому перезапуск без изменений зависимостей стоит пару секунд, а не полной установки. Поэтому это можно спокойно оставить: работающий код и объявленные зависимости никогда не разойдутся, если при каждом запуске процесса одно ставится из другого. Остальная часть этой команды - число воркеров, адрес привязки, порт - описана в статье как развернуть FastAPI или Flask.

Несколько переменных окружения делают это тише и меньше:

env
PIP_DISABLE_PIP_VERSION_CHECK=1PIP_NO_CACHE_DIR=1PYTHONDONTWRITEBYTECODE=1PYTHONUNBUFFERED=1

Последняя не имеет отношения к упаковке, но именно её люди упускают чаще всего: без неё Python буферизует стандартный вывод, когда тот не подключён к терминалу, и консоль ничего не показывает, пока буфер не заполнится. Где задавать эти переменные и какие из них секретные, рассказано в статье переменные окружения и секреты. На RE:NODE консоль панели - это живой вывод без фильтрации с командной строкой, поэтому именно туда вы смотрите первым делом, когда установка падает, - и она пуста, пока вы не зададите эту переменную.

Если хост забирает ваш репозиторий с GitHub при каждом запуске, как в тарифах для приложений, порядок такой: pull, установка, запуск. Управляют этим два переключателя - pull при запуске и deploy при push, который перезапускает уже работавший сервер, - и у каждого деплоя своя запись, так что деплой, который установил всё чисто, и деплой, упавший во время установки, потом различимы. В статье как развернуть приложение Node из GitHub те же два переключателя разобраны со стороны JavaScript; механизм тот же.

Wheel-пакеты, компиляторы и пакеты, которые причиняют боль#

Большинство пакетов ставится как wheel: готовый архив, который распаковывается за секунды. Некоторые нет, и тогда pip откатывается к сборке из исходников на той машине, где идёт установка. На тарифе с половиной ядра это разница между пятисекундной установкой и десятиминутной или установкой, падающей с ошибкой компилятора про заголовочный файл, которого ни у кого нет.

Обычные нарушители и что с ними делать:

  • psycopg2 собирается против libpq. Используйте psycopg2-binary или современный psycopg[binary], у которых есть wheel. Django-сторона этого выбора описана в статье как развернуть Django в продакшене.
  • cryptography требует набор инструментов Rust, если подходящего wheel нет. Он публикует wheel для распространённых платформ Linux, так что проблема возникает только на необычных архитектурах или очень старых версиях pip.
  • Pillow, lxml и mysqlclient при сборке из исходников хотят системные библиотеки. Предпочитайте wheel; сначала обновите pip, потому что теги совместимости wheel со временем улучшались, и древний pip проигнорирует wheel, который подошёл бы.
  • У numpy, pandas и всего научного отличные wheel, причём огромные. Они устанавливаются без проблем и съедают диск.

Чтобы узнать заранее, а не на собственном горьком опыте, полностью запретите сборку из исходников:

bash
$ pip install --only-binary=:all: -r requirements.txt

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

Диск, кэши и маленькие тарифы#

Диск тарифа для приложений конечен - 5 GB на самом маленьком уровне, до 50 GB на самом верхнем, - и Python заполняет его тремя способами: окружением, кэшем pip и скомпилированным байт-кодом. Проверьте через du -sh .venv и pip cache dir.

Venv простого веб-приложения занимает 60-150 МБ. Добавьте pandas и numpy, и вы уже в нескольких сотнях. Добавьте стек машинного обучения, и гигабайты - норма; тогда диск становится частью тарифа, который вам нужен, а не запоздалой мыслью. pip cache purge и uv cache clean освобождают место сразу; --no-cache-dir не даёт ему накапливаться с самого начала, ценой повторной загрузки при следующей установке.

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

Что идёт не так#

`ModuleNotFoundError` для пакета, который определённо установлен. Два интерпретатора. Вы ставили системным pip, а запускаете .venv/bin/python, или наоборот. .venv/bin/python -c "import sys; print(sys.executable)" решает вопрос.

Локально работало, на сервере сломалось. Что-то в дереве не было закреплено. Пересоздайте локальное окружение из файла requirements - удалите .venv, создайте новый, установите, - и обычно оно сломается и локально, что отлаживать гораздо проще.

Установка занимает десять минут. Сборка из исходников. Запустите с --only-binary=:all:, чтобы выявить её, затем перейдите на пакет, у которого есть wheel.

`No space left on device` во время установки. Кэш плюс частично распакованный wheel. Очистите кэш, затем используйте --no-cache-dir в стартовой команде.

Пакет обновился сам при перезапуске. Где-то в дереве диапазон вместо закреплённой версии. Скомпилируйте lock-файл и ставьте только из него.

`error: externally-managed-environment`. Вы устанавливаете в системный Python. Создайте venv.

Окружение сломалось после обновления платформы. Базовый интерпретатор сменил версию, а venv по-прежнему указывает на старый путь. Удалите .venv и создайте заново; поэтому окружения никогда нет в git, а файл requirements есть всегда.

FAQ#

Нужен ли virtualenv внутри контейнера?

Строго говоря, нет: контейнер уже изолирует приложение. На практике venv всё равно помогает: он отделяет ваши пакеты от тех, что поставил образ, заставляет pip вести себя одинаково локально и удалённо и обходит ошибку externally-managed-environment в дистрибутивах, где действует PEP 668. Цена - один каталог.

Достаточно ли pip freeze?

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

Стоит ли коммитить lock-файл?

Да. Lock-файл - это и есть воспроизводимая часть; без него в репозитории установка на сервере - это новое разрешение зависимостей, которое может отличаться от того, что вы тестировали. Коммитьте и входной файл, и скомпилированный результат и просматривайте diff, когда он меняется.

uv или pip-tools?

Оба выдают обычный файл requirements, который установит любой pip, так что выбор обратим. uv заметно быстрее и умеет ещё и создавать окружение и запускать команды; pip-tools старше, уже по охвату и совершенно предсказуем. На медленном сервере скорость важнее, чем кажется, потому что от неё зависит, приемлемо ли переустанавливать при каждом запуске.

Как безопасно обновить один пакет?

Измените его во входном файле, перекомпилируйте и прочитайте diff сгенерированного файла, прежде чем коммитить. С pip-tools это pip-compile --upgrade-package django; в uv тот же флаг. Обновить один пакет сознательно и прочитать, что вместе с ним сдвинулось, - вот и вся дисциплина.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000