Skip to Content
TroubleshootingMemory / goroutine leak

Утечка памяти / goroutine

Runbook: сервис со временем растёт по памяти и не освобождает, OOM-kill от Kubernetes, go_memstats_heap_inuse_bytes график только вверх, или go_goroutines уходит за тысячи. Эта страница — как локализовать источник через pprof и починить.

Reference по observability — в ../conventions/observability. Про context, shutdown и goroutine lifecycle — в ../conventions/shutdown.

Содержание

Симптомы

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_objectsgoroutines — стабильноHeap leak. Кто-то держит ссылки, GC не освобождает. §Heap
goroutinesheap_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/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 сейчас и через час.

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.

Связанные разделы

Last updated on