← назад к разделу

У 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 без эмуляции.

Что почитать дальше