DevOps

Оптимизация Docker-образов для Go: многоэтапная сборка, distroless и минимальный attack surface

Ruslan Ismailov Опубликовано 14 мин чтения
О

Введение: почему размер и безопасность Docker-образов критичны в 2026 году

В 2026 году контейнеризация — не просто удобный инструмент, а стандарт de facto для production-деплоя. Kubernetes-кластеры обслуживают тысячи подов, CI/CD пайплайны запускают сотни сборок в день, а compliance-требования (SOC 2, PCI DSS, ISO 27001) напрямую затрагивают содержимое контейнерных образов. В этом контексте три вещи становятся критичными.

Первое — скорость деплоя. Образ весом 800 МБ против образа весом 8 МБ — это разница в десятки секунд при pull из registry, что при rolling update в Kubernetes напрямую влияет на время деградации сервиса. Второе — attack surface. Каждый лишний пакет в образе — потенциальная CVE-уязвимость. Образ на базе ubuntu:22.04 содержит 200+ пакетов, большинство из которых вашему Go-приложению не нужны никогда. Третье — соответствие политикам безопасности. Сканеры уязвимостей в CI/CD пайплайне блокируют деплой при обнаружении критических CVE, и чем меньше в образе компонентов, тем меньше поверхность для атаки.

В этой статье мы пройдём путь от наивного Dockerfile до production-ready образа весом менее 10 МБ с нулевыми CVE-уязвимостями.

Базовая многоэтапная сборка Go-приложения

Самая распространённая ошибка начинающих — использовать один образ и для сборки, и для запуска приложения. Это приводит к образам весом 300–900 МБ, содержащим весь Go toolchain, исходный код и все зависимости сборки.

Многоэтапная сборка (multi-stage build) решает эту проблему: на первом этапе компилируем бинарник, на втором — копируем только его в минимальный runtime-образ.

Ключевой момент — статическая линковка через CGO_ENABLED=0. Это отключает CGO и создаёт полностью статически слинкованный бинарник, который не требует никаких системных библиотек в runtime-образе. Без этой настройки Go-бинарник будет динамически линковаться с glibc, что создаёт зависимость от конкретной версии libc в runtime-образе.

# Dockerfile.basic — базовая многоэтапная сборка

# ── Этап 1: сборка ──────────────────────────────────────────────
FROM golang:1.23-alpine AS builder

WORKDIR /app

# Сначала копируем только файлы зависимостей — это важно для кэша
COPY go.mod go.sum ./
RUN go mod download

# Теперь копируем исходный код
COPY . .

# Статическая линковка: CGO_ENABLED=0 и GOOS=linux
# -ldflags="-w -s" убирает отладочную информацию и символьную таблицу
# -trimpath убирает абсолютные пути из бинарника (важно для reproducible builds)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Этап 2: минимальный runtime ─────────────────────────────────
FROM alpine:3.20 AS runtime

# Обновляем пакеты для уменьшения CVE
RUN apk update && apk upgrade --no-cache

# Копируем только бинарник
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Этот Dockerfile даёт образ ~12–15 МБ вместо ~300 МБ с golang:1.23. Флаги -w (без DWARF) и -s (без символьной таблицы) уменьшают бинарник ещё на 20–30%.

Эволюция базовых образов: scratch vs alpine vs distroless

Выбор базового образа для runtime — ключевое архитектурное решение, влияющее на размер, безопасность и удобство работы.

scratch — абсолютный минимум

scratch — это пустой образ без каких-либо файлов. Ваш бинарник — единственное содержимое контейнера. Это даёт минимальный размер и нулевой attack surface по системным пакетам.

Проблемы: нет CA-сертификатов (HTTPS-запросы упадут), нет timezone-данных, нет /etc/passwd (нельзя запустить от непривилегированного пользователя без дополнительных шагов), абсолютно нет инструментов отладки.

alpine — популярный компромисс

Alpine Linux весит ~5 МБ и содержит минимальный набор утилит. Использует musl libc вместо glibc, что иногда вызывает проблемы совместимости. Содержит пакетный менеджер apk, что удобно для добавления зависимостей. Типичное количество CVE на образ: 0–5 при регулярном обновлении.

