Multi-stage build для Go: маленький образ, быстрый старт

Multi-stage build для Go: маленький образ, быстрый старт

Multi-stage build - главный приём для Go: собираем бинарник в одном образе (с компилятором), запускаем в другом (без компилятора). Итог: образ вместо 1 ГБ весит 10-20 МБ.

Проблема: образ с компилятором

# Один stage - образ ~1 ГБ
FROM golang:1.22-alpine
WORKDIR /src
COPY . .
RUN go build -o /bin/app ./cmd/api
EXPOSE 8080
CMD ["/bin/app"]

Этот образ включает компилятор Go, все исходники и промежуточные файлы сборки. В продакшене ничего из этого не нужно - только бинарник.

Решение: multi-stage

# ---------- stage 1: builder ----------
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/app ./cmd/api

# ---------- stage 2: runtime ----------
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /bin/app /app/app
EXPOSE 8080
CMD ["/app/app"]

COPY --from=builder - ключевая магия. Мы берём из первого stage только готовый бинарник и кладём в чистый alpine.

Multi-stage: builder с Go-компилятором (~1 GB) собирает бинарь, runtime берёт только 15 MB

Go умеет компилировать статические бинарники без зависимости от C-библиотек. `CGO_ENABLED=0` отключает CGO - результат работает в любом Linux-контейнере, даже в scratch (пустом). Без этого флага бинарник может зависеть от glibc, которой нет в alpine/scratch.

Выбор runtime-образа

<ComparisonTable title="Варианты runtime-образа" headers={["Образ", "Размер", "Shell", "Пакеты", "Когда использовать"]} rows={[ ["scratch", "0 МБ", "Нет", "Ничего", "Минимум: только бинарник"], ["alpine:3.19", "~5 МБ", "sh", "apk (минимальный набор)", "Когда нужен shell для отладки"], ["distroless", "~2 МБ", "Нет", "Минимальный runtime", "Безопасность: нет shell - нет атак"], ["ubuntu:24.04", "~70 МБ", "bash", "apt (полный набор)", "Когда нужны системные пакеты"] ]} />

scratch - пустой образ

FROM scratch
COPY --from=builder /bin/app /app
CMD ["/app"]

Образ = только твой бинарник. Плюс - минимальная поверхность атаки. Минус - нет shell, нет docker exec -it ... sh, нет CA-сертификатов (HTTPS не работает).

alpine - оптимальный баланс

FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /bin/app /app/app
CMD ["/app/app"]

Shell для отладки, CA-сертификаты для HTTPS, часовые пояса для корректных timestamp.

distroless - безопасность без shell

FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/app /app
CMD ["/app"]

Содержит CA-сертификаты и tzdata, но нет shell. Злоумышленник не может получить интерактивную сессию, даже эксплуатируя уязвимость.

Если приложение делает HTTPS-запросы (к API, S3, OAuth), в scratch не будет CA-сертификатов. Решения: alpine + `ca-certificates`, distroless, или скопировать сертификаты из builder: `COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/`.

Оптимизация кэша: go mod download

# Сначала go.mod/go.sum - кэш зависимостей
COPY go.mod go.sum ./
RUN go mod download

# Потом остальной код
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/api

Если изменился только код (без новых зависимостей), go mod download берётся из кэша. Экономия 30-60 секунд на каждой сборке.

Build arguments

FROM golang:1.22-alpine AS builder
ARG VERSION=dev
ARG COMMIT=unknown
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
 -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" \
 -o /bin/app ./cmd/api
docker build \
 --build-arg VERSION=1.2.3 \
 --build-arg COMMIT=$(git rev-parse --short HEAD) \
 -t myapp:1.2.3 .

-ldflags вшивает версию и хэш коммита прямо в бинарник - удобно для health endpoint и логов.

Сравнение размеров

# Собрать обе версии
docker build -f Dockerfile.single -t app:single .    # без multi-stage
docker build -f Dockerfile.multi -t app:multi .       # с multi-stage

docker images | grep app
# app   single   1.1 GB
# app   multi    15 MB    ← в 70 раз меньше
Маленький образ = быстрый pull (10 МБ vs 1 ГБ по сети), меньше поверхность атаки (меньше пакетов = меньше CVE), быстрый запуск. В CI это экономия минут на каждой сборке.

Полный production Dockerfile

# ---------- builder ----------
FROM golang:1.22-alpine AS builder
ARG VERSION=dev
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
 -ldflags "-s -w -X main.version=${VERSION}" \
 -o /bin/app ./cmd/api

# ---------- runtime ----------
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata \
  && addgroup -S app && adduser -S app -G app

WORKDIR /app
COPY --from=builder /bin/app /app/app

USER app
EXPOSE 8080
CMD ["/app/app"]

Флаги -s -w в ldflags убирают символы отладки - бинарник ещё на 20-30% меньше.

Мини-задание

  • Собери Go-приложение multi-stage и посмотри размер: docker images
  • Сравни с однослойной сборкой (FROM golang, без multi-stage)
  • Попробуй FROM scratch вместо alpine - что изменится?
  • Добавь --build-arg VERSION=1.0.0 и проверь, что версия попала в бинарник

Итог

  • Multi-stage: builder (компиляция) → runtime (только бинарник). Образ в 50-100 раз меньше.
  • CGO_ENABLED=0 - статический бинарник без C-зависимостей, работает в alpine/scratch.
  • Alpine - баланс: shell для отладки, CA-сертификаты для HTTPS, 5 МБ базы.
  • scratch - минимум, distroless - безопасность. Выбирай под задачу.
  • Кэш: go.mod + go mod downloadCOPY . . → build. Build args для версии.

Типичная ошибка

Забыть CGO_ENABLED=0 и получить бинарник, который зависит от glibc. В alpine используется musl, не glibc - результат: exec format error или not found при запуске. Если видишь такую ошибку в alpine/scratch - первым делом проверь CGO_ENABLED.

Вторая ошибка - копировать в runtime stage весь /src вместо одного бинарника. COPY --from=builder /src /app - и весь исходный код в продакшен-образе.

Мини-практика (10-15 минут)

Создай минимальный Go HTTP-сервер и собери его тремя способами:

# 1. Создай приложение
mkdir /tmp/go-multi && cd /tmp/go-multi
go mod init example.com/test

cat > main.go <<'EOF'
package main

import (
    "fmt"
    "net/http"
)

var version = "dev"

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "version: %s\n", version)
    })
    http.ListenAndServe(":8080", nil)
}
EOF

# 2. Собери multi-stage (alpine runtime)
# Напиши Dockerfile, собери, запусти, проверь curl localhost:8080

# 3. Замени runtime на scratch - что произойдёт?
# 4. Добавь --build-arg VERSION=1.0.0 - проверь ответ сервера

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.