Go компилируется в один самодостаточный исполняемый файл, и это открывает возможность, недоступную большинству языков: упаковать сервис в образ размером буквально в несколько мегабайт. На практике же команды раз за разом собирают Go-приложение в образе на полтораста мегабайт, таская за ним целый дистрибутив операционной системы, который приложению совершенно не нужен. Образы без дистрибутива решают эту расточительность, но взамен забирают привычную оболочку, и первая же попытка зайти внутрь контейнера для отладки заканчивается ошибкой. Разберём, сколько реально экономит такой образ, чем он отличается от полностью пустого, и как отлаживать сервис, в котором нет ни оболочки, ни единой утилиты.
Откуда берётся лишний вес обычного образа
Чтобы понять масштаб экономии, посмотрим на цифры. Если собрать Go-приложение на основе образа с языком и дистрибутивом, итоговый образ выходит около двухсот тридцати мегабайт. Сравним это с образом без дистрибутива: минимальный статический образ весит порядка двух мегабайт. Разница больше чем в сотню раз. Даже по сравнению с популярным минималистичным дистрибутивом, который весит около семи мегабайт, образ без дистрибутива составляет меньше трети его размера, а рядом с полноценным дистрибутивом на сто двадцать с лишним мегабайт это меньше двух процентов.
Откуда взялись эти лишние сотни мегабайт? Из того, что приложению не нужно вовсе: оболочка командной строки, менеджер пакетов, системные утилиты, библиотеки, документация. Для скомпилированного Go-приложения всё это балласт. Само приложение это один исполняемый файл, которому от операционной системы нужно совсем немного, а в случае статической сборки почти ничего.
Что такое образ без дистрибутива и чем он отличается от пустого
Тут важно развести два похожих, но разных подхода. Полностью пустой образ это абсолютно ничего: ни оболочки, ни системной библиотеки, ни менеджера пакетов, ни файловой системы с утилитами. Сборка на его основе начинается с чистого листа, и в образе оказывается только сам исполняемый файл приложения. Для статически скомпилированного Go-бинарника это работает, потому что он несёт все зависимости в себе.
Образ без дистрибутива чуть богаче пустого и именно поэтому удобнее. Вопреки названию, он всё же содержит крайне урезанный набор от дистрибутива, но без менеджера пакетов, оболочки и прочих типичных компонентов. В минимальный статический образ кладут то, без чего реальное приложение спотыкается: корневые сертификаты для защищённых соединений, данные часовых поясов, запись о пользователе в системном файле и каталог для временных файлов. Именно нехватка этих мелочей превращает работу с полностью пустым образом в мучение, тогда как образ без дистрибутива закрывает их из коробки.
Разница на практике решающая. Пустой образ хорош для простейшего самодостаточного приложения, но как только сервису нужны защищённые соединения наружу или корректная работа с часовыми поясами, на пустом образе приходится вручную докладывать сертификаты и данные зон. Образ без дистрибутива снимает эту заботу, оставаясь при этом крошечным.
Как собрать Go-сервис в такой образ
Ключ к маленькому образу это многоэтапная сборка. На первом этапе берётся полноценный образ с языком, где есть компилятор и все инструменты, и в нём собирается исполняемый файл. На втором этапе берётся образ без дистрибутива, и в него копируется только готовый бинарник. Всё, что нужно было для сборки, остаётся на первом этапе и в финальный образ не попадает.
# Этап сборки: компилируем статический бинарник
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
# Финальный этап: только бинарник в образе без дистрибутива
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Критически важная деталь здесь это отключение связывания с системными библиотеками при сборке. Этот флаг заставляет Go собрать полностью статический бинарник, который не зависит от внешних библиотек и потому прекрасно живёт в минимальном образе. Без него бинарник потянет за собой динамические зависимости, которых в статическом образе без дистрибутива просто нет, и приложение не запустится. Дополнительные флаги компоновки выбрасывают отладочную информацию, ещё немного ужимая бинарник.
Зачем вообще нужна вся эта экономия
Маленький образ это не самоцель, у него есть осязаемые выгоды. Меньший размер означает более быстрый старт приложения, потому что образ быстрее скачивается на узел. В конвейере сборки это сокращает время прогона и снижает издержки на обслуживание системы непрерывной интеграции. Сканирование на уязвимости проходит быстрее, потому что сканировать почти нечего.
Но самая весомая выгода это безопасность. Отсутствие оболочки и менеджера пакетов резко сокращает поверхность атаки: у злоумышленника, пробравшегося в контейнер, попросту нет инструментов для дальнейших действий. Нет оболочки, чтобы выполнить команды, нет менеджера пакетов, чтобы доставить вредонос, нет утилит, чтобы осмотреться. Среда выполнения оказывается защищённой от внешнего вмешательства уже самим фактом своей пустоты. Для статически собранного Go-приложения на пустом образе уязвимостей уровня операционной системы нет вовсе, потому что нет самих пакетов, которые можно было бы эксплуатировать.
В чём обратная сторона и почему отладка ломается
Та самая пустота, что даёт безопасность, отбирает удобство. В образе без дистрибутива нет оболочки, и привычная попытка зайти внутрь работающего контейнера проваливается, потому что выполнять там нечего.
# Это не сработает: оболочки внутри нет
docker exec -it mycontainer /bin/sh
# выдаст ошибку, что исполняемый файл не найден
Для инженера, привыкшего при любой проблеме нырнуть в контейнер и осмотреться, это шок. Привычные приёмы отладки разом перестают работать: нельзя посмотреть файлы, нельзя проверить переменные окружения изнутри, нельзя запустить диагностическую утилиту. Отсюда у многих рождается соблазн отказаться от образа без дистрибутива ради удобства, но это неверный вывод. Правильный путь это освоить новые приёмы отладки, которые вдобавок оказываются чище старых.
Какие способы отладки приходят на смену оболочке
Первый и самый простой способ это отладочный вариант образа. Создатели образов без дистрибутива предусмотрели это и выпускают для каждого образа отладочную разновидность со встроенной минимальной оболочкой. Достаточно сменить основу на отладочную, и внутри появляется простенькая оболочка по особому пути.
# Только для отладки, не для продакшена
FROM gcr.io/distroless/static-debian12:debug
# оболочка доступна по пути /busybox/sh
Это удобный компромисс для тестовых сред: там, где нужно заглянуть внутрь, берут отладочный вариант, а в продакшен катят обычный без оболочки. Смешивать их нельзя: отладочный образ в продакшене сводит на нет всю выгоду по безопасности.
Второй и более правильный для продакшена способ это временные контейнеры. Это часть программного интерфейса Kubernetes, позволяющая запустить вспомогательный контейнер с оболочкой и инструментами прямо рядом с работающим приложением, не трогая его образ. Временный контейнер подсаживается в тот же под, получает доступ к пространству процессов основного контейнера и даёт всё нужное для диагностики, оставляя боевой контейнер чистым.
# Подсадить отладочный контейнер к работающему поду
kubectl debug -it mypod --image=busybox:latest \
--target=myapp --share-processes
Флаг общего доступа к процессам даёт временному контейнеру видимость процессов основного контейнера: можно осмотреть открытые файлы, переменные окружения, сетевые соединения, всё необходимое для отладки без изменения исходного образа. Этот приём полезен даже для обычных образов, потому что держит боевой контейнер чистым от вспомогательных инструментов и не требует запускать его с лишними правами.
Третий способ, по сути предпочтительный для продакшена, это вообще не лезть внутрь контейнера. Доступ к боевой системе разумно ограничивать даже для разработчиков, а вместо захода в под полагаться на нормальную наблюдаемость: структурированные логи, метрики и трассировку. Если приложение грамотно отдаёт логи и метрики наружу, нужда зайти внутрь контейнера возникает куда реже, чем кажется по привычке.
Какие инструменты упрощают жизнь Go-разработчику
Для Go есть и более прямой путь, чем писать сборочный файл вручную. Существует специальный инструмент, который собирает и публикует минимальные образы Go-приложений вообще без сборочного файла, сам кладя бинарник в образ без дистрибутива. Это удобно, когда не хочется поддерживать сборочный файл и хочется получить маленький защищённый образ по умолчанию. Для команд, которые целиком в экосистеме Go, такой инструмент заметно сокращает рутину.
Какой стратегии придерживаться
Образ без дистрибутива для Go-сервиса это почти бесплатный выигрыш, если правильно к нему подойти. Разумная логика складывается из нескольких решений. Собирать через многоэтапную сборку, оставляя в финальном образе только статический бинарник. Обязательно отключать связывание с системными библиотеками, иначе приложение не запустится в минимальном образе. Выбирать минимальный статический образ без дистрибутива как разумную основу по умолчанию, а полностью пустой образ брать лишь тогда, когда готов вручную доложить сертификаты и часовые зоны ради последних килобайт. Для отладки в тестовых средах пользоваться отладочным вариантом образа, а в продакшене временными контейнерами и хорошей наблюдаемостью вместо захода в под.
Главная смена мышления тут в том, что отсутствие оболочки это не потеря, а приобретение. Тот, кто держится за привычку нырять в контейнер и потому тащит в продакшен полный дистрибутив, платит за это сотней лишних мегабайт и широкой поверхностью атаки. А тот, кто принял минимальный образ и освоил временные контейнеры, получает сервис, который стартует мгновенно, почти ничего не весит и не даёт злоумышленнику ни единого инструмента, оставаясь при этом полностью отлаживаемым через современные средства.