Оптимизация сборки Python Docker образа: размер меньше на -43% (-57%)

в 19:15, , рубрики: build, docker, multistage, билда, оптимизация, размер, скорость

Всем привет. Я Backend разработчик, в основном на Python и немного Go. Хотел бы рассказать про свой опыт оптимизации docker образов и написать некий «туториал». Он скорее будет полезен для разработчиков или начинающим DevOps. Для опытных DevOps инженеров, возможно будет мало интересного и полезного

Не претендую на правильность во всем, полноту. Мое основное ремесло – эффективно делать эффективный бекенд. Направление инфраструктуры мне интересно, стараюсь активно изучать и в ходе работы, так или иначе приходилось и приходится с этим работать. 

Цели: 

  • собрать свои наработки и структурировать их. Помочь сэкономить время тем, кто занимается тем же

  • получить фидбек и улучшить показатели оптимизации 

Статья может показаться не супер-дружной и легко-читаемой. Есть профессиональный лексикон, объяснений в данной версии статьи может быть маловато.

Допущения этой статьи

 Для управления зависимостями используется poetry. Да, есть супер быстрый uv. Исторически сложилось, что poetry - мой инструмент для управления зависимостями и кто-то сочтет poetryустаревшим, но предлагаю в рамках этой статьи обсуждать именно оптимизацию образа, а не пакетные менеджеры. В целом, возможно перейду на использование uv в скором времени.

Теория

Для чего оптимизировать? 

  1. Увеличить скорость разворачивания подов в кубах (kubernates), чтобы в моменты повышения нагрузки деградация сервиса была ниже. Логика т��кая: под поднимается, когда нагрузка увеличилась, и чем быстрее запустится новый под, тем меньше времени пользователи будут получать задержки. А для разворачивания сервиса, нужно его образ перенести внутрь пода. И, собственно, чем меньше размер, тем быстрее копируется

  2. Экономия ресурсов хранилища

  3. Экономия сетевого ресурса

Уровень: Базовый. Справятся все

 

1) Использовать .dockerignore 

Обратим внимание на то, что директория tests/ включена в список исключений, для того чтобы в runtimeобразе, был только исполняемый код. А так как тесты нужны только на стадии тестирования, то из финального образа их убираем 

2) Использовать slim версию питона

 Даже не измерял размер образа с не-slim версией python. Это будет на сотни мегабайтов, а то и гигабайт больше

3) Отключение кеша для pip или poetry

Внутри runtime образа кеши для быстрой установки зависимостей с помощью pip/poetry не нужны, так как все зависимости уже собраны на этапе билда

Поэтому их можно не хранить в кеше.

Использовать флаги

Для pip:

pip install -r requriments.txt –no-cache-dir

Для poetry

4) Многоэтапная сборка 

Заметил, что не все компании используют многоэтапную сборку, хотя, казалось бы, все туториалы говорят об этом. 

Результаты: 

Время первого билда, с нуля сильно увеличилось, но нужно понимать, что при правильном запуске сборки образа, все слой билдера (builder) будут переиспользоваться в будущем и все последующие обновления будут занимать значительно меньше времени, и будут даже быстрее чем, при одноэтапной сборке 

 

Время сборки с нуля

Время сборки после изменения кода

Размер образа, Мб

Было

58

56

894.84

Стало

315

16

508

Изменение

+600%

-71%

-43%

Финальный Dockerfile: 

Скрытый текст
# ───────────────────────────────────────────────────────────────

#  Builder stage — здесь ставим всё для сборки и тестов

# ───────────────────────────────────────────────────────────────

FROM python:3.13-slim-bookworm AS builder

ENV PYTHONUNBUFFERED=1     LANG=C.UTF-8     PYTHONDONTWRITEBYTECODE=1

 

# Устанавливаем Poetry + необходимые системные пакеты, если нужны для сборки некоторых пакетов

RUN apt-get update -qq &&     apt-get install -y --no-install-recommends       curl       gcc       libc-dev     && pip install --no-cache-dir 'poetry==2.3.2'    && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

WORKDIR /code

 

# Копируем только файлы зависимостей, для оптимизации скорости повторной сборки 

COPY pyproject.toml poetry.lock* ./

 

# Настраиваем Poetry и ставим зависимости

RUN poetry config virtualenvs.create false   && poetry install --no-root --no-dev --no-interaction --no-ansi     && poetry cache clear --all pypi --no-interaction || true     && rm -rf ~/.cache/pip

 

# Копируем весь проект и запускаем тесты

COPY . .

 

# Тесты — выполняем в builder

RUN pytest

 

# ───────────────────────────────────────────────────────────────

#  Runtime stage — максимально лёгкий финальный образ

# ───────────────────────────────────────────────────────────────

FROM python:3.13-slim-bookworm

ENV PYTHONUNBUFFERED=1     PYTHONDONTWRITEBYTECODE=1     LANG=C.UTF-8

WORKDIR /code

 

# Копируем только установленные пакеты и бинарники

COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages

COPY --from=builder /usr/local/bin/gunicorn       /usr/local/bin/gunicorn

COPY --from=builder /usr/local/bin/uvicorn        /usr/local/bin/uvicorn

# Если есть другие бинарники, которые ставит poetry  их нужно добавить сюда

 

# Копируем код приложения

COPY --from=builder /code /code

Уровень: посложнее. Нужно подумать стоит ли это делать

Методы оптимизации ниже, уже не базовые и стоит оценить плюсы и минусы. Готовы ли бизнес и разработчики платить за получаемый "профит", усложнением кода, более сложной отладкой.  

