Деплой и наблюдаемость 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, меньшая поверхность атаки, дешёвое хранение.
~1 ГБ
(компилятор, кеши)"] -->|multi-stage| B["distroless/static
~15 МБ
(только бинарник)"] style A fill:#d55 style B fill:#2d5
Три столпа наблюдаемости
В проде вы не видите, что происходит внутри процесса, — видите только то, что он излучает. Три вида телеметрии отвечают на разные вопросы:
- Логи — что случилось в конкретный момент (дискретные события).
- Метрики — сколько/как часто (агрегированные числа во времени: 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.
Источники
- Profiling Go Programs и Diagnostics — официально.
- OpenTelemetry Go, Prometheus client_golang.
- distroless — минимальные образы.
- Docker-гайд по Go: Docker docs — Go language guide.
Что дальше
Мы прошли путь от синтаксиса до прода. В финальной главе соберём всё в контекст SDLC: CI/CD, релизы, безопасность зависимостей — и дам курированный список ресурсов, чтобы расти дальше.