pprof от и до: профили CPU, heap, goroutine и mutex — от живого сервиса до утечки
net/http/pprof превращает прод в самоописывающую лабораторию: CPU на 100 Гц, heap inuse/alloc, goroutine, mutex и block. Флеймграф читается по ширине, diff снимков кучи через -base загоняет утечку в угол, а continuous profiling страхует от непредсказанных инцидентов.
Биллинговый сервис каждые четыре дня перезапускал cron, владеть которым никто не хотел. Память росла на ~200 МБ в день; автор кода уволился; перезапуск был «временным» восемь месяцев назад. Инженер, который в итоге убил этот cron, сделал это двумя curl с разницей в сутки: curl -o monday.pb localhost:6060/debug/pprof/heap, то же во вторник, затем go tool pprof -base monday.pb tuesday.pb. Diff вычел всё стабильное — буферы, кеши, пулы соединений, весь шум, делающий сырой heap-профиль нечитаемым, — и оставил ровно одну растущую грань: 190 МБ/день inuse_space, аллоцированных в audit.(*Recorder).Append и удерживаемых пакетным слайсом, в который фича «audit trail» писала, а читать не читал никто. Два снимка, одно вычитание — и восемь месяцев тайны исчезли. Профиль всё это время лежал на порту 6060, в двух флагах от любого, кто спросит. pprof — не кризисный инструмент, а постоянный прибор; кризис — лишь момент, когда о нём вспоминают.
После этого урока вы будете знать, какой профиль отвечает на какой вопрос, как двумя curl-командами закрыть восьмимесячную загадку и почему профайлер должен работать до инцидента, а не во время него.
Инструментируем прод: net/http/pprof по уму
import _ "net/http/pprof" регистрирует хендлеры под /debug/pprof/ — на http.DefaultServeMux, и вот тут кусается. Если основной сервер использует дефолтный mux, ваши профилировочные эндпоинты теперь публичны: смотрящий в интернет /debug/pprof/ сливает имена символов и пути исходников и позволяет любому запускать 30-секундные захваты CPU. Продакшен-паттерн — отдельный внутренний листенер:
import (
"net/http"
_ "net/http/pprof" // регистрируется на http.DefaultServeMux
"runtime"
)
func main() {
runtime.SetMutexProfileFraction(100) // семплировать 1/100 событий contention
runtime.SetBlockProfileRate(10_000) // семплировать блокировки ≥10 мкс (в нс)
go func() {
// только внутренний порт: сеть кластера / localhost, никогда не LB
http.ListenAndServe("127.0.0.1:6060", nil) // отдаёт DefaultServeMux
}()
// основной API-сервер на своём mux и своём порту …
}
// Захват с рабочей машины (в k8s — port-forward):
// go tool pprof -http=:8080 "localhost:6060/debug/pprof/profile?seconds=30"
// go tool pprof -http=:8080 "localhost:6060/debug/pprof/heap"
// curl -o now.pb localhost:6060/debug/pprof/heap # снимок для будущего diffВечное возражение — оверхед, и честные числа его снимают: CPU-профайлер семплирует на 100 Гц и обычно стоит единицы процентов во время захвата — и ничего в простое. Heap-профиль семплируется в момент аллокации (по умолчанию один семпл на ~512 КБ выделенного) и включён всегда; чтение почти бесплатно. Профили mutex и block выключены по умолчанию (fraction/rate = 0) и стоят ровно столько, сколько просит ваша частота семплирования. Держать pprof доступным на внутреннем порту — стандартная практика в любой Go-компании, которую будили пейджером хотя бы дважды.
Меню профилей: какой на какой вопрос отвечает
- CPU (
/debug/pprof/profile?seconds=30): куда уходят такты? Рантайм прерывает каждый исполняющийся поток 100 раз в секунду и записывает стек; профиль — статистическая перепись из 3 000 семплов за 30 с. Он видит только время на CPU — сервис, медленный потому, что ждёт (локи, I/O, GC assist), может показать почти пустой CPU-профиль. - Heap (
/debug/pprof/heap): два взгляда на одни данные, и неверный выбор стоит потерянного дня.inuse_space— байты, живые сейчас, привязанные к точке аллокации: вопрос «что заполняет память?» — утечки, кеши, удержание.alloc_space— накопленные байты за всё время: вопрос «что создаёт давление на GC?» — churn, упаковка fmt из прошлых уроков. Точка может доминировать в alloc_space с нулевым inuse (аллоцировал-и-бросил) и наоборот (один гигантский удерживаемый слайс). - Goroutine (
/debug/pprof/goroutine): все горутины, сгруппированные по одинаковому стеку, — перепись утечек из юнита о конкурентности. - Mutex и block: где конкурируемые локи и ожидания каналов/select сжигают настенное время. Они отвечают на загадку пустого CPU-профиля: сервис, медленный при видимом простое. Оба требуют включения семплирования (код выше) — пустой mutex-профиль обычно значит, что его не включали, а не что contention нет.
Вместе эти четыре профиля — дерево решений: горит CPU — читайте CPU-профиль; растёт память — сравнивайте снимки inuse_space; латентность высокая, а CPU похоже спит — включайте и читайте mutex/block. Без правильного профиля вы гадаете не на том слое.
Память сервиса стабильно растёт. Вы открываете heap-профиль, и наверху по alloc_space — хелпер JSON-кодирования с 4 ТБ накопленных аллокаций. Это ваша утечка?
Чтение флеймграфа: ширина — правда, высота — шум
go tool pprof -http=:8080 profile.pb поднимает интерактивный UI; основное чтение происходит во флеймграфе. Правила: ширина = инклюзивная стоимость (функция плюс всё, что она вызвала), поэтому деньги — в самых широких прямоугольниках; высота — лишь глубина вызовов: высокий узкий пик — это глубокий стек, стоящий копейки, и погоня за ним — классический крюк новичка. Когда открываете флеймграф впервые, удержите инстинкт гнаться за самой высокой башней — ищите вместо неё самое широкое плато. Ищите широкие плато у верхней кромки: блок без детей, занимающий ширину, — это self time, настоящие циклы, memcpy, работа на уровне строк кода — туда и приземляется оптимизация. Два системных хода лучше разглядывания: вид top, отсортированный по flat (своё) против cum (инклюзивное), говорит, дорога ли функция сама или лишь председательствует над дорогими детьми; а «focus» (клик или -focus=regex) перерисовывает граф по одному поддереву, превращая профиль сервиса с 60 потоками в читаемую историю. Повторяющиеся Go-фигуры: широкая башня runtime.gcBgMarkWorker — давление GC (чините аллокации, а не GC); широкий runtime.mallocgc под вашими функциями — то же сообщение с доставкой на дом; плато runtime.futex/runtime.semacquire указывают на contention локов — подтверждайте mutex-профилем, а не CPU.
Рабочий процесс утечки: diff, затем continuous profiling
Ход с двумя снимками из пролога — канонический workflow утечки, и он должен стать механическим рефлексом: снять базу (curl -o base.pb …/heap), подождать роста (час, день — пока сигнал не задавит шум), снять снова и запустить go tool pprof -base base.pb current.pb. Diff вычитает стабильную кучу — пулы, кеши, буферы устоявшегося режима — и остаётся рост, привязанный к точке аллокации. Дальше вопрос всегда один — «кто это удерживает?» — и ответ обычно из списка: append-only структура уровня пакета (пролог), map-кеш без вытеснения, утечки горутин с захваченными буферами (сверьтесь с goroutine-профилем) или тонкое удержание через подслайс большого массива. Continuous profiling (непрерывное профилирование) — этот workflow, превращённый в продукт: агент (Grafana Pyroscope, Parca, профайлеры облачных вендоров) снимает профили раз в несколько минут, хранит их как временной ряд и позволяет делать diff через границу деплоя или спрашивать «что изменилось в 14:32?». Разговор об оверхеде тот же: семплирующие профайлеры на низкой частоте стоят ~1–2%, а выигрыш в том, что профиль до инцидента существует — curl по требованию этого не даст никогда.
p99 вырос втрое, но 30-секундный CPU-профиль почти пуст — широких кадров нет, семплов сильно меньше, чем 30 с × ядра. Куда смотреть дальше?
Расставьте шаги диагностики утечки памяти через pprof в правильном порядке:
- 1 Снять базовый снапшот кучи — curl -o base.pb localhost:6060/debug/pprof/heap — пока рост ещё не перекрыл шум
- 2 Подождать достаточно долго (часы или сутки), чтобы сигнал утечки превысил churn и буферы в постоянном состоянии
- 3 Снять второй снапшот той же командой в current.pb
- 4 Выполнить diff командой go tool pprof -base base.pb current.pb, чтобы вычесть стабильную кучу и выделить рост
- 5 Читать inuse_space, чтобы найти аллокационный сайт, ответственный за рост, затем выяснить, кто удерживает память (глобальный слайс, map без вытеснения или горутина с захваченным буфером)
- 01Сопоставьте каждый тип профиля pprof с продакшен-вопросом, на который он отвечает, и назовите два выключенных по умолчанию.
- 02Опишите workflow утечки через два снимка и почему continuous profiling превосходит версию по требованию.
pprof — постоянный прибор, а не кризисный инструмент: импортируйте net/http/pprof, отдавайте его на отдельном внутреннем листенере (грабли с дефолтным mux — это публичный профайлер) и включайте семплирование mutex/block на старте, потому что эти два профиля молчат, пока их не включат. Каждый профиль отвечает ровно на один вопрос: CPU семплирует стеки на процессоре на 100 Гц и не видит ожиданий; heap делится на inuse_space (что удерживается — метрика утечек) и alloc_space (что когда-либо выделялось — метрика давления на GC, где прячется churn); goroutine пересчитывает припаркованных; mutex и block меряют простои настенным временем. Флеймграф читается по ширине: инклюзивная стоимость — в ширине блока, своё время — в бездетных плато; системные ходы — flat против cum и focus; башни GC и ширина mallocgc означают «чините аллокации», плато futex — «читайте mutex-профиль». Workflow утечки — два снимка и вычитание: pprof -base стирает стабильную кучу и называет растущую точку аллокации, как с восьмимесячным перезапускным cron биллинга. Continuous profiling крутит эту петлю постоянно за 1–2% оверхеда, сохраняя профиль, который захват по требованию не даст никогда, — из времени до поломки. И диагностическая загадка, которую стоит выучить наизусть: латентность растёт, CPU-профиль пуст — сервис ждёт, и ответ лежит в профилях mutex, block и goroutine.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.