Frog Proger 13 ноября 2024

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Docker-образы часто страдают от лишнего веса. Это не просто проблема занимаемого места – это вопрос безопасности и эффективности. Но есть решение: многоэтапная сборка.
🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки
Этот материал взят из нашей еженедельной email-рассылки, посвященной бэкенду. Подпишитесь, чтобы быть в числе первых, кто получит дайджест.

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

Почему образы получаются такими большими

У приложений есть зависимости двух типов:

  • Зависимости для сборки (build-time) – библиотеки и инструменты, необходимые для компиляции и подготовки к запуску.
  • Зависимости для выполнения (run-time) – то, что нужно только в продакшене.

Когда мы используем один и тот же образ для сборки и запуска, то в продакшн попадают лишние инструменты – интерпретаторы, компиляторы, линтеры и т.д. Избежать этого можно только с помощью разделения этапов сборки и выполнения.

Примеры неправильных Dockerfile

Рассмотрим Dockerfile для Go и Node.js приложений, где допущены ошибки, приводящие к ненужному раздуванию образов.

Неправильный Dockerfile для Go-приложения

Здесь использован образ golang:1.23, который включает не только скомпилированное приложение, но и весь инструментарий Go вместе с зависимостями – больше 800 Мб, множество из которых становятся уязвимостями в продакшене:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Структура этого раздутого образа выглядит так:

Большая часть этого увесистого образа не нужна для запуска приложения и опасна в продакшене
Большая часть этого увесистого образа не нужна для запуска приложения и опасна в продакшене

Неправильный Dockerfile для Node.js-приложения

Здесь используются команды npm ci и npm run build. Первая команда устанавливает зависимости и для разработки, и для продакшена. Но при попытке убрать девелоперские зависимости команда npm run build не сможет завершиться, так как для сборки нужны оба типа зависимостей:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Такой докерфайл создает образ с 500 Мб лишних компонентов:

На долю самого приложения в этом образе приходится всего 50 Мб
На долю самого приложения в этом образе приходится всего 50 Мб

Как работает многоэтапная сборка

Концепция многоэтапной сборки основана на паттерне проектирования «Строитель». Чтобы понять, как этот подход работает в Docker, сначала нужно познакомиться с двумя мощными возможностями Dockerfile – копированием файлов из другого образа и определением нескольких образов в одном докерфайле.

Копирование файлов из другого образа

Одна из самых распространенных инструкций в Dockerfile – это COPY. Обычно команда используется для копирования файлов с хоста в образ:

        COPY host/path/to/file image/path/to/file
    

Однако файлы можно также копировать напрямую из других Docker-образов. Например, можно скопировать файл конфигурации nginx.conf из официального образа nginx:latest прямо в свой текущий образ:

        COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
    

Именно фича COPY --from= помогает реализовать паттерн «Строитель» – например, при создании образа для Node.js:

        # Основной образ для выполнения приложения
FROM node:lts-slim
# Указываем рабочую директорию
WORKDIR /app
# Копируем собранные артефакты из вспомогательного образа "build:v1"
COPY --from=build:v1 /app/.output .
# Настройки окружения
ENV NODE_ENV=production
EXPOSE 3000
# Команда запуска
CMD ["node", "/app/.output/index.mjs"]

    

Определение нескольких образов в одном Dockerfile

С 2018 года Docker поддерживает многоцелевые Dockerfile: в одном файле можно указать несколько инструкций FROM, каждая из которых создает отдельный целевой образ. Здесь определены три разных образа с их собственными настройками:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Эта особенность позволяет выбрать цель сборки с помощью параметра --target в команде docker build, давая возможность одному Dockerfile создавать разные образы в зависимости от выбранной цели:

Собрать образ на основе первого FROM:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Собрать образ на основе второго FROM:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Собрать образ на основе третьего FROM:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки
♾️ Библиотека devops’a
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека devops’a»
♾️🎓 Библиотека DevOps для собеса
Подтянуть свои знания по DevOps вы можете на нашем телеграм-канале «Библиотека DevOps для собеса»
♾️🧩 Библиотека задач по DevOps
Интересные задачи по DevOps для практики можно найти на нашем телеграм-канале «Библиотека задач по DevOps»

Как использовать эти две возможности для многоэтапной сборки

Dockerfile для сборки и Dockerfile для выполнения можно объединить в один файл с несколькими инструкциями FROM. Здесь на первом этапе происходит сборка приложения с помощью npm run build, а второй этап копирует результат сборки и запускает приложение:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Важно помнить:

  • Порядок этапов имеет значение. Нельзя выполнить COPY --from из этапа, который определен после текущего. Этапы должны быть описаны в логической последовательности.
  • Алиасы этапов. Использование AS (например, AS build) позволяет дать этапу понятное имя, но их применение опционально: если имя не указано, на этапы можно ссылаться по их порядковому номеру (например, COPY --from=0).
  • По умолчанию собирается последний этап. Если не указать флаг --target, команда docker build соберет последний этап и все этапы, от которых он зависит.

Несколько примеров многоэтапной сборки

Приложение на Go

Go-приложения всегда компилируются на этапе сборки. Итоговый бинарник может быть двух типов:

  1. Статически связанный (собран с CGO_ENABLED=0) – все необходимые зависимости включены внутрь самого бинарника. Такой бинарник можно запускать даже на минималистичных базовых образах, например, gcr.io/distroless/static или scratch. Последний – это вообще пустой образ, поэтому нужно быть очень осторожным при его использовании.
  2. Динамически связанный (собран с CGO_ENABLED=1) – он требует внешних библиотек, таких как стандартные C-библиотеки. Для него нужен базовый образ, в котором эти библиотеки уже есть. Например, это может быть gcr.io/distroless/cc, alpine или даже debian.

В большинстве случаев выбор базового образа для этапа выполнения не меняет структуру многоэтапного Dockerfile – вы просто выбираете подходящий образ в зависимости от нужного типа бинарника:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Приложение на Rust

Rust-приложения обычно компилируются из исходного кода с помощью утилиты cargo. Официальный Docker-образ для Rust включает cargo, rustc (компилятор) и другие инструменты для разработки и сборки. Из-за этого размер образа получается довольно большим – почти 2 Гб. Поэтому для Rust-приложений обязательно используют многоэтапную сборку, чтобы итоговый образ для выполнения приложения был как можно более компактным и не содержал лишних инструментов. Финальный выбор базового образа для этапа выполнения будет зависеть от того, какие библиотеки нужны вашему Rust-приложению:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки

Приложение на Java

Java-приложения компилируются из исходного кода с помощью Maven или Gradle, и для их выполнения нужна Java Runtime Environment (JRE). Когда Java-приложение запускается в контейнере, обычно используют разные базовые образы для этапов сборки и выполнения. На этапе сборки нужен Java Development Kit (JDK), который включает инструменты для компиляции и упаковки кода. А вот для этапа выполнения достаточно более легкой Java Runtime Environment (JRE), так как она содержит только необходимое для запуска приложения. Dockerfile выглядит намного сложнее, чем сценарии для приложений на Go и Rust, поскольку файл для Java включает дополнительный этап тестирования, да и сам процесс сборки включает больше действий:

🐳🔨 Стройные контейнеры: как уменьшить Docker-образ с помощью многоэтапной сборки
***

А какие хитрости оптимизации Docker-образов используете вы? Поделитесь своим опытом в комментариях!

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Java Team Lead
Москва, по итогам собеседования
Senior Java Developer
Москва, по итогам собеседования
Go-разработчик
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