Go-сервис падает по OOM раз в сутки, а мониторинг честно рисует график растущей памяти и молчит о причине. Знакомая картина? В другой экосистеме на этом месте началась бы установка тяжёлого агента и перезапуск процесса с потерей состояния. В Go профилировщик встроен в сам рантайм: снимки делаются у живого продакшен-процесса по HTTP, без изменения логики и без остановки. pprof показывает точные стеки, в которых сгорает процессорное время, и точные строки кода, где рождаются объекты, съедающие память. Ниже разобран полный цикл диагностики: подключение к сервису, чтение процессорных снимков, работа с пламенными графами и поиск утечек по разнице двух дампов кучи.
Что умеет встроенный профилировщик Go без внешних инструментов
Инструментарий живёт в двух пакетах. Пакет runtime/pprof подходит программам, которые запускаются и завершаются: скрипты, батч-задачи, бенчмарки. Пакет net/http/pprof добавляет HTTP-эндпоинты к долгоживущему сервису и снимает те же данные через обычный GET-запрос. Оба выдают сжатый protobuf с расширением .pb.gz, оба читаются штатной командой go tool pprof, которая идёт вместе с компилятором. Продвинутая сборка того же инструмента развивается в проекте google/pprof и ставится командой go install github.com/google/pprof@latest, но для большинства задач хватает встроенной версии.
Рантайм собирает профили шести видов:
1. cpu фиксирует стеки горутин, активно исполняющихся в момент выборки;
2. heap отражает статистическую выборку аллокаций и текущий состав кучи;
3. goroutine выводит стеки всех живых горутин вместе с их состояниями;
4. threadcreate показывает, где программа порождает потоки операционной системы;
5. block вскрывает места ожидания на каналах и примитивах синхронизации;
6. mutex указывает конкретные точки конкуренции за блокировки.
Профили block и mutex по умолчанию выключены, их включают вызовами runtime.SetBlockProfileRate и runtime.SetMutexProfileFraction. Остальные доступны сразу после импорта пакета. Эндпоинт /debug/pprof/trace отдаёт трассировку исполнения, но это отдельный инструмент: трасса отвечает на вопросы планировщика, профили отвечают на вопросы стоимости. Есть и кастомные профили через pprof.NewProfile, когда команде нужно считать события собственной природы, например обращения к стороннему кэшу.
Безопасное подключение pprof к живому сервису в продакшене
Код подключения умещается в несколько строк:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
if err := run(); err != nil {
log.Fatal(err)
}
}
Пустой импорт со знаком подчёркивания регистрирует обработчики на DefaultServeMux, отдельный роутинг писать не нужно. Порт 6060 слушают на локальном или внутреннем интерфейсе: эндпоинт отдаёт стеки вызовов, значения меток и сведения об объектах в памяти. Выносить его в публичную сеть нельзя, на практике порт закрывают фаерволом или шлюзом с проверкой источника запроса.
Батч-задачам HTTP ни к чему, там применяют флаги командной строки:
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
func main() {
flag.Parse()
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
if err != nil {
log.Fatal(err)
}
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
run()
}
Вызов StopCPUProfile обязателен: без него буфер профилировщика не сбрасывается на диск, и файл останется пустым. defer в main закрывает задачу при любом исходе.
Снимок процессорного времени и чтение топа функций
Процессорный профиль снимается одной командой:
go tool pprof -seconds 30 http://127.0.0.1:6060/debug/pprof/profile
Через 30 секунд откроется интерактивная оболочка pprof. Рантайм делает примерно 100 выборок в секунду, каждая выборка - это стек горутины, работавшей в тот миг. За полминуты набегает около 3000 сэмплов, и функция, съедающая десятую часть процессорного времени, наберёт около 300 сэмплов. Точность пропорциональна окну измерения: пятисекундный снимок покажет только шум.
Команда top10 перечисляет главных потребителей:
(pprof) top10
Total: 2984 samples
312 10.4% 10.4% 312 10.4% runtime.mapaccess1_fast64
268 9.0% 19.4% 2124 71.2% main.FindLoops
201 6.7% 26.1% 445 14.9% runtime.mallocgc
188 6.3% 32.4% 188 6.3% runtime.memmove
172 5.8% 38.2% 990 33.2% main.DFS
Первые две колонки называют плоский (flat) вклад: сколько сэмплов функция провела на вершине стека. Колонки четвёртая и пятая показывают кумулятивный (cum) вклад: сколько сэмплов функция встретила в стеке на любой глубине, включая работу вызванных детей. main.FindLoops сама исполнялась 9% времени, а её поддерево съело 71.2%. Виновник почти всегда прячется в cum, локальный вклад ищут во flat. Ключ -cum пересортировывает топ по кумулятивному столбцу и находит точки ветвления, команда list main.DFS разворачивает профиль вплоть до строк исходника с процентами у каждой, peek подсказывает, кто зовёт функцию и кого зовёт она.
Есть тонкость, экономящая часы работы. Процессорный профиль видит только активный код. Если сервис тормозит, а процессор загружен на 15%, искать горячие функции бессмысленно: время утекает в ожидание, и инструментом становится профиль блокировок или трасса, но никак не CPU.
Flame graph и веб-интерфейс go tool pprof
Текстовый топ отвечает на вопрос "кто виноват", но не показывает форму дерева вызовов. Ключ -http поднимает локальный веб-интерфейс:
go tool pprof -http=:8080 cpu.pb.gz
Интерфейс открывает виды TOP, GRAPH, FLAME GRAPH, PEEK и SOURCE. Пламенный граф читается так: каждая полоса - функция, ширина полосы - доля сэмплов, стек растёт снизу вверх, родитель внизу, дети сверху. Широкое плато из полос одинаковой ширины означает горячий цикл, который уже не дробится на вызовы: функций-детей внутри нет, всё сгорает в самих строках цикла. Клик по полосе приближает поддерево, поиск по имени подсвечивает вхождения по всему графу.
Классическая картина выглядит так: обработчик HTTP зовёт сериализацию ответа, сериализация уходит в encoding/json, половину стека занимают вызовы рефлексии из пакета reflect, а львиная доля сэмплов падает на runtime.mallocgc. Каждое превращение структуры в JSON через рефлексию аллоцирует промежуточные интерфейсы и слайсы байтов. Дальше варианты: сгенерированный сериализатор без рефлексии, кэш готовых ответов, пересмотр схемы данных. Главное - по графу понятно, что чинить, ещё до первой строчки изменений.
Поиск утечек памяти по профилям кучи и разнице снимков
Профиль кучи - выборочный. По умолчанию рантайм регистрирует примерно одну аллокацию на каждые 512 КиБ выделенной памяти, за это отвечает runtime.MemProfileRate со значением 524288 байт. Для рутинной диагностики этого достаточно. Точность растёт с уменьшением порога вплоть до единицы, когда профилировщик видит каждую аллокацию, но каждая единица замедляет программу, поэтому детальный режим включают короткими окнами.
Индексов четыре, и они отвечают на разные вопросы. inuse_space показывает байты, живущие в куче прямо сейчас, он главный инструмент при поиске утечек. inuse_objects считает живые объекты. alloc_space копит все аллокации с момента старта и после сборки мусора не убывает, alloc_objects делает то же для штук. Для снижения давления на GC смотрят alloc_space, для забытых ссылок ищут inuse_space. Переключение выполняется ключом -sample_index=alloc_space.
Классическая диагностика строится на разнице двух снимков:
curl -s "http://127.0.0.1:6060/debug/pprof/heap?gc=1" -o /tmp/heap_t1.pb.gz
sleep 3600
curl -s "http://127.0.0.1:6060/debug/pprof/heap?gc=1" -o /tmp/heap_t2.pb.gz
go tool pprof -base /tmp/heap_t1.pb.gz /tmp/heap_t2.pb.gz
Параметр gc=1 заставляет рантайм пройти полный цикл сборки мусора перед снимком, чтобы в дамп не попали объекты-однодневки. Ключ -base строит разницу: pprof показывает только то, что прибавилось за час, сортировка по cum моментально выводит на виновника. Есть и серверный вариант: параметр seconds=3600 на эндпоинте heap отдаёт дельту за час одним запросом.
Типовые находки знакомы каждому, кто хоть раз гонял дифф. За час куча прибавила 400 МБ, весь прирост сидит в map[string]*sessionCache: ключи добавляются в обработчике входа, удаления по таймауту нет, вытеснения устаревших записей никто не завёл. Второй паттерн - горутина, навсегда повисшая на пустом канале и держащая слайс на сотню мегабайт: в диффе кучи она видна как bytes.Buffer, в профиле горутин - как вечное ожидание. Утечка горутин и утечка памяти часто одна история, рассказанная дважды.
Диагностика зависших горутин и конкуренции за мьютексы
Эндпоинт goroutine работает в двух режимах. debug=1 группирует одинаковые стеки и печатает счётчики:
curl -s "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1" | head -3
goroutine profile: total 15234
Пятнадцать тысяч горутин у сервиса, которому хватает пары сотен, - приговор, подписанный самим рантаймом. Режим debug=2 печатает полные стеки с временем ожидания. Ищут строки вида chan receive, 142 minutes: горутина, ждущая полтора часа на пустом канале, почти наверняка не дождётся никогда. Причина в девяти случаях из десяти - родитель создал контекст с отменой и забыл вызвать cancel, либо обработчик вышел по таймауту, а фоновая горутина осталась держать буфер.
Профиль блокировок включается двумя строками в main:
func main() {
runtime.SetBlockProfileRate(1_000_000) // одна выборка на 1 мс ожидания
runtime.SetMutexProfileFraction(1) // фиксировать каждый конфликт
// ...
}
Читаются снимки с тех же эндпоинтов:
go tool pprof http://127.0.0.1:6060/debug/pprof/block
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
Профиль mutex показывает, кто держит блокировку и кто на ней выстроился в очередь. Под ростом нагрузки это единственный способ отличить честное ожидание от затяжной конкуренции на горячем мьютексе.
Точности добавляют метки, появившиеся в Go 1.9. Запрос помечают перед входом в обработчик:
pprof.Do(ctx, pprof.Labels("endpoint", "/api/v1/upload"), func(ctx context.Context) {
processUpload(ctx)
})
После этого в веб-интерфейсе pprof расходы фильтруются по метке: сразу видно, какой эндпоинт сжигает процессор или аллоцирует больше прочих, без слепого перебора по стекам.
Цена профилирования и регулярные снимки в эксплуатационной практике
Профилирование небесплатно. Постоянно включённый CPU-профиль снижает пропускную способность, детальная запись аллокаций замедляет сервис, а одновременный съём нескольких профилей искажает картину: точный профиль памяти портит процессорный, профиль блокировок мешает трассировке планировщика. Правила простые: снимать один профиль за раз, тяжёлые режимы включать короткими окнами, штраф замерять заранее на стенде, а не выяснять по просадке метрик в продакшене.
Рабочая схема для фермы однотипных реплик выглядит так: раз в час выбирать случайную реплику и снимать 30 секунд процессорного профиля, раз в сутки забирать пару дампов кучи, складывать файлы .pb.gz в объектное хранилище и автоматически сравнивать со вчерашним. Утечка, которая копится неделю, ловится по суточному диффу ещё до срабатывания аларма по памяти. Бенчмарки живут по тем же правилам:
go test -bench=Serialize -memprofile mem.pb.gz -cpuprofile cpu.pb.gz ./...
Флаги -blockprofile и -mutexprofile у go test включают соответствующие снимки в бенчмарках, а переменная окружения GODEBUG=gctrace=1 печатает сводку каждой сборки мусора в stderr и помогает увидеть частоту GC ещё до всякого профилирования.
pprof не экстренное спасение, а приборная панель сервиса. Метрики мониторинга говорят "стало хуже", стек из профиля называет строку кода. Сервис, у которого снимают профили еженедельно, деградирует медленнее и чинится быстрее: каждая находка ложится в копилку команды, а разница двух дампов кучи превращает спор о гипотезах в одну команду go tool pprof -base. В конечном счёте ценят именно это: не обещание быстрого кода, а способ найти конкретное место, за которое платят процессор и память.