Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

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

Многие 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 Мб

Привет!

Мы запустили еженедельную email-рассылку, посвященную последним новостям и тенденциям в мире бэкенда. В еженедельных письмах ты найдешь:

  • Языки программирования и фреймворки для бэкенда.
  • Архитектура и проектирование серверных приложений.
  • Базы данных и управление данными.
  • Безопасность и защита данных.
  • Облачные технологии и DevOps.
  • API и интеграции.
  • Тестирование и отладка.
  • Инструменты и утилиты для бэкенд-разработчиков.
  • Лучшие практики и паттерны проектирования.

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

Концепция многоэтапной сборки основана на паттерне проектирования «Строитель». Чтобы понять, как этот подход работает в 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=<another-image> помогает реализовать паттерн «Строитель» – например, при создании образа для 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 создавать разные образы в зависимости от выбранной цели:

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

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

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

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

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

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

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

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

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить Docker-образ с помощью многоэтапной сборки

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

  • Порядок этапов имеет значение. Нельзя выполнить 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 – вы просто выбираете подходящий образ в зависимости от нужного типа бинарника:

Итоги недели в мире бэкенда и обзоры новых сервисов: как уменьшить 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-образ с помощью многоэтапной сборки

👨‍💻🎨 Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

⏰ 8 стратегий уменьшения времени ожидания в распределенных системах

Время ожидания (latency) – это время, которое требуется на доставку пакета данных от источника к пункту назначения. В контексте распределенных систем это особенно важно: данные хранятся и обрабатываются на разных серверах, и высокая задержка может сделать приложение практически непригодным к использованию. А медленные приложения раздражают пользователей даже больше, чем совсем неработающие: те, по крайней мере, не тратят зря их время. Поэтому разработчикам стоит уделять особое внимание выбору оптимальной стратегии (или их комбинации) для максимального снижения времени ожидания.

Кэширование

Кэширование – базовая техника улучшения производительности и уменьшения нагрузки на бэкенд-сервисы в распределенных системах. Основная цель кэширования – минимизировать затратные запросы к базе данных и избегать повторного выполнения высокозатратных вычислений. Для этого часто запрашиваемые данные хранятся в кэше, что позволяет быстро их получать, а не тратить время на обращение к источнику (базе данных или удаленному сервису).

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

Типичная схема кэширования
Типичная схема кэширования

# Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека шарписта»

CDN (сеть доставки контента)

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

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

Два сценария работы сети доставки контента
Два сценария работы сети доставки контента

Балансировка нагрузки

Балансировка нагрузки – важнейшая техника распределения запросов между несколькими машинами. Использование балансировщиков нагрузки позволяет равномерно распределять входящий трафик между несколькими серверами, чтобы избежать перегрузки одного сервера.

Когда клиент отправляет запрос в распределенную систему, балансировщик нагрузки становится первым пунктом контакта. Он получает запрос и перенаправляет его на один из доступных серверов, исходя из заранее заданных алгоритмов. Наиболее распространенные алгоритмы балансировки нагрузки включают:

  • Круговое распределение. Запросы равномерно распределяются по серверам, один за другим, по кругу. Например, если у вас есть три сервера, первый запрос идет на первый сервер, второй на второй, третий на третий, а четвертый снова на первый и так далее.
  • Наименьшее количество подключений. Балансировщик направляет запрос на сервер, у которого меньше всего текущих соединений. Этот метод помогает равномерно распределять нагрузку, когда выполнение запросов занимает разное время.
  • Хеширование IP. Метод использует хеш-функцию, которая вычисляется на основе IP-адреса клиента. Это позволяет направлять запросы от одного и того же клиента всегда на один и тот же сервер.

Подробнее алгоритмы балансировки нагрузки разобраны здесь.

Балансировщик распределяет нагрузку в соответствии с определенным алгоритмом
Балансировщик распределяет нагрузку в соответствии с определенным алгоритмом

🦫 Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go-разработчика»

Асинхронная обработка

Когда в распределенной системе нужно обрабатывать задачи, которые могут занять много времени, важно учитывать как пользовательский опыт, так и общую отзывчивость системы. Внедрение асинхронной обработки позволяет системе быстро реагировать на запросы пользователя, при этом выполняя более длительные задачи в фоновом режиме.

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

Отдельный фоновый процесс (или пул процессов) постоянно следит за этой очередью, извлекая задачи, как только они становятся доступными. Рабочий процесс выполняет долгосрочную задачу асинхронно, не блокируя основной поток приложения и не влияя на отзывчивость системы. Это позволяет системе оставаться быстрой и отзывчивой, даже если некоторые операции занимают много времени.

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

Индексирование в базах данных

Базы данных могут стать значительным источником задержек. Для обеспечения оптимальной производительности базы данных и более быстрого выполнения запросов важно правильно настроить индексы и выявлять, а затем оптимизировать медленные запросы. Индексы – это специальные структуры данных, которые значительно ускоряют поиск и извлечение данных из базы данных. Если для часто используемых столбцов или их комбинаций создаются индексы, база данных может быстро найти нужную информацию, не просматривая всю таблицу.

Когда таблицы базы данных содержат миллионы строк, индексы критически важны для быстрого поиска и обработки запросов
Когда таблицы базы данных содержат миллионы строк, индексы критически важны для быстрого поиска и обработки запросов

📱 Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика»

Сжатие данных

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

Алгоритмы сжатия находят и удаляют повторяющиеся элементы в данных, заменяя их более компактными представлениями. В результате:

  • Уменьшается объем трафика и нагрузка на сеть.
  • Сокращается время ожидания.
  • Требуется меньше вычислительных ресурсов для передачи и обработки данных.

Предварительное кэширование

Предварительное кэширование – техника, при которой данные заранее загружаются в кэш до того, как они действительно понадобятся пользователю. Система предварительного кэширования анализирует, какие данные, скорее всего, потребуются пользователю (например, похожие товары в онлайн-магазине или музыкальные треки в стриминговом сервисе). Когда пользователь запрашивает эти данные, они уже готовы к быстрой выдаче.

Эффективность предварительного кэширования зависит от точности, с которой система определяет потенциальную востребованность данных
Эффективность предварительного кэширования зависит от точности, с которой система определяет потенциальную востребованность данных

👾 Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека хакера»

Постоянные соединения

TCP-рукопожатие (процесс установления соединения между клиентом и сервером) требует как минимум 3 этапа обмена данными:

  • Клиент: «Привет! Хочу установить соединение».
  • Сервер: «Окей, я готов».
  • Клиент: «Отлично, начинаем общение».

Каждое такое «рукопожатие» занимает время и ресурсы. Постоянное соединение (Keep-Alive) решает эту проблему и позволяет существенно ускорить работу веб-приложений за счет повторного использования существующих соединений вместо создания новых:

  • Соединение не закрывается сразу после завершения запроса, а остается активным некоторое время.
  • Новые запросы используют уже установленное соединение.
  • Не нужно тратить время на новое «рукопожатие».
TCP-рукопожатие – трехэтапная процедура
TCP-рукопожатие – трехэтапная процедура

Автор рассылки: Наталья Кайда

11
Начать дискуссию