Всем привет, меня зовут Сергей Прощаев. Я руководитель направления Java-разработки в FinTech. В этой статье я хочу поговорить о том, что часто остаётся за кадром хайповых докладов — о конкретной, измеримой эффективности Docker-образов. О том, как сборка образа перестаёт быть «лишь бы запустить» и превращается в инженерную дисциплину, влияющую на скорость деплоя, безопасность и даже кошелёк компании.

Покажу вам на реальных примерах, как оптимизация образа может сократить время развёртывания в 5–7 раз, и расскажу, какие практики сегодня используют команды, которым действительно важно доставлять код быстро и надёжно. А в конце поделюсь, где можно научиться проектировать такие системы не на костылях, а на рельсах современных подходов.

Толстый образ — пожиратель ресурсов инфраструктуры

В сети есть не мало описаний перевода монолита на независимые сервисы. При этом если к этому относиться как попало — то простой docker pull нового образа может занимать до 3–4 минут. А если нужно было обновить сразу 5 сервисов? Деплой может растянуться на полчаса. Если посмотреть, что в итоге таскается по сети, то вполне реально найти сервисы с весом до 2-х гигабайт. Два гигабайта! Зачем? Мы затащили туда JDK, Maven, все зависимости для сборки, кучу системных утилит «на всякий случай». Образ был собран по принципу «что вижу, то пою» — установил всё в один слой, скопировал исходники, собрал прямо там.

Образ — это не просто упаковка кода. Это артефакт, который должен быть минимальным, безопасным и быстрым в транспортировке. И вот почему размер образа — это не просто цифра в реестре. Давайте выделим основные моменты:

  • Скорость деплоя. Каждый мегабайт образа тянется по сети. Если у вас кластер на 50 нод, и каждая тянет 2 ГБ, считайте трафик и время. Даже в дата-центре с гигабитными линками это ощутимо. А в облаке за трафик ещё и платить.

  • Поверхность атаки. Внутри образа часто валяются ненужные бинарники: компиляторы, отладчики, bash, утилиты. Если злоумышленник получит доступ к контейнеру, эти инструменты станут его верными помощниками.

  • Время запуска. Чем больше слоёв и файлов, тем дольше контейнерный рантайм распаковывает образ. Особенно это критично для serverless и функций, которые стартуют «на лету».

  • Ресурсы реестра. Хранить и сканировать огромные образы — дорого (в прямом и переносном смысле).

Рекомендации по цифрам из практики: после первой такой оптимизации у вас сократится размер среднего образа с 800 МБ до 120 МБ. За ним упадет время docker pull с 2 минут до 20 секунд.

Почему образы жиреют?

Прежде чем лечить, надо понять причины. Типичный «толстый» Dockerfile выглядит примерно так:

FROM maven:3.8-openjdk-11

WORKDIR /app
COPY . .
RUN mvn clean package

CMD ["java", "-jar", "target/myapp.jar"]

Красиво? Да. Работоспособно? Да. Оптимально? Нет.

Что здесь не так?

  1. Базовый образ. maven:3.8-openjdk-11 весит под 800 МБ. Внутри полно всего: сам Maven, JDK, куча библиотек Linux. Нам для рантайма нужна только JRE (а ещё лучше — JRE на минималках).

  2. Инструменты сборки в рантайме. Maven нужен только чтобы собрать jar. Но он остаётся в финальном образе. Это как нанять строителей, а потом оставить их жить в построенном доме.

  3. Кэширование слоёв. Каждый раз при изменении любого файла исходников слой COPY . . инвалидируется, и Maven скачивает все зависимости заново. Деплой тормозит ещё и на этапе сборки.

  4. Права root. Приложение по умолчанию запускается от root. Если злоумышленник взломает приложение, он получит полный контроль над контейнером (и, возможно, над хостом, если контейнер запущен с привилегиями).

Лекарство: Best Practices, проверенные деплоем

Со временем индустрия выработала набор практик, которые превращают Dockerfile в произведение инженерного искусства. Я разберу основные, а потом покажу, как их применить на Java-проекте.

