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.
Выбор 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. Злоумышленник не может получить интерактивную сессию, даже эксплуатируя уязвимость.
Оптимизация кэша: 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 раз меньше
Полный 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 download→COPY . .→ 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 - проверь ответ сервера