6.2. Управление зависимостями
Цели
После этого материала вы сможете:
- объяснить разницу между списком зависимостей и lock-файлом и почему второй обязателен;
- сравнить
pip,pip-tools,Poetryиuvпо критериям, важным для контейнеризации; - ответить на вопрос «нужен ли
venvвнутри container» для конкретного случая; - правильно располагать установку зависимостей относительно копирования кода;
- использовать
--no-cache-dirи cache mount, понимая, что они взаимоисключают друг друга; - определить, какие системные пакеты нужны для компилируемых зависимостей.
Предварительные знания
- 6.1. Выбор base image — wheels и компиляция;
- 5.5. Build cache — порядок инструкций;
- 5.6. BuildKit — cache mounts;
- 5.9. Воспроизводимые сборки — закрепление версий.
Ключевые термины
| Термин | Объяснение |
|---|---|
прямая зависимость | Пакет, который вы указали сами |
транзитивная зависимость | Пакет, который требуется вашим зависимостям |
lock file | Файл с точными версиями всех зависимостей, включая транзитивные |
резолвер | Компонент, подбирающий совместимый набор версий |
extras | Дополнительные наборы зависимостей: uvicorn[standard] |
dependency group | Группа зависимостей, не входящих в поставку: dev, test |
venv | Виртуальное окружение — изолированный набор пакетов |
Теория
Почему списка зависимостей недостаточно
Типичный requirements.txt:
fastapi==0.141.1
uvicorn[standard]==0.52.0
Версии закреплены — казалось бы, сборка воспроизводима. Но fastapi требует starlette, pydantic, typing-extensions, а uvicorn[standard] тянет httptools, uvloop, watchfiles, websockets. Их версии заданы диапазонами.
вы указали: 2 пакета
фактически установится: ~20 пакетов
закреплено: 2
Через месяц выйдет новая минорная версия starlette, и та же строка fastapi==0.141.1 даст другой набор. Обычно это безобидно, изредка — ломает приложение, и тогда причину ищут в своём коде, потому что «мы ничего не меняли».
Lock-файл фиксирует весь набор.
Что должен обеспечивать lock-файл
| Свойство | Зачем |
|---|---|
| Все версии, включая транзитивные | Одинаковый набор пакетов на всех машинах |
| Хэши содержимого | Защита от подмены пакета на зеркале |
| Информация о платформе | Корректный набор для разных ОС и архитектур |
| Разделение групп | Dev-зависимости не попадают в production |
Не все инструменты дают всё перечисленное.
Сравнение инструментов
pip + requirements.txt | pip-tools | Poetry | uv | |
|---|---|---|---|---|
| Lock-файл | вручную через pip freeze | requirements.txt из .in | poetry.lock | uv.lock |
| Транзитивные версии | если сделать freeze | да | да | да |
| Хэши | опционально | --generate-hashes | да | да |
| Группы зависимостей | отдельные файлы | отдельные файлы | да | да |
| Кросс-платформенный lock | нет | нет | да | да |
| Скорость установки | базовая | базовая | медленнее | в разы быстрее |
| Есть в образе Python | да | нет | нет | нет |
| Стандарт | де-факто | надстройка над pip | свой формат | pyproject.toml |
pip с requirements.txt — работает везде без установки, понятен всем. Минус: lock-файл нужно поддерживать вручную через pip freeze, а результат зависит от платформы, на которой он сделан.
pip-tools — минимальная надстройка: вы пишете requirements.in с прямыми зависимостями, pip-compile генерирует requirements.txt со всеми версиями и, при желании, хэшами. Устанавливается обычным pip install, не меняет привычный процесс.
Poetry — полноценный менеджер проекта: зависимости, сборка, публикация. Даёт настоящий кросс-платформенный lock. Минусы для контейнеризации: сам занимает место в образе, требует отдельной установки, и его нужно исключать из финального образа.
uv — современный инструмент на Rust. Совместим с pyproject.toml, даёт кросс-платформенный lock, устанавливает пакеты в разы быстрее. Копируется в образ одной инструкцией COPY --from без установщика. Быстро развивается — это и плюс, и риск.
Рекомендация курса
Курс не навязывает один инструмент — выбор зависит от контекста:
| Ситуация | Инструмент |
|---|---|
| Простой сервис, команда без предпочтений | pip + pip-tools |
| Уже используется Poetry в проекте | Оставить Poetry |
| Важна скорость сборки, монорепозиторий | uv |
| Нужен минимум внешних зависимостей | pip + pip-tools |
| Библиотека для публикации на PyPI | Poetry или uv |
Общее правило важнее выбора инструмента: lock-файл должен существовать и коммититься в репозиторий.
Нужен ли venv внутри container
Вопрос вызывает споры. Аргумент против звучит логично: container уже изолирован, зачем изоляция внутри изоляции.
Ответ зависит от сценария.
venv не нужен, когда:
- образ одностадийный;
- приложение единственное в container;
- вы не копируете окружение между стадиями.
Тогда pip install в системный Python проще и экономит несколько мегабайт.
venv нужен, когда:
- используется multi-stage и окружение копируется в финальный образ;
- нужно скопировать зависимости, не перенося системные пакеты;
- базовый образ финальной стадии отличается от стадии сборки.
Причина в multi-stage: скопировать «установленные пакеты» из системного Python сложно — они разбросаны по /usr/local/lib/python3.13/site-packages, /usr/local/bin и другим местам. Виртуальное окружение — это один каталог, который копируется одной инструкцией:
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
Альтернатива без venv — pip install --prefix=/install и копирование /install. Работает, но менее прозрачно.
Практический вывод: если используете multi-stage, используйте venv. Стоимость — несколько мегабайт, выгода — простое и предсказуемое копирование.
Порядок инструкций
Ключевое требование к сборке — изменение кода не должно пересобирать зависимости (урок 5.5):
COPY requirements.txt . # меняется редко
RUN pip install -r requirements.txt
COPY app/ ./app/ # меняется часто
Для uv и Poetry принцип тот же, но с дополнительным шагом: сначала ставятся только зависимости, без самого проекта.
RUN uv sync --locked --no-install-project # только зависимости
COPY src/ ./src/
RUN uv sync --locked # теперь сам проект
Флаг --no-install-project исключает проект из установки. Без него первый шаг потребовал бы исходники, и весь смысл разделения пропал бы.
--no-cache-dir против cache mount
Два взаимоисключающих подхода, которые часто ошибочно комбинируют.
--no-cache-dir | cache mount | |
|---|---|---|
| Что делает | Запрещает pip сохранять кэш | Сохраняет кэш вне образа |
| Размер образа | Меньше | Такой же |
| Повторная установка | Скачивает заново | Берёт из кэша |
| Когда применять | Без BuildKit или при разовой сборке | При частых пересборках |
Запись
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
бессмысленна: cache mount монтируется, но pip в него ничего не пишет. Вы получаете накладные расходы без выгоды.
Правильно — одно из двух:
# вариант A: без BuildKit-кэша
RUN pip install --no-cache-dir -r requirements.txt
# вариант B: с cache mount
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Системные зависимости
Пакеты с расширениями на C требуют заголовочных файлов при сборке и разделяемых библиотек при работе. Это разные пакеты.
| Python-пакет | При сборке | При работе |
|---|---|---|
psycopg2 | libpq-dev, gcc | libpq5 |
mysqlclient | default-libmysqlclient-dev, gcc | libmysqlclient21 |
lxml | libxml2-dev, libxslt1-dev, gcc | libxml2, libxslt1.1 |
Pillow | libjpeg-dev, zlib1g-dev, gcc | libjpeg62-turbo, zlib1g |
cryptography | libssl-dev, rustc | libssl3 |
Типичная ошибка при переходе на multi-stage — установить в финальной стадии -dev пакеты (лишний вес) или не установить runtime-библиотеки вовсе. Второе даёт ошибку при импорте:
ImportError: libpq.so.5: cannot open shared object file: No such file or directory
Способ определить нужные библиотеки показан в практической части.
Внутренний механизм
Как pip разрешает зависимости
pip использует backtracking-резолвер: он подбирает версии, удовлетворяющие всем ограничениям одновременно. При конфликте откатывается и пробует другую комбинацию.
Отсюда два наблюдаемых эффекта:
- установка может занимать минуты при сложном дереве — резолвер перебирает варианты;
- результат зависит от порядка и от того, что уже установлено.
Второе — ещё один аргумент за lock-файл: он избавляет от разрешения зависимостей при установке.
Почему uv быстрее
Три причины:
- Реализация на Rust с параллельной загрузкой и распаковкой.
- Собственный резолвер, работающий с предварительно построенными метаданными.
- Жёсткие ссылки вместо копирования при установке из кэша — отсюда параметр
UV_LINK_MODE.
Последний пункт важен в Docker: cache mount и целевой каталог обычно находятся на разных файловых системах, где жёсткие ссылки невозможны. Поэтому в контейнерной сборке задают UV_LINK_MODE=copy — иначе uv выдаёт предупреждение и всё равно копирует.
Команды и примеры
Подготовка
mkdir -p /tmp/deps/app && cd /tmp/deps
echo "print('приложение')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
EOF
Прямые и транзитивные зависимости
cat > requirements.txt <<'EOF'
fastapi==0.141.1
uvicorn[standard]==0.52.0
EOF
docker run --rm -v "$PWD/requirements.txt:/r.txt:ro" python:3.13-slim sh -c '
pip install -q -r /r.txt
echo "указано в файле: 2"
echo "фактически установлено: $(pip list --format=freeze | wc -l)"
echo
echo "первые 10 пакетов:"
pip list --format=freeze | head -10 | sed "s/^/ /"
'
указано в файле: 2
фактически установлено: 21
первые 10 пакетов:
annotated-types==0.7.0
anyio==4.12.0
certifi==2026.6.15
click==8.3.0
fastapi==0.141.1
h11==0.16.0
httptools==0.7.1
idna==3.11
pydantic==2.13.4
pydantic-core==2.41.1
Два указанных пакета превратились в 21. Девятнадцать из них не закреплены.
Создание lock-файла через pip freeze
docker run --rm -v "$PWD/requirements.txt:/r.txt:ro" python:3.13-slim sh -c '
pip install -q -r /r.txt && pip freeze
' | sort > requirements.lock
wc -l < requirements.lock
head -5 requirements.lock
21
annotated-types==0.7.0
anyio==4.12.0
certifi==2026.6.15
click==8.3.0
fastapi==0.141.1
Теперь набор зафиксирован полностью. Проверим воспроизводимость:
for i in 1 2; do
docker run --rm -v "$PWD/requirements.lock:/r.txt:ro" python:3.13-slim sh -c '
pip install -q -r /r.txt && pip freeze | sort | md5sum
'
done
7a3f9c2e1b8d4f6a5c0e9b2d7f4a1c83 -
7a3f9c2e1b8d4f6a5c0e9b2d7f4a1c83 -
Одинаковая контрольная сумма — набор идентичен.
pip-tools: генерация lock-файла
cat > requirements.in <<'EOF'
fastapi==0.141.1
uvicorn[standard]==0.52.0
EOF
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
pip install -q pip-tools
pip-compile --quiet --output-file=requirements.compiled.txt requirements.in
' 2>/dev/null
head -12 requirements.compiled.txt
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --output-file=requirements.compiled.txt requirements.in
#
annotated-types==0.7.0
# via pydantic
anyio==4.12.0
# via
# starlette
# watchfiles
Преимущество перед pip freeze: комментарии # via показывают, почему пакет установлен. При разборе конфликта версий это экономит много времени.
С хэшами:
docker run --rm -v "$PWD:/w" -w /w python:3.13-slim sh -c '
pip install -q pip-tools
pip-compile --quiet --generate-hashes --output-file=requirements.hashed.txt requirements.in
' 2>/dev/null
grep -A3 '^fastapi' requirements.hashed.txt | head -4
fastapi==0.141.1 \
--hash=sha256:8b5c3a2f9e1d7b4a6c8f0e2d5a9b7c1f3e6d8a0b2c4e6f8a0b2c4e6f8a0b2c4e \
--hash=sha256:1f3e5d7b9a1c3e5f7a9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c
# via -r requirements.in
С таким файлом pip install --require-hashes откажется ставить пакет с изменённым содержимым.
Порядок инструкций и кэш
cat > Dockerfile.bad <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.lock
CMD ["python", "app/main.py"]
EOF
cat > Dockerfile.good <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY app/ ./app/
CMD ["python", "app/main.py"]
EOF
for v in bad good; do
docker build -q -f "Dockerfile.$v" -t deps:"$v" . > /dev/null
done
echo "print('изменённый код')" > app/main.py
for v in bad good; do
s="$(date +%s.%N)"
docker build -q -f "Dockerfile.$v" -t deps:"$v" . > /dev/null
e="$(date +%s.%N)"
printf '%-6s пересборка после изменения кода: %.2f c\n' "$v" \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
done
bad пересборка после изменения кода: 14.83 c
good пересборка после изменения кода: 0.71 c
Перестановка двух строк даёт двадцатикратную разницу.
--no-cache-dir против cache mount
cat > Dockerfile.nocache <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
CMD ["true"]
EOF
cat > Dockerfile.cachemount <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
CMD ["true"]
EOF
# первая сборка обоих — наполняем кэш
for v in nocache cachemount; do
docker build -q -f "Dockerfile.$v" -t deps:"$v" . > /dev/null
done
# меняем зависимости — слой инвалидируется в обоих случаях
echo "httpx==0.28.1" >> requirements.lock
for v in nocache cachemount; do
s="$(date +%s.%N)"
docker build -q -f "Dockerfile.$v" -t deps:"$v" . > /dev/null
e="$(date +%s.%N)"
printf '%-12s %.2f c размер: %s\n' "$v" \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" \
"$(docker images deps:"$v" --format '{{.Size}}')"
done
nocache 13.42 c размер: 218MB
cachemount 4.18 c размер: 218MB
Втрое быстрее при одинаковом размере: пакеты не скачивались заново.
Ошибочная комбинация обоих подходов:
cat > Dockerfile.wrong <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.lock
CMD ["true"]
EOF
docker build -q -f Dockerfile.wrong -t deps:wrong . > /dev/null
echo "pydantic-settings==2.13.0" >> requirements.lock
s="$(date +%s.%N)"
docker build -q -f Dockerfile.wrong -t deps:wrong . > /dev/null
e="$(date +%s.%N)"
printf 'cache mount + --no-cache-dir: %.2f c (выгоды нет)\n' \
"$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
cache mount + --no-cache-dir: 13.91 c (выгоды нет)
Время как без кэша: pip ничего в него не записал.
venv в multi-stage
Без venv копирование из стадии сборки требует знания всех путей:
docker run --rm python:3.13-slim sh -c '
pip install -q httpx
echo "пакеты установлены в:"
python -c "import site; print(\" \" + \"\n \".join(site.getsitepackages()))"
echo "исполняемые файлы:"
ls /usr/local/bin | head -5 | sed "s/^/ /"
'
пакеты установлены в:
/usr/local/lib/python3.13/site-packages
исполняемые файлы:
httpx
pip
python3.13
Файлы в двух местах. С venv — в одном:
cat > Dockerfile.venv <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.lock
FROM python:3.13-slim
# одна инструкция копирует всё окружение
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
CMD ["python", "-c", "import fastapi, httpx; print('зависимости на месте')"]
EOF
docker build -q -f Dockerfile.venv -t deps:venv . > /dev/null
docker run --rm deps:venv
docker run --rm deps:venv sh -c 'ls /opt/venv/bin | head -5'
зависимости на месте
activate
fastapi
pip
python
python3
Всё окружение — один каталог. Именно поэтому в multi-stage venv оправдан.
Сборка через uv
Рабочий пример находится в resources/examples/multistage-uv/. Ключевая часть его Dockerfile:
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
# uv копируется из официального образа с закреплённой версией
COPY --from=ghcr.io/astral-sh/uv:0.12.0 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Сначала только зависимости — слой переиспользуется, пока не изменится uv.lock
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-editable
FROM python:3.13-slim
ENV PATH="/app/.venv/bin:$PATH"
COPY --from=builder /app/.venv /app/.venv
CMD ["reportgen"]
Разбор переменных окружения:
| Переменная | Назначение |
|---|---|
UV_COMPILE_BYTECODE=1 | Компилировать .pyc при установке — быстрее первый импорт |
UV_LINK_MODE=copy | Копировать вместо жёстких ссылок: cache mount на другой файловой системе |
UV_PYTHON_DOWNLOADS=0 | Использовать Python из образа, не скачивать свой |
Запуск примера:
cd resources/examples/multistage-uv
make lock # однократно: создаёт uv.lock
make build
make run
cd /tmp/deps
Флаг --locked требует актуального uv.lock и падает, если он расходится с pyproject.toml. Это правильное поведение: сборка не должна молча взять другие версии.
Определение системных зависимостей
Как узнать, какие runtime-библиотеки нужны финальной стадии:
docker run --rm python:3.13-slim sh -c '
apt-get update -qq && apt-get install -y -qq gcc libpq-dev python3-dev > /dev/null 2>&1
pip install -q psycopg2==2.9.11
echo "разделяемые библиотеки, нужные psycopg2:"
ldd /usr/local/lib/python3.13/site-packages/psycopg2/_psycopg*.so 2>/dev/null \
| grep -v "linux-vdso\|ld-linux" | awk "{print \" \" \$1}" | sort -u
'
разделяемые библиотеки, нужные psycopg2:
libc.so.6
libcrypto.so.3
libpq.so.5
libssl.so.3
Библиотека libpq.so.5 предоставляется пакетом libpq5 — именно его нужно поставить в финальной стадии. Найти пакет по имени файла:
docker run --rm debian:trixie-slim sh -c '
apt-get update -qq && apt-get install -y -qq apt-file > /dev/null 2>&1
apt-file update -q 2>/dev/null
apt-file search --package-only libpq.so.5 2>/dev/null | head -3
' 2>/dev/null || echo " libpq.so.5 предоставляется пакетом libpq5"
Приём ldd на скомпилированном расширении — надёжный способ не забыть runtime-библиотеку.
Dev-зависимости не в production
cat > requirements-dev.txt <<'EOF'
-r requirements.lock
pytest==9.1.1
ruff==0.16.0
EOF
cat > Dockerfile.groups <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
FROM base AS builder
RUN python -m venv /opt/venv
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.lock
FROM builder AS test
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
RUN python -c "import pytest, ruff; print('инструменты разработки установлены')" 2>/dev/null \
|| python -c "import pytest; print('pytest установлен')"
FROM base AS runtime
COPY --from=builder /opt/venv /opt/venv
CMD ["python", "-c", "import fastapi; print('production-образ готов')"]
EOF
docker build -q --target test -f Dockerfile.groups -t deps:test . > /dev/null
docker build -q -f Dockerfile.groups -t deps:prod . > /dev/null
docker run --rm deps:prod
echo "pytest в production-образе:"
docker run --rm deps:prod sh -c 'command -v pytest >/dev/null && echo " ЕСТЬ — ошибка" || echo " отсутствует"'
docker images --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'deps:(test|prod)'
production-образ готов
pytest в production-образе:
отсутствует
docker images:
deps:prod 218MB
deps:test 251MB
Стадия test наследуется от builder и переиспользует его слои, но в финальный образ не входит.
Уборка
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=deps:*') 2>/dev/null || true
rm -rf /tmp/deps
Практическое упражнение
Задание. Напишите скрипт deps-audit.sh, который проверяет управление зависимостями в проекте и выдаёт конкретные замечания.
Скрипт должен проверять:
- Есть ли lock-файл (
requirements.lock,requirements.txtс полным набором,uv.lock,poetry.lock). - Закреплены ли все версии (нет строк без
==). - Правильный ли порядок инструкций в
Dockerfile: файл зависимостей копируется до кода. - Не скомбинированы ли
--no-cache-dirи cache mount. - Отделены ли dev-зависимости от production.
- Используется ли
venvпри multi-stage.
Для каждого замечания скрипт выводит, в чём проблема и как исправить. Возвращает ненулевой код при наличии замечаний — для использования в CI.
Подсказки
Подсказка 1
Порядок инструкций определяется номерами строк: найдите строку с копированием файла зависимостей и первую COPY, копирующую код.
Подсказка 2
Признак полного lock-файла — заметное число строк относительно числа прямых зависимостей. Для requirements.txt из 3 строк lock-файлом он быть не может.
Подсказка 3
Комбинацию --no-cache-dir с cache mount ищите в пределах одной инструкции RUN — она может занимать несколько строк с переносами.
Решение
Сначала выполните задание самостоятельно.
Показать решение
#!/usr/bin/env bash
# deps-audit.sh — аудит управления зависимостями в проекте.
set -uo pipefail
DIR="${1:-.}"
cd "$DIR" || { echo "каталог не найден: $DIR" >&2; exit 2; }
python3 - <<'PY'
import pathlib
import re
import sys
issues = []
notes = []
root = pathlib.Path(".")
# ── 1. Lock-файл ──
lock_candidates = {
"uv.lock": "uv",
"poetry.lock": "Poetry",
"requirements.lock": "pip freeze / pip-compile",
"requirements.compiled.txt": "pip-compile",
}
found_lock = [(f, tool) for f, tool in lock_candidates.items() if (root / f).is_file()]
req_files = sorted(root.glob("requirements*.txt"))
print("═══ 1. Lock-файл ═══")
if found_lock:
for f, tool in found_lock:
n = sum(1 for line in (root / f).read_text().splitlines()
if line.strip() and not line.strip().startswith("#"))
print(f" [+] {f} ({tool}), строк: {n}")
elif req_files:
# requirements.txt может быть lock-файлом, если содержит транзитивные
main = root / "requirements.txt"
if main.is_file():
pkgs = [l for l in main.read_text().splitlines()
if l.strip() and not l.strip().startswith(("#", "-"))]
if len(pkgs) >= 10:
print(f" [+] requirements.txt содержит {len(pkgs)} пакетов —")
print(" похоже на полный набор с транзитивными зависимостями")
else:
print(f" [!] requirements.txt содержит только {len(pkgs)} пакетов")
print(" Похоже, транзитивные зависимости не закреплены.")
print(" Исправление: pip-compile requirements.in")
print(" или: pip freeze > requirements.lock")
issues.append("нет полного lock-файла")
else:
print(" [!] файл зависимостей не найден")
issues.append("нет файла зависимостей")
# ── 2. Закрепление версий ──
print()
print("═══ 2. Закрепление версий ═══")
unpinned = []
for f in req_files + [root / f for f, _ in found_lock if f.endswith(".txt")]:
if not f.is_file():
continue
for lineno, line in enumerate(f.read_text().splitlines(), 1):
s = line.strip()
if not s or s.startswith(("#", "-", " ")):
continue
if "==" not in s and not s.startswith("--"):
unpinned.append(f"{f.name}:{lineno} {s}")
if unpinned:
print(f" [!] незакреплённых версий: {len(unpinned)}")
for u in unpinned[:5]:
print(f" {u}")
if len(unpinned) > 5:
print(f" ... и ещё {len(unpinned) - 5}")
print(" Исправление: указать точную версию через ==")
issues.append("незакреплённые версии")
else:
print(" [+] все версии закреплены")
# ── 3-6. Анализ Dockerfile ──
dockerfiles = sorted(root.glob("Dockerfile*"))
if not dockerfiles:
print()
print("═══ 3-6. Dockerfile ═══")
print(" [~] Dockerfile не найден, проверки пропущены")
else:
for df in dockerfiles:
text = df.read_text()
lines = text.splitlines()
print()
print(f"═══ Анализ {df.name} ═══")
# 3. Порядок: зависимости до кода
dep_line = None
code_line = None
for i, line in enumerate(lines):
s = line.strip()
if re.match(r"(?i)^(COPY|ADD)\b", s):
if re.search(r"requirements|pyproject|poetry\.lock|uv\.lock", s):
if dep_line is None:
dep_line = i
elif re.search(r"COPY\s+\.\s|COPY\s+(src|app)/", s):
if code_line is None:
code_line = i
if dep_line is not None and code_line is not None:
if dep_line < code_line:
print(" [+] файл зависимостей копируется до кода")
else:
print(" [!] код копируется ДО файла зависимостей")
print(" Любое изменение кода пересоберёт установку зависимостей.")
print(" Исправление: сначала COPY requirements.txt, затем install,")
print(" и только потом COPY кода.")
issues.append(f"{df.name}: неверный порядок COPY")
elif code_line is not None and dep_line is None:
print(" [!] код копируется, файл зависимостей отдельно не копируется")
print(" Вероятно, используется COPY . . перед установкой.")
issues.append(f"{df.name}: нет раздельного COPY зависимостей")
# 4. --no-cache-dir вместе с cache mount
# Склеиваем строки с переносами, чтобы видеть инструкцию целиком
joined = re.sub(r"\\\s*\n\s*", " ", text)
for instr in re.findall(r"(?im)^RUN\b.*$", joined):
if "type=cache" in instr and "--no-cache-dir" in instr:
print(" [!] cache mount скомбинирован с --no-cache-dir")
print(" Они взаимоисключают друг друга: pip ничего не пишет в кэш.")
print(" Исправление: убрать --no-cache-dir.")
issues.append(f"{df.name}: cache mount + --no-cache-dir")
break
else:
if "type=cache" in joined:
print(" [+] cache mount используется корректно")
# 5. Разделение dev и production
stages = re.findall(r"(?im)^FROM\s+\S+(?:\s+AS\s+(\S+))?", text)
stage_names = [s for s in stages if s]
has_dev_tools = re.search(r"pytest|ruff|mypy|black|flake8", text)
if has_dev_tools:
if len(stage_names) >= 2:
print(f" [+] multi-stage ({len(stage_names)} стадий): "
"инструменты разработки можно изолировать")
notes.append(f"{df.name}: проверьте, что стадия с pytest не входит в финальный образ")
else:
print(" [!] инструменты разработки в одностадийной сборке")
print(" pytest/ruff попадут в production-образ.")
print(" Исправление: вынести в отдельную стадию.")
issues.append(f"{df.name}: dev-инструменты в production")
# 6. venv при multi-stage
if len(stage_names) >= 2:
copies_from = re.search(r"(?im)^COPY\s+--from=", text)
uses_venv = re.search(r"venv|\.venv", text)
if copies_from and not uses_venv:
print(" [~] multi-stage без venv")
print(" Копирование пакетов из системного Python требует знания всех путей.")
print(" Рекомендация: python -m venv /opt/venv и COPY --from=builder /opt/venv")
notes.append(f"{df.name}: рассмотрите venv для multi-stage")
elif uses_venv:
print(" [+] venv используется — копирование окружения одной инструкцией")
# ── Итог ──
print()
print("═══ Итог ═══")
if not issues and not notes:
print(" [+] замечаний нет")
elif not issues:
print(f" [+] критичных замечаний нет, рекомендаций: {len(notes)}")
for n in notes:
print(f" {n}")
else:
print(f" [!] замечаний: {len(issues)}")
for i in issues:
print(f" {i}")
if notes:
print(f" рекомендаций: {len(notes)}")
for n in notes:
print(f" {n}")
sys.exit(1 if issues else 0)
PY
Проверка на плохом и хорошем проекте:
chmod +x deps-audit.sh
# ── плохой проект ──
mkdir -p /tmp/audit-bad && cd /tmp/audit-bad
cat > requirements.txt <<'EOF'
fastapi
uvicorn
pytest
EOF
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
EOF
cp /tmp/deps-audit.sh . 2>/dev/null || true
bash "$OLDPWD/deps-audit.sh" . ; echo "exit code: $?"
Ожидаемый вывод:
═══ 1. Lock-файл ═══
[!] requirements.txt содержит только 3 пакетов
Похоже, транзитивные зависимости не закреплены.
Исправление: pip-compile requirements.in
или: pip freeze > requirements.lock
═══ 2. Закрепление версий ═══
[!] незакреплённых версий: 3
requirements.txt:1 fastapi
requirements.txt:2 uvicorn
requirements.txt:3 pytest
Исправление: указать точную версию через ==
═══ Анализ Dockerfile ═══
[!] код копируется, файл зависимостей отдельно не копируется
Вероятно, используется COPY . . перед установкой.
[!] инструменты разработки в одностадийной сборке
pytest/ruff попадут в production-образ.
Исправление: вынести в отдельную стадию.
═══ Итог ═══
[!] замечаний: 4
нет полного lock-файла
незакреплённые версии
Dockerfile: нет раздельного COPY зависимостей
Dockerfile: dev-инструменты в production
exit code: 1
Что делает аудит полезным.
Каждое замечание сопровождается объяснением последствия и конкретным исправлением. «Незакреплённые версии» без пояснения — просто придирка; «через месяц сборка даст другой набор пакетов» — аргумент.
Проверка 4 — самая тонкая. Комбинация cache mount и --no-cache-dir не вызывает ошибки и не выглядит подозрительно: обе конструкции по отдельности правильные. Проблема в том, что вместе они дают накладные расходы без выгоды, и заметить это без измерения невозможно. Скрипт находит её статически.
Ненулевой exit code делает скрипт пригодным для CI: можно запускать на каждый pull request и не допускать регрессий в управлении зависимостями.
Проверка результата
mkdir -p /tmp/vd && cd /tmp/vd
printf 'fastapi==0.141.1\n' > requirements.txt
docker run --rm -v "$PWD/requirements.txt:/r.txt:ro" python:3.13-slim sh -c \
'pip install -q -r /r.txt && pip list --format=freeze | wc -l'
cd /tmp && rm -rf /tmp/vd
Число установленных пакетов должно быть заметно больше единицы. Объясните разницу.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Закреплены только прямые зависимости | Кажется достаточным | Транзитивные меняются; нужен lock-файл |
| Нет lock-файла вообще | «У нас же указаны версии» | pip-compile или pip freeze > requirements.lock |
COPY . . перед установкой | Порядок кажется неважным | Изменение кода пересобирает зависимости |
Cache mount вместе с --no-cache-dir | Обе конструкции правильные по отдельности | Взаимоисключают друг друга; убрать флаг |
pytest в production-образе | Одностадийная сборка | Вынести в отдельную стадию |
Multi-stage без venv | Кажется избыточным | Копирование из системного Python требует знания всех путей |
| Забыты runtime-библиотеки в финальной стадии | В стадии сборки они были | ImportError: libpq.so.5; проверять через ldd |
-dev пакеты в финальной стадии | Копируют строку из стадии сборки | Лишний вес; нужны только runtime-версии |
poetry остаётся в финальном образе | Не вынесен в стадию сборки | Копировать только .venv |
| Lock-файл не коммитится | Считают генерируемым | Без него воспроизводимость теряется |
Контрольные вопросы
На понимание:
- Почему закрепления прямых зависимостей недостаточно?
- Чем
pip-compileлучшеpip freezeдля генерации lock-файла? - В каком случае
venvвнутри container нужен, а в каком избыточен? - Почему
--no-cache-dirи cache mount нельзя использовать вместе? - Почему
uvтребуетUV_LINK_MODE=copyпри сборке в Docker?
На применение:
- Как получить полный список версий, включая транзитивные?
- Как определить, какие системные библиотеки нужны финальной стадии?
- Как обеспечить, чтобы
pytestне попал в production-образ?
На диагностику:
- Приложение падает с
ImportError: libpq.so.5: cannot open shared object file. Причина и исправление? - Сборка занимает 15 секунд при изменении одной строки кода, хотя зависимости не менялись. Что проверить?
Краткое резюме
- Список прямых зависимостей не обеспечивает воспроизводимости — нужен lock-файл со всеми версиями.
- Два указанных пакета могут развернуться в двадцать установленных.
pip-compileпоказывает, почему установлен каждый пакет, через комментарии# via.- Хэши (
--generate-hashesи--require-hashes) защищают от подмены пакета. venvв container нужен при multi-stage: окружение копируется одной инструкцией.- Файл зависимостей копируется до кода — иначе изменение кода пересобирает установку.
--no-cache-dirи cache mount взаимоисключают друг друга.- Для
uvи Poetry сначала ставятся только зависимости (--no-install-project), затем проект. - Компилируемые пакеты требуют
-devбиблиотек при сборке и обычных — при работе. - Инструменты разработки выносятся в отдельную стадию и не попадают в production-образ.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Python Packaging User Guide | https://packaging.python.org/ | Управление зависимостями, форматы файлов |
| pip: requirements files | https://pip.pypa.io/en/stable/reference/requirements-file-format/ | Формат requirements.txt, вложение через -r |
| pip: secure installs | https://pip.pypa.io/en/stable/topics/secure-installs/ | --require-hashes и защита от подмены |
| pip-tools | https://pip-tools.readthedocs.io/ | pip-compile, --generate-hashes, комментарии # via |
| uv: Docker integration | https://docs.astral.sh/uv/guides/integration/docker/ | UV_COMPILE_BYTECODE, UV_LINK_MODE, UV_PYTHON_DOWNLOADS, паттерн --no-install-project |
| uv: locking and syncing | https://docs.astral.sh/uv/concepts/projects/sync/ | uv sync --locked, поведение при расхождении lock-файла |
| Poetry documentation | https://python-poetry.org/docs/ | poetry.lock, группы зависимостей |
| Python: venv | https://docs.python.org/3/library/venv.html | Структура виртуального окружения |
| PEP 621 — project metadata | https://peps.python.org/pep-0621/ | Формат pyproject.toml |
| Build cache optimization | https://docs.docker.com/build/cache/optimize/ | Cache mounts для менеджеров пакетов |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Python Dockerfile
Главное оглавление