golang

Go profiling lifecycle: от разработки до прода. Инструменты и практики

  • суббота, 22 ноября 2025 г. в 00:00:08
https://habr.com/ru/articles/968660/

Привет, Хабр! В данной статье хотел бы раскрыть тему - почему на 'младших' стендах api работает стабильно, но в проде начинаются проблемы: рост памяти, кол-во горутин множится, и через несколько часов - просадка производительности, gc не справляется, out of memory killer и т. д.

Давайте разберемся, что разработчику может помочь, чтобы он мог спать спокойно после деплоя своего решения. Попробуем детально разобраться в природе утечек ресурсов, научимся находить их с помощью профилировщиков и построим систему защиты от самых распространённых паттернов утечек.

Что внутри

Разобьем на несколько частей, в 1-ой части:

  • немного пробежимся по теории: вспомним как работает gc, планировщик и модель памяти go;

  • pprof, trace, системная диагностика;

  • практика: алгоритм действий, скрипты, на что обратить внимание;

  • runtime tracing для сложных случаев.

Во 2-ой части:

  • разберем частые и типичные утечки с разбором причин и решений;

  • разберем реальный кейс в проде - api с большим кол-ом зависших горутин;

  • вспомним про graceful shutdown, rate limiting, circuit breaker и причем тут они;

  • поговорим какие метрики собирать и как их мониторить в реальном времени;

  • сюда же включим защитные паттерны и продвинутые техники.

Содержимое 1ой части:

1: Теоретические основы

1.1 Модель памяти и gc

Go использует concurrent mark-and-sweep gc с алгоритмом tri-color marking. Это важно понимать, потому что утечки памяти напрямую влияют на производительность gc.
Ключевые моменты:

  • Сборщик мусора помечает объекты тремя цветами: белый(не просканирован), серый(просканирован, но ссылки из него еще нет), черный(полностью обработан);

  • stw(stop the world) фазы - gc стопит все горутины на короткие промежутки: в начале для инициализации, а в конце для финализации;

  • wb(write barriers) - механизм отслеживания изменений указателей во время работы gc Почему утечки критичны:

рост heap -> gc работает чаще -> stw паузы длиннее -> ловим рост latency -> api-шка деградирует -> боль и грусть. 

При утечке памяти heap растёт, gc вынужден сканировать всё больше объектов, паузы увеличиваются. Это прямо влияет на latency api.

Например: heap вырос с 500mb до 8gb за 2 часа -> 
gc работает каждые 10 секунд вместо минуты -> 
stw паузы выросли с 1ms до 50ms -> 
p99 latency увеличилась с 100ms до 2s

1.2 Вспоминаем про модель горутин и планировщик

go runtime использует m:n scheduler - отображение множества горутин на множество os threads. Здесь не будем останавливаться на внутрянке разделений физических и логических ядер процессора. Подсвечу лишь основные компоненты планировщика go:

  • g(горутина): легковесный поток выполнения:

    • stack ~2kb(растет динамически);

    • переключение контекста на порядок выше чем os треда, примерно 200 наносекунд.

  • m(machine): os тред, который выполняет горутины:

    • создаётся runtime по требованию;

    • ~8mb stack.

  • p (processor): контекст выполнения:

    • кол-во = GOMAXPROCS (по умолчанию = cpu cores);

    • содержит local run queue горутин.

Что важно помнить - горутина сама не завершится. Если она заблокирована или зациклена, то будет жить вечно, потребляя:

  • минимум 2kb памяти (stack);

  • дескрипторы, если работает с i/o;

  • cpu время, если зациклена.

1.3 Разберем типологию утечек ресурсов

  • Первый тип - утечка горутин или goroutine leaks. Горутина остаётся в памяти, не завершая выполнение. Здесь поможет гпт-ка привести пример кода, оставлю лишь ситуации: блокировка на chan операциях, бесконечное ожидание syscall, забытые тикеры.

  • Второй тип - memory leaks. Объекты в heap не освобождаются gc. Пример: глобальные коллекции растут бесконечно, замыкания удерживают большие структуры, кэши без eviction(инвалидации - отсутствия ttl, lru/lfu и тд).

  • Третий тип - resource leaks. Системные ресурсы не освобождаются. Пример: файловые дескрипторы, tcp соединения, db connections pool exhaustion.

2: Практика. Инструментарий диагностики

Перейдем к практике, чем пользуюсь сам и что действительно помогает.

2.1 Профилирование с pprof

Первым делом добавим профилирование:

  • import side-effect _ "net/http/pprof";

  • добавляем профилирование мьютексов и блокировок;

func init() {
    runtime.SetMutexProfileFraction(1)
    runtime.SetBlockProfileRate(1)
}
  • не забываем прокинуть pprof на отдельный порт - служба информационной безопасности при пентестах обращает внимание. Кратко подсвечу как обращаться в таком случае к "ручкам" pprof(форвордим порты в кубе, ssh туннели к серверу, internal load balancer, firewall правила)

2.2 Типы профилей и их применение

  • Heap Profile - аллокации памяти показывает, где аллоцируется память:

  • снимаем текущее состояние heap-а curl https://k8s.backend.server/debug/pprof/heap > heap.pb

  • открываем браузер go tool pprof -http=:8080 heap.pb Что смотрим:

  • inuse_space - сколько памяти используется сейчас;

  • inuse_objects - количество живых объектов;

  • alloc_space - сколько было аллоцировано всего (помогает найти churning);

  • alloc_objects - количество аллокаций.

  • Goroutine Profile - активные горутины. Cнимаем горутиныcurl https://k8s.backend.server/debug/pprof/goroutine?debug=2 > goroutines.txt Смотрим примерный вывод:

goroutine profile: total 45123
45000 @ 0x43a6e6 0x40b6c1 0x40b28c 0x4072e1 0x46e801
#   0x4072e0    net/http.(*Transport).dialConn+0x1300

Говорит, что 45k горутин зависли в одном месте.

  • Allocs Profile - все аллокации. Включает объекты, которые уже собрал gc

curl https://k8s.backend.server/debug/pprof/allocs > allocs.pb
go tool pprof -http=:8080 allocs.pb

Полезен для поиска "горячих" мест с частыми аллокациями.

  • Block Profile - блокировки Показывает, где горутины тратят время на ожидание:

curl https://k8s.backend.server/debug/pprof/block > block.pb
go tool pprof -http=:8080 block.pb

Помогает найти узкие места в синхронизации.

2.3 Как читать dump-ы горутин

Вначале давайте рассмотрим пример проблемного дампа:

goroutine 1247 [chan send, 47 minutes]:
gitlab.com/k8s/backend/api/example/worker.(*Pool).process(0xc00012e000)
    /app/worker/pool.go:89 +0x245

goroutine 1248 [IO wait, 47 minutes]:
internal/poll.(*FD).Accept(0xc000184000)
    /usr/local/go/src/internal/poll/fd_unix.go:401 +0x165

goroutine 1249 [semacquire, 47 minutes]:
sync.runtime_Semacquire(0xc0001a6050)
    /usr/local/go/src/runtime/sema.go:56 +0x25

Переведем и составим таблицу:

Состояние

Значение

Возможная проблема

chan send

ждём отправки в канал

никто не читает из канала

chan receive

ждём получения из канала

никто не пишет в канал

io wait

ожидание I/O

cетевой запрос завис

semacquire

ждём семафор

deadlock на sync.Mutex/WaitGroup

select

ждём select

все case заблокированы

sleep

time.Sleep()

в целом нормально, в адекватных цифрах

Время в квадратных скобках - как долго горутина в этом состоянии. Если минуты/часы - это утечка.

2.4 Системная диагностика. Файловые дескрипторы

В целом тянет на отдельную статью, если хотим разобрать практику - пишите "Linux" в комментариях :)
Кратко оставлю пул команд, к которым часто обращаюсь:

cat /proc/<PID>/limits -- лимиты процесса
ls /proc/<PID>/fd | wc -l  -- открытые дескрипторы
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn -- группировка по типам
ss -tapn | grep <PID> -- все соединения процесса
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn -- группировка по состояниям

Проблемные места:

  • close_wait - мы не закрыли соединение

  • time_wait - если много = высокая нагрузка А пока давайте перейдем к следующей части.

3: Практические методики поиска утечек.

3.1 Воспроизведение утечек на dev-стенде.

Не ждем пока решение дойдет до стабильного стенда - кодим, пишем тесты, доку, деплоим на самый младший стенд и пытаемся воспроизводить утечку. Чем пользуюсь - обычно sh скрипты, где с помощью wrk или ab(apache bench) генерируем нагрузку. Краткий алгоритм:

  • 100,000 запросов, 100 конкурентных, 10 минут ab -n 100000 -c 100 -t 600 https://dev.k8s.backend.server/api/v1/todo

  • собираем профили каждую минуту(горутины, heap, дамп горутин) см. п.2.2

  • сравниваем heap профили go tool pprof -top -base profiles/heap_1.pb profiles/heap_10.pb

  • сравниваем количество горутин grep "goroutine profile:" profiles/goroutine_1.txt && grep "goroutine profile:" profiles/goroutine_10.txt

3.2 Анализ собранных профилей

  • Сравнение heap профилей открываем в браузере:

go tool pprof -http=:8080 \
    -base profiles/heap_1.pb \
    profiles/heap_10.pb

Вывод при утечках:

Showing nodes accounting for 512MB, 98.5% of 520MB total
      flat  flat%   sum%        cum   cum%
  256.00MB 49.23% 49.23%   256.00MB 49.23%  gitlab.com/k8s/backend/cache.(*Store).Set
  128.00MB 24.62% 73.85%   128.00MB 24.62%  net/http.(*Transport).dialConn
   64.00MB 12.31% 86.15%    64.00MB 12.31%  runtime.malg

Рост на 512MB за 10 минут - явная утечка в cache.Store.

  • Анализ горутин Здесь выручает гпт-ка - sh скрипты легко, быстро и главное полезно. Go не дает гибких и удобных инструментов в проведении анализа горутин. Они конечно есть, но мне нужен формат, который я описывал выше в рамках таблицы. Сам анализ:

grep -A 5 "^goroutine" profiles/goroutine_10.txt | \
    grep -E "^goroutine|^\s+/" | \
    head -20

Скрипт для подсчёта горутин по функциям:

#!/bin/bash

awk '
/^goroutine/ {
    state = $3
    gsub(/[\[\],]/, "", state)
}
/^\t/ && NF > 0 {
    func = $1
    counts[func ":" state]++
}
END {
    for (key in counts) {
        split(key, parts, ":")
        printf "%6d  %-50s %s\n", counts[key], parts[1], parts[2]
    }
}' profiles/goroutine_10.txt | sort -rn

Вывод при утечках:

45000  net/http.(*Transport).dialConn          chan send
   156  runtime.gopark                         IO wait
    42  time.Sleep                             sleep

Согласитесь - понятно и по делу.

3.3 Runtime tracing для сложных случаев

Когда профили не дают ответа, используем execution tracer. В подавляющем большинстве его не используют в своих приложениях.

  • в main trace.Start(out_file) из пакета "runtime/trace"

  • не забываем стопить в defer по завершению приложения defer trace.Stop()

  • запускаем go run main.go

  • открываем go tool trace trace.out

  • в браузере смотрим:

    • view trace - timeline всех горутин и событий

    • goroutine analysis - статистика по горутинам

    • network blocking profile - где горутины ждут сети

    • synchronization blocking profile - блокировки на примитивах

    • syscall blocking profile - системные вызовы

  • Что ищем:

    • горутины, которые долго находятся в состоянии "waiting"

    • частые блокировки на одних и тех же примитивах

    • долгие syscall-ы

4: Заключение первой части

Мы разобрали теоретический фундамент и практический инструментарий для диагностики утечек ресурсов в Go-приложениях. Теперь у вас есть:

  • Понимание природы проблемы: как работает gc, планировщик и почему утечки критичны

  • Набор инструментов: pprof профили, дампы горутин, системная диагностика

  • Практические методики: воспроизведение и анализ утечек

  • Готовые скрипты: для автоматизации диагностики

Это базовый минимум для начала охоты на утечки.

Что будет во второй части

Перейдем от теории к практике:

  • Конкретные примеры утечек с кодом и решениями

  • Разберем production-кейс: детективная история с зависшими горутинами

  • Настройка мониторинга: Prometheus, Grafana, алерты

  • Защитные паттерны: graceful shutdown, timeouts, circuit breaker

Примените описанные инструменты к своим сервисам - уверен, найдете что-то интересное.

P.S. Есть вопросы или интересные кейсы? Делитесь в комментариях! Лучшие разберу в следующей части.

Полезные ссылки: