Dockerfile для Python-приложения: пошаговая сборка с кэшированием зависимостей | AdminWiki

Dockerfile для Python-приложения: пошаговая сборка с кэшированием зависимостей

26 сентября 2026 11 мин. чтения

Зачем контейнеризировать Python-приложение и что даёт кэширование зависимостей

Docker фиксирует окружение целиком: версию интерпретатора, набор пакетов, системные библиотеки. Один и тот же образ запускается на ноутбуке разработчика, в CI и в проде, поэтому расхождение «на моей машине работает» исчезает, а вручную ставить зависимости на каждой машине больше не нужно.

Кэш слоёв даёт второй эффект, и он про скорость. Слой с pip install зависит только от requirements.txt. Пока файл не менялся, Docker подставляет готовый слой из кэша, и повторная сборка после правки app.py проходит заметно быстрее, чем первая. Docker использует кэш сборки, переиспользуя слои из предыдущих сборок, поэтому редко меняющиеся команды размещают в начале Dockerfile, а часто меняющиеся — ближе к концу. Точные цифры зависят от железа, версии Python и канала до PyPI, сверяйтесь со своим выводом docker build.

Важная оговорка: кэш хорошо работает на машине разработчика при повторных сборках, но в средах вроде CI-раннеров существующий кэш образов почти никогда недоступен, поэтому там выигрыш нужно обеспечивать отдельно. Всё держится на порядке инструкций. Поставьте COPY . . до установки зависимостей, и хеш слоя изменится от любой правки в коде, а pip install будет запускаться при каждом коммите. Схема COPY requirements.txt, затем RUN pip install, затем COPY . . делает кэш зависимостей постоянным.

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

Выбираем базовый образ: python:3.12-slim, alpine или полный

Первая строка FROM определяет размер образа, скорость сборки и то, как pip будет ставить пакеты.

Базовый образРазмер, порядокКогда подходит
python:3.12полный образ, заметно тяжелее slimкомпилятор и системные библиотеки из коробки; удобно для отладки, в прод тяжёл
python:3.12-slimсжатый размер около 45 МБрабочий выбор для веб-приложений, API и воркеров
python:3.12-alpineсамый компактный из трёхпроекты на чистом Python без C-расширений

Размер образа зависит от релиза и архитектуры, поэтому проверяйте фактические значения командой docker images после сборки. Для типового приложения берите python:3.12-slim: внутри есть pip, apt и минимальный набор библиотек, а отсутствие компилятора закрывается установкой build-зависимостей, когда они нужны.

Тег без минорной версии, например python:3 или python:latest, не фиксирует окружение. Обновление образа на Docker Hub меняет содержимое, и сборка может сломаться без единой правки в вашем репозитории. Как закрепить версии и digest, разбираем в статье про воспроизводимые сборки Docker-образов.

Почему alpine не всегда хороший выбор для Python

alpine собран на musl libc, а бинарные колёса на PyPI с тегами manylinux собраны под glibc. Проект manylinux как раз и создан, чтобы распространять бинарные расширения Python в виде колёс на Linux, и общее правило такое: бинарники, собранные на одном дистрибутиве Linux, работают только на других дистрибутивах того же возраста или новее. Для Alpine нужен отдельный путь: собирать платформенно-специфичные колёса и переименовывать их под musl, например в mypkg-1.0+musl-cp37-cp37m-linux_x86_64.whl. Отдельно существует PEP 656, который ввёл тег musllinux для Alpine-совместимых колёс, но покрытие такими колёсами на PyPI неполное, поэтому часть пакетов всё равно придётся собирать из исходников: в образ нужно добавить gcc, musl-dev, python3-dev и заголовки конкретных библиотек. Время сборки растёт, а готовый образ легко обгоняет slim по размеру.

Научный стек показывает это наглядно. numpy, scipy и scikit-learn на alpine собираются долго и часто падают на этапе компиляции из-за отсутствующих заголовков. Правило простое: чистый Python без C-расширений, alpine подойдёт; numpy, pandas, psycopg2 или cryptography, берите slim.

Есть и другой подход, когда зависимости запекают прямо в базовый образ. В проекте IPyDocker образ уже включает numpy, scipy и scikit-learn, а Dockerfile правится, если нужны другие библиотеки; сборка запускается командой docker build -t krop-img . Для обычного приложения этот путь неудобен: версии пакетов тогда живут в образе, а не в requirements.txt, и обновлять их сложнее.

Пишем Dockerfile пошагово: от WORKDIR до CMD

