Сборка контейнера для среднего Python-сервиса по умолчанию даёт что-то в районе гигабайта, а то и полутора. На локальной машине это незаметно. Проблемы начинаются позже - кластер Kubernetes тянет такие образы минутами, билды на CI едят минуты впустую, реестр контейнеров плодит счета за хранилище, а команда безопасности приходит с длинным списком CVE из дистрибутива, который к приложению вообще не относится. Сжать образ до 150 МБ без потери функциональности можно, и это не история про какие-то экзотические трюки. Нужны три инструмента, которые умеют работать вместе - multi-stage сборка, минимальный рантайм-образ и быстрый менеджер пакетов.
Откуда вообще берётся 1.2 ГБ в обычном Python-образе
Если собрать самую банальную сборку на основе python:3.12 без всяких оптимизаций, получается примерно такая картина. Базовый образ python:3.12 (не slim, а полный) весит уже около 1 ГБ. Поверх него лягут apt-зависимости для сборки бинарных колёс (gcc, libpq-dev, build-essential), кэш pip с исходниками пакетов, тесты, сама виртуалка с библиотеками. В итоге слои разрастаются до того самого гигабайта с хвостом.
Каждый компонент в этой массе имеет своё объяснение. Полный образ Python включает в себя инструменты разработки, документацию, заголовочные файлы, отладочные символы. Apt-менеджер тащит за собой кэши и индексы. Pip оставляет временные файлы и build-артефакты. Сама копия исходников приложения на этом фоне выглядит ничтожной, обычно мегабайт пять, а всё остальное - сервисное.
Простой запуск типичного образа выглядит так:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN apt-get update && apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "-m", "myapp"]
Такой Dockerfile рабочий, но размер получившегося образа в районе 1.2 ГБ - это не патология, а норма для подобного подхода. Главная проблема в том, что всё содержимое (системные библиотеки, build-инструменты, кэши, исходники колёс) остаётся в финальном образе, хотя для запуска нужно совсем не это.
Multi-stage build как фундамент сжатия
Идея multi-stage сборки проста и формулируется одной фразой - разделить окружение, в котором собирается приложение, и окружение, в котором оно работает. На этапе сборки можно использовать жирный образ с компиляторами и всеми зависимостями, а в финальный образ перенести только готовые артефакты. Docker позволяет описать несколько стадий в одном Dockerfile и копировать файлы между ними через COPY --from.
Базовый каркас выглядит так:
FROM python:3.12 AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
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 AS runtime
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
CMD ["python", "-m", "myapp"]
Уже этот шаг даёт ощутимый выигрыш. Финальный образ собирается на основе python:3.12-slim (это примерно 130 МБ против гигабайта у полного), а build-инструменты и кэш pip остаются на стадии builder и в финал не попадают. Размер обычно сокращается до 400-500 МБ. Это уже не катастрофа, но и не идеал. Дальше начинается работа над тем, что осталось.
Зачем здесь uv и что именно он меняет
uv - это менеджер пакетов от компании Astral, написанный на Rust. На фоне pip он работает в десятки раз быстрее, использует глобальный кэш по жёстким ссылкам, поддерживает lock-файлы из коробки и заметно лучше работает в контейнерных сборках. В контексте Docker у uv есть несколько ключевых преимуществ.
Во-первых, образ uv поставляется как distroless-бинарник, который копируется в build-стадию одной строчкой COPY. Никаких pip install uv с тяжёлыми зависимостями. Во-вторых, uv умеет ставить пакеты с замороженным lock-файлом без повторного разрешения версий, что делает сборки воспроизводимыми и быстрыми. В-третьих, uv нативно дружит с BuildKit и его cache mounts - пакеты, скачанные один раз, переиспользуются между билдами без необходимости держать их в слое.
Рабочая build-стадия с uv выглядит так:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/app/.venv
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
Несколько важных мест в этом фрагменте стоит разобрать. UV_COMPILE_BYTECODE=1 включает компиляцию .py в .pyc прямо во время установки - это слегка увеличивает размер, но заметно ускоряет старт приложения. UV_LINK_MODE=copy нужен, потому что cache mount живёт на отдельной файловой системе, и попытка использовать жёсткие ссылки между ними приводит к ошибке. UV_PYTHON_DOWNLOADS=never запрещает uv качать собственные сборки Python в build-стадии - используем интерпретатор из базового образа.
Двухшаговая установка зависимостей (сначала uv sync --no-install-project, потом полный sync) - это трюк для кэширования слоёв. Первый шаг ставит только зависимости из lock-файла. Этот слой не пересобирается, пока не меняется uv.lock или pyproject.toml. Второй шаг копирует исходники и ставит сам проект. Когда меняется только код приложения (это происходит на каждом коммите), пересобирается лишь второй короткий шаг, а тяжёлая часть с зависимостями остаётся в кэше.
Distroless как финальный шаг к минимальному размеру
Образы distroless от Google это контейнеры, в которых нет ничего, кроме самого приложения и минимально необходимого рантайма. Нет shell, нет apt, нет coreutils, нет ничего, что можно было бы запустить через docker exec и пошарить руками. Для Python есть отдельная разновидность - gcr.io/distroless/python3-debian12, которая содержит интерпретатор и базовые системные библиотеки, но абсолютно ничего лишнего.
Финальная стадия сборки с distroless выглядит лаконично:
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/myapp /app/myapp
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/.venv/lib/python3.12/site-packages" \
PYTHONUNBUFFERED=1
USER nonroot
ENTRYPOINT ["/app/.venv/bin/python", "-m", "myapp"]
Тэг :nonroot обеспечивает запуск под непривилегированным пользователем UID 65532. Это полезно с точки зрения безопасности - даже если в приложении найдут уязвимость, у атакующего не будет прав root в контейнере. Отсутствие shell делает работу руками невозможной, но и большинство классических атак на эскалацию привилегий внутри контейнера тоже отрубает.
После такой сборки размер обычно укладывается в 130-160 МБ для среднего FastAPI-приложения с пятью-десятью зависимостями. Распределение примерно такое - около 50 МБ занимает сам distroless с Python, 80-100 МБ это виртуалка с пакетами, остаток - код приложения.
Полный Dockerfile в работающем виде
Соединение всех частей даёт вот такой Dockerfile, который можно копировать и адаптировать под свой проект. Здесь предполагается, что в проекте используется uv и есть файлы pyproject.toml и uv.lock:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/app/.venv
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM gcr.io/distroless/python3-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/myapp /app/myapp
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/.venv/lib/python3.12/site-packages" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER nonroot
EXPOSE 8000
ENTRYPOINT ["/app/.venv/bin/python", "-m", "myapp"]
Команда сборки запускается с обязательной поддержкой BuildKit - без него cache mounts не работают:
DOCKER_BUILDKIT=1 docker build -t myapp:slim .
docker images myapp:slim
На среднем FastAPI-приложении с pydantic, sqlalchemy, asyncpg и uvicorn такой Dockerfile стабильно даёт образ размером 140-160 МБ. Если приложение проще, можно добиться и сотни.
Подводные камни distroless, которые ловят на первом же запуске
Переход на distroless редко проходит гладко с первой попытки. Большинство граблей связано с тем, что чего-то в финальном образе попросту нет. Несколько типичных ситуаций:
- Отсутствует glibc или нужная версия libc - некоторые пакеты с нативными расширениями (компилированные на alpine или slim) могут не запуститься на distroless, потому что ожидают другой libc;
- Нет shell, поэтому скрипты вида CMD ["sh", "-c", "..."] не работают, нужно вызывать Python напрямую;
- Нет ничего для отладки - ни bash, ни ls, ни cat. Если нужно зайти внутрь, поможет только версия distroless с тэгом :debug, в которой добавлен busybox;
- Сертификаты CA могут отсутствовать в некоторых вариантах. Для distroless/python3 они есть, но для distroless/static-cc или distroless/cc нужно проверять отдельно;
- Healthcheck через curl или wget невозможен - этих утилит просто нет. Проверки делаются через Python-скрипт.
Healthcheck для distroless выглядит как-то так:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app/.venv/bin/python", "-c", \
"import urllib.request, sys; \
sys.exit(0) if urllib.request.urlopen('http://localhost:8000/health').status == 200 else sys.exit(1)"]
Версия distroless с :debug подключается тогда, когда нужно зайти и посмотреть, что внутри. Делается это сменой одной строки в финальной стадии:
FROM gcr.io/distroless/python3-debian12:debug-nonroot AS runtime
После сборки можно зайти через docker exec и использовать busybox. В продакшен такой образ не катят, но для отладки в стейджинге он экономит часы.
.dockerignore как недооценённый источник лишних мегабайт
Маленькая, но важная деталь. Без .dockerignore Docker отправляет весь контекст сборки демону, и COPY . . тянет в образ всё подряд - локальные venv, кэши IDE, тесты, фикстуры, документацию, history-файлы git. Это и наполняет образ лишним, и замедляет каждую сборку.
Базовый .dockerignore для Python-проекта выглядит так:
.git
.gitignore
.dockerignore
Dockerfile*
README.md
docs/
tests/
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.env
.env.*
.vscode/
.idea/
*.log
.DS_Store
node_modules/
Каждая строчка экономит мегабайты. На больших проектах с обширной историей git и кэшами линтеров игнорирование .git и кэшей легко срезает с контекста сборки сотни мегабайт.
Численная разница на реальных проектах
Чтобы цифры были не голословными, вот примерная таблица для типичного FastAPI-сервиса со стандартным набором зависимостей. Все размеры приведены для образов, собранных одинаково и измеренных через docker images:
- python:3.12 + pip без оптимизаций - около 1.2 ГБ;
- python:3.12-slim + pip без multi-stage - около 750 МБ;
- python:3.12-slim + pip + multi-stage - около 350 МБ;
- python:3.12-slim + uv + multi-stage - около 220 МБ;
- distroless/python3 + uv + multi-stage - около 150 МБ.
Финальная цифра в каждом случае зависит от количества и веса зависимостей. Если в проекте используется что-то тяжёлое вроде numpy, scipy или pandas, общий объём вырастет на 100-200 МБ независимо от способа сборки - сами по себе эти библиотеки большие. Если зависимости только чистый Python (без бинарных колёс), distroless-сборка может опуститься до 80-100 МБ.
Что ещё имеет смысл сделать после первого успешного билда
Базовая сборка работает, и образ съёжился в восемь раз. Дальше можно крутить детали, которые на больших масштабах тоже играют роль:
- Включить scan на уязвимости через docker scout или trivy и регулярно обновлять distroless-теги, потому что в них тоже находят CVE;
- Использовать BuildKit-функцию --mount=type=secret для проброса токенов приватных индексов пакетов, чтобы они не оставались в слоях;
- Включить сжатие с алгоритмом zstd в реестре, если он это поддерживает (artifactory, harbor, ghcr) - это экономит ещё 10-20% при пересылке;
- Перейти на multi-arch билды через buildx, если приложение деплоится одновременно на amd64 и arm64. Это уже не про размер одного образа, а про удобство;
- Добавить SBOM через docker buildx с --sbom=true, чтобы фиксировать состав образа для аудита.
Главная мысль во всём этом такая. Жирный Python-контейнер не неизбежность, и за уменьшение размера в десять раз не платят ни функциональностью, ни стабильностью. Платят временем на первоначальную настройку Dockerfile и привычкой пользоваться uv вместо pip. Зато потом каждый деплой становится быстрее, каждое обновление пула в Kubernetes тратит меньше времени и сети, а отчёты сканеров безопасности перестают приходить с тремя экранами уязвимостей в дистрибутиве, которые к самому приложению отношения не имеют.