Оптимизация Docker-образов с помощью многоэтапной сборки: Полное руководство

Освойте многоэтапную сборку Docker, чтобы значительно уменьшить размер образов, ускорить развертывание и повысить безопасность. Это подробное руководство содержит пошаговые инструкции, практические примеры для Go и Node.js, а также основные рекомендации. Узнайте, как оптимизировать Dockerfile, разделяя зависимости сборки и гарантируя, что в конечный образ времени выполнения попадут только необходимые компоненты. Обязательно к прочтению для всех, кто стремится создавать эффективные и безопасные контейнерные приложения.

Оптимизация Docker-образов с помощью многоэтапной сборки: Полное руководство

Многоэтапная сборка решает очень распространенную проблему Docker: инструменты, необходимые для сборки приложения, обычно не нужны для его запуска.

Компилятор Go, кеш пакетов Node, репозиторий Maven, тестовый фреймворк и заголовочные файлы сборки полезны во время сборки образа. В образе времени выполнения они становятся балластом. Они замедляют загрузку, увеличивают объем программного обеспечения, которое необходимо обновлять, и затрудняют понимание того, что на самом деле работает в продакшене.

С помощью многоэтапного Dockerfile вы выполняете сборку на одном этапе и копируете только готовый артефакт на меньший этап времени выполнения. Финальный образ не наследует этап сборки, если вы явно не скопируете из него файлы.

Проблема одноэтапных образов

Рассмотрим типичное приложение на Go. Для его компиляции требуется инструментарий Go. Как только у вас есть бинарный файл Linux, компилятор больше не нужен. Одноэтапный образ все равно его сохраняет:

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

Это работает, но финальный образ все еще содержит инструментарий Go и кеш сборки. Та же ситуация возникает с Node, Java, Rust, пакетами Python с нативными расширениями и сборками фронтенда.

Цена этого — практическая:

  • Увеличение размера образа: Больше слоев, больше данных для загрузки и хранения.
  • Увеличение времени развертывания: Более крупные образы дольше передаются.
  • Появление угроз безопасности: Большая поверхность атаки из-за ненужного программного обеспечения.
  • Запутывание среды выполнения: Затрудняет понимание того, что действительно необходимо.

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

Что делают многоэтапные сборки

Каждая инструкция FROM начинает новый этап. Вы можете дать этапу имя и скопировать из него файлы позже:

FROM golang:1.21-alpine AS builder
# файлы сборки здесь

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

Второй этап начинается с чистого листа. Он не содержит /usr/local/go, исходные файлы, кеши пакетов или инструменты сборки из первого этапа, если вы их не скопируете.

Чистый пример на Go

Вот небольшое приложение:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello from optimized Docker image!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Server starting on :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Многоэтапный Dockerfile:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

Файлы go.mod и go.sum копируются до полного дерева исходников, чтобы Docker мог повторно использовать слой загрузки зависимостей при изменении только кода приложения. CGO_ENABLED=0 полезен, когда нужен статический бинарный файл. Если ваше приложение зависит от библиотек C, вам может понадобиться образ времени выполнения, включающий эти библиотеки, вместо принудительной статической сборки.

Соберите и сравните:

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

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

Выбор базового образа времени выполнения

alpine популярен из-за своего малого размера, но маленький не всегда означает совместимый. Alpine использует musl libc, в то время как многие распространенные дистрибутивы Linux используют glibc. Большинство статических бинарных файлов Go работают нормально. Некоторые пакеты Python, Node, Java или нативные пакеты могут вести себя иначе.

Распространенные варианты времени выполнения:

База времени выполнения Хорошо подходит Компромисс
alpine Маленькие образы, простые бинарные файлы Различия в совместимости musl
debian:bookworm-slim Широкая совместимость с Linux Больше, чем Alpine
Distroless образы Среды выполнения для продакшена с меньшим количеством инструментов Сложнее отлаживать внутри контейнера
scratch Только статические бинарные файлы Нет оболочки, CA-сертификатов или менеджера пакетов, если не скопированы

Если приложение вызывает HTTPS-эндпоинты, убедитесь, что финальный образ включает CA-сертификаты. Образ scratch без сертификатов может выдавать ошибку, похожую на сетевую проблему.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

Многоэтапная сборка для других языков/фреймворков

Та же идея работает везде, где есть этап сборки.

Для фронтенда на Node:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Для API на Node не копируйте node_modules из установки для разработки, если он включает dev-зависимости:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Для Java:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

Кеш сборки тоже важен

Многоэтапная сборка уменьшает размер финального образа, но порядок инструкций в Dockerfile по-прежнему управляет кешированием. Размещайте стабильные файлы зависимостей перед изменяемыми исходными файлами. Используйте npm ci вместо npm install в воспроизводимых сборках. Фиксируйте версии базовых образов вместо использования latest в продакшене.

С BuildKit кеш-монтирования могут ускорить загрузку пакетов без сохранения кеша в финальном образе:

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

Этот кеш предназначен для сборочной машины, а не для образа времени выполнения.

Что копировать, а что нет

Копируйте минимальный полный набор для времени выполнения. Для скомпилированного сервиса это может быть один бинарный файл плюс шаблоны конфигурации и CA-сертификаты. Для фронтенда — каталог dist. Для Java — jar-файл плюс JRE.

Не копируйте исходный код, кеши менеджера пакетов, тестовые фикстуры, локальные файлы .env, SSH-ключи или результаты сборки, которые не запускаются. Используйте файл .dockerignore, чтобы эти файлы не попадали в контекст сборки:

.git
node_modules
coverage
dist
*.log
.env

Файл .dockerignore не заменяет аккуратные инструкции COPY, но предотвращает случайное раздувание контекста и утечку секретов.

Отладка многоэтапных сборок

Давайте имена этапам. Именованный этап проще использовать как цель:

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

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

Вы также можете проверить файлы, скопированные в финальный образ:

docker run --rm -it --entrypoint sh my-image

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

Практическое правило

Используйте один этап для каждой отдельной задачи: зависимости, сборка, тестирование, время выполнения. Держите этап времени выполнения простым. Если кто-то откроет финальный этап Dockerfile, он должен быстро ответить на один вопрос: какие файлы на самом деле нужны этому контейнеру для работы?

В этом и заключается реальная ценность многоэтапной сборки. Меньшие образы — это хорошо. Четкие границы времени выполнения — еще лучше.