1. Multi-stage builds — разделяй и властвуй

Это главный инструмент для удаления сборочного мусора. Идея проста: в одном Dockerfile описывается несколько этапов (стадий). На первом этапе мы собираем артефакт, используя тяжёлый образ со всеми инструментами. На втором — берём минимальный базовый образ и копируем в него только готовый артефакт.

2. Правильный базовый образ

  • eclipse-temurin:17-jre-alpine (или -slim) — JRE на Alpine Linux, ~150–200 МБ.

  • openjdk:17-slim — Debian Slim, чуть больше, но привычнее.

  • gcr.io/distroless/java17 — от Google, вообще без shell и лишних утилит, только JRE и приложение. Минимальный размер и максимальная безопасность. Минус — в контейнер не зайти с docker exec, но в проде это и не нужно.

3. Использование .dockerignore

Аналог .gitignore. Не тащите в образ исходники, .git, логи, временные файлы. Это не только уменьшает размер, но и ускоряет сборку (контекст меньше).

4. Минимизация слоёв и кэширование зависимостей

Каждая инструкция RUNCOPYADD создаёт слой. Слои кэшируются. Если расположить команды так, чтобы редко меняющиеся слои были вверху, сборка будет летать. Для Java это означает: сначала скопировать pom.xml и скачать зависимости, потом уже копировать исходники.

COPY pom.xml .
RUN mvn dependency:go-offline  # скачает все зависимости и закэширует слой
COPY src ./src
RUN mvn package

5. Запуск от непривилегированного пользователя

Создайте пользователя в контейнере и переключитесь на него.

RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 appuser --ingroup appgroup
USER appuser

Как это делают в Uber: история про Python и distroless

Когда речь заходит о промышленных масштабах, кейс Uber — хрестоматийный пример. Несколько лет назад их команда столкнулась с проблемой: тысячи микросервисов на Python, каждый со своим окружением. Базовые образы были большими, содержали кучу пакетов ОС, и, что хуже, в них регулярно находили уязвимости (CVE). Каждое обновление безопасности требовало пересборки всех образов, что занимало вечность.

Инженеры Uber решили пойти радикальным путём: они начали использовать distroless-образы для Python. Это специальные образы от Google, в которых есть только интерпретатор Python и необходимые системные библиотеки — никакого shell, никаких пакетных менеджеров, ничего лишнего. Но как же тогда устанавливать Python-зависимости? Ведь нужен pip!

Решение — классический multi-stage. На этапе сборки использовался тяжёлый образ с pip, туда копировался requirements.txt, устанавливались пакеты в отдельную папку. На финальном этапе из distroless-образа брался голый Python, и в него копировалась папка с установленными пакетами и код приложения.

Результат: размер образа сократился в среднем на 40%, количество уязвимостей упало на 97%, а время деплоя уменьшилось кратно. Кроме того, distroless-образы запрещают запуск shell, ��то делает эксплуатацию уязвимостей типа RCE значительно сложнее — злоумышленнику просто нечем будет пользоваться.

Кейс достаточно интересный, и если ранее с этим не работали — можно попробовать перевести несколько Java-сервисов на distroless. По началу будет непривычно, что нельзя зайти в контейнер и посмотреть логи (логи должны быть в stdout). Но безопасность и скорость этого стоит!

Визуализируем: как устроен оптимальный образ

Чтобы понимать разницу, давайте посмотрим на схемы. Первая показывает классический «толстый» образ

Рис. 1 Классический «толстый» образ Docker
Рис. 1 Классический «толстый» образ Docker

и оптимальный — с multi-stage, изображенный на рисунке 2:

Рис. 2 Образ Docker с multi-stage
Рис. 2 Образ Docker с multi-stage

Следующая схема, изображенная на рисунке 3, показывает процесс multi-stage сборки.

Рис. 3 Процесс multi-stage сборки в Docker
Рис. 3 Процесс multi-stage сборки в Docker