distroless — золотой стандарт для production

Проект Google distroless предоставляет образы, содержащие только runtime-зависимости приложения — без shell, без пакетного менеджера, без лишних утилит. Для Go используется gcr.io/distroless/static-debian12 (для статических бинарников) или gcr.io/distroless/base-debian12 (если нужна glibc).

Сравнение по размеру и CVE (данные типичного Go-сервиса, 2024–2025):

  • golang:1.23 — ~600 МБ, 50–150 CVE (включая критические)
  • ubuntu:22.04 — ~75 МБ, 20–80 CVE
  • alpine:3.20 — ~8–12 МБ, 0–5 CVE
  • gcr.io/distroless/static-debian12 — ~2–5 МБ, 0–2 CVE
  • scratch — <1 МБ overhead, 0 CVE от OS (но требует CA-сертификаты вручную)

Для большинства production Go-приложений distroless/static — оптимальный выбор: он включает CA-сертификаты, timezone-данные, файл /etc/passwd с пользователем nonroot, но не содержит shell и пакетного менеджера.

Практический переход на distroless

Переход с alpine на distroless требует решения нескольких практических задач.

# Dockerfile.distroless — production-ready образ

# ── Этап 1: сборка ──────────────────────────────────────────────
FROM golang:1.23-bookworm AS builder

WORKDIR /app

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

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Этап 2: distroless runtime ──────────────────────────────────
# :nonroot тег запускает образ от пользователя nonroot (uid=65532)
# :debug вариант содержит busybox shell для отладки
FROM gcr.io/distroless/static-debian12:nonroot AS runtime

# distroless/static уже содержит:
# - /etc/ssl/certs/ca-certificates.crt (CA-сертификаты)
# - /usr/share/zoneinfo (timezone данные)
# - /etc/passwd с пользователем nonroot
# - /tmp директорию

COPY --from=builder /app/server /server

# USER уже nonroot благодаря :nonroot тегу
# но явно указываем для ясности
USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]

Отладка без shell

Отсутствие shell в distroless — это особенность, а не баг. Для отладки существует несколько подходов.

Первый — использовать :debug тег образа, который содержит busybox. Важно: :debug только для локальной отладки, никогда в production.

Второй — kubectl debug в Kubernetes: он позволяет прикрепить ephemeral container с нужными инструментами к работающему поду без перезапуска.

# Отладка работающего пода в Kubernetes
kubectl debug -it my-pod \
  --image=busybox \
  --target=my-container \
  -- sh

# Или использовать debug-вариант образа локально
docker run --rm -it \
  --entrypoint sh \
  gcr.io/distroless/static-debian12:debug

Если нужны timezone-данные из кода

Образ distroless/static-debian12 уже содержит /usr/share/zoneinfo. Если используете distroless/base или scratch, timezone-данные нужно копировать явно:

# Копирование timezone данных для scratch-образа
FROM builder AS tz-builder
RUN apt-get install -y tzdata

FROM scratch AS runtime
# CA-сертификаты
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Timezone данные
COPY --from=tz-builder /usr/share/zoneinfo /usr/share/zoneinfo
# /etc/passwd для non-root пользователя
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/server /server
USER nobody
ENTRYPOINT ["/server"]

Оптимизация кэширования слоёв

Правильный порядок инструкций в Dockerfile критически влияет на время пересборки. Docker и BuildKit кэшируют слои — при изменении одного слоя все последующие инвалидируются.

Золотое правило: от наименее изменяемого к наиболее изменяемому. Зависимости (go.mod/go.sum) меняются редко, исходный код — часто.

# Dockerfile.optimized — оптимизация кэша с BuildKit
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

# ШАГ 1: Сначала только файлы зависимостей
# Этот слой кэшируется до тех пор, пока не изменятся go.mod/go.sum
COPY go.mod go.sum ./

# BuildKit cache mount: кэширует модульный кэш Go между сборками
# Это особенно полезно в CI/CD — go mod download не скачивает пакеты заново
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

# ШАГ 2: Только после этого копируем исходный код
COPY . .

# Компиляция также с cache mount для build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Директива # syntax=docker/dockerfile:1.9 включает последнюю версию BuildKit frontend с поддержкой cache mounts. Флаг --mount=type=cache создаёт постоянный кэш между сборками — go mod download и компиляция используют локальный кэш, не скачивая пакеты заново.

Прирост от BuildKit cache mounts на типичном проекте: первая сборка — без изменений, повторная сборка при изменении только исходников — ускорение в 3–10 раз (от 2–3 минут до 15–30 секунд).

Включение BuildKit при локальной сборке:

DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Или через современный docker buildx:
docker buildx build -t myapp:latest .

Сканирование уязвимостей: Trivy и Docker Scout

Сканирование образов на уязвимости — обязательный шаг в production-процессе. Два наиболее распространённых инструмента: Trivy от Aqua Security и Docker Scout.

Trivy — быстрое локальное и CI-сканирование

# Установка Trivy
brew install aquasecurity/trivy/trivy  # macOS
# или
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Сканирование образа
trivy image myapp:latest

# Только HIGH и CRITICAL уязвимости
trivy image --severity HIGH,CRITICAL myapp:latest

# Вывод в формате JSON для CI
trivy image --format json --output trivy-report.json myapp:latest

# Завершить с ненулевым кодом при наличии CRITICAL (блокирует CI)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

Интеграция Trivy в GitHub Actions

# .github/workflows/build.yml
name: Build and Scan

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.optimized
          push: false
          load: true
          tags: myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

Docker Scout

Docker Scout встроен в Docker Desktop и Docker Hub. Для быстрой проверки:

# Анализ локального образа
docker scout cves myapp:latest

# Сравнение с базовым образом
docker scout compare myapp:latest --to myapp:previous

# Рекомендации по обновлению базового образа
docker scout recommendations myapp:latest

Типичные результаты сканирования после оптимизации: golang:1.23 builder — 87 CVE (включая 12 HIGH, 3 CRITICAL); финальный distroless/static образ — 0 CVE.

Настройка non-root пользователя, read-only filesystem и capabilities

Минимальный размер образа — только половина задачи. Правильные runtime-настройки безопасности не менее важны.

# Dockerfile.secure — полный production-ready пример с безопасностью
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Runtime: distroless с nonroot ───────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot

# distroless:nonroot запускается от uid=65532 gid=65532
# Нет необходимости создавать пользователя вручную

COPY --from=builder --chown=nonroot:nonroot /app/server /server

USER nonroot:nonroot

# Метаданные образа (OCI Labels)
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp" \
      org.opencontainers.image.revision="${GIT_COMMIT}" \
      org.opencontainers.image.created="${BUILD_DATE}"

EXPOSE 8080
ENTRYPOINT ["/server"]

В Kubernetes-манифесте дополнительно настраиваем securityContext:

# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      # Запрет запуска от root на уровне pod
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: myapp
          image: myregistry/myapp:latest
          ports:
            - containerPort: 8080
          securityContext:
            # Read-only корневая файловая система
            readOnlyRootFilesystem: true
            # Запрет эскалации привилегий
            allowPrivilegeEscalation: false
            # Сбрасываем все capabilities
            capabilities:
              drop:
                - ALL
          # Если приложению нужна запись — монтируем tmpfs
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 64Mi

Комбинация readOnlyRootFilesystem: true, allowPrivilegeEscalation: false и capabilities.drop: ALL максимально ограничивает возможности атакующего даже при компрометации приложения.

Сборка multi-platform образов с Docker Buildx

Kubernetes-кластеры всё чаще используют ARM-ноды (AWS Graviton, Azure Ampere) наряду с x86_64. Multi-platform образы позволяют использовать один тег для обеих архитектур.

# Настройка buildx с поддержкой multi-platform
docker buildx create \
  --name multiplatform-builder \
  --driver docker-container \
  --platform linux/amd64,linux/arm64 \
  --use

# Проверка доступных платформ
docker buildx inspect --bootstrap

# Сборка и push multi-platform образа
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --file Dockerfile.secure \
  --tag myregistry/myapp:latest \
  --tag myregistry/myapp:1.2.3 \
  --push \
  .

# Проверка манифеста
docker buildx imagetools inspect myregistry/myapp:latest

