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