5) Использование Chainguard образа 

Идея: использовать chainguard образы, в которых нет установщиков пакетов (apk, apt), оболочек (shell) или лишних библиотек.

Плюсы:

  • Нет shell, apt (apk) и соответственно меньше поверхность атаки и лучше безопасность. В некоторых образах заявляется полное отсутствие CVE уязвимостей

  • Меньше размер образа

Минусы:

  • Сложность отладки из-за отсутствия shell и библиотек

  • Насколько мне известно, бесплатные образы имеют только latest теги, соответственно нельзя зафиксировать версию. 

  • Платные образы стоят достаточно дорого

Экономию размера лично я ни разу не вычислял. Говорят что экономит от десятков до сотни мегабайт.

6) Ручной анализ файлов в образе

Это более душно, тяжелее и с меньшим шансом на успех. Тщательно подумайте перед тем, как тратить на это часы времени.

Идея: посмотреть размер слоев. Посмотреть какие размер директорий, которые добавляются на каждом этапе сборки и попытаться найти директории и файлы, которые не нужны для функционирования сервиса.

Нужен опыт linux систем для анализа.

Честно говоря, мне это�� метод не подошел. Экономия памяти невелика, вероятность успеха не высокая, но требует много времени на анализ и отладку. После взвешивания "за" и "против" практически всегда отказываюсь от этого метода, но держу в арсенале.

Итак, процесс:

- В терминале посмотреть размер слоев

docker history --format "{{.CreatedBy}}: {{.Size}}" ваш_образ:tag

Или в Docker desktop нажать на образ во вкладке "Images"

  • Будет отображаться размер каждого слоя. Начинаем анализ со слоев с наибольшим размеров и наболее близким к Runtime слою. Это и будет направлением куда смотреть

  • Запускаем dive командой

docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest <ваш_образ:tag>
  • И смотрим на каждый слой какой размер, и начиная со слоя, с самым большим размеров, смотрим справа (переключение по Tab) какие директории и файлы занимают много места 

На Shift + Пробел можно скрывать содержимое папки (Collapse)

  • Удаляем ненужные библиотеки из билда

Уровень: Экстрим! - скорее всего это вам не нужно

Идея: Использование scratch образа для runtime этапа

scratch - это образ, который представляет из себя практический полное отсутствие всего в операционной системе. Никаких системных библиотек, пакетов. Конечно же в нем нет shell 

Так как нет системных библиотек, то их нужно копировать при сборке. Причем точечно только те, которые нужны для работы вашего сервиса.

Минусы:

  • После добавления новых зависимостей, может отказаться что недостаточно системных библиотек и придется потратить час, а то и более на добавление нужной библиотеки и отладку. Следственно, это дороже с точки зрения бизнеса

Результат: Мне это позволило уменьшить размер того же образа до 384 Мб.

Сравнение результата

 

Размер было, Мб

Размер стало, Мб

%

БЕЗ многоэтапной сборки

894

 

384

 

- 57%

 

С многоэтапной сборки

508

384

-24%

Этот вариант часто используется в Docker для других языков, например Golang, где это не создает столько трудностей как в Python, так как в процессе компиляции можно забрать все зависимости с бинарник.

Возможно, это стоит того с точки зрения бизнеса и разработки и окупается, в некоторых очень нагруженных проектах, где поды должны быть очень быстро разворачиваемыми и в больших количествах. Но перед тем, как заниматься этим, возможно будет потребность в смене языка Python на что-то более быстрое. Поэтому допускаю, что возможно в рамках Python Backend разработки, данный этап не целесообразен, но не всегда и не везде.

Возможно, целесообразно когда тысячи подов или при Serverless / Knative / Cloud Run / Fargate, чтобы ROI этой работы был достаточным.

TODO: Что я не пробовал и предстоит сделать

1)    Отказ от poetry в runtime этапе

2)    Wolfi образы 

Бонус: Оптимизация времени установки

Пока не собрал достаточно наработок для отдельной статьи про уменьшения времени сборки, напишу некоторые тут

Порядок слоев

Идея: Сначала копировать requirements.txt/pyproject.toml, и устанавливать зависимости перед копированием всего кода.

В таком случае при изменении кода не нужно переустанавливать все зависимости.

Например было:

RUN pip install --no-cache-dir 'poetry==2.3.2'

RUN mkdir /code

COPY . /code/  #  <- Тут копируется весь код

WORKDIR /code

RUN poetry install #  <- Тут устанавливаются зависимости

RUN pytest

В моем случае в docker запускаются pytest в самом конце.

После изменения кода билд составлял 58 секунд. Так как каждое изменение кода -> билд почти с самого начала, с переустановкой зависимостей. Используется кэш только тех слоев, которые идут до копирования кода COPY . . или COPY . ./code 

Стало

RUN pip install --no-cache-dir 'poetry==2.3.2'

RUN mkdir /code

COPY pyproject.toml poetry.lock /code/ #  <- Тут копируется только список зависимостей 

WORKDIR /code

RUN poetry install #  <- Установка зависимостей

COPY . /code/ # <- Тут копируется весь код

RUN pytest

B после изменения кода время билда после правок кода сократилось до 14 секунд. Время необходимо только на копирование кода и тесты. Зависимости копируются из кэша

Ускорение в 4 раза!

Буду очень рад конструктивной критике, идеям и предложениям по улучшению показателей. 

Спасибо за внимание!

Мой Github

Автор: abdullin-rail

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js