Пример на Java: от плохого к хорошему

Давайте возьмём простое Spring Boot приложение с одним REST-контроллером. Я создал два Dockerfile — плохой и хороший. Плохой — это копия того, что я часто вижу на стажировках.

Плохой Dockerfile.bad:

FROM maven:3.8-openjdk-11

WORKDIR /app
COPY . .
RUN mvn clean package

EXPOSE 8080
CMD ["java", "-jar", "target/demo-0.0.1-SNAPSHOT.jar"]

Размер образа после сборки: ~720 МБ. Внутри куча всего: Maven, исходники, кэш зависимостей.

Хороший Dockerfile.good (multi-stage):

## ---- Builder stage ----
FROM maven:3.8-openjdk-11 AS builder

WORKDIR /build
# Копируем только pom.xml для кэширования зависимостей
COPY pom.xml .
# Скачиваем все зависимости (слой закэшируется)
RUN mvn dependency:go-offline -B

# Теперь копируем исходники и собираем
COPY src ./src
RUN mvn clean package -DskipTests

## ---- Final stage ----
FROM eclipse-temurin:17-jre-alpine

# Создаём непривилегированного пользователя
RUN addgroup -g 1001 -S appuser && adduser -u 1001 -S appuser -G appuser

WORKDIR /app
# Копируем только собранный jar из builder
COPY --from=builder /build/target/demo-0.0.1-SNAPSHOT.jar app.jar

# Переключаемся на непривилегированного пользователя
USER appuser

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Размер образа: ~180 МБ. Это в 4 раза меньше. И безопаснее: нет shell, нет компилятора, приложение не от root.

Можно пойти дальше и использовать distroless:

FROM maven:3.8-openjdk-11 AS builder
# ... сборка как выше ...

FROM gcr.io/distroless/java17-debian11
WORKDIR /app
COPY --from=builder /build/target/demo-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
CMD ["app.jar"]

Размер станет около 150 МБ, а поверхность атаки ещё меньше. Правда, отладка в таком контейнере сложнее — нет shell. Но для прода это плюс.

Что ещё можно улучшить?

На этом оптимизация не заканчивается. Вот несколько приёмов из арсенала профи:

  • Линтеры. Используйте docker scan или trivy для поиска уязвимостей. hadolint проверит Dockerfile на антипаттерны.

  • Сжатие слоёв. Инструменты типа docker-slim анализируют приложение и выбрасывают из образа всё, что не используется во время выполнения.

  • Разделение по слоям в Spring Boot. Начиная с Spring Boot 2.3, можно собирать jar со слоями (layers), что позволяет кэшировать зависимости отдельно от кода даже без multi-stage. В Dockerfile можно скопировать слои по отдельности.

Заключение: от ремесла к инженерии

Сборка Docker-образа — это не просто запись инструкций в файл. Это этап, на котором закладываются эксплуатационные характеристики вашего сервиса. Маленький образ быстрее доставляется, безопаснее, экономит ресурсы. Лучшие практики, о которых мы говорили — мультистейдж, минимальные базовые образы, непривилегированный пользователь, — должны стать вашим стандартом.

Многие разработчики считают: «Подумаешь, образ большой — диски сейчас дешёвые». Но когда у вас 100 микросервисов, которые обновляются по 10 раз в день, эти «мелочи» превращаются в недели простоя и миллионы потраченного впустую трафика. Инвестируйте время в правильную сборку образа сейчас — сэкономите его потом многократно!

Если вам интересно глубже разобраться в том, как проектировать микросервисные архитектуры, управлять их жизненным циклом и внедрять современные практики DevOps (разработка и эксплуатация), обратите внимание на курс «DevOps (разработка и эксплуатация) практики и инструменты». Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 2 марта, 20:00. «Docker образы. Микросервисы». Записаться

  • 17 марта, 20:00. «Работа с данными и сетями в Docker». Записаться

  • 24 марта, 20:00. «Технология контейнеризации. Введение в Docker». Записаться