- PVSM.RU - https://www.pvsm.ru -
Привет! В данной статье хотел бы раскрыть тему - почему на 'младших' стендах 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 %sn", 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. Есть вопросы или интересные кейсы? Делитесь в комментариях! Лучшие разберу в следующей части.
Полезные ссылки:
go dev pprof [16]
go dev wiki [17]
allocation [18]
Автор: dndev-tech
Источник [19]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/monitoring/437326
Ссылки в тексте:
[1] 1: Теоретические основы : #theoretical%20minimum
[2] 1.1 Модель памяти и gc : #mmgc
[3] 1.2 Вспоминаем про модель горутин и планировщик : #scheduler
[4] 1.3 Разберем типологию утечек ресурсов : #typology
[5] 2: Практика. Инструментарий диагностики : #practice
[6] 2.1 Профилирование с pprof : #pprof
[7] 2.2 Типы профилей и их применение : #type%20profile
[8] 2.3 Как читать dump-ы горутин : #dump
[9] 2.4 Системная диагностика. Файловые дескрипторы : #sysdiagnose
[10] 3: Практические методики поиска утечек : #practice%20method
[11] 3.1 Воспроизведение утечек на dev-стенде : #dev
[12] 3.2 Анализ собранных профилей : #collect%20profiles
[13] 3.3 Runtime tracing для сложных случаев : #runtime%20tracing
[14] 4: Заключительная часть : #endoffile
[15] Что будет во второй части : #production
[16] go dev pprof: https://go.dev/blog/pprof
[17] go dev wiki: https://go.dev/wiki/Performance
[18] allocation: https://go.dev/doc/effective_go#allocation_new
[19] Источник: https://habr.com/ru/articles/968660/?utm_source=habrahabr&utm_medium=rss&utm_campaign=968660
Нажмите здесь для печати.