У Go в Docker есть козырь, которого нет у Java, Node и Python: результат сборки — один статический файл. Ему не нужен рантайм, интерпретатор и даже оболочка, поэтому рабочий образ сервиса весит 10–20 МБ и содержит ровно одну программу. Но у этого козыря свои грабли: бинарник, который неожиданно оказался динамическим из-за CGO, пустой scratch без сертификатов и зоны времени, проверка здоровья, которой нечем выполнить wget. Разберём Dockerfile от наивного к рабочему и каждую из этих граблей по дороге.
Самый простой Dockerfile и что в нём дорого
Первое, что пишут, когда образ нужен «прямо сейчас»:
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o app ./cmd/app
CMD ["./app"]
Работает. И стоит дорого по трём статьям. Образ golang:1.23 весит около 800 МБ: в прод уезжает компилятор, стандартная библиотека в исходниках и набор утилит Debian. COPY . . до скачивания зависимостей сбрасывает кэш слоя при любой правке кода, и go build каждый раз качает модули заново. А ещё в образе лежат исходники и .git, то есть всё, чего в рабочем контейнере быть не должно. Общий принцип разделения сборки и запуска разобран в статье про multi-stage, здесь — его версия для Go.
Многоэтапная сборка: golang для сборки, distroless для запуска
# syntax=docker/dockerfile:1
# ── Этап 1: сборка ──────────────────────────────────────────
FROM golang:1.23 AS build
WORKDIR /src
# зависимости отдельно: слой переживёт правку кода
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# ── Этап 2: финальный образ ──────────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Что здесь важно построчно. go mod download до COPY . . даёт слой с зависимостями, который меняется только вместе с go.mod и go.sum. Два кэш-монтирования — кэш модулей и кэш компиляции — переживают пересборки, но в образ не попадают: вторая сборка после правки одного файла идёт секунды, а не минуты. CGO_ENABLED=0 делает бинарник статическим, без этого финальный образ без libc не запустится, об этом ниже. -trimpath убирает из бинарника пути сборочной машины, -ldflags="-s -w" выбрасывает таблицу символов и отладочную информацию, минус треть размера. Это не мешает трассировкам стека паник: имена функций в них остаются.
Финальный образ — distroless/static. В нём нет оболочки, пакетного менеджера и вообще ничего исполняемого: только сертификаты, зоны времени, /etc/passwd с пользователем nonroot и ваш файл. Размеры для ориентира:
| Вариант | Размер |
|---|---|
golang:1.23 со сборкой внутри | 800–900 МБ |
debian:12-slim + бинарник | около 90 МБ |
alpine + бинарник | около 20 МБ |
distroless/static + бинарник | 10–15 МБ |
scratch + бинарник | размер бинарника, 8–12 МБ |
scratch против distroless: сертификаты, зона времени, пользователь
FROM scratch — пустой образ, в нём нет ничего, и это ломается не на старте, а при первом обращении наружу. Три вещи, которых там не хватает.
Сертификаты. Первый https-запрос к внешнему API падает с x509: certificate signed by unknown authority: системного хранилища корневых сертификатов в образе нет. Лечится копированием /etc/ssl/certs/ca-certificates.crt из этапа сборки.
Зоны времени. time.LoadLocation("Europe/Moscow") возвращает ошибку unknown time zone: каталога /usr/share/zoneinfo нет. Либо копируют каталог из этапа сборки, либо встраивают базу в бинарник одной строкой import _ "time/tzdata" — это плюс примерно 450 КБ, зато образ ни от чего не зависит.
Пользователь. В scratch нет /etc/passwd, поэтому USER nonroot по имени не сработает — только числом: USER 65532:65532. Kubernetes с runAsNonRoot: true именно числовой uid и проверяет.
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
В distroless/static всё это уже лежит, поэтому разница между ним и scratch — два мегабайта и три строки Dockerfile. Берите distroless: экономия на scratch не стоит часа отладки x509 на проде.
CGO: когда бинарник не статический
go build без CGO_ENABLED=0 на Linux может собрать бинарник, динамически слинкованный с libc: стандартный резолвер DNS и пакет os/user при наличии компилятора C уходят в системные библиотеки. В golang:1.23 компилятор есть, поэтому результат зависит от пакетов, которые вы импортируете. В distroless/static библиотек нет, и контейнер падает с no such file or directory на несуществующий файл: это ядро не нашло динамический загрузчик.
Проверка одной командой на этапе сборки: file /out/app должно печатать statically linked, а ldd /out/app — not a dynamic executable. Если какая-то зависимость действительно требует CGO (sqlite3 через mattn/go-sqlite3, часть драйверов и криптографии), статику не получить: тогда финальный образ — distroless/base-debian12, в котором есть glibc, или debian:12-slim. Второй путь — найти чистую Go-реализацию (modernc.org/sqlite вместо mattn).
Кто получает SIGTERM: exec-форма и signal.NotifyContext
docker stop посылает SIGTERM процессу с номером 1 и через десять секунд добивает SIGKILL. Чтобы сигнал дошёл до вашего кода, нужны две вещи. Первая — exec-форма точки входа: ENTRYPOINT ["/app"], а не ENTRYPOINT /app. Строковая форма запускает оболочку, оболочка становится PID 1 и сигнал дальше не передаёт; в distroless оболочки вообще нет, и строковая форма просто не стартует. Вторая — обработчик в коде:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}
Без обработчика рантайм Go на SIGTERM завершает процесс сразу, обрывая запросы на середине: мягкого останова не будет, хотя код выхода и правильный. Таймаут Shutdown держат меньше таймаута docker stop и terminationGracePeriodSeconds в Kubernetes, иначе на последних секундах всё равно придёт SIGKILL. Подробно про порядок остановки — в статье про дренаж HTTP.
Процесс Go вполне может быть PID 1: он не плодит дочерних процессов и зомби не копит. Если сервис всё же запускает внешние команды (exec.Command), добавьте docker run --init или init: true в Compose, чтобы за зомби следил крошечный init-процесс.
Конфигурация и секреты: переменные среды и файлы
Бинарник один, окружений много, поэтому в образ не зашивают ни адреса, ни пароли. Настройки читают из переменных среды при старте: стандартная библиотека даёт os.Getenv и os.LookupEnv, а разбор в структуру с типами и значениями по умолчанию — библиотеки вроде caarlos0/env или kelseyhightower/envconfig:
type Config struct {
Addr string `env:"ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
Timeout time.Duration `env:"HTTP_TIMEOUT" envDefault:"5s"`
}
Секреты лучше получать файлами, а не переменными: переменные видны в docker inspect, в дампах окружения и в дочерних процессах. Docker Compose и Kubernetes монтируют секреты в /run/secrets/<имя>, и приложение читает файл один раз при старте: os.ReadFile("/run/secrets/db_password"). Удобная договорённость — переменная DATABASE_PASSWORD_FILE с путём, из которого берётся значение, если самой DATABASE_PASSWORD нет.
И .dockerignore: .git, bin/, локальные .env и каталоги среды разработки не должны попадать в контекст сборки. Иначе они и сборку замедляют, и в слой COPY . . этапа сборки попадают вместе с паролями из .env.
HEALTHCHECK без оболочки
В distroless нет ни wget, ни curl, ни sh, поэтому привычная строка HEALTHCHECK CMD wget -qO- http://localhost:8080/health не выполнится. Решение — научить бинарник проверять самого себя:
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
resp, err := http.Get("http://localhost:8080/health/ready")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app", "--healthcheck"]
Запуск второго экземпляра бинарника ради проверки стоит несколько миллисекунд и пару мегабайт памяти: Go стартует мгновенно. --start-period у Go-сервиса короткий, но нужен: подключение к базе и прогрев кэшей занимают секунды. В Kubernetes инструкция HEALTHCHECK игнорируется, там те же проверки описывают пробами readinessProbe и livenessProbe, и бинарник с флагом --healthcheck пригодится и там, где в образе нет wget.
Глубже: архитектура: arm64 на ноутбуке, amd64 на серверерасширенное
Образ, собранный на ноутбуке с Apple Silicon, по умолчанию получается linux/arm64 и на сервере amd64 не стартует: exec format error. У Go это решается без эмуляции, потому что компилятор умеет кросс-компиляцию из коробки:
FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/app
Этап сборки всегда идёт на архитектуре машины сборки ($BUILDPLATFORM), а бинарник собирается под целевую (TARGETARCH), которую подставляет docker build --platform linux/amd64,linux/arm64. Так образ под две архитектуры собирается за время одной сборки, без медленного QEMU. Общая механика мультиплатформенных образов и публикации — в статье про реестры и CI.
Глубже: ko и buildpacks: когда не писать Dockerfileрасширенное
Для Go есть инструмент, который собирает и публикует образ без Docker вовсе: ko компилирует бинарник локально, кладёт его поверх distroless/static и отправляет слои прямо в реестр одной командой ko build ./cmd/app. Он удобен в конвейере без доступа к Docker и для десятков маленьких сервисов с одинаковой упаковкой. Cloud Native Buildpacks (Paketo) делают то же универсальнее и тяжелее. Dockerfile остаётся нужен, когда в образ надо положить что-то кроме бинарника: миграции, шаблоны, системные пакеты.
Коротко
- Рабочий образ Go — один статический бинарник поверх
distroless/static: 10–15 МБ, без оболочки и пакетного менеджера; сборка вgolang:1.23остаётся в первом этапе. go mod downloadдоCOPY . .и кэш-монтирования/go/pkg/modи/root/.cache/go-buildделают пересборку после правки кода секундной.CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"; проверкаfile /out/app→statically linked. Нужен CGO — финальный образdistroless/baseилиdebian:12-slim.- В
scratchнет сертификатов (x509: unknown authority), зон времени (unknown time zone) и/etc/passwd: копировать из этапа сборки илиimport _ "time/tzdata",USERчислом. В distroless всё это есть. ENTRYPOINT ["/app"]exec-формой плюсsignal.NotifyContextиsrv.Shutdownс таймаутом корочеdocker stop; дочерние процессы —--init.- Настройки из переменных среды, секреты файлами из
/run/secrets,.dockerignoreс.git,bin/и.env. HEALTHCHECK CMD ["/app", "--healthcheck"]: бинарник проверяет сам себя, потому чтоwgetв образе нет; в Kubernetes то же делают пробы.- Кросс-компиляция через
--platform=$BUILDPLATFORMиGOARCH=$TARGETARCHсобираетamd64иarm64без эмуляции.
Что почитать дальше
- Multi-stage сборка и кэш слоёв — почему этапы и порядок инструкций решают размер и время.
- Go в контейнере: GOMAXPROCS, GOMEMLIMIT и сигналы — что рантайм видит внутри лимитов и как это настроить.
- Маленькие и безопасные образы — теги и дайджесты, non-root, сканеры, секреты вне слоёв.
- Дренаж HTTP при остановке на Go — полный порядок мягкой остановки сервера.