Олег Марков
Мониторинг производительности в Go Golang
Введение
Мониторинг производительности нужен, чтобы понимать не только «быстро ли работает приложение», но и почему оно ведет себя так, как ведет. Когда вы начинаете ловить длинные отклики, всплески загрузки CPU или утечки памяти, без нормального мониторинга любые попытки оптимизации превращаются в угадывание.
Здесь мы разберем, как в Go организовать мониторинг производительности на практике. Я покажу вам, как:
- измерять время выполнения и находить «узкие места»;
- работать с профилировщиком Go (pprof);
- собирать и экспортировать метрики (Prometheus формат);
- анализировать использование CPU, памяти и блокировок;
- мониторить задержки HTTP и gRPC;
- использовать трассировки (tracing) для разбора сложных сценариев.
Давайте шаг за шагом посмотрим, как собрать систему мониторинга, которая реально помогает, а не просто рисует красивые графики.
Основные подходы к мониторингу производительности
Что вообще измерять
Прежде чем включать инструменты, важно договориться с самим собой, что вы хотите видеть. Чаще всего мониторят:
- Время ответа
- среднее
- p95 / p99 (95 и 99 перцентиль)
- Производительность
- RPS / QPS (requests per second / queries per second)
- Ресурсы
- загрузка CPU
- использование памяти
- количество горутин
- Ошибки
- количество ошибок в единицу времени
- доля ошибок от общего числа запросов
- Внутренние операции
- время работы отдельных функций
- время работы запросов к БД и внешним сервисам
- длительность операций GC (сборщика мусора)
Смотрите, идея такая: вы описываете систему через набор метрик и дальше уже не «чувствуете», а измеряете производительность.
Встроенные средства Go для измерений
В Go уже есть несколько полезных возможностей:
time.Since/time.Now— точечные измерения времени;- пакет
runtime— сведения о горутинах, памяти, GC; - пакет
runtime/pprof— профили CPU и памяти; - пакет
net/http/pprof— HTTP-обертка над профилями; - пакет
runtime/trace— трассировка выполнения.
Давайте двигаться от простого к сложному: сначала локальные измерения, потом профилировщик, потом метрики и трассировки.
Измерение времени выполнения кода
Измерение времени отдельной операции
Для начала возьмем самый простой подход — измерим время работы функции. Вот пример, как можно это сделать:
package main
import (
"log"
"time"
)
// slowOperation имитирует медленную операцию
func slowOperation() {
// Здесь мы просто спим 200 миллисекунд
time.Sleep(200 * time.Millisecond)
}
func main() {
start := time.Now() // Запоминаем время начала
slowOperation() // Вызываем интересующую нас функцию
elapsed := time.Since(start) // Считаем, сколько времени прошло
log.Printf("slowOperation took %s", elapsed)
}
Здесь вы видите базовый паттерн — замер до и после операции. Но в «боевом» коде так часто делать не очень удобно.
Обертка с defer для измерения
Давайте упростим себе жизнь и сделаем небольшую функцию-обертку, которую можно переиспользовать:
package main
import (
"log"
"time"
)
// measureTime принимает имя операции и время начала
// и в отложенном вызове выводит, сколько заняла операция.
func measureTime(name string, start time.Time) {
elapsed := time.Since(start)
log.Printf("%s took %s", name, elapsed)
}
func handleRequest() {
// Используем defer чтобы всегда замерять время
defer measureTime("handleRequest", time.Now())
// Здесь какая-то полезная работа
time.Sleep(150 * time.Millisecond)
}
func main() {
handleRequest()
}
Смотрите, что тут происходит:
- мы вызываем
defer measureTime("handleRequest", time.Now())в начале функции; deferзапомнит аргументы сейчас, а саму функцию вызовет в конце;- в
measureTimeмы считаем разницу между текущим временем и сохраненным.
Такой подход особенно полезен, когда вам нужно временно «подсветить» подозрительные участки кода.
Измерение времени на уровне HTTP маршрутов
Теперь давайте посмотрим, как измерять время обработки HTTP-запросов. Здесь я покажу вам пример middleware, который логирует длительность:
package main
import (
"log"
"net/http"
"time"
)
// loggingMiddleware измеряет время обработки запроса и пишет лог.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() // Запоминаем начало обработки
next.ServeHTTP(w, r)
elapsed := time.Since(start)
// Логируем метод, путь и время обработки
log.Printf("%s %s took %s", r.Method, r.URL.Path, elapsed)
})
}
func mainHandler(w http.ResponseWriter, r *http.Request) {
// Имитируем работу
time.Sleep(120 * time.Millisecond)
w.Write([]byte("OK"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", mainHandler)
// Оборачиваем весь mux в промежуточный обработчик
loggedMux := loggingMiddleware(mux)
http.ListenAndServe(":8080", loggedMux)
}
Такой подход уже дает вам базовую картинку по задержкам, но для полноценного мониторинга этого мало. Дальше нам нужен профилировщик и метрики.
Профилирование с помощью pprof
Какие профили бывают
Пакет pprof в Go умеет собирать несколько типов профилей:
- CPU профили — показывают, где тратится процессорное время;
- Heap профили — распределение памяти по типам и местам выделения;
- Goroutine профили — какие горутины есть и в каком они состоянии;
- Block / Mutex профили — где происходят блокировки по мьютексам и каналам.
Смотрите, их удобно использовать поэтапно:
- сначала CPU — где код «жрет» больше всего процессора;
- затем Heap — ищем утечки или лишние аллокации;
- при проблемах с конкурентностью — goroutine, block и mutex профили.
Быстрый старт: net/http/pprof
Самый простой способ включить профилирование — добавить HTTP-эндпоинты с профилями. Давайте посмотрим, как это делается.
package main
import (
"log"
"net/http"
_ "net/http/pprof" // Импортируем pprof только для side-effect
)
func main() {
// Ваше основное приложение может слушать другой порт.
// Здесь мы поднимаем отдельный HTTP-сервер только для pprof.
go func() {
log.Println("Starting pprof server on :6060")
// DefaultServeMux уже содержит обработчики из net/http/pprof
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Здесь мог бы быть основной HTTP-сервер приложения
select {} // Блокируем main-горутинию, чтобы программа не завершилась
}
Что мы здесь сделали:
- импортировали
net/http/pprofс подчеркиванием — нам нужны только егоinit-эффекты; - подняли HTTP-сервер на
localhost:6060; - теперь можно подключаться к
http://localhost:6060/debug/pprof/.
Как снимать CPU профиль
Допустим, ваше приложение уже запущено с pprof. Теперь вы хотите снять CPU профиль за 30 секунд. Можно сделать так:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
Команда:
- запустит сбор CPU-профиля;
- через 30 секунд откроет интерактивную консоль
pprof.
Внутри pprof можно использовать команды:
top— показать функции с наибольшим потреблением CPU;top -cum— кумулятивное время, учитывающее вызовы из других функций;list <FuncName>— показать исходник функции с подсветкой затрат;web— построить SVG-граф (нужен Graphviz).
Например:
(pprof) top
Showing nodes accounting for 2.36s, 78.67% of 3.00s total
flat flat% sum% cum cum%
1.20s 40.00% 40.00% 1.20s 40.00% runtime.mallocgc
0.80s 26.67% 66.67% 0.80s 26.67% myapp/heavy.Compute
0.36s 12.00% 78.67% 0.36s 12.00% bytes.makeSlice
Как видите, здесь сразу видно, что много времени уходит на mallocgc — это сигнал, что стоит подумать об уменьшении количества аллокаций.
Съем профиля памяти (Heap)
Теперь давайте посмотрим, как снять профиль памяти. Можно использовать тот же HTTP-интерфейс:
go tool pprof http://localhost:6060/debug/pprof/heap
В интерактивной консоли изучаем, где происходит больше всего аллокаций. Часто полезно смотреть на кумулятивный размер:
(pprof) top -cum
Если вы часто видите какую-то вашу функцию вверху списка, значит именно она генерирует много выделений памяти.
Локальное профилирование без HTTP
Иногда удобнее снимать профили прямо из кода, например при бенчмарках или локальном тесте. Давайте разберем пример:
package main
import (
"log"
"os"
"runtime/pprof"
"time"
)
func heavyWork() {
// Имитируем тяжелую CPU-нагрузку
sum := 0
for i := 0; i < 1e8; i++ {
sum += i
}
_ = sum
}
func main() {
// Создаем файл для CPU-профиля
f, err := os.Create("cpu.prof")
if err != nil {
log.Fatalf("could not create CPU profile: %v", err)
}
defer f.Close()
// Запускаем профилирование CPU
if err := pprof.StartCPUProfile(f); err != nil {
log.Fatalf("could not start CPU profile: %v", err)
}
// Останавливаем профилирование в конце main
defer pprof.StopCPUProfile()
// Выполняем нашу тяжелую работу
heavyWork()
time.Sleep(2 * time.Second) // Немного подождем
}
После запуска программы появится файл cpu.prof. Анализируем его:
go tool pprof cpu.prof
Для памяти можно сделать так:
// После завершения работы собираем heap профиль
hf, err := os.Create("heap.prof")
if err != nil {
log.Fatalf("could not create heap profile: %v", err)
}
defer hf.Close()
// Сбрасываем текущий heap-профиль в файл
if err := pprof.WriteHeapProfile(hf); err != nil {
log.Fatalf("could not write heap profile: %v", err)
}
Сбор и экспорт метрик (Prometheus)
Профилировщик помогает глубоко разбираться в проблемах, но его неудобно использовать как постоянный мониторинг. Для «онлайнового» наблюдения за системой обычно используют метрики, чаще всего в формате Prometheus.
Базовые типы метрик
В Prometheus и его клиентах (включая prometheus/client_golang) есть несколько основных типов:
- Counter — счетчик, который только растет (например, количество запросов);
- Gauge — величина, которая может расти и падать (например, число горутин);
- Histogram — гистограмма, позволяет строить распределения по «корзинам»;
- Summary — перцентили на стороне клиента.
Для мониторинга производительности HTTP часто используют связку:
- Counter — количество запросов и ошибок;
- Histogram — время ответа по «корзинам».
Подключение Prometheus к HTTP-сервису
Давайте посмотрим, как добавить метрики в простое HTTP-приложение на Go.
package main
import (
"log"
"math/rand"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// httpRequestsCount считает все HTTP-запросы по методам и коду ответа.
var httpRequestsCount = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
)
// httpRequestDuration измеряет длительность запросов.
var httpRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency",
Buckets: prometheus.DefBuckets, // Стандартные корзины по времени
},
[]string{"method", "path"},
)
func init() {
// Регистрируем метрики в глобальном реестре Prometheus
prometheus.MustRegister(httpRequestsCount)
prometheus.MustRegister(httpRequestDuration)
}
// monitoringMiddleware измеряет время и увеличивает счетчики.
func monitoringMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Оборачиваем http.ResponseWriter чтобы перехватить код ответа
ww := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(ww, r)
duration := time.Since(start).Seconds()
// Записываем метрики
httpRequestsCount.WithLabelValues(r.Method, r.URL.Path, http.StatusText(ww.statusCode)).Inc()
httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
})
}
// statusRecorder нужен чтобы записать HTTP код ответа.
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
func mainHandler(w http.ResponseWriter, r *http.Request) {
// Имитируем случайную задержку
delay := time.Duration(rand.Intn(300)) * time.Millisecond
time.Sleep(delay)
w.Write([]byte("OK"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", mainHandler)
// Оборачиваем в мониторинг
monitoredMux := monitoringMiddleware(mux)
// Отдельный обработчик для /metrics
http.Handle("/metrics", promhttp.Handler())
http.Handle("/", monitoredMux)
log.Println("Server is running on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Обратите внимание:
httpRequestsCountиhttpRequestDurationпомогают отслеживать частоту и задержки запросов;statusRecorderперехватывает HTTP код ответа;- метрики доступны по адресу
/metricsв формате, который понимает Prometheus.
Теперь Prometheus можно настроить на сбор этих метрик, а в Grafana построить графики, например:
- RPS по endpoint-ам;
- p95 / p99 latency для ключевых маршрутов;
- долю ошибок (5xx, 4xx).
Метрики по runtime Go
Клиент client_golang умеет экспортировать базовые метрики runtime:
- число горутин;
- статистику по GC;
- использование памяти.
Достаточно добавить регистрацию стандартного набора:
import "github.com/prometheus/client_golang/prometheus/promauto"
import "github.com/prometheus/client_golang/prometheus/collectors"
func init() {
// Регистрируем стандартные метрики Go runtime
prometheus.MustRegister(collectors.NewGoCollector())
prometheus.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
}
Теперь вы увидите, как меняется число горутин или частота работы GC.
Мониторинг GC и памяти
Что важно знать про GC в Go
Сборщик мусора в Go — параллельный, инкрементальный, с паузами. Для мониторинга важно:
- длительность пауз GC;
- доля времени, которую процесс тратит на GC;
- общий объем выделенной памяти;
- частота циклов GC.
Все это можно посмотреть через runtime.ReadMemStats или уже в виде метрик (через Prometheus).
Чтение статистики GC из кода
Давайте посмотрим простой пример:
package main
import (
"log"
"runtime"
"time"
)
func logMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Выводим несколько ключевых полей
log.Printf("Alloc = %v MiB", m.Alloc/1024/1024) // Текущая выделенная память
log.Printf("TotalAlloc = %v MiB", m.TotalAlloc/1024/1024) // Сколько всего выделили за всю жизнь
log.Printf("Sys = %v MiB", m.Sys/1024/1024) // Сколько всего попросили у ОС
log.Printf("NumGC = %v", m.NumGC) // Количество циклов GC
}
func main() {
for {
logMemStats()
time.Sleep(10 * time.Second)
}
}
Так вы можете собрать базовую информацию о поведении памяти, даже без внешних инструментов.
Как понять, что GC тормозит приложение
Признаки:
- увеличиваются задержки (latency), особенно при пиках нагрузки;
- в pprof CPU видно много времени в функциях GC (
runtime.gcBgMarkWorker,runtime.mallocgc); - в метриках видно, что доля времени GC растет.
В таких ситуациях обычно:
- уменьшают количество временных аллокаций (повторное использование буферов,
sync.Pool); - избегают большого количества «мелких объектов»;
- пересматривают структуры данных.
Анализ конкуренции и блокировок
Когда вы оптимизировали CPU и память, но приложение все равно «подвисает», возможно, проблема в блокировках:
- долгие ожидания мьютексов;
- блокировки на каналах;
- «узкие» секции критического кода.
Включение профиля блокировок
Чтобы увидеть, где происходят блокировки, нужно включить блок-профиль:
import "runtime"
// В какой-то инициализации
func enableBlockProfile() {
// Устанавливаем частоту семплирования блокировок
runtime.SetBlockProfileRate(1) // 1 означает запись всех блокировок
}
После этого через pprof вы можете посмотреть профиль /debug/pprof/block.
Похожим образом включается профиль мьютексов:
import "runtime"
func enableMutexProfile() {
// Частота семплирования мьютексов
runtime.SetMutexProfileFraction(1)
}
Затем анализируете профиль /debug/pprof/mutex. Там будет видно, в каких функциях горутины проводят много времени в ожидании блокировок.
Трассировка (runtime/trace)
Когда нужен tracing
Профилировщик отвечает на вопрос «где тратится время и ресурсы в среднем». Но иногда вам нужно увидеть конкретный сценарий:
- последовательность событий при одном запросе;
- порядок работы горутин;
- как распределяются задачи по процессорам.
Здесь помогает runtime/trace. Это уже более низкоуровневый инструмент, который показывает:
- когда стартуют и завершаются горутины;
- какие события происходят по каналам;
- как работает планировщик.
Пример использования runtime/trace
Давайте разберем минимальный пример:
package main
import (
"log"
"os"
"runtime/trace"
"time"
)
func worker(id int, ch <-chan int) {
for v := range ch {
// Имитируем обработку
time.Sleep(50 * time.Millisecond)
_ = v
}
}
func main() {
// Создаем файл для трассировки
f, err := os.Create("trace.out")
if err != nil {
log.Fatalf("failed to create trace file: %v", err)
}
defer f.Close()
// Запускаем трассировку
if err := trace.Start(f); err != nil {
log.Fatalf("failed to start trace: %v", err)
}
// Останавливаем трассировку в конце main
defer trace.Stop()
ch := make(chan int)
for i := 0; i < 3; i++ {
go worker(i, ch)
}
for i := 0; i < 20; i++ {
ch <- i
}
close(ch)
time.Sleep(1 * time.Second)
}
После запуска:
go test -run=none -trace trace.out
или:
go tool trace trace.out
Откроется веб-интерфейс, где вы увидите:
- таймлайны горутин;
- события по каналам;
- загрузку по потокам.
Там удобно разбирать сложные конкурентные сценарии.
Организация процесса мониторинга
Как встроить мониторинг в рабочий цикл
Мониторинг производительности — это не разовая операция, а постоянный процесс. На практике удобно действовать так:
На этапе разработки
- пишете бенчмарки для критичных функций;
- периодически гоняете
go test -benchсpprof; - оцениваете базовые метрики (latency, RPS).
На этапе тестового окружения
- включаете
net/http/pprof(можно закрыть по IP или паролю); - настраиваете Prometheus и Grafana;
- прогоняете нагрузочные тесты и смотрите, как ведут себя метрики.
- включаете
В продакшене
- собираете метрики постоянно (Prometheus или аналоги);
- включаете pprof и tracing точечно, по необходимости;
- анализируете пиковые нагрузки и регрессии.
Типичные паттерны работы
Смотрите, удобный паттерн выглядит так:
- замечаете рост задержек на графиках;
- открываете pprof CPU/heap и ищете «тяжелые» функции;
- точечно оптимизируете (уменьшаете аллокации, убираете лишние копирования);
- повторяете нагрузочный тест и сравниваете профили «до» и «после».
Очень важно всегда сравнивать именно до и после. Изолированный профиль иногда вводит в заблуждение — вы можете оптимизировать участок, который и так не был узким местом.
Антипаттерны в мониторинге
Несколько моментов, на которые стоит обратить внимание:
- Собирать метрики слишком детально
- Например, использовать в label-ах userid, requestid и т.п.
- Это приводит к огромному количеству временных рядов и проблемам с хранилищем метрик.
- Оставлять pprof открытым наружу
- Эндпоинты
/debug/pprofмогут «палить» внутреннее устройство и сильно нагружать приложение при профилировании. - Лучше ограничить их только для внутренних сетей или за аутентификацией.
- Эндпоинты
- Оптимизировать без измерений
- Когда вы меняете код «потому что так быстрее», но не подтверждаете это цифрами, есть риск ухудшить ситуацию.
Заключение
Мониторинг производительности в Go — это комбинация нескольких уровней:
- точечные измерения времени выполнения;
- профилировщик pprof для глубокого анализа CPU, памяти и блокировок;
- метрики (Prometheus) для постоянного наблюдения за системой;
- трассировки (runtime/trace и внешние системы) для сложных сценариев.
Смотрите, ключевая идея здесь в том, чтобы каждую гипотезу подтверждать данными. Перед тем как «оптимизировать» код, вы:
- фиксируете текущие показатели (профили, метрики, бенчмарки);
- вносите изменения;
- повторно измеряете и сравниваете.
Так вы постепенно строите систему, в которой проблемы с производительностью не являются сюрпризом, а становятся просто задачами с понятными шагами решения.
Частозадаваемые технические вопросы
Как совместить pprof и Prometheus на одном HTTP-сервере
Можно использовать один http.ServeMux и повесить на него и /metrics, и /debug/pprof/*.
Мини-инструкция:
- Импортируйте
net/http/pprofиpromhttp. - Создайте
ServeMuxи зарегистрируйте хендлеры:mux.Handle("/metrics", promhttp.Handler())mux.HandleFunc("/debug/pprof/", pprof.Index)и другие.
- Передайте
muxвhttp.ListenAndServe.
Как безопасно открыть pprof в продакшене
Рекомендуется:
- Слушать pprof-сервер только на
localhostили во внутренней сети. - Закрыть доступ на уровне reverse-proxy (nginx, Traefik) или firewall.
- При необходимости повесить простую Basic Auth на
/debug/pprof/*.
Либо поднимать pprof на отдельном порту и пробрасывать к нему доступ только с админских машин.
Как измерить p95 и p99 без Prometheus
Если Prometheus недоступен:
- Собирайте длительность запросов в слайс (осторожно с памятью).
- Периодически сортируйте слайс и берите элемент с индексом
int(0.95*len(data))иint(0.99*len(data)). - После расчета очищайте слайс или используйте скользящее окно (ring-buffer).
Лучше всего сделать отдельный воркер, который раз в N секунд обрабатывает накопленные данные.
Как профилировать только один конкретный HTTP-запрос
Подход:
- Добавьте в код флаг (например, через query-параметр
?profile=1). - Внутри обработчика при наличии флага:
- запускайте
pprof.StartCPUProfileв файл; - выполняйте оставшуюся часть логики;
- в
deferостанавливайтеpprof.StopCPUProfile.
- запускайте
- Так вы получите профиль, относящийся именно к одному запросу.
Используйте аккуратно, такой запрос сам по себе станет тяжелым.
Как профилировать бенчмарки с go test
Инструкция:
- Добавьте бенчмарк в
_test.goсfunc BenchmarkXxx(b *testing.B). - Запустите:
go test -bench=BenchmarkXxx -run=^$ -cpuprofile=cpu.out- или
-memprofile=mem.out.
- Анализируйте:
go tool pprof cpu.outgo tool pprof mem.out.
Так вы получите профили именно на бенчмарк, а не на реальное приложение.