Go Деплой и наблюдаемость Go: статический бинарник, Docker, метрики и трейсинг
0%

Деплой и наблюдаемость Go: статический бинарник, Docker, метрики и трейсинг

Деплой и наблюдаемость Go-сервисов

Здесь Go по-настоящему сияет. Результат сборки — один статический бинарник без внешних зависимостей и рантайма. Никакой JVM на сервере, никакого node_modules, никакого интерпретатора. Скопировал файл — запустил. Это радикально упрощает деплой и делает контейнеры крошечными. В этой главе — как собрать артефакт правильно, упаковать его в минимальный образ и сделать сервис наблюдаемым в проде.

Сборка статического бинарника

Базовая сборка:

go build -o server ./cmd/server

Для прода важны флаги. Ключевой — CGO_ENABLED=0: он отключает связывание с C-библиотеками (в частности с системным libc), давая полностью статический бинарник, который запустится даже в пустом образе scratch.

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

Что делают флаги:

  • CGO_ENABLED=0 — статическая линковка, без зависимости от libc хост-системы.
  • -trimpath — убирает абсолютные пути к исходникам из бинарника (безопасность + воспроизводимость сборки).
  • -ldflags="-s -w" — выкидывает таблицу символов и отладочную информацию, уменьшая размер (обычно на 20-30%). Минус: усложняет отладку прод-бинарника, поэтому символы иногда оставляют.

Вшивание версии в бинарник

Идиома — прокинуть версию/коммит через -ldflags -X при сборке:

package main

var (
	version = "dev"     // перезапишется при сборке
	commit  = "none"
)
go build -ldflags="-X main.version=1.4.2 -X main.commit=$(git rev-parse --short HEAD)" ./cmd/server

Так /healthz или флаг --version показывают, что именно крутится в проде. Альтернатива с Go 1.18+ — читать информацию сборки из runtime/debug.ReadBuildInfo() (VCS-ревизия вшивается автоматически).

Кросс-компиляция

Одна из суперспособностей Go: собрать под любую ОС/архитектуру с любой машины, без тулчейнов кросс-компиляции.

GOOS=linux   GOARCH=arm64 go build -o server-linux-arm64 ./cmd/server
GOOS=darwin  GOARCH=arm64 go build -o server-mac-arm64  ./cmd/server
GOOS=windows GOARCH=amd64 go build -o server.exe        ./cmd/server
go tool dist list   # полный список поддерживаемых GOOS/GOARCH

Это делает Go идеальным для CLI-инструментов: одна CI-джоба собирает релиз под все платформы.

Docker: минимальный образ

Наивный образ на golang:1.24 весит ~1 ГБ — это исходники, компилятор, кеши, всё лишнее в проде. Правильный подход — многоступенчатая сборка (multi-stage build): собираем в жирном образе, а в финальный кладём только бинарник.

# --- Стадия сборки ---
FROM golang:1.24 AS build
WORKDIR /src

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

# Потом исходники и сборка
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /server ./cmd/server

# --- Финальная стадия: distroless ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Разбор ключевых решений:

  • Кеширование слоёв. go.mod/go.sum копируем и качаем зависимости до копирования кода. Пока зависимости не менялись, Docker берёт слой из кеша — сборки быстрые.
  • Финальный базовый образ. Три варианта, от «минимальнее» к «удобнее»:
    • scratch — абсолютно пустой образ, ~0 байт базы. Только для полностью статических бинарников (CGO_ENABLED=0). Нет даже сертификатов и /etc/passwd — их надо докладывать вручную (ca-certificates для HTTPS).
    • gcr.io/distroless/static-debian12 — «золотая середина» от Google: нет shell и пакетного менеджера (меньше поверхность атаки), но есть CA-сертификаты, /etc/passwd, tzdata. Вариант :nonroot запускает от непривилегированного пользователя. Рекомендуется по умолчанию.
    • alpine — крошечный (~5 МБ), но с shell (удобно дебажить) и musl libc. Требует CGO_ENABLED=0 или сборки под musl.
  • Непривилегированный пользователь. Никогда не запускайте контейнер от root — USER nonroot.

Результат — образ обычно 10-20 МБ против гигабайта. Это быстрый pull, меньшая поверхность атаки, дешёвое хранение.

Три столпа наблюдаемости

В проде вы не видите, что происходит внутри процесса, — видите только то, что он излучает. Три вида телеметрии отвечают на разные вопросы:

  • Логичто случилось в конкретный момент (дискретные события).
  • Метрикисколько/как часто (агрегированные числа во времени: RPS, латентность, ошибки).
  • Трейсыгде именно в цепочке сервисов ушло время (путь одного запроса сквозь систему).

Логи — уже умеем

Структурный JSON через slog из прошлой главы. В контейнере пишите в stdout/stderr, а не в файлы — сбор логов (Loki, Fluent Bit, ELK) — забота платформы, приложение просто «излучает» в поток. Прокидывайте trace_id в каждый лог запроса — это связывает логи с трейсами.

Метрики: Prometheus

