Введение
Первый Docker-образ для моего Go-приложения весил 1.92 GB. Для микросервиса на 100 строк — абсурдно. Решил разобраться, куда именно уходит место и как добиться максимально лёгкого образа.
За несколько итераций оптимизации удалось уменьшить образ в 91 раз — до 21 MB production вариант. С дополнительным UPX-сжатием в 213 раз — до 9 MB.
В статье
Максимальная оптимизация Docker-образа для Go
Выбор базового образа и техник для каждого сценария
Создал простенький мониторинг микросервис
Функционал:
/health— Показывает работает ли приложение, сколько времени оно запущено и какая версия./ready— Отвечает на вопрос готово ли приложение принимать запросы./metrics— Показывает сколько памяти использует, сколько потоков работает, сколько ядер процессора доступно.
Сервер корректно завершается при отправке сигнала остановки: не принимает новые запросы и ждет 5 секунд пока закончатся текущие.
Стек: Go 1.24 + Gin, >100 строк кода.
Наивный подход (1.92 GB)
Начал с самого простого и очевидного на первый взгляд — официального образа golang:1.24, но не забыл про две важные практики:
Правильное копирование зависимостей —
go.modиgo.sumкопируем перед основным кодом. Docker кеширует этот слой, и при изменении исходников зависимости не будут скачиваться заново.
Файл
.dockerignore— исключает из контекста сборки ненужные файлы и директории.
Пример .dockerignore:
logs/
*.log
.git
.gitignore
*.md
.vscode/
dist/
build/
bin/
*.exe
.env
*.local
Полный код образа:
FROM golang:1.24
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /server
EXPOSE 8080
CMD ["/server"]


golang:1.24 базируется на Debian, использует glibc, включает множество системных утилит, так что занимает очень много места.
Переход на Alpine (998 MB)
Первая оптимизация — замена базового образа на golang:1.24-alpine3.20. Код остается прежним, меняется только базовый образ.
Важно: фиксируем версию Alpine для предсказуемости повторных сборок.
Полный код образа:
FROM golang:1.24-alpine3.20
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /server
EXPOSE 8080
CMD ["/server"]


golang:1.24-alpine3.20 построен на Alpine Linux — минималистичном дистрибутиве, который использует musl libc вместо стандартной glibc и содержит только необходимый набор пакетов.
Multi-stage build (33 MB)
В предыдущем подходе финальный образ содержал весь Go SDK (компилятор, стандартная библиотека, утилиты сборки), хотя для запуска нужен только скомпилированный бинарник.
Разделим Dockerfile на две стадии:
Стадия сборки (builder): Тяжёлый образ
golang:1.24-alpineс Go SDK — компилирует приложение.Стадия запуска (runtime): Лёгкий образ
alpine:3.20— копирует только готовый бинарник.
В итоге в финальный образ попадет только то, что явно скопировано через COPY --from=builder. Весь Go SDK остаётся в стадии сборки.
Важно:
CGO_ENABLED=0— делаем бинарник полностью статическим, не требующим динамических библиотек, но если ваш проект использует cgo (например, драйверы, требующие системных библиотек), сборка сCGO_ENABLED=0упадёт. Тогда нужно включитьCGO_ENABLED=1и установить необходимые toolchain (gcc, musl-dev и т.п.) в builder стадии.
-ldflags="-s -w"— удаляем символы отладки и таблицы символов, сокращая размер на 25–30%.RUN apk add --no-cache ca-certificates tzdata— устанавливаем два пакета — корневые сертификаты (ca-certificates) и базу часовых поясов (tzdata).Для безопасности обязательно нужно создать непривилигированного пользователя:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup.
Критичные объекты, которые нужно скопировать в финальную стадию:
SSL-сертификаты
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Без них: x509: certificate signed by unknown authority
Timezone данные
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
Без них: unknown time zone Europe/Moscow
Ну и про бинарник не забыть c указанием владельца и прав
COPY --from=builder --chown=appuser:appgroup --chmod=755 /server /server
Полный код образа:
FROM golang:1.24-alpine3.20 AS builder
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
FROM alpine:3.20
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder --chown=appuser:appgroup --chmod=755 /server /server
USER appuser
EXPOSE 8080
CMD ["/server"]


Отделив сборку от запуска мы добились уменьшения в 58 раз.
Пустой scratch (21 MB)
scratch — буквально пустой образ размером 0 байт. Внутри нет ОС, утилит, библиотек, файловой системы.
Go компилируется в самодостаточный бинарник:
Не требует runtime окружения.
Работает напрямую с ядром Linux без промежуточных слоёв.
Проблема: в отличие от прошлого образа мы не можем создать пользователя в финальной стадии.
Решение: cоздаем пользователя в стадии сборки и копируем сформированные файлы в финальную:
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
Полный код образа:
FROM golang:1.24-alpine3.20 AS builder
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
&& apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
COPY --from=builder --chown=appuser:appgroup --chmod=755 /server /server
USER appuser
EXPOSE 8080
CMD ["/server"]


