Профиль памяти в Go: разбор утечки на pprof и heap
Сервис на Go рос по RSS с 200 МБ до 3 ГБ за сутки и падал по OOM. Разбираем, как pprof heap показал реальный источник и чем отличается «утечка» в Go от классической.

Сервис-агрегатор на Go 1.22 держал стабильные 200 МБ RSS первые часы после деплоя, а через сутки доходил до 3 ГБ и убивался OOM-киллером в Kubernetes. Перезапуск возвращал память к 200 МБ, дальше цикл повторялся. Классический сборщик мусора в Go не должен допускать бесконечного роста, если объекты действительно недостижимы — значит, что-то удерживало ссылки. Ниже — как через pprof нашли источник и почему runtime.MemStats сначала уводил в сторону.
Что мерили и на чём
Инцидент воспроизводился на 4 vCPU / 4 ГБ, входящий поток около 300 RPS, тело ответов от внешних API — от 2 до 40 КБ. Go 1.22, сборка без CGo. Первое, что сделали — сняли метрики рантайма, не выключая трафик.
В Go важно различать три величины, потому что их часто путают, и на этой путанице теряют часы:
- RSS — сколько физической памяти держит процесс с точки зрения ОС. Именно по нему бьёт OOM-киллер.
- HeapAlloc — сколько байт занято живыми объектами в куче Go прямо сейчас.
- HeapInuse / HeapReleased — сколько кучи зарезервировано у ОС и сколько возвращено.
Расхождение между HeapAlloc и RSS — нормальная ситуация: рантайм не всегда сразу отдаёт страницы ОС. Но если растёт именно HeapAlloc и не падает после GC — это уже удержание живых объектов, а не особенность возврата памяти.
Снимаем MemStats без профилировщика
Первый замер — прямо в коде, чтобы понять, куда смотреть: в кучу Go или мимо неё.
package main
import (
"fmt"
"runtime"
)
// логируем ключевые счётчики рантайма
func dumpMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc = %d MiB\n", m.HeapAlloc/1024/1024)
fmt.Printf("HeapInuse = %d MiB\n", m.HeapInuse/1024/1024)
fmt.Printf("HeapReleased = %d MiB\n", m.HeapReleased/1024/1024)
fmt.Printf("NumGC = %d\n", m.NumGC)
}
На пике HeapAlloc показывал около 2.6 ГБ и не снижался после принудительного runtime.GC(). Это сразу отсекло гипотезу «рантайм просто не отдаёт память ОС»: объекты были живыми, кто-то держал на них ссылку.
Heap-профиль: где живут байты
Дальше подключили net/http/pprof. Достаточно импортировать пакет ради его side-эффекта — он регистрирует обработчики на стандартном mux.
import (
"net/http"
_ "net/http/pprof" // регистрирует /debug/pprof/*
)
func init() {
// отдельный порт для профилировщика, не смешиваем с прод-трафиком
go func() {
_ = http.ListenAndServe("localhost:6060", nil)
}()
}
Снимок кучи с работающего пода снимается так:
# inuse_space — что занято живыми объектами прямо сейчас
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
Здесь важен выбор режима профиля. У heap-профиля их четыре, и разбор инцидента зависит от того, какой вы смотрите:
- inuse_space — байты живых объектов. Это то, что нужно при подозрении на утечку.
- inuse_objects — количество живых объектов. Полезно, когда течёт много мелких структур.
- alloc_space — сколько всего аллоцировано за время жизни процесса. Показывает нагрузку на GC, а не утечку.
- alloc_objects — сколько объектов создано всего.
Частая ошибка — смотреть alloc_space при поиске утечки и удивляться гигантским цифрам у функции, которая на самом деле давно всё освободила. Для удержания памяти нужен именно inuse_space.
Что показал профиль
В top первой строкой шёл bytes.(*Buffer).grow, а по list и web путь вёл к самописному кешу ответов внешних API. Кеш был map[string][]byte без ограничения размера и без вытеснения. Ключом служил полный URL с query-параметрами, а параметры включали таймстемп — так что каждый запрос создавал новый ключ, и карта росла бесконечно.
Утечки в Go — это почти всегда не «забыли free», а живая ссылка, которую забыли отпустить: элемент в глобальной map, подписка в канале, горутина, зависшая на чтении. Сборщик честно не трогает то, что достижимо.
Почему GC не помогал
Сборщик мусора в Go освобождает только недостижимые объекты. Пока карта-кеш жила в глобальной переменной и на неё был путь от корней, все её значения считались живыми. Формально утечки в терминах «висячего указателя» не было — была неограниченная структура, которую программа сама наполняла и не чистила.
Отдельный сбивающий фактор: GOGC по умолчанию 100. При растущем живом наборе GC запускается реже относительно абсолютного объёма, паузы становятся заметнее, а RSS ещё какое-то время держится выше HeapAlloc из-за отложенного возврата страниц. Это создаёт иллюзию, что «GC не справляется», хотя проблема — в приложении.
Сравнение симптомов и причин
| Симптом | На что смотреть | Вероятная причина |
|---|---|---|
| HeapAlloc растёт и не падает после GC | inuse_space heap | Живые ссылки: рост map/slice, зависшие горутины |
| HeapAlloc стабилен, RSS выше и не снижается | HeapReleased, MADV-политика | Отложенный возврат страниц ОС, фрагментация |
| Пилообразный RSS, высокий alloc_space | alloc_space, частота GC | Много короткоживущих аллокаций, давление на GC |
| Растёт число горутин | /debug/pprof/goroutine | Горутины виснут на канале или сетевом чтении |
Как локализовали окончательно
Одного снимка мало: он показывает состояние, а не динамику. Чтобы доказать рост, сняли два heap-профиля с интервалом 15 минут и сравнили их.
# базовый снимок
curl -s http://localhost:6060/debug/pprof/heap > base.pprof
# через 15 минут под нагрузкой
curl -s http://localhost:6060/debug/pprof/heap > later.pprof
# diff: что выросло между снимками
go tool pprof -inuse_space -base base.pprof later.pprof
В diff-профиле кеш-карта прибавила около 400 МБ за 15 минут — линейный рост без плато. Это и был доказанный источник: не разовый всплеск, а монотонное накопление.
Что исправили
- Убрали таймстемп из ключа кеша — из-за него каждый запрос был уникальным.
- Заменили бесконечную
mapна LRU с фиксированным лимитом записей. - Добавили TTL, чтобы старые ответы вытеснялись даже без переполнения.
- Вынесли снятие heap-профиля в регламент: два снимка с интервалом и diff при любом подозрении на рост RSS.
После деплоя RSS стабилизировался в районе 300–350 МБ и перестал расти по времени. OOM-рестарты прекратились.
Где такой подход ломается
Heap-профиль видит только память, которой управляет рантайм Go. Он не покажет:
- память, выделенную в C через CGo — она мимо кучи Go и не попадёт в inuse_space;
- mmap-регионы и буферы, которые держит ядро от вашего имени;
- рост из-за зависших горутин — их стеки стоит смотреть отдельно через
/debug/pprof/goroutine, а не heap.
Ещё одна граница: heap-профиль по умолчанию сэмплирующий (одна выборка примерно на каждые 512 КБ аллокаций). Мелкие, но многочисленные объекты он покажет статистически, а не поштучно — для точности сравнивайте относительные величины между снимками, а не абсолютные байты в одном.
Что делать дальше
Если у вас похожая картина роста RSS: снимите два heap-профиля в режиме inuse_space с интервалом и сравните через -base — это отделит утечку от отложенного возврата страниц за пять минут. Параллельно держите на графиках go_memstats_heap_alloc_bytes и число горутин: расхождение RSS и HeapAlloc при стабильном HeapAlloc — это про возврат памяти ОС, а не про приложение. Для непрерывного профилирования в проде посмотрите в сторону continuous profiling — снимать снимки руками при каждом инциденте быстро надоедает.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Profiling Go Programs — The Go Blog, net/http/pprof — Go Package Documentation
Частые вопросы
А если у меня Go 1.20 или старее — команды pprof те же?
Профиль не показал ни одной подозрительной функции, а RSS всё равно растёт. Что делать?
Опасно ли включать pprof в проде?
Чем inuse_space отличается от alloc_space и что брать при поиске утечки?
Помогает ли ручной вызов runtime.GC() или изменение GOGC от утечки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.