Стандарт де-факто — Prometheus. Сервис отдаёт метрики на эндпоинте /metrics, Prometheus их периодически «соскребает» (pull-модель).

import (
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var httpDuration = promauto.NewHistogramVec(
	prometheus.HistogramOpts{
		Name:    "http_request_duration_seconds",
		Help:    "Длительность HTTP-запросов",
		Buckets: prometheus.DefBuckets,
	},
	[]string{"method", "path", "status"},
)

func main() {
	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.Handler())   // Prometheus будет скрести отсюда
	// ... оборачиваем хендлеры middleware, который меряет httpDuration
}

Полезный ориентир — метод RED для сервисов: Rate (запросов в секунду), Errors (доля ошибок), Duration (латентность, обязательно перцентили p50/p95/p99, а не среднее). Для ресурсов — метод USE (Utilization, Saturation, Errors). Не забывайте про рантайм-метрики Go (goroutines, память, паузы GC) — их даёт стандартный коллектор client_golang.

Трейсинг: OpenTelemetry

OpenTelemetry (OTel) — вендоронезависимый стандарт телеметрии, поглотивший OpenTracing и OpenCensus. Он даёт единый SDK, а бэкенд (Jaeger, Tempo, Grafana, коммерческие APM) подключается экспортёром. Трейс показывает путь запроса через все сервисы как дерево спанов с таймингами — незаменимо в микросервисах.

import (
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/trace"
)

func (s *UserService) Rename(ctx context.Context, id int64, name string) error {
	ctx, span := otel.Tracer("user-service").Start(ctx, "UserService.Rename")
	defer span.End()
	span.SetAttributes(attribute.Int64("user.id", id))

	if err := s.repo.Save(ctx, ...); err != nil {   // ctx несёт спан дальше по цепочке
		span.RecordError(err)
		return err
	}
	return nil
}

Инициализация OTel в main настраивает провайдер трейсов и OTLP-экспортёр (обычно в OpenTelemetry Collector). Ключевое — контекст ctx переносит трассировочный контекст через все вызовы и по сети (через заголовки), так что спаны из разных сервисов сшиваются в один трейс. OTel умеет и метрики, и логи — тенденция в том, чтобы вся телеметрия шла через него.

Профилирование: pprof

Когда сервис ест слишком много CPU или течёт память, гадать не нужно — Go даёт встроенный профайлер pprof. Достаточно импортировать net/http/pprof — и появятся эндпоинты профилирования (в проде вешайте их на отдельный внутренний порт, не наружу).

import _ "net/http/pprof"   // регистрирует хендлеры на /debug/pprof/

func main() {
	// Внутренний порт только для диагностики
	go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
	// ... основной сервер на :8080
}

Снятие и анализ профилей:

# CPU-профиль за 30 секунд под нагрузкой
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Профиль кучи (что аллоцирует память)
go tool pprof http://localhost:6060/debug/pprof/heap

# Интерактивно откроется prompt: top, list <func>, web (граф), flamegraph
(pprof) top10
(pprof) web

Виды профилей: profile (CPU), heap (память), goroutine (стек всех горутин — так ловят утечки горутин из главы про конкурентность), mutex, block (где горутины блокируются). Профили можно снимать и в тестах (go test -cpuprofile). Флеймграф из pprof — лучший способ увидеть, где реально уходит время. Классический разбор оптимизации по pprof — статья Profiling Go Programs в официальном блоге.

Практическое правило: сначала измерьте, потом оптимизируйте. Интуиция про производительность почти всегда врёт; pprof говорит правду. И помните про аллокации — часто главный тормоз не CPU, а давление на GC (смотрите heap и -benchmem из главы про тесты).

Health checks и готовность

Оркестратор (Kubernetes) должен знать, жив ли сервис и готов ли принимать трафик. Разделяйте два эндпоинта:

// liveness — процесс жив (не завис). Не проверяйте здесь БД!
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
})

// readiness — готов принимать трафик (зависимости доступны)
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if err := db.PingContext(r.Context()); err != nil {
		http.Error(w, "db down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

Разница принципиальна: если liveness падает — под перезапустят; если readiness — просто уберут из балансировки, пока БД не вернётся. Путать их — значит устраивать рестарт-шторм при кратковременной недоступности БД.

Чек-лист прод-готовности к деплою

  • CGO_ENABLED=0, -trimpath, версия вшита через -ldflags -X.
  • Multi-stage Docker, distroless/scratch, non-root, образ < 30 МБ.
  • Логи — JSON в stdout, с trace_id.
  • /metrics для Prometheus, RED-метрики + рантайм Go.
  • OpenTelemetry-трейсинг, контекст пробрасывается сквозь вызовы.
  • pprof на внутреннем порту для диагностики.
  • /healthz (liveness) и /readyz (readiness) разделены.
  • Graceful shutdown по SIGTERM.

Источники

Что дальше

Мы прошли путь от синтаксиса до прода. В финальной главе соберём всё в контекст SDLC: CI/CD, релизы, безопасность зависимостей — и дам курированный список ресурсов, чтобы расти дальше.

08. SDLC и лучшие ресурсы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов