Перейти к содержанию

Утечка памяти / 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.

Содержание

Симптомы

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 сейчас и через час.

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.md). Никакой 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.md. 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.md).
  • После деплоя фикса: heap/goroutine graph плоский под трафиком.
  • Incident log с root cause.

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