Утечка памяти / goroutine¶
Runbook: сервис со временем растёт по памяти и не освобождает, OOM-kill
от Kubernetes, go_memstats_heap_inuse_bytes график только вверх, или
go_goroutines уходит за тысячи. Эта страница — как локализовать
источник через pprof и починить.
Reference по observability — в
../conventions/observability.md. Про
context, shutdown и goroutine lifecycle — в
../conventions/shutdown.md.
Содержание¶
- Симптомы
- Быстрая классификация
- 1. Включить pprof
- 2. Heap profile
- 3. Goroutine profile
- 4. Diff между двумя snapshot'ами
- Типовые причины: heap
- Типовые причины: goroutine
- Временные меры на prod
- Чеклист
- Связанные разделы
Симптомы¶
Heap leak (утечка памяти)¶
process_resident_memory_bytesрастёт монотонно, не возвращается после пиков нагрузки.go_memstats_heap_inuse_bytesтоже растёт.go_memstats_heap_objectsрастёт линейно во времени.- Kubernetes делает OOM-kill: в логах pod'а
Killed, в событияхOOMKilled.
Goroutine leak¶
go_goroutinesрастёт линейно / ступенчато, не снижается.- Отзывчивость сервиса падает (GC чаще, латентность растёт).
- При долгой работе — тоже OOM, но с огромным
runtime.stack(каждая goroutine — 8 KB минимум).
Быстрая классификация¶
Открой Grafana, смотри 3 графика за последние 24 часа:
| Что растёт | Что не растёт | Диагноз |
|---|---|---|
heap_inuse, heap_objects |
goroutines — стабильно |
Heap leak. Кто-то держит ссылки, GC не освобождает. §Heap |
goroutines |
heap_inuse — растёт вместе, но медленнее |
Goroutine leak. Горутины накапливаются. §Goroutine |
| оба растут резко | при пиках трафика | Пиковая нагрузка, не обязательно leak. Проверь, спадают ли значения после пика. |
heap_inuse растёт, но освобождается |
heap_objects стабильно |
Нормально. Pool'ы / arena буферизуют. |
1. Включить pprof¶
Pprof endpoint'ы включены в prod — см.
../conventions/observability.md —
через net/http/pprof:
import _ "net/http/pprof"
// ...
r.Mount("/debug", chimw.BasicAuth("pprof", map[string]string{
"admin": cfg.Debug.PprofPassword,
}))
// либо через отдельный serving port внутренний (не роутится gateway'ем)
В prod pprof доступен через port-forward:
kubectl port-forward deployment/<service> 6060:6060
# теперь pprof на http://localhost:6060/debug/pprof/
Если pprof не включён — это срочная задача, включи и задеплой, без pprof диагностика утечек в Go почти невозможна.
2. Heap profile¶
Снять snapshot¶
# sample памяти «кто аллоцировал то, что ещё живое»
curl -s http://localhost:6060/debug/pprof/heap > heap-$(date +%Y%m%d-%H%M).pprof
# или сразу интерактивно в pprof
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
Flag'и для разных views:
inuse_space(default) — сколько памяти сейчас занято объектами, аллоцированными в функции.inuse_objects— сколько объектов сейчас живых.alloc_space— суммарно аллоцировано с момента старта.alloc_objects— суммарно создано объектов.
Для поиска утечки смотри inuse_space / inuse_objects. Высокое
alloc_space без inuse_space — это hot-path, не утечка.
Чтение pprof UI¶
В браузере http://localhost:8080:
- Top — функции, удерживающие больше всего памяти.
- Graph — call graph; толстые стрелки — большие аллокации.
- Flame Graph — горизонтальная развёртка по call stack'у. Шире — больше памяти.
- Source — подсветит конкретные строчки в Go-коде.
Типичный паттерн утечки: одна функция на top'е с 70%+ inuse_space,
которая не должна столько держать.
3. Goroutine profile¶
Снять snapshot¶
# компактный текст: сколько goroutines в каких состояниях
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' > goroutines.txt
# полный stack-trace каждой goroutine
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutines-full.txt
# для pprof-UI
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
Чтение¶
debug=1 группирует goroutines по identical stack trace и показывает
счётчик:
goroutine profile: total 1247
1200 @ 0x...
# 0x... runtime.gopark+0x...
# 0x... internal/poll.(*FD).Read+0x...
# 0x... net.(*conn).Read+0x...
# 0x... net/http.(*persistConn).readLoop+0x...
1200 goroutines в одной точке → leak. Нормально — 20-50 фоновых goroutines + N по количеству активных запросов.
4. Diff между двумя snapshot'ами¶
Самый надёжный способ увидеть рост: snapshot сейчас и через час.
-base показывает разницу: что прибавилось. Если в diff одна
функция держит +500MB за час — это утечка.
Аналогично для goroutines.
Типовые причины: heap¶
a) Неограниченный in-memory cache¶
Симптом. map[string]*User накапливает записи, никогда не
удаляет.
Фикс. Cache — всегда с bounded-ом (LRU, TTL) или через Redis
(см. ../conventions/caching.md). Никакой
map без delete() в долгоживущем процессе.
b) Response body не закрыт¶
Симптом. http.Response не закрыт → keep-alive соединение
держится → буферы в http.Transport.
Фикс.
Линтер bodyclose (в golangci-lint preset) ловит это на CI.
c) pgx.Rows не закрыт¶
Симптом. pool исчерпан, при этом запросы не идут. pg_stat_activity
показывает idle-соединения.
Фикс.
Без defer rows.Close() соединение возвращается в pool только когда
rows доходит до конца (все строки прочитаны). Если цикл прерван по
return err — утечка.
d) Большие буферы в sync.Pool¶
Симптом. sync.Pool заполняется буферами по 1MB, GOGC чистит
их редко.
Фикс. - Ограничь размер: не клади в Pool буферы > 1KB. - Или не используй Pool для больших объектов — лучше аллокация на каждый запрос, её оптимизирует GC.
e) Прошивка в goroutine-stack¶
Симптом. Ни Heap, ни gc не растут сильно, но process_resident_memory_bytes
— да. Это goroutine-stack, сумма по всем живым goroutine.
Проверка. goroutine profile (§3).
Фикс. §Goroutine.
f) Trace exporter backpressure¶
Симптом. OpenTelemetry batch-exporter не успевает отправлять span'ы в Tempo → буфер растёт.
Фикс.
- В sdktrace.NewBatcher выставь WithMaxQueueSize, WithMaxExportBatchSize.
- Проверь OTLP endpoint: доступен ли, отвечает ли.
g) Goroutine-leak с захватом большого объекта¶
Фактически это goroutine leak, но заметен как heap (goroutine удерживает ссылку на payload, GC не освобождает). См. §Goroutine.
Типовые причины: goroutine¶
a) http.Client без таймаута¶
Симптом. readLoop и writeLoop goroutines накапливаются.
Фикс. Всегда Timeout + Transport:
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
b) context.Background() в handler'е¶
Симптом. Handler делает long-running операцию в новой goroutine
с context.Background() вместо r.Context(). При отмене запроса
goroutine живёт до естественного завершения.
// ПЛОХО
func (h *Handler) Create(w, r) {
go h.svc.Process(context.Background(), ...) // никогда не отменится
w.WriteHeader(202)
}
Фикс. Либо r.Context() (но он отменится, когда response ушёл —
плохо для fire-and-forget), либо outbox: сохрани команду в БД,
обработай в отдельном воркере с собственным lifecycle.
c) Channel без sender'а¶
Симптом. Goroutine заблокирована на <-ch, а отправлять в ch
больше никто не будет.
Фикс. Всегда парой: либо close(ch), либо ctx + select:
d) Kafka consumer не закрывается при shutdown¶
Симптом. При SIGTERM goroutine читает pubSub.Messages(), но
pubSub не закрыт — goroutine висит.
Фикс. Правильный shutdown — см.
../conventions/shutdown.md. router.Close()
закрывает все message-channel'ы → handler-goroutines завершаются.
e) time.Tick без освобождения¶
time.Tick возвращает канал, который нельзя освободить — утечка
ticker'а. Используй time.NewTicker + defer ticker.Stop():
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case t := <-ticker.C:
do(t)
case <-ctx.Done():
return
}
}
f) Запросы без отмены по ctx¶
Симптом. Handler получил timeout от chi middleware, но внутренняя goroutine продолжает работать с исходным ctx, делает SQL, ждёт ответа.
Фикс. Каждая goroutine, порождённая handler'ом, должна получить
production ctx (или child от него) и останавливаться на ctx.Done().
Временные меры на prod¶
Пока идёт разбор, можно снизить давление:
- HPA по памяти —
targetMemoryUtilization: 70%. Новые pod'ы при росте памяти. terminationGracePeriodSeconds: 60— даёт сервису корректно сдрейнить при рестарте.- CronJob рестарта каждые N часов — грязный workaround, но позволяет дождаться фикса без OOM. Только как временная мера, с тикетом на реальный фикс. Пропиши в incident log §Частые ошибки.
- Увеличить memory limit pod'а — дольше дотянешь до следующего рестарта. Тоже временная мера.
Не путай «фикс» и «workaround». Перезапуск каждые 6 часов скрывает проблему, утечка никуда не делась.
Чеклист¶
- Grafana-график подтвердил монотонный рост
heap_inuseилиgo_goroutinesза 24+ часов. - pprof доступен (port-forward).
- Снял 2 snapshot'а heap с разницей в час+.
-
go tool pprof -baseпоказал конкретные функции, растущие в diff. - Для goroutine leak:
debug=2показал, на каком stack висят накапливающиеся goroutines. - Причина определена: cache / незакрытый body / rows / channel / ctx.
- Фикс протестирован локально через
go test -raceи проигран под нагрузкой (например, через k6 — см.../how-to/load-test.md). - После деплоя фикса: heap/goroutine graph плоский под трафиком.
- Incident log с root cause.
Связанные разделы¶
../conventions/observability.md—go_goroutines,go_memstats_*, включение pprof.../conventions/shutdown.md— корректное завершение goroutines при SIGTERM.../how-to/profile-service.md— CPU и memory profiling через pprof, continuous profiling.../conventions/caching.md— bounded caches через Redis.kafka-consumer-stuck.md— если goroutines накапливаются на блокированном handler'е.test-hangs.md— те же паттерны в тестах, ловятся раньше prod.