В Dockerfile для multi-platform сборки используем TARGETPLATFORM и BUILDPLATFORM для кросс-компиляции Go:

# Dockerfile.multiplatform
# syntax=docker/dockerfile:1.9

FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS builder

# Переменные для кросс-компиляции
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Go кросс-компиляция через GOOS/GOARCH из TARGETPLATFORM
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Важно: используем --platform=$BUILDPLATFORM для builder-этапа, чтобы компиляция всегда происходила нативно на машине сборки (не через эмуляцию QEMU), а кросс-компиляция реализуется через Go toolchain. Это даёт прирост скорости в 5–20 раз по сравнению с QEMU-эмуляцией.

Измерение результатов: до и после

Приведём конкретные цифры для типичного Go HTTP-сервиса (~5000 строк кода, несколько зависимостей).

Размер образов

  • golang:1.23 (без multi-stage) — 862 МБ
  • multi-stage + ubuntu:22.04 — 78 МБ
  • multi-stage + alpine:3.20 — 13.2 МБ
  • multi-stage + distroless/static — 7.8 МБ
  • multi-stage + scratch (с CA-сертификатами) — 6.1 МБ

Количество CVE (Trivy, HIGH + CRITICAL)

  • golang:1.23 (без multi-stage) — 15 HIGH, 3 CRITICAL
  • multi-stage + ubuntu:22.04 — 8 HIGH, 1 CRITICAL
  • multi-stage + alpine:3.20 — 1 HIGH, 0 CRITICAL
  • multi-stage + distroless/static — 0 HIGH, 0 CRITICAL
  • multi-stage + scratch — 0 HIGH, 0 CRITICAL

Время сборки (CI, повторная сборка с изменением одного файла)

  • Без BuildKit cache mounts — 3 мин 20 сек
  • С BuildKit cache mounts (GHA cache) — 28 сек

Команды для проверки результатов

# Размер образа
docker image ls myapp:latest --format "{{.Size}}"

# Подробный анализ слоёв
docker history myapp:latest

# Детальная инспекция с dive (утилита для анализа слоёв)
dive myapp:latest

# Время сборки
time docker buildx build -t myapp:latest .

# Полный отчёт Trivy
trivy image --format table myapp:latest

# Проверка, что бинарник статически слинкован
docker run --rm --entrypoint sh \
  gcr.io/distroless/static-debian12:debug \
  -c "file /server" \
  myapp:latest
# Ожидаемый вывод: /server: ELF 64-bit LSB executable, statically linked

# Проверка пользователя
docker run --rm myapp:latest id
# Ожидаемый вывод: uid=65532(nonroot) gid=65532(nonroot)

Заключение и production-ready чеклист

Оптимизация Docker-образов для Go — это не разовая задача, а набор практик, которые нужно закладывать с первого Dockerfile. Переход от наивного образа к production-ready решению сокращает размер образа в 100+ раз, полностью устраняет OS-уровневые CVE и ускоряет CI/CD пайплайн в несколько раз.

Эти оптимизации особенно важны в Kubernetes-окружениях, где скорость pull образа напрямую влияет на время запуска подов, а security policies всё чаще блокируют деплой образов с критическими уязвимостями.

Чеклист production-ready Docker-образа для Go

  1. Используется многоэтапная сборка (builder + runtime)
  2. CGO_ENABLED=0 — статическая линковка бинарника
  3. Флаги -ldflags="-w -s" и -trimpath для уменьшения бинарника
  4. Базовый образ — distroless/static-debian12:nonroot или scratch
  5. go.mod/go.sum копируются отдельно до исходного кода для кэширования
  6. BuildKit cache mounts для /go/pkg/mod и /root/.cache/go-build
  7. Trivy сканирование в CI с блокировкой при CRITICAL CVE
  8. Приложение запускается от non-root пользователя (USER nonroot)
  9. Kubernetes securityContext: readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: ALL
  10. Multi-platform сборка (amd64 + arm64) через docker buildx
  11. OCI-метки (LABEL org.opencontainers.image.*) для трейсабилити
  12. Регулярное обновление базового образа (автоматические PR через Dependabot или Renovate)

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →