Ваш Dockerfile, вероятно, слишком велик для продакшена
Вы только что закончили писать приложение. Оно компилируется, работает локально, и вы готовы к релизу. Вы пишете Dockerfile, собираете образ, пушите его в registry и разворачиваете на сервере. Деплой проходит успешно, но что-то не так. Образ весит 1,2 ГБ. Его выгрузка на сервер занимает три минуты. Затраты на хранение растут. И где-то в глубине души вы задаётесь вопросом: действительно ли мне нужна полноценная операционная система с пакетным менеджером и shell, чтобы запустить свой Go-бинарник?
Это тот момент, когда большинство команд осознают: собрать образ и собрать образ, пригодный для доставки, — это две разные задачи. Dockerfile, который вы пишете для локального тестирования, не годится для продакшена. Если ваш образ велик, медленно собирается или содержит лишние инструменты, страдает весь пайплайн. Каждый pull занимает больше времени. Каждая сборка тратит ресурсы впустую. Каждый сканер уязвимостей находит больше проблем, чем должен.
Хорошая новость в том, что для исправления ситуации не требуется переписывать всю инфраструктуру. Достаточно понять три вещи: как на самом деле работают Dockerfile, как поддерживать образы небольшими и как делать сборки воспроизводимыми.
Как Dockerfile превращается в образ
Dockerfile — это текстовый файл с инструкциями. Каждая инструкция создаёт один слой. При пересборке образа Docker проверяет, какие слои не изменились, и использует их из кэша. Именно поэтому вторая сборка быстрее первой: неизменённые слои не пересобираются.
Проблема в том, что большинство людей пишут Dockerfile так, будто составляют скрипт установки. Они устанавливают всё, копируют всё и только потом осознают, что финальный образ содержит компиляторы, отладочные инструменты и системные утилиты, не имеющие отношения к запуску приложения. Каждый лишний инструмент в образе — это дополнительный вес при pull, дополнительная поверхность для атак и дополнительная сложность при попытке понять, что же на самом деле находится в образе.
Решение не в том, чтобы писать меньше кода. Решение в том, чтобы разделить среду сборки и среду выполнения.
Многоэтапные сборки: самый важный паттерн
Многоэтапные сборки позволяют определить несколько этапов в одном Dockerfile. Первый этап содержит всё необходимое для компиляции приложения: полный SDK, компиляторы, инструменты сборки и зависимости. Второй этап содержит только то, что нужно для запуска скомпилированного артефакта: бинарник, библиотеки времени выполнения и ничего больше.
Вот как это выглядит для Go-приложения:
Диаграмма ниже иллюстрирует, как работают два этапа:
# Этап 1: Сборка
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .
# Этап 2: Запуск
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
Первый этап использует полный Go SDK. Второй этап использует минимальный образ Alpine, содержащий только самое необходимое. Go SDK, исходный код и кэш сборки никогда не попадают в финальный образ. В результате получается образ размером от 20 до 30 мегабайт вместо гигабайта.
Тот же паттерн применим к любому компилируемому языку. Для интерпретируемых языков, таких как Python или Node.js, можно использовать многоэтапные сборки для установки зависимостей на одном этапе и копирования только установленных пакетов в финальный этап, оставляя кэш пакетного менеджера и инструменты сборки за бортом.
Безопасность начинается с того, что вы исключаете
Каждый инструмент в вашем образе — это потенциальная уязвимость. Shell, например Bash, или пакетный менеджер, например apt, могут показаться безобидными, но если злоумышленник получит доступ к вашему контейнеру, эти инструменты станут оружием. Они позволят атакующему установить новое ПО, загрузить вредоносные файлы или переместиться в другие системы.
Принцип прост: не включайте ничего, что не требуется для запуска приложения. Если вам изредка нужно отлаживать работающий контейнер, не храните отладочные инструменты в production-образе. Вместо этого создайте отдельный образ для отладки или используйте эфемерные контейнеры, которые монтируют необходимые инструменты во время выполнения.
Для production-образов рассмотрите использование distroless-образов. Эти образы содержат только среду выполнения и библиотеки, необходимые вашему приложению. Ни shell, ни пакетного менеджера, ни утилит. Google поддерживает distroless-образы для нескольких языков, включая Go, Python, Java и Node.js. Они малы, минималистичны и значительно сокращают поверхность атаки.
Если distroless кажется слишком ограничивающим, Alpine — разумная альтернатива. Он мал и включает shell, но использует musl libc вместо glibc, что может вызвать проблемы совместимости с некоторыми приложениями. Протестируйте своё приложение на Alpine, прежде чем остановиться на нём.
Воспроизводимость — не опция
Образ, который невозможно идентично пересобрать через шесть месяцев, — это обуза. Самая частая причина невоспроизводимых сборок — использование тега latest для базовых образов. latest меняется каждый раз, когда мейнтейнер публикует новую версию. Сегодняшняя сборка может использовать Go 1.22, но сборка через месяц может использовать Go 1.23, и вы узнаете об этом только когда что-то сломается.
Всегда фиксируйте базовый образ на конкретном теге версии или, что ещё лучше, на хеше дайджеста. Дайджест — это криптографическая контрольная сумма содержимого образа. Она никогда не меняется. Если вы используете golang:1.22-alpine@sha256:abc123..., вы гарантированно получите тот же самый базовый образ каждый раз, независимо от времени и места сборки.
FROM golang:1.22-alpine@sha256:abc123def456 AS builder
Это относится к каждому образу, который вы тянете, включая промежуточные образы в многоэтапных сборках. Если вы не можете найти дайджест для конкретной версии, используйте максимально конкретный тег, например golang:1.22.0-alpine3.19 вместо golang:1.22-alpine.
Порядок слоёв влияет на скорость сборки
Docker кэширует слои на основе порядка инструкций. Если вы измените файл, который копируется рано в Dockerfile, все последующие слои будут признаны недействительными и пересобраны. Если вы измените файл, который копируется поздно, пересобраны будут только слои после этого момента.
Практическое правило: размещайте инструкции, которые меняются редко, в начале, а инструкции, которые меняются часто, — в конце.
- Сначала установите системные зависимости.
- Скопируйте манифесты зависимостей (например,
go.modиgo.sum) и выполните шаг загрузки зависимостей. - Скопируйте исходный код в последнюю очередь.
Таким образом, если вы измените только исходный код, шаг установки зависимостей будет использован из кэша. Сборка будет быстрее, потому что Docker не будет перезагружать пакеты, которые не изменились.
Практический чек-лист для вашего Dockerfile
Прежде чем отправить следующий образ в продакшен, пройдитесь по этому чек-листу:
- Содержит ли финальный образ только то, что нужно для запуска приложения?
- Исключены ли инструменты сборки, SDK и исходный код из финального этапа?
- Зафиксирован ли базовый образ на конкретной версии или дайджесте, а не на
latest? - Является ли базовый образ минимальным (distroless или Alpine), а не полноценным OS-образом?
- Размещены ли редко изменяемые инструкции перед часто изменяемыми?
- Отсутствуют ли shell и пакетный менеджер в production-образе, если это не абсолютно необходимо?
Что важнее всего
Dockerfile — это не просто скрипт сборки. Это контракт между вашим процессом сборки и производственной средой. Хороший Dockerfile создаёт образ, который мал, безопасен и воспроизводим. Плохой Dockerfile создаёт образ, который медленно выгружается, трудно отлаживается и который невозможно пересобрать с уверенностью.
В следующий раз, когда будете писать Dockerfile, начните с финального образа. Спросите себя: что на самом деле нужно этому приложению для работы? И исключите всё остальное. Ваш пайплайн станет быстрее, деплои — плавнее, а у команды безопасности будет одной проблемой меньше.