Главное, что нужно усвоить о контейнеризации Go: компилятор выдаёт один статически слинкованный бинарник, которому в идеале не нужно вообще ничего вокруг — ни рантайма, ни системных библиотек, ни интерпретатора. Это позволяет собрать образ, в котором лежит буквально один файл, а его размер измеряется единицами-десятками мегабайт (против сотен у типичного managed-стека). Но чтобы это получить, образ собирают определённым образом: в две стадии и с правильным кэшированием.
В этой главе — анатомия такого образа: multi-stage build, статическая линковка, выбор базового образа, кэширование слоёв. Параллели с .NET тут даны кратко; подробный разбор «почему Go-образ настолько меньше» — в главе сравнения.
Наивный Dockerfile кладёт в образ всё подряд: компилятор Go, исходники, кэш модулей, плюс саму программу. Так образ раздувается до сотен мегабайт — компилятор и тулчейн там не нужны после сборки, они лишь занимают место и расширяют поверхность атаки.
Решение — multi-stage build: первая стадия (builder) на полном образе golang:1.x компилирует бинарник, вторая (финальная) стадия начинается с пустого/минимального образа и копирует из первой только готовый бинарник. Всё, что было в стадии сборки, в итоговый образ не попадает.
# ── Стадия 1: сборка ───────────────────────────────────────────────
# Полный образ с компилятором Go и тулчейном. Версию фиксируем явно.
FROM golang:1.22 AS builder
WORKDIR /src
# Сначала только манифесты зависимостей — ради кэша слоёв (см. ниже).
COPY go.mod go.sum ./
RUN go mod download
# Теперь исходники.
COPY . .
# Статическая сборка: без C-зависимостей, со срезанной debug-информацией.
# Указываем целевую платформу явно (по умолчанию = платформа стадии).
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /app ./cmd/server
# ── Стадия 2: финальный образ ──────────────────────────────────────
# Пустой образ: в нём не будет НИЧЕГО, кроме нашего бинарника.
FROM scratch
# Корневые сертификаты — иначе HTTPS-вызовы из бинарника упадут (см. ниже).
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Копируем единственный артефакт из стадии сборки.
COPY --from=builder /app /app
# Запуск не от root (UID:GID; в scratch нет /etc/passwd для имени).
USER 65534:65534
ENTRYPOINT ["/app"]Идея ровно та же, что в каноничном Dockerfile для .NET (SDK-образ собирает, aspnet-образ запускает) — но финальная стадия в Go получается несопоставимо тоньше: там не нужен runtime-образ, достаточно scratch или distroless.
flowchart LR
subgraph S1["Стадия builder (golang:1.22)"]
A["go.mod / go.sum"] --> B["go mod download"]
B --> C["COPY исходники"]
C --> D["go build → /app"]
end
subgraph S2["Финальная стадия (scratch)"]
E["/app (только бинарник)"]
F["ca-certificates.crt"]
end
D -- "COPY --from=builder" --> E
G["золотое правило:<br/>в итог попадает только бинарник"]
Параллель с .NET: это прямой аналог двухстадийной сборки
mcr.microsoft.com/dotnet/sdk→mcr.microsoft.com/dotnet/aspnet. Разница не в подходе, а в результате: в .NET вторая стадия всё равно содержит managed-рантайм, в Go — может содержать вообще ничего, кроме вашего файла.
Чтобы бинарник можно было положить в пустой scratch, он должен быть полностью самодостаточным — не дёргать при старте системные .so-библиотеки. За это отвечают флаги сборки.
По умолчанию Go может задействовать cgo — мост к C-коду. Как только в сборку попадает cgo (например, через стандартные пакеты net или os/user, которые в некоторых режимах резолвят имена через системный C-резолвер), бинарник начинает динамически линковаться с libc хост-системы (обычно glibc). Такой бинарник на scratch упадёт сразу: там нет libc.so, нет динамического загрузчика — ничего.
CGO_ENABLED=0 отключает cgo. Тогда Go использует чистые Go-реализации (в том числе встроенный DNS-резолвер) и собирает полностью статический бинарник без зависимости от libc:
RUN CGO_ENABLED=0 go build -o /app ./cmd/serverЭто и есть условие, при котором образ на scratch работает. Платой за это иногда называют то, что встроенный резолвер ведёт себя не на 100% идентично системному (нюансы nsswitch.conf), но для подавляющего большинства сервисов это незаметно и предпочтительно.
Параллель с .NET: концептуально это близко к разнице «динамически слинкованный нативный код, зависящий от системного glibc» против «самодостаточный артефакт». В .NET аналогичную самодостаточность даёт
--self-contained(рантайм едет с приложением) и особенно Native AOT, где на выходе тоже нативный бинарник; подробнее — в главе сравнения.
Флаги линковщика -s -w удаляют из бинарника отладочную информацию:
-s— убирает таблицу символов;-w— убирает DWARF-отладочную информацию.
Бинарник от этого уменьшается заметно — нередко на четверть-треть, в зависимости от приложения. Программа работает идентично; теряется лишь возможность пошаговой отладки (delve) и читаемых символов в некоторых трассировках. Для production-образа это стандартная практика; для отладочных сборок флаги опускают.
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/serverСтектрейсы при панике (с именами функций и номерами строк)
-s -wне ломает — они формируются из отдельной таблицы, а не из DWARF. Так что наблюдаемость черезpanic/recoverсохраняется.
Go умеет собирать под любую целевую ОС/архитектуру с любой машины — это встроено в тулчейн, отдельный кросс-компилятор не нужен. Управляют этим переменные GOOS (операционная система) и GOARCH (архитектура):
# Linux x86-64
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app ./cmd/server
# Linux ARM64 (например, AWS Graviton, Apple Silicon под Linux-контейнером)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /app ./cmd/serverВ Dockerfile целевую платформу часто задают явно — это делает сборку детерминированной независимо от того, на какой машине она запущена. Для мультиархитектурных образов это сочетают с docker buildx и встроенными аргументами TARGETOS/TARGETARCH:
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags="-s -w" -o /app ./cmd/serverПараллель с .NET:
GOOS/GOARCH— это аналог RID (Runtime Identifier,-r linux-arm64) вdotnet publish. Но кросс-компиляция Go проще и быстрее: не нужны целевые рантаймы, всё делает один тулчейн «из коробки».
Раз бинарник самодостаточен, встаёт вопрос — во что его класть. Три типичных варианта, от самого минимального к самому «обжитому».
scratch — это специальный «нулевой» образ: в нём нет ничего (ни файлов, ни shell, ни libc). Вы кладёте туда только свой бинарник.
FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]- ✅ Минимальный размер: итоговый образ ≈ размеру самого бинарника (единицы МБ для простого сервиса, больше — для крупного).
- ✅ Минимальная поверхность атаки: нет shell — нечего эксплуатировать, нет пакетов — нечему иметь CVE.
- ❌ Нет
ca-certificates,tzdata,/etc/passwd, нет shell дляdocker execи отладки. Всё, что нужно, придётся доложить вручную (см. ниже).
alpine — это полноценный минималистичный дистрибутив (на базе musl libc), размером порядка ~5 МБ. В нём есть пакетный менеджер apk, shell и базовые утилиты.
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app /app
ENTRYPOINT ["/app"]- ✅ Есть shell (
/bin/sh) — удобно дляdocker exec, healthcheck-скриптов, отладки. - ✅ Легко доставить
ca-certificates/tzdataодной командойapk add. - ❌ Чуть больше
scratchи чуть шире поверхность атаки (есть пакеты → бывают CVE). - ❌ Важный нюанс: alpine — это musl, а не glibc. Чистый статический Go-бинарник (
CGO_ENABLED=0) на musl работает прекрасно. Но если вам нужен cgo, то под alpine надо собирать с musl-совместимым toolchain — иначе динамически слинкованный с glibc бинарник там не запустится.
gcr.io/distroless/static от Google — золотая середина: образ содержит ca-certificates, актуальные часовые пояса (tzdata), /etc/passwd с пользователем nonroot, но не содержит shell и пакетного менеджера.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]- ✅ Сертификаты и
tzdataуже внутри — ничего докладывать не надо. - ✅ Нет shell → существенно безопаснее, чем alpine (нельзя получить интерактивную оболочку при компрометации).
- ✅ Есть готовый непривилегированный пользователь
nonroot. - ❌ Нет shell → отладка через
docker execневозможна напрямую (для этого есть отдельные:debug-теги). - Размер: немного больше
scratch, но всё ещё на порядок меньше runtime-образов managed-языков.
distroless(static) — разумный дефолт для большинства production-сервисов: безопасно (нет shell), сертификаты и таймзоны на месте, естьnonroot-пользователь.scratch— когда нужен абсолютный минимум и вы готовы вручную доложить сертификаты/таймзоны и обойтись без отладки изнутри контейнера.alpine— когда нужен shell внутри образа (healthcheck-скрипты, отладка, миграционные утилиты рядом) или когда без cgo не обойтись.
| Базовый образ | Размер (порядок) | Shell | CA-сертификаты | tzdata | Лучше всего для |
|---|---|---|---|---|---|
scratch |
≈ размер бинарника | ❌ | ❌ (копировать) | ❌ (копировать) | абсолютный минимум |
distroless/static |
немного больше scratch | ❌ | ✅ встроены | ✅ встроены | безопасный дефолт |
alpine |
~5 МБ + бинарник | ✅ | через apk add |
через apk add |
нужен shell / cgo |
Для scratch (и местами alpine) типичны три «забытые мелочи», на которых сервис падает в проде:
-
CA-сертификаты. Без файла корневых сертификатов любой исходящий HTTPS-вызов (к внешнему API, БД по TLS) упадёт с ошибкой проверки сертификата. Для
scratchего копируют из стадии сборки:COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -
Часовые пояса (
tzdata). Если код используетtime.LoadLocation("Europe/Moscow"), наscratchбез базы зон это вернёт ошибку. Варианты: скопироватьtzdataв образ или встроить базу зон прямо в бинарник импортомimport _ "time/tzdata"(тогда внешний файл не нужен — удобно именно дляscratch). -
Непривилегированный пользователь. Запускать процесс от root в контейнере — плохая практика. В
scratchнет/etc/passwd, поэтому пользователя задают числовымUID:GID(например,USER 65534:65534— этоnobody); вdistrolessесть готовыйUSER nonroot:nonroot.
Docker кэширует слои по содержимому. Любое изменение инвалидирует этот слой и все последующие. Самая частая ошибка — копировать весь исходный код до установки зависимостей: тогда правка одной строки кода заставляет Docker заново скачивать все зависимости, потому что слой с go mod download идёт после COPY . . и тоже инвалидируется.
Правильный порядок — от редко меняющегося к часто меняющемуся. Файлы go.mod/go.sum меняются редко (только при смене зависимостей), а исходный код — постоянно. Значит, манифесты копируем и скачиваем зависимости первыми, отдельным слоем, а исходники — потом:
# ✅ Правильно: зависимости кэшируются отдельным слоём
COPY go.mod go.sum ./
RUN go mod download # этот слой переиспользуется, пока go.mod/go.sum не изменились
COPY . . # правки кода инвалидируют только отсюда и ниже
RUN go build -o /app ./cmd/server# ❌ Неправильно: любая правка кода = повторное скачивание ВСЕХ зависимостей
COPY . .
RUN go mod download
RUN go build -o /app ./cmd/serverflowchart TD
A["COPY go.mod go.sum"] --> B["RUN go mod download<br/>(кэш живёт, пока манифесты не менялись)"]
B --> C["COPY . . (исходники)"]
C --> D["RUN go build"]
E["Правка .go-файла"] -. "инвалидирует только" .-> C
E -. "и" .-> D
F["Слой зависимостей B остаётся в кэше ✅"]
Параллель с .NET: это тот же приём, что и в Dockerfile для .NET, где сначала копируют
*.csprojи делаютdotnet restore, и лишь затем копируют остальной код передdotnet build/publish. Мотив идентичен: вынести медленный шаг восстановления зависимостей в стабильно кэшируемый слой.
Кэш слоёв переиспользует готовый слой целиком, но если слой всё же пересобирается (например, поменялся go.sum), go mod download и компиляция стартуют с нуля. Чтобы между сборками сохранялись кэш модулей ($GOPATH/pkg/mod) и кэш сборки ($GOCACHE, скомпилированные пакеты), используют BuildKit-фичу --mount=type=cache — персистентный кэш, не попадающий в слои образа:
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
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 -ldflags="-s -w" -o /app ./cmd/serverЗдесь два кэша:
/go/pkg/mod— скачанные модули (не качать повторно одни и те же версии);/root/.cache/go-build— кэш компиляции Go (не перекомпилировать неизменившиеся пакеты).
Этот кэш не увеличивает финальный образ — он существует только во время сборки на стороне BuildKit. На повторных сборках после изменения кода это резко ускоряет шаг компиляции, так как Go перекомпилирует только затронутые пакеты.
Строка
# syntax=docker/dockerfile:1в начале включает современный синтаксис Dockerfile (BuildKit), необходимый для--mount. В свежих версиях Docker BuildKit активен по умолчанию.
Параллель с .NET: аналогично можно монтировать кэш NuGet (
--mount=type=cache,target=/root/.nuget/packages), чтобыdotnet restoreне качал пакеты заново на каждой пересборке слоя. Тот же принцип «персистентный кэш сборки отдельно от слоёв образа».
Точные цифры зависят от приложения (объём кода, число и «вес» зависимостей, встроенные ассеты), поэтому ниже — порядки величин, а не обещания:
| Что | Порядок размера | Комментарий |
|---|---|---|
Сам Go-бинарник (простой сервис, -s -w) |
единицы–десятки МБ | растёт с числом зависимостей; крупные сервисы — десятки МБ |
Образ на scratch |
≈ размер бинарника (единицы–десятки МБ) | сверху лишь сертификаты/таймзоны, если добавили |
Образ на distroless/static |
бинарник + единицы МБ | сертификаты и tzdata уже внутри |
Образ на alpine |
бинарник + ~5 МБ | плюс то, что доставили через apk |
Образ-стадия golang:1.x (builder) |
сотни МБ–~1 ГБ | в финальный образ не попадает благодаря multi-stage |
Главный вывод: благодаря multi-stage гигабайтная стадия сборки остаётся «за бортом», а в реестр и на узлы едет компактный финальный образ. Для сравнения с типичными размерами ASP.NET-образов — см. таблицу в главе сравнения.
- Multi-stage build — основа: стадия
golang:1.xкомпилирует бинарник, финальная стадия (scratch/distroless/alpine) копирует из неё только готовый файл. Компилятор, исходники и кэш в итоговый образ не попадают. - Статическая линковка делает бинарник самодостаточным:
CGO_ENABLED=0отвязывает от libc (условие дляscratch),-ldflags="-s -w"срезает debug-информацию и заметно уменьшает размер; кросс-компиляция — черезGOOS/GOARCHбез отдельного тулчейна. - Базовый образ:
distroless/static— безопасный дефолт (нет shell, сертификаты и tzdata внутри);scratch— абсолютный минимум (доложитьca-certificates/tzdata/пользователя вручную);alpine— когда нужен shell или cgo (помнить про musl). - Кэширование слоёв: копировать
go.mod/go.sumи делатьgo mod downloadдоCOPY . ., иначе любая правка кода заново тянет все зависимости;--mount=type=cacheсохраняет кэш модулей и компиляции между сборками, не раздувая образ. - Размеры зависят от приложения, но порядок — единицы-десятки МБ для финального образа против гигабайтной стадии сборки, которая благодаря multi-stage в реестр не едет.
Дальше — что и почему исключать из build-контекста и из репозитория через .dockerignore и .gitignore.
⌂ Главная · ↑ Раздел · ← Предыдущий: Раздел 12. Развертывание, Docker и Инфраструктура · → Следующий: Игнор-файлы: .gitignore и .dockerignore