Главная/Python внутри Container/Урок

6.2. Управление зависимостями

Цели

После этого материала вы сможете:

  • объяснить разницу между списком зависимостей и lock-файлом и почему второй обязателен;
  • сравнить pip, pip-tools, Poetry и uv по критериям, важным для контейнеризации;
  • ответить на вопрос «нужен ли venv внутри container» для конкретного случая;
  • правильно располагать установку зависимостей относительно копирования кода;
  • использовать --no-cache-dir и cache mount, понимая, что они взаимоисключают друг друга;
  • определить, какие системные пакеты нужны для компилируемых зависимостей.

Предварительные знания

Ключевые термины

ТерминОбъяснение
прямая зависимостьПакет, который вы указали сами
транзитивная зависимостьПакет, который требуется вашим зависимостям
lock fileФайл с точными версиями всех зависимостей, включая транзитивные
резолверКомпонент, подбирающий совместимый набор версий
extrasДополнительные наборы зависимостей: uvicorn[standard]
dependency groupГруппа зависимостей, не входящих в поставку: dev, test
venvВиртуальное окружение — изолированный набор пакетов

Теория

Почему списка зависимостей недостаточно

Типичный requirements.txt:

text
fastapi==0.141.1
uvicorn[standard]==0.52.0

Версии закреплены — казалось бы, сборка воспроизводима. Но fastapi требует starlette, pydantic, typing-extensions, а uvicorn[standard] тянет httptools, uvloop, watchfiles, websockets. Их версии заданы диапазонами.

text
   вы указали:              2 пакета
   фактически установится:  ~20 пакетов
   закреплено:              2

Через месяц выйдет новая минорная версия starlette, и та же строка fastapi==0.141.1 даст другой набор. Обычно это безобидно, изредка — ломает приложение, и тогда причину ищут в своём коде, потому что «мы ничего не меняли».

Lock-файл фиксирует весь набор.

Что должен обеспечивать lock-файл

СвойствоЗачем
Все версии, включая транзитивныеОдинаковый набор пакетов на всех машинах
Хэши содержимогоЗащита от подмены пакета на зеркале
Информация о платформеКорректный набор для разных ОС и архитектур
Разделение группDev-зависимости не попадают в production

Не все инструменты дают всё перечисленное.

Сравнение инструментов

pip + requirements.txtpip-toolsPoetryuv
Lock-файлвручную через pip freezerequirements.txt из .inpoetry.lockuv.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
Библиотека для публикации на PyPIPoetry или 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 и другим местам. Виртуальное окружение — это один каталог, который копируется одной инструкцией:

dockerfile
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

Альтернатива без venvpip install --prefix=/install и копирование /install. Работает, но менее прозрачно.

Практический вывод: если используете multi-stage, используйте venv. Стоимость — несколько мегабайт, выгода — простое и предсказуемое копирование.

Порядок инструкций

Ключевое требование к сборке — изменение кода не должно пересобирать зависимости (урок 5.5):

dockerfile
COPY requirements.txt .           # меняется редко
RUN pip install -r requirements.txt
COPY app/ ./app/                  # меняется часто

Для uv и Poetry принцип тот же, но с дополнительным шагом: сначала ставятся только зависимости, без самого проекта.

dockerfile
RUN uv sync --locked --no-install-project   # только зависимости
COPY src/ ./src/
RUN uv sync --locked                        # теперь сам проект

Флаг --no-install-project исключает проект из установки. Без него первый шаг потребовал бы исходники, и весь смысл разделения пропал бы.

--no-cache-dir против cache mount

Два взаимоисключающих подхода, которые часто ошибочно комбинируют.

--no-cache-dircache mount
Что делаетЗапрещает pip сохранять кэшСохраняет кэш вне образа
Размер образаМеньшеТакой же
Повторная установкаСкачивает зановоБерёт из кэша
Когда применятьБез BuildKit или при разовой сборкеПри частых пересборках

Запись

dockerfile
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt

бессмысленна: cache mount монтируется, но pip в него ничего не пишет. Вы получаете накладные расходы без выгоды.

Правильно — одно из двух:

dockerfile
# вариант 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-пакетПри сборкеПри работе
psycopg2libpq-dev, gcclibpq5
mysqlclientdefault-libmysqlclient-dev, gcclibmysqlclient21
lxmllibxml2-dev, libxslt1-dev, gcclibxml2, libxslt1.1
Pillowlibjpeg-dev, zlib1g-dev, gcclibjpeg62-turbo, zlib1g
cryptographylibssl-dev, rustclibssl3

Типичная ошибка при переходе на multi-stage — установить в финальной стадии -dev пакеты (лишний вес) или не установить runtime-библиотеки вовсе. Второе даёт ошибку при импорте:

text
ImportError: libpq.so.5: cannot open shared object file: No such file or directory