С помощью связи multi-stage и scratch мы добиваемся уменьшения размера в 91 раз.
Плюсы: минимальный размер, максимальная безопасность, мгновенный запуск.
Минус: нет утилит для дебага.
Когда использовать scratch, а когда alpine
Критерий |
scratch |
alpine |
|---|---|---|
Размер |
минимальный |
компактный |
Поддержка CGO |
нет |
есть |
Shell/Debug |
нет |
есть |
Безопасность |
максимальная |
высокая |
Удобство отладки |
низкое |
хорошее |
Scratch когда:
Чистый Go без CGO.
Нужна максимальная безопасность.
Критична скорость pull/deployment.
Нет зависимости от системных библиотек.
Alpine когда:
Нужен CGO.
Требуется shell для дебага.
Используете сторонние утилиты.
Нужен package manager для runtime-установки.
Компромисс между alpine и scratch: distroless (25 MB)
Distroless‑образ содержит только необходимые для запуска библиотеки: нет shell, пакетного менеджера и утилит, за счёт чего уменьшается поверхность атаки и снижается количество уязвимых компонентов. В отличие от alpine это не полноценный дистрибутив, а упакованный runtime, поэтому управлять им проще в production, если интерактивный дебаг не требуется. Для Go это удобный компромисс между полезностью и размером: меньше, чем alpine, но чуть тяжелее scratch.
Когда выбирать
Нужен минимальный и безопасный runtime без shell, но с системными библиотеками, необходимыми приложению.
Продакшн‑окружение, где важны малый размер и низкая поверхность атаки, а интерактивный дебаг не является обязательным сценарием.
Go‑сервисы без CGO, где тонкий рантайм предпочтительнее полноценного дистрибутива.
Пользователь и группа nonroot уже встроены. Сертификаты и данные часовых поясов также присутствуют, на builder стадии устанавливаем их для корректной сборки, копировать в runtime не требуется.
Полный код образа:
FROM golang:1.24-alpine3.20 AS builder
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder --chown=nonroot:nonroot --chmod=755 /server /server
USER nonroot
EXPOSE 8080
CMD ["/server"]


Бонус: добавляем UPX-сжатие в образ c multi-stage + scratch (9 MB)
UPX (Ultimate Packer for eXecutables) — компрессор исполняемых файлов:
Сжимает бинарник алгоритмом LZMA (как в 7zip).
Добавляет встроенный декомпрессор в начало файла (~50KB).
Распаковывает себя в RAM при запуске.
Плюс: размер уменьшается в 2-3 раза.
Минус: замедление старта и рост потребления оперативной памяти.
Важно:
Cкачать upx:
RUN apk add --no-cache upx
Сжать собранный бинарник:
RUN upx --best --lzma /server
Полный код образа:
FROM golang:1.24-alpine3.20 AS builder
RUN addgroup -S appgroup && adduser -S appuser -G appgroup \
&& apk add --no-cache ca-certificates tzdata upx
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
RUN upx --best --lzma /server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
COPY --from=builder --chown=appuser:appgroup --chmod=755 /server /server
USER appuser
EXPOSE 8080
CMD ["/server"]


С помощью такого подхода образ уменьшается в 213 раз, но редко применяется в production из-за ряда недостатков:
При каждом старте CPU тратит время на распаковку.
В нагруженных микросервисах увеличивает холодный старт.
Некоторые антивирусы и системы безопасности помечают UPX-файлы как подозрительные.
Использовать можно: для AWS, для CLI-утилит, для дистрибуции инструментов без зависимости от ОС.
Вывод: UPX — нишевый инструмент, в production без крайней необходимости лучше не использовать.
Заключение
Оптимизация образов — это часть инженерной культуры: осознанный выбор базового образа, разделение этапов сборки и выполнения, чистый Dockerfile и строгий .dockerignore.
Лёгкие образы собираются быстрее, экономят трафик и дисковое пространство, а также уменьшают поверхность атаки при деплое.
Буду благодарен вашим комментариям, правкам и конструктивной критике.
Комментарии (7)

AdrianoVisoccini
01.11.2025 06:39Как я уменьшил вес моего автомобиля с 2 тонн до 1 тонны
Начал с того, что убрал кузова мешки с цементом, которые я возил просто так
CitizenOfDreams
01.11.2025 06:39Помнится, в каком-то спецжигуле для спецслужб в багажнике штатно лежала чугунная спецплита, чтобы спецдвигатель не перевешивал...

diderevyagin
01.11.2025 06:39Хорошая заметка, которая четко показывает что правильным подходом является использование multi-stage с самого начала.
А вот UPX - мне кажется подход немного спорный. Замедление старта, лишние расходы по CPU (а процессор ресурс довольно дефицитный). Выигрыш по сети и хранении места образа в такой ситуации мне кажется не перевешивает негативные стороны.

Hamletghost
01.11.2025 06:39Откройте для себе distroless - минимальные runtime образы для разных языковых стеков от Google. В частности для Go есть static, который - по сути то к чему вы пришли вручную (scratch + серты около 2Мб в сумме) - только бинарник скопировать и все.
Выигрыша от UPX примерно 0 т к слои образов в регистри хранятся и так в сжатом виде (gzip), соотвественно pull качает меньший объем данных. Если вы дополнительно сожмете бинарник, то сжатие этого слоя будет просто неэффективным - лишнее нагревание воздуха. После пула, образы хранятся локально уже в распакованном виде - это позволяет получить быстрый старт, кажется найти несколько лишних Мб для этого не проблема. Т.к. чудес не бывает и в памяти ваше приложение все равно займет ровно столько места сколько было до сжатия, но при этом опять нагрев воздух затратами на распаковку (впрочем тут CPU надо меньше чем на сжатие).

Kahelman
01.11.2025 06:39«Вы даете нереальные планы» люди не в курсе что в go, все модно в один бинарное компильнуть и скопировать куда надо. Чем дев расы заниматься будут?

suprunchuk
01.11.2025 06:39Согласен с автором выше. Посмотрите вот на эту статью.
В конце статьи показан пример (Multi-stage builds), который использует образы
distrolessот Google. Удобно
CitizenOfDreams
Оптимизация - это когда была программа размером N мегабайт, а стала 0.9N. Или даже 0.5N, если первая версия была ну очень неоптимальной. Ну пусть даже 0.1N, ладно.
Но когда образ одной и той же программы может весит 9 мегабайт, а может 2000 - это уже ни фига не "оптимизация", это уже из области "да вы охренели, что ли?".