Docker-образы часто страдают от лишнего веса. Это не просто проблема занимаемого места – это вопрос безопасности и эффективности. Но есть решение: многоэтапная сборка.
Этот материал взят из нашей еженедельной email-рассылки, посвященной бэкенду. Подпишитесь, чтобы быть в числе первых, кто получит дайджест.
(function () { let link = document .getElementById ("ab5fe8fa-43c2-4fe2-9400-1e3aaaa349ef-https://proglib.io/w/14f46dc0-2"); if (! link) return; let href = link .getAttribute ("href"); if (! href) return; let prefix = link .dataset .prefix; let action = link .dataset .action; link .addEventListener ("click", function (e) { let data = new FormData (); data .append ("url", href); apiFetch (action, { method: "POST", body: data }) .then (function (res) {}) .catch (function (err) { console .error (err); }); }) })();
Многие Dockerfile включают в себя как зависимости для сборки приложения, так и зависимости для его выполнения в продакшене. Это приводит к тому, что в финальные Docker-образы попадает куда больше компонентов, чем необходимо для запуска приложения. А ведь большие образы с ненужными зависимостями не только занимают лишнее место, но и повышают вероятность появления уязвимостей.
Почему образы получаются такими большими
У приложений есть зависимости двух типов:
- Зависимости для сборки (build-time) – библиотеки и инструменты, необходимые для компиляции и подготовки к запуску.
- Зависимости для выполнения (run-time) – то, что нужно только в продакшене.
Когда мы используем один и тот же образ для сборки и запуска, то в продакшн попадают лишние инструменты – интерпретаторы, компиляторы, линтеры и т.д. Избежать этого можно только с помощью разделения этапов сборки и выполнения.
Примеры неправильных Dockerfile
Рассмотрим Dockerfile для Go и Node.js приложений, где допущены ошибки, приводящие к ненужному раздуванию образов.
Неправильный Dockerfile для Go-приложения
Здесь использован образ golang:1.23, который включает не только скомпилированное приложение, но и весь инструментарий Go вместе с зависимостями – больше 800 Мб, множество из которых становятся уязвимостями в продакшене:
Структура этого раздутого образа выглядит так:
Неправильный Dockerfile для Node.js-приложения
Здесь используются команды npm ci и npm run build. Первая команда устанавливает зависимости и для разработки, и для продакшена. Но при попытке убрать девелоперские зависимости команда npm run build не сможет завершиться, так как для сборки нужны оба типа зависимостей:
Такой докерфайл создает образ с 500 Мб лишних компонентов:
Статья по теме
♾️💎 20 лайфxаков для DevOps-инженеров
Как работает многоэтапная сборка
Концепция многоэтапной сборки основана на паттерне проектирования «Строитель». Чтобы понять, как этот подход работает в Docker, сначала нужно познакомиться с двумя мощными возможностями Dockerfile – копированием файлов из другого образа и определением нескольких образов в одном докерфайле.
Копирование файлов из другого образа
Одна из самых распространенных инструкций в Dockerfile – это COPY. Обычно команда используется для копирования файлов с хоста в образ:
Однако файлы можно также копировать напрямую из других Docker-образов. Например, можно скопировать файл конфигурации nginx.conf из официального образа nginx:latest прямо в свой текущий образ:
Именно фича COPY --from= помогает реализовать паттерн «Строитель» – например, при создании образа для Node.js:
Определение нескольких образов в одном Dockerfile
С 2018 года Docker поддерживает многоцелевые Dockerfile: в одном файле можно указать несколько инструкций FROM, каждая из которых создает отдельный целевой образ. Здесь определены три разных образа с их собственными настройками:
Эта особенность позволяет выбрать цель сборки с помощью параметра --target в команде docker build, давая возможность одному Dockerfile создавать разные образы в зависимости от выбранной цели:
Собрать образ на основе первого FROM:
Собрать образ на основе второго FROM:
Собрать образ на основе третьего FROM:
♾️ Библиотека devops’a
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека devops’a»
♾️🎓 Библиотека DevOps для собеса
Подтянуть свои знания по DevOps вы можете на нашем телеграм-канале «Библиотека DevOps для собеса»
♾️🧩 Библиотека задач по DevOps
Интересные задачи по DevOps для практики можно найти на нашем телеграм-канале «Библиотека задач по DevOps»
Как использовать эти две возможности для многоэтапной сборки
Dockerfile для сборки и Dockerfile для выполнения можно объединить в один файл с несколькими инструкциями FROM. Здесь на первом этапе происходит сборка приложения с помощью npm run build, а второй этап копирует результат сборки и запускает приложение:
Важно помнить:
- Порядок этапов имеет значение. Нельзя выполнить COPY --from из этапа, который определен после текущего. Этапы должны быть описаны в логической последовательности.
- Алиасы этапов. Использование AS (например, AS build) позволяет дать этапу понятное имя, но их применение опционально: если имя не указано, на этапы можно ссылаться по их порядковому номеру (например, COPY --from=0).
- По умолчанию собирается последний этап. Если не указать флаг --target, команда docker build соберет последний этап и все этапы, от которых он зависит.
Несколько примеров многоэтапной сборки
Приложение на Go
Go-приложения всегда компилируются на этапе сборки. Итоговый бинарник может быть двух типов:
- Статически связанный (собран с CGO_ENABLED=0) – все необходимые зависимости включены внутрь самого бинарника. Такой бинарник можно запускать даже на минималистичных базовых образах, например, gcr.io/distroless/static или scratch. Последний – это вообще пустой образ, поэтому нужно быть очень осторожным при его использовании.
- Динамически связанный (собран с CGO_ENABLED=1) – он требует внешних библиотек, таких как стандартные C-библиотеки. Для него нужен базовый образ, в котором эти библиотеки уже есть. Например, это может быть gcr.io/distroless/cc, alpine или даже debian.
В большинстве случаев выбор базового образа для этапа выполнения не меняет структуру многоэтапного Dockerfile – вы просто выбираете подходящий образ в зависимости от нужного типа бинарника:
Приложение на Rust
Rust-приложения обычно компилируются из исходного кода с помощью утилиты cargo. Официальный Docker-образ для Rust включает cargo, rustc (компилятор) и другие инструменты для разработки и сборки. Из-за этого размер образа получается довольно большим – почти 2 Гб. Поэтому для Rust-приложений обязательно используют многоэтапную сборку, чтобы итоговый образ для выполнения приложения был как можно более компактным и не содержал лишних инструментов. Финальный выбор базового образа для этапа выполнения будет зависеть от того, какие библиотеки нужны вашему Rust-приложению:
Приложение на Java
Java-приложения компилируются из исходного кода с помощью Maven или Gradle, и для их выполнения нужна Java Runtime Environment (JRE). Когда Java-приложение запускается в контейнере, обычно используют разные базовые образы для этапов сборки и выполнения. На этапе сборки нужен Java Development Kit (JDK), который включает инструменты для компиляции и упаковки кода. А вот для этапа выполнения достаточно более легкой Java Runtime Environment (JRE), так как она содержит только необходимое для запуска приложения. Dockerfile выглядит намного сложнее, чем сценарии для приложений на Go и Rust, поскольку файл для Java включает дополнительный этап тестирования, да и сам процесс сборки включает больше действий:
***
А какие хитрости оптимизации Docker-образов используете вы? Поделитесь своим опытом в комментариях!