Всем привет. Я 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