Введение

Первый 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 на две стадии:

  1. Стадия сборки (builder): Тяжёлый образ golang:1.24-alpine с Go SDK — компилирует приложение.

  2. Стадия запуска (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.

Лёгкие образы собираются быстрее, экономят трафик и дисковое пространство, а также уменьшают поверхность атаки при деплое.

Буду благодарен вашим комментариям, правкам и конструктивной критике.

Комментарии (6)


  1. CitizenOfDreams
    01.11.2025 06:39

    Оптимизация - это когда была программа размером N мегабайт, а стала 0.9N. Или даже 0.5N, если первая версия была ну очень неоптимальной. Ну пусть даже 0.1N, ладно.

    Но когда образ одной и той же программы может весит 9 мегабайт, а может 2000 - это уже ни фига не "оптимизация", это уже из области "да вы охренели, что ли?".


  1. AdrianoVisoccini
    01.11.2025 06:39

    Как я уменьшил вес моего автомобиля с 2 тонн до 1 тонны
    Начал с того, что убрал кузова мешки с цементом, которые я возил просто так


    1. CitizenOfDreams
      01.11.2025 06:39

      Помнится, в каком-то спецжигуле для спецслужб в багажнике штатно лежала чугунная спецплита, чтобы спецдвигатель не перевешивал...


  1. diderevyagin
    01.11.2025 06:39

    Хорошая заметка, которая четко показывает что правильным подходом является использование multi-stage с самого начала.

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


  1. Hamletghost
    01.11.2025 06:39

    Откройте для себе distroless - минимальные runtime образы для разных языковых стеков от Google. В частности для Go есть static, который - по сути то к чему вы пришли вручную (scratch + серты около 2Мб в сумме) - только бинарник скопировать и все.

    Выигрыша от UPX примерно 0 т к слои образов в регистри хранятся и так в сжатом виде (gzip), соотвественно pull качает меньший объем данных. Если вы дополнительно сожмете бинарник, то сжатие этого слоя будет просто неэффективным - лишнее нагревание воздуха. После пула, образы хранятся локально уже в распакованном виде - это позволяет получить быстрый старт, кажется найти несколько лишних Мб для этого не проблема. Т.к. чудес не бывает и в памяти ваше приложение все равно займет ровно столько места сколько было до сжатия, но при этом опять нагрев воздух затратами на распаковку (впрочем тут CPU надо меньше чем на сжатие).


  1. suprunchuk
    01.11.2025 06:39

    Согласен с автором выше. Посмотрите вот на эту статью.

    В конце статьи показан пример (Multi-stage builds), который использует образы distroless от Google. Удобно