Порядок инструкций ниже рассчитан на веб-приложение, которое слушает порт 8000 и запускается файлом app.py.

  • FROM python:3.12-slim - фиксируем интерпретатор. Slim даёт pip и системные утилиты без компилятора.
  • ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 - первая переменная запрещает создавать .pyc внутри образа, вторая убирает буферизацию stdout, чтобы docker logs показывал логи сразу.
  • WORKDIR /app - задаёт рабочую директорию. Все следующие COPY и RUN выполняются относительно неё, и относительные пути перестают зависеть от случайного cwd.
  • COPY requirements.txt . - переносим в образ только список зависимостей.
  • RUN pip install --no-cache-dir -r requirements.txt - ставим пакеты. Флаг --no-cache-dir не даёт pip кешировать скачанные архивы внутри образа.
  • COPY . . - копируем код. Шаг стоит после установки, чтобы правки не били по кэшу зависимостей.
  • EXPOSE 8000 - документирует порт. Публикацию делает docker run -p, сам EXPOSE ничего не открывает.
  • CMD ["python", "app.py"] - команда запуска в exec-форме, без shell. Процесс получает сигналы напрямую, контейнер корректно останавливается.

Готовый файл целиком:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["python", "app.py"]

requirements.txt держите в корне проекта и обновляйте отдельно от кода: локально в venv это делается командой pip freeze > requirements.txt, если в проекте нет lock-файла. Для продакшена фиксируйте версии пакетов, иначе сборка перестанет быть повторяемой.

Почему COPY requirements.txt идёт до COPY . .

Docker собирает образ слоями и кэширует каждый слой по хешу инструкции и содержимого скопированных файлов. Инструкция COPY . . кладёт в слой весь контекст сборки, поэтому её хеш меняется при правке любого файла: app.py, шаблона, теста. Все следующие шаги, включая RUN pip install, теряют кэш и выполняются заново.

Разделение копирования убирает зависимость от кода. Слой с COPY requirements.txt меняется только тогда, когда меняется сам файл. Дальше слой RUN pip install остаётся валидным до следующего обновления списка пакетов.

На практике разница такая: первая сборка с тяжёлыми зависимостями идёт заметно дольше, а повторная после правки app.py заканчивается быстро, потому что шаг установки помечен CACHED. Проверить у себя просто: соберите образ, измените строку в коде, соберите снова и сравните время.

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

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

Если venv всё же создаёте, пропишите путь явно:

ENV PATH="/opt/venv/bin:$PATH"

Второй сценарий, где venv оправдан, это multi-stage сборка. Стадия builder ставит пакеты в /opt/venv, runtime-стадия копирует только окружение и код, без компилятора и dev-заголовков:

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY . .
CMD ["python", "app.py"]

Такой образ несёт только готовые зависимости. Для чистого Python без сборки из исходников выгода скромная, а при наличии gcc и dev-пакетов разница в размере заметная.

Кэширование зависимостей на практике: проверяем, что слой переиспользуется

Четыре шага дают однозначный ответ, работает ли кэш:

  1. Соберите образ и засеките время: docker build -t myapp .
  2. Измените строку в app.py, например текст приветствия.
  3. Соберите снова: docker build -t myapp . В выводе напротив шага установки появится отметка CACHED.
  4. Добавьте пакет в requirements.txt и повторите сборку. Шаг pip install выполнится заново, а слои до него останутся из кэша.

Строка вывода выглядит так:

=> CACHED [4/6] RUN pip install --no-cache-dir -r requirements.txt

Размер образа смотрите через docker images myapp, детальный разбор слоёв даёт docker history myapp. Начиная с Docker Engine 23.0.0 Buildx и BuildKit устанавливаются как builder по умолчанию на Linux, а команда docker build становится алиасом для docker buildx build; легаси-builder всё ещё можно вернуть, явно выставив DOCKER_BUILDKIT=0. Общую механику сборки и слоёв разбираем в статье про пошаговую сборку Docker-образа.

Что инвалидирует кэш зависимостей и как этого избежать

  • Изменился requirements.txt. Это ожидаемый сброс, другого поведения и не нужно.
  • Обновился базовый образ. Тег python:3.12-slim плавающий, Docker видит новую версию и пересобирает всё после FROM. Фиксация digest убирает эффект, но лишает обновлений безопасности, выбирайте осознанно.
  • COPY . . стоит раньше pip install. Правка любого файла сбрасывает кэш, лечится перестановкой инструкций.
  • Изменился ARG, от которого зависит RUN. Аргумент сборки меняет хеш инструкции, поэтому версии пакетов держите в requirements.txt.
  • Сборка идёт с --no-cache или из другого каталога контекста. В CI проверьте, что контекст и путь к Dockerfile совпадают между запусками.

Системные пакеты ставьте до COPY requirements.txt и одной инструкцией, иначе apt-кэш устареет между слоями:

RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq-dev gcc && \
    rm -rf /var/lib/apt/lists/*

Флаг --no-install-recommends не тянет рекомендованные пакеты, а удаление /var/lib/apt/lists в том же слое не оставляет после себя индекс пакетов. Объединять обновление индекса и установку нужно в одной инструкции, иначе apt-get update из старого слоя может отдать 404.

Уменьшаем образ и ускоряем сборку: .dockerignore и multi-stage

Без .dockerignore инструкция COPY . . затягивает в образ весь каталог: историю git, локальный venv, __pycache__, тесты, датасеты. Образ растёт, сборка замедляется, а файлы вроде .env попадают в слой и остаются в истории образа.

.git
.venv
__pycache__
*.pyc
.env
tests
.pytest_cache

Файл кладётся рядом с Dockerfile и читается автоматически. Секреты в образ не копируйте в принципе: передавайте их переменными окружения при docker run, через docker secrets или переменные CI.

Multi-stage build разделяет установку и запуск. Стадия builder несёт компилятор и dev-заголовки, финальная стадия получает только готовые зависимости и код. Поскольку в финальный runtime-образ не попадает GCC, он получается заметно легче и безопаснее: инструменты сборки и её зависимости отбрасываются, остаётся компактный образ для продакшена. Отдельно стоит помнить, что Alpine, будучи самым маленьким вариантом, часто увеличивает время сборки, если под него не находится готовых бинарников, и тогда их приходится собирать самостоятельно, что может увеличить и размер образа, и время сборки. Пошаговые примеры для Python, Go и Node.js собраны в статье про многоэтапную сборку в Docker.

Для проекта на чистом Python без сборки из исходников multi-stage не обязателен. Он нужен там, где pip тянет исходники: psycopg2, numpy из sdist, cryptography.

Найти лишний вес в готовом образе помогают docker inspect и dive, разбор команд и подходов к анализу слоёв в статье про архитектуру Docker-образов и анализ слоёв.

Собираем и запускаем: команды и проверка результата

docker build -t myapp .
docker run -d -p 8000:8000 --name myapp myapp
docker logs -f myapp
docker exec -it myapp python -c "import sys; print(sys.version)"
docker exec -it myapp pip list
curl http://localhost:8000/health

Флаг -d отправляет контейнер в фон, -p пробрасывает порт с хоста, --name даёт предсказуемое имя для логов и exec. Для одноразового запуска добавляйте --rm, для разработки монтируйте код каталогом: docker run -d -p 8000:8000 -v "$PWD":/app myapp. Тогда правки видны без пересборки, а зависимости остаются из образа.

Сценарий с переменными окружения, сетями и поиском причин падения контейнера описан в статье про развёртывание Docker-приложения.

Чек-лист после запуска:

  • контейнер в статусе Up, docker ps это подтверждает;
  • логи идут в stdout и читаются через docker logs -f;
  • приложение отвечает на запрос по проброшенному порту;
  • docker exec -it myapp pip list показывает ожидаемые версии пакетов;
  • повторная сборка после правки кода помечает шаг pip install как CACHED.

Финальный файл для копирования:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["python", "app.py"]

Команда сборки: docker build -t myapp . Затем docker run -d -p 8000:8000 --name myapp myapp.

Типичные ошибки при написании Dockerfile для Python

  1. COPY . . до pip install. Кэш зависимостей сбрасывается от любой правки. Исправление: сначала COPY requirements.txt и RUN pip install, затем COPY . .
  2. Плавающий или устаревший базовый образ. python:latest и python:2.7 дают невоспроизводимую сборку. Ставьте python:3.12-slim с фиксированной минорной версией и обновляйте её осознанно.
  3. Запуск от root. По умолчанию процесс идёт с правами root внутри контейнера. Создайте пользователя и переключитесь: RUN useradd -m app && chown -R app /app, затем USER app перед CMD.
  4. Секреты внутри образа. Ключи и .env, скопированные в слой, остаются в истории. Передавайте их при запуске: docker run -e DATABASE_URL=... или через docker secrets.
  5. Нет .dockerignore. В образ уезжают .git, venv, __pycache__ и тестовые данные. Добавьте файл рядом с Dockerfile.
  6. pip install без --no-cache-dir. Кеш загруженных архивов остаётся в образе и добавляет десятки и сотни мегабайт. Флаг убирает лишнее.
  7. CMD там, где нужен ENTRYPOINT, и наоборот. CMD легко переопределить аргументами docker run, ENTRYPOINT задаёт неизменяемую команду. Для приложения хватает CMD в exec-форме: ["python", "app.py"].
  8. Нет PYTHONUNBUFFERED. Логи буферизуются и не появляются в docker logs до остановки контейнера. Переменная со значением 1 решает вопрос.

Сверьтесь перед деплоем:

  • версия Python и версии пакетов зафиксированы;
  • сборка повторяется с тем же результатом на чистой машине;
  • контейнер запускается не от root;
  • секретов в образе нет;
  • образ собирается из чистого клона репозитория, без локальных файлов;
  • повторная сборка использует кэш зависимостей.
Поделиться: