Перейти к содержанию

Как профилировать сервис

Рецепт использования pprof для поиска CPU-bottleneck'ов, утечек памяти и зависших goroutine. Профайлинг — это инструмент диагностики, включай его на сервисе осознанно и никогда не выставляй endpoint наружу.

Содержание

Зачем профилировать

Профайлинг применяется, когда уже есть измеримый симптом:

  • Perf-регрессия в http_request_duration_seconds после деплоя.
  • Memory растёт линейно со временем (go_memstats_alloc_bytes).
  • Goroutine count растёт без возврата к baseline (go_goroutines).
  • CPU throttling в k8s при нормальной нагрузке.

Без симптома pprof не нужен — это не «регулярная гигиена», а инструмент расследования.

Setup

Подключай net/http/pprof только на отдельный internal port, никогда не на основной HTTP-сервер:

// cmd/server/main.go
import (
    "net/http"
    _ "net/http/pprof" // регистрирует handler'ы в http.DefaultServeMux
)

go func() {
    srv := &http.Server{
        Addr:    "127.0.0.1:6060",
        Handler: http.DefaultServeMux,
    }
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Error("pprof server", "err", err)
    }
}()

Правила:

  • Только 127.0.0.1:6060 на pod. Доступ из вне pod'а — через kubectl port-forward или sidecar debug-контейнер.
  • Никогда не монтировать /debug/pprof на основной router, даже под auth. Случайный misconfig открытого internal-port'а приводит к утечке stack-trace'ов и memory-layout'ов.
  • Альтернативно — включать pprof только при DEBUG_PPROF=true env-флаге на конкретной реплике (для длительной диагностики в prod).

Для block/mutex-профилей включи sampling на старте:

runtime.SetBlockProfileRate(1)       // все события contention
runtime.SetMutexProfileFraction(1)   // все события mutex-contention

В prod — не всегда. SetBlockProfileRate(1) даёт overhead; в prod используй сэмплинг: SetBlockProfileRate(10000) (одна из 10k).

Endpoints

Под http://127.0.0.1:6060/debug/pprof/:

Endpoint Что
/debug/pprof/ Index
/debug/pprof/heap Memory snapshot (allocated bytes / objects)
/debug/pprof/allocs Все аллокации с начала процесса
/debug/pprof/profile?seconds=30 CPU sample 30 сек
/debug/pprof/goroutine Все goroutine stacks
/debug/pprof/block Contention (нужен SetBlockProfileRate)
/debug/pprof/mutex Mutex-contention (нужен SetMutexProfileFraction)
/debug/pprof/threadcreate OS threads
/debug/pprof/trace?seconds=5 Go execution trace (для go tool trace)

Сбор profile

В prod-pod'е port-forward'ишь локально:

kubectl -n kazmaps-prod port-forward deploy/review 6060:6060

Потом собираешь с localhost:6060:

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

# Memory heap snapshot
go tool pprof http://localhost:6060/debug/pprof/heap

# Goroutines — текстовый дамп в файл
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

Сохрани файл для post-mortem:

curl -s http://localhost:6060/debug/pprof/heap > heap-$(date +%s).pb.gz

Анализ в pprof

В REPL-режиме (go tool pprof <file-or-url>):

(pprof) top            # top-N функций по flat time
(pprof) top -cum       # по cumulative
(pprof) list Create    # исходник функции Create с samples per-line
(pprof) web            # callgraph в браузере (требует graphviz)
(pprof) peek Create    # кто вызывает Create
(pprof) disasm Create  # assembly (редко нужно)

Полезное:

  • top -cum — функции, внутри которых (включая вызванные) проведено больше всего времени. Начинай отсюда.
  • list <func> — посмотри, на какой строке сидит hot spot. Часто это конкретный json.Unmarshal, regexp.Compile в цикле, time.Now() в горячем пути.

Browser UI

Для interactive-explore:

go tool pprof -http=:8000 profile.pb.gz

Открывает UI с:

  • Graph — callgraph.
  • Flame Graph — визуализация времени по стеку.
  • Source — код с per-line samples.
  • Top — табличный вид.

Flame graph читается так: ширина = время (CPU) / память (heap), высота = глубина стека. Ищи широкие «плато» на верхних этажах — это hot path.

CPU bottleneck — flow

  1. Поймай момент нагрузки. Smoke-тест / нагрузочный тест / pod под реальным прод-трафиком. Метрика http_request_duration_seconds должна показывать повышенный p99.
  2. Сними CPU profile:
go tool pprof -http=:8000 http://localhost:6060/debug/pprof/profile?seconds=30
  1. top -cum → найди функцию с cum% > 5.
  2. list <func> → посмотри, какая строка жрёт samples.
  3. Fix. Типовые случаи:
  4. json.Unmarshal на hot path — закэшируй, используй готовые схемы, не парси одно и то же дважды.
  5. regexp.MustCompile внутри handler'а — вынеси в var re = ....
  6. time.Now() в тугом цикле — батчь.
  7. Аллокации в цикле — pre-allocate slices, sync.Pool.
  8. Проверь. Снова сняли profile, top -cum должен показывать уменьшенные проценты на той же функции.

Memory leak — flow

Симптом: go_memstats_alloc_bytes или RSS растёт линейно со временем без плато.

  1. Baseline. Сразу после warm-up сними heap:
curl -s http://localhost:6060/debug/pprof/heap > heap-baseline.pb.gz
  1. Wait — минимум 1 час нормальной работы.
  2. Current. Сними второй снимок:
curl -s http://localhost:6060/debug/pprof/heap > heap-current.pb.gz
  1. Diff:
go tool pprof -http=:8000 -base=heap-baseline.pb.gz heap-current.pb.gz
  1. top -cum в diff-режиме → функции с растущими аллокациями.
  2. Типовые причины leak'а:
  3. Глобальная map без лимита и без очистки.
  4. Goroutine, которая держит ссылку на большой буфер (см. goroutine flow ниже).
  5. Незакрытые *sql.Rows, http.Response.Body, файлы.
  6. strings.Builder/bytes.Buffer как поля в long-lived struct, куда постоянно пишут.

Goroutine leak — flow

Симптом: go_goroutines растёт и не возвращается к baseline.

  1. Сними text-dump:
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > g.txt
  1. Сгруппируй по stack-trace'ам (есть одинаковые stack'и — одна причина). В dump'е каждая группа помечена количеством:
goroutine 4321 [semacquire, 23 minutes]:
sync.runtime_Semacquire(...)
  1. Типовые паттерны leak'ов:
  2. [chan receive, N minutes] — горутина ждёт сообщения из канала, который никто не закрывает. Обычно — забыт close(ch) или отменённый ctx, который не наблюдается.
  3. [select, N minutes] без ctx.Done() в ветке — горутина не может выйти по cancel.
  4. [IO wait] — сетевой вызов без timeout'а.
  5. [semacquire, N minutes] — взят mutex, который никто не отпускает.

  6. Фикси в коде:

  7. Добавь case <-ctx.Done(): return в select.
  8. Используй http.Client с таймаутом, а не http.DefaultClient.
  9. context.WithTimeout для любых внешних вызовов.
  10. defer rows.Close(), defer resp.Body.Close().

См. также ../troubleshooting/test-hangs.md — goroutine dump так же применим к зависшим тестам.

Continuous profiling

Для prod — Pyroscope / Grafana Phlare. Агент поверх pprof-endpoint'а непрерывно сэмплирует профили и складывает в хранилище.

Полезно, когда регрессия воспроизводится редко: у тебя всегда есть profile за любой момент последнего периода retention'а, post-mortem не требует воспроизведения.

Setup (типовой):

  • Sidecar / DaemonSet с Pyroscope-агентом.
  • Агент периодически обращается в /debug/pprof/profile?seconds=10 и /debug/pprof/heap.
  • UI в Grafana → Explore → Pyroscope datasource.

Benchmark-first

Не жди prod-инцидента. Для hot path подсемьи пиши benchmark'и:

func BenchmarkReviewCreate(b *testing.B) {
    svc := newTestReviewService(b)
    for i := 0; i < b.N; i++ {
        _, _ = svc.Create(ctx, testCmd)
    }
}

Запуск с profile:

go test -bench=BenchmarkReviewCreate -cpuprofile=cpu.pb.gz -memprofile=mem.pb.gz ./internal/service
go tool pprof -http=:8000 cpu.pb.gz

Так профилировать намного дешевле, чем на prod: воспроизведение идеальное, без сетевых шумов, можно сравнивать «до/после» оптимизации.

Security

Профилировочные endpoint'ы раскрывают:

  • Stack trace'ы с именами функций и переменных (частично).
  • Allocated objects — в heap-снимке видны, каких структур сколько.
  • Package structure — легко понять, какие библиотеки и версии используются (attack surface reconnaissance).

Поэтому:

  • Pprof всегда на 127.0.0.1 или на отдельном internal-port'е.
  • NetworkPolicy в k8s — доступ к port 6060 только из SRE-namespace или через kubectl port-forward.
  • Gateway никогда не роутит /debug/* наружу.
  • Если нужен удалённый доступ — через jump-host / sidecar, не через публичный endpoint.

Anti-patterns

  • Pprof в prod без предупреждения команды. 30-секундный CPU profile даёт ~2-5% overhead; на edge-нагрузке это превращается в latency spike. Согласовывай окно.
  • Long-running CPU profile (> 60 сек). Сэмплы рассеиваются, профиль размазывается, полезный сигнал теряется.
  • Profile на одной реплике без контекста. Если из 10 pod'ов профилируешь один, убедись, что он репрезентативен (по roadshow нагрузки, по affinity).
  • Premature optimization по profile. Функция занимает 2% CPU — оптимизировать её бессмысленно. Работай по правилу: оптимизируй только то, что > 5% и что имеет измеримое влияние на SLO.
  • Pprof endpoint без auth на публичном интерфейсе. Prod-инцидент одним ошибочным PR → утечка всего адресного пространства процесса на /debug/pprof/heap?debug=2 для любого в интернете.
  • Сравнение heap-снимков без warm-up. Первый снимок сразу после старта показывает инициализацию, не leak.

Связанные разделы