Способ определить нужные библиотеки показан в практической части.


Внутренний механизм

Как pip разрешает зависимости

pip использует backtracking-резолвер: он подбирает версии, удовлетворяющие всем ограничениям одновременно. При конфликте откатывается и пробует другую комбинацию.

Отсюда два наблюдаемых эффекта:

  • установка может занимать минуты при сложном дереве — резолвер перебирает варианты;
  • результат зависит от порядка и от того, что уже установлено.

Второе — ещё один аргумент за lock-файл: он избавляет от разрешения зависимостей при установке.

Почему uv быстрее

Три причины:

  1. Реализация на Rust с параллельной загрузкой и распаковкой.
  2. Собственный резолвер, работающий с предварительно построенными метаданными.
  3. Жёсткие ссылки вместо копирования при установке из кэша — отсюда параметр UV_LINK_MODE.

Последний пункт важен в Docker: cache mount и целевой каталог обычно находятся на разных файловых системах, где жёсткие ссылки невозможны. Поэтому в контейнерной сборке задают UV_LINK_MODE=copy — иначе uv выдаёт предупреждение и всё равно копирует.


Команды и примеры

Подготовка

bash
mkdir -p /tmp/deps/app && cd /tmp/deps
echo "print('приложение')" > app/main.py
cat > .dockerignore <<'EOF'
Dockerfile*
.dockerignore
__pycache__
EOF

Прямые и транзитивные зависимости

bash
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/^/  /"
'
text
указано в файле: 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

bash
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
text
21
annotated-types==0.7.0
anyio==4.12.0
certifi==2026.6.15
click==8.3.0
fastapi==0.141.1

Теперь набор зафиксирован полностью. Проверим воспроизводимость:

bash
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
text
7a3f9c2e1b8d4f6a5c0e9b2d7f4a1c83  -
7a3f9c2e1b8d4f6a5c0e9b2d7f4a1c83  -

Одинаковая контрольная сумма — набор идентичен.

pip-tools: генерация lock-файла

bash
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
text
#
# 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 показывают, почему пакет установлен. При разборе конфликта версий это экономит много времени.

С хэшами:

bash
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
text
fastapi==0.141.1 \
    --hash=sha256:8b5c3a2f9e1d7b4a6c8f0e2d5a9b7c1f3e6d8a0b2c4e6f8a0b2c4e6f8a0b2c4e \
    --hash=sha256:1f3e5d7b9a1c3e5f7a9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c
    # via -r requirements.in

С таким файлом pip install --require-hashes откажется ставить пакет с изменённым содержимым.

Порядок инструкций и кэш

bash
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
text
bad    пересборка после изменения кода: 14.83 c
good   пересборка после изменения кода: 0.71 c

Перестановка двух строк даёт двадцатикратную разницу.

--no-cache-dir против cache mount

bash
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
text
nocache      13.42 c   размер: 218MB
cachemount    4.18 c   размер: 218MB

Втрое быстрее при одинаковом размере: пакеты не скачивались заново.

Ошибочная комбинация обоих подходов:

bash
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}')"
text
cache mount + --no-cache-dir: 13.91 c (выгоды нет)

Время как без кэша: pip ничего в него не записал.

venv в multi-stage

Без venv копирование из стадии сборки требует знания всех путей:

bash
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/^/  /"
'
text
пакеты установлены в:
  /usr/local/lib/python3.13/site-packages
исполняемые файлы:
  httpx
  pip
  python3.13

Файлы в двух местах. С venv — в одном:

bash
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'
text
зависимости на месте
activate
fastapi
pip
python
python3

Всё окружение — один каталог. Именно поэтому в multi-stage venv оправдан.

Сборка через uv

Рабочий пример находится в resources/examples/multistage-uv/. Ключевая часть его Dockerfile:

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 из образа, не скачивать свой

Запуск примера:

bash
cd resources/examples/multistage-uv
make lock      # однократно: создаёт uv.lock
make build
make run
cd /tmp/deps

Флаг --locked требует актуального uv.lock и падает, если он расходится с pyproject.toml. Это правильное поведение: сборка не должна молча взять другие версии.

Определение системных зависимостей

Как узнать, какие runtime-библиотеки нужны финальной стадии:

bash
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
'
text
разделяемые библиотеки, нужные psycopg2:
  libc.so.6
  libcrypto.so.3
  libpq.so.5
  libssl.so.3

Библиотека libpq.so.5 предоставляется пакетом libpq5 — именно его нужно поставить в финальной стадии. Найти пакет по имени файла:

bash
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

bash
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)'
text
production-образ готов
pytest в production-образе:
  отсутствует
docker images:
  deps:prod 218MB
  deps:test 251MB

Стадия test наследуется от builder и переиспользует его слои, но в финальный образ не входит.

Уборка

bash
cd /tmp
docker rmi -f $(docker images -q --filter 'reference=deps:*') 2>/dev/null || true
rm -rf /tmp/deps

Практическое упражнение

Задание. Напишите скрипт deps-audit.sh, который проверяет управление зависимостями в проекте и выдаёт конкретные замечания.

Скрипт должен проверять:

  1. Есть ли lock-файл (requirements.lock, requirements.txt с полным набором, uv.lock, poetry.lock).
  2. Закреплены ли все версии (нет строк без ==).
  3. Правильный ли порядок инструкций в Dockerfile: файл зависимостей копируется до кода.
  4. Не скомбинированы ли --no-cache-dir и cache mount.
  5. Отделены ли dev-зависимости от production.
  6. Используется ли venv при multi-stage.

Для каждого замечания скрипт выводит, в чём проблема и как исправить. Возвращает ненулевой код при наличии замечаний — для использования в CI.

Подсказки

Подсказка 1

Порядок инструкций определяется номерами строк: найдите строку с копированием файла зависимостей и первую COPY, копирующую код.

Подсказка 2

Признак полного lock-файла — заметное число строк относительно числа прямых зависимостей. Для requirements.txt из 3 строк lock-файлом он быть не может.

Подсказка 3

Комбинацию --no-cache-dir с cache mount ищите в пределах одной инструкции RUN — она может занимать несколько строк с переносами.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/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

Проверка на плохом и хорошем проекте:

bash
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: $?"

Ожидаемый вывод:

text
═══ 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 и не допускать регрессий в управлении зависимостями.

Проверка результата

bash
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-файл не коммититсяСчитают генерируемымБез него воспроизводимость теряется

Контрольные вопросы

На понимание:

  1. Почему закрепления прямых зависимостей недостаточно?
  2. Чем pip-compile лучше pip freeze для генерации lock-файла?
  3. В каком случае venv внутри container нужен, а в каком избыточен?
  4. Почему --no-cache-dir и cache mount нельзя использовать вместе?
  5. Почему uv требует UV_LINK_MODE=copy при сборке в Docker?

На применение:

  1. Как получить полный список версий, включая транзитивные?
  2. Как определить, какие системные библиотеки нужны финальной стадии?
  3. Как обеспечить, чтобы pytest не попал в production-образ?

На диагностику:

  1. Приложение падает с ImportError: libpq.so.5: cannot open shared object file. Причина и исправление?
  2. Сборка занимает 15 секунд при изменении одной строки кода, хотя зависимости не менялись. Что проверить?

Краткое резюме

  1. Список прямых зависимостей не обеспечивает воспроизводимости — нужен lock-файл со всеми версиями.
  2. Два указанных пакета могут развернуться в двадцать установленных.
  3. pip-compile показывает, почему установлен каждый пакет, через комментарии # via.
  4. Хэши (--generate-hashes и --require-hashes) защищают от подмены пакета.
  5. venv в container нужен при multi-stage: окружение копируется одной инструкцией.
  6. Файл зависимостей копируется до кода — иначе изменение кода пересобирает установку.
  7. --no-cache-dir и cache mount взаимоисключают друг друга.
  8. Для uv и Poetry сначала ставятся только зависимости (--no-install-project), затем проект.
  9. Компилируемые пакеты требуют -dev библиотек при сборке и обычных — при работе.
  10. Инструменты разработки выносятся в отдельную стадию и не попадают в production-образ.

Официальные источники

ИсточникСсылкаЧто подтверждает
Python Packaging User Guidehttps://packaging.python.org/Управление зависимостями, форматы файлов
pip: requirements fileshttps://pip.pypa.io/en/stable/reference/requirements-file-format/Формат requirements.txt, вложение через -r
pip: secure installshttps://pip.pypa.io/en/stable/topics/secure-installs/--require-hashes и защита от подмены
pip-toolshttps://pip-tools.readthedocs.io/pip-compile, --generate-hashes, комментарии # via
uv: Docker integrationhttps://docs.astral.sh/uv/guides/integration/docker/UV_COMPILE_BYTECODE, UV_LINK_MODE, UV_PYTHON_DOWNLOADS, паттерн --no-install-project
uv: locking and syncinghttps://docs.astral.sh/uv/concepts/projects/sync/uv sync --locked, поведение при расхождении lock-файла
Poetry documentationhttps://python-poetry.org/docs/poetry.lock, группы зависимостей
Python: venvhttps://docs.python.org/3/library/venv.htmlСтруктура виртуального окружения
PEP 621 — project metadatahttps://peps.python.org/pep-0621/Формат pyproject.toml
Build cache optimizationhttps://docs.docker.com/build/cache/optimize/Cache mounts для менеджеров пакетов

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Python Dockerfile
Главное оглавление

Markdown на GitHub ↗