Утечка памяти / goroutine
Runbook: сервис со временем растёт по памяти и не освобождает, OOM-kill
от Kubernetes, go_memstats_heap_inuse_bytes график только вверх, или
go_goroutines уходит за тысячи. Эта страница — как локализовать
источник через pprof и починить.
Reference по observability — в
../conventions/observability. Про
context, shutdown и goroutine lifecycle — в
../conventions/shutdown.
Содержание
- Симптомы
- Быстрая классификация
- 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 —
через 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/heapFlag’и для разных 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 сейчас и через час.
go tool pprof -http=:8080 -base heap-14h00.pprof heap-15h00.pprof-base показывает разницу: что прибавилось. Если в diff одна
функция держит +500MB за час — это утечка.
Аналогично для goroutines.
Типовые причины: heap
a) Неограниченный in-memory cache
Симптом. map[string]*User накапливает записи, никогда не
удаляет.
Фикс. Cache — всегда с bounded-ом (LRU, TTL) или через Redis
(см. ../conventions/caching). Никакой
map без delete() в долгоживущем процессе.
b) Response body не закрыт
Симптом. http.Response не закрыт → keep-alive соединение
держится → буферы в http.Transport.
// ПЛОХО
resp, _ := client.Get(url)
data, _ := io.ReadAll(resp.Body)
// забыл resp.Body.Close()Фикс.
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
// ...Линтер bodyclose (в golangci-lint preset) ловит это на CI.
c) pgx.Rows не закрыт
Симптом. pool исчерпан, при этом запросы не идут. pg_stat_activity
показывает idle-соединения.
// ПЛОХО
rows, _ := pool.Query(ctx, q)
for rows.Next() { ... }
// rows.Close() не вызванФикс.
rows, err := pool.Query(ctx, q)
if err != nil { return err }
defer rows.Close()Без 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 накапливаются.
// ПЛОХО
client := &http.Client{} // zero timeoutФикс. Всегда 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
больше никто не будет.
// ПЛОХО
ch := make(chan int)
go func() {
val := <-ch // никогда не получит — sender забыт
}()Фикс. Всегда парой: либо close(ch), либо ctx + select:
select {
case val := <-ch:
use(val)
case <-ctx.Done():
return ctx.Err()
}d) Kafka consumer не закрывается при shutdown
Симптом. При SIGTERM goroutine читает pubSub.Messages(), но
pubSub не закрыт — goroutine висит.
Фикс. Правильный shutdown — см.
../conventions/shutdown. router.Close()
закрывает все message-channel’ы → handler-goroutines завершаются.
e) time.Tick без освобождения
// ПЛОХО
func worker() {
for t := range time.Tick(time.Second) {
do(t)
}
}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). - После деплоя фикса: heap/goroutine graph плоский под трафиком.
- Incident log с root cause.
Связанные разделы
../conventions/observability—go_goroutines,go_memstats_*, включение pprof.../conventions/shutdown— корректное завершение goroutines при SIGTERM.../how-to/profile-service— CPU и memory profiling через pprof, continuous profiling.../conventions/caching— bounded caches через Redis.kafka-consumer-stuck— если goroutines накапливаются на блокированном handler’е.test-hangs— те же паттерны в тестах, ловятся раньше prod.