Go profiling lifecycle: от разработки до прода. Инструменты и практики
- суббота, 22 ноября 2025 г. в 00:00:08
Привет, Хабр! В данной статье хотел бы раскрыть тему - почему на 'младших' стендах api работает стабильно, но в проде начинаются проблемы: рост памяти, кол-во горутин множится, и через несколько часов - просадка производительности, gc не справляется, out of memory killer и т. д.
Давайте разберемся, что разработчику может помочь, чтобы он мог спать спокойно после деплоя своего решения. Попробуем детально разобраться в природе утечек ресурсов, научимся находить их с помощью профилировщиков и построим систему защиты от самых распространённых паттернов утечек.
Разобьем на несколько частей, в 1-ой части:
немного пробежимся по теории: вспомним как работает gc, планировщик и модель памяти go;
pprof, trace, системная диагностика;
практика: алгоритм действий, скрипты, на что обратить внимание;
runtime tracing для сложных случаев.
Во 2-ой части:
разберем частые и типичные утечки с разбором причин и решений;
разберем реальный кейс в проде - api с большим кол-ом зависших горутин;
вспомним про graceful shutdown, rate limiting, circuit breaker и причем тут они;
поговорим какие метрики собирать и как их мониторить в реальном времени;
сюда же включим защитные паттерны и продвинутые техники.
Содержимое 1ой части:
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
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 время, если зациклена.
Первый тип - утечка горутин или goroutine leaks. Горутина остаётся в памяти, не завершая выполнение. Здесь поможет гпт-ка привести пример кода, оставлю лишь ситуации: блокировка на chan операциях, бесконечное ожидание syscall, забытые тикеры.
Второй тип - memory leaks. Объекты в heap не освобождаются gc. Пример: глобальные коллекции растут бесконечно, замыкания удерживают большие структуры, кэши без eviction(инвалидации - отсутствия ttl, lru/lfu и тд).
Третий тип - resource leaks. Системные ресурсы не освобождаются. Пример: файловые дескрипторы, tcp соединения, db connections pool exhaustion.
Перейдем к практике, чем пользуюсь сам и что действительно помогает.
Первым делом добавим профилирование:
import side-effect _ "net/http/pprof";
добавляем профилирование мьютексов и блокировок;
func init() {
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1)
}
не забываем прокинуть pprof на отдельный порт - служба информационной безопасности при пентестах обращает внимание. Кратко подсвечу как обращаться в таком случае к "ручкам" pprof(форвордим порты в кубе, ssh туннели к серверу, internal load balancer, firewall правила)
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
Помогает найти узкие места в синхронизации.
Вначале давайте рассмотрим пример проблемного дампа:
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() | в целом нормально, в адекватных цифрах |
Время в квадратных скобках - как долго горутина в этом состоянии. Если минуты/часы - это утечка.
В целом тянет на отдельную статью, если хотим разобрать практику - пишите "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 - если много = высокая нагрузка А пока давайте перейдем к следующей части.
Не ждем пока решение дойдет до стабильного стенда - кодим, пишем тесты, доку, деплоим на самый младший стенд и пытаемся воспроизводить утечку. Чем пользуюсь - обычно 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
Сравнение 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
Согласитесь - понятно и по делу.
Когда профили не дают ответа, используем 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-ы
Мы разобрали теоретический фундамент и практический инструментарий для диагностики утечек ресурсов в Go-приложениях. Теперь у вас есть:
Понимание природы проблемы: как работает gc, планировщик и почему утечки критичны
Набор инструментов: pprof профили, дампы горутин, системная диагностика
Практические методики: воспроизведение и анализ утечек
Готовые скрипты: для автоматизации диагностики
Это базовый минимум для начала охоты на утечки.
Перейдем от теории к практике:
Конкретные примеры утечек с кодом и решениями
Разберем production-кейс: детективная история с зависшими горутинами
Настройка мониторинга: Prometheus, Grafana, алерты
Защитные паттерны: graceful shutdown, timeouts, circuit breaker
Примените описанные инструменты к своим сервисам - уверен, найдете что-то интересное.
P.S. Есть вопросы или интересные кейсы? Делитесь в комментариях! Лучшие разберу в следующей части.
Полезные ссылки: