January 25

Как я отлавливал утечку памяти в высоконагруженном Go-сервисе

Как возникла проблема?

Мы в компании постепенно переходим на Go, и одна из первых задач — переписать самые узки места, узлы, где качество решения влияет на всю систему целиком.

Недавно я взялся за Gateway-сервис, который стоит на входе: принимает запросы, подписывает его секретами, шлет дальше и возвращает ответ. Проще говоря, это прокси-слой между остальными сервисами и внешними API.

Раньше он у нас был на Python, но был заметно тяжел — потреблял сотни мегабайт памяти, медленно отрабатывал пиковые нагрузки и работал только на подпись запроса и не отправлял сам запрос, т. к. мы боялись, что если он полностью будет этим заниматься, то просто не справится (там надо было про горизонтальное масштабирование через процессы или поды, т. к. одного не хватало бы и т. д.). Поскольку скоро дело идет в продакшен, я переписал его на Golang с фреймворком chi, сделал базовое кэширование и минимизировал бизнес-логику. Это также был хороший выбор для перевода на Go, т. к. у него был четко определен круг задач вокруг одной бизнес сущности и он целиком полностью отвечал только за нее — буквально одна таблица в БД, один репозиторий, один сервис.

Сначала это казалось победой: после запуска сервис едва потреблял 20-30 МБ RAM, что резко отличалось от прежних 200-300 МБ. Но к вечеру я заметил тревожный рост — через несколько часов работы сервис уже жрал почти 1 ГБ памяти.

Такое поведение было для нас неприемлемо. Понимать, что происходить во времени, практически невозможно простым наблюдением — нужны инструменты, которые покажут почему память растёт.

Профилирование с помощью pprof

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

Добавить его в сервис можно буквально за пару строк:

import _ "net/http/pprof"

func main(){
    go func() {
        http.ListenAndServe("0.0.0.0:6060", nil)
    }()
}

После этого в работающем сервисе становятся доступны эндпоинты:

  • /debug/pprof/heap — снимок текущего распределения памяти;
  • /debug/pprof/allocs — распределение аллокаций;
  • /debug/pprof/goroutine — текущие горутины;
  • /debug/pprof/profile — CPU-профиль

Эти данные можно анализировать как локально, так и в браузере.

Команды, которые реально помогают

Анализ heap-снимка:

go tool pprof http://localhost:6060/debug/pprof/heap

В интерактивной консоли pprof команды вроде top, top -cum, list <func> позволяют быстро найти функции, которые потребляют наибольшую часть памяти.

Для аллокаций используем:

go tool pprof http://localhost:6060/debug/pprof/allocs

Это полезно, когда память вроде освобождается, но общее выделение растет со временем.

Чтобы посмотреть, сколько горутин живёт и где они зависают:

go tool pprof http://localhost:6060/debug/pprof/goroutine

А если хочется визуального представления, можно открыть граф вызовов прямо в браузере:

go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

Как смотреть профили из Kubernetes?

Очень часть проблема проявляется только под нагрузкой в staging или даже в prod. Поскольку pprof слушает порт, его легко использовать внутри Kubernetes через перенаправление портов:

kubectl port-forward pod/your-pod 6060:6060

Локально становятся доступны профили по localhost:6060, и дальше можно использовать те же команды go tool pprof или открыть интерфейс в браузере. Это работает без изменения конфигурации ingress, без проброса портов наружу, просто через доступ к кластеру.

Что показал анализ?

Сначала я думал, что дело в логике или в каком-то конкретном кэше. Однако pprof наглядно показал, что память накапливается не из-за кода, который я писал, а из-за внешней библиотеки для Redis. Новая версия драйвера держала соединения открытыми и не освобождала их вовремя.

Как только я зафиксировал и вернул стабильную версию библиотеки (спасибо ишью на Github, где указали на какой версии все работает правильно) — рост памяти прекратился, и сервис занял устойчивое плато, колеблясь лишь на уровне обычного поведения GC.

Какие ошибки я нашёл в собственном коде?

Пока искал корень проблемы, нашёл и собственные промахи:

  • в некоторых местах response.Body закрывался только при успешном результате — при ошибках тело оставалось висеть;
  • я создавал HTTP клиент на каждый вызов функции, что просто заставляло выполнять лишнюю работу сервис и выделять на каждый запрос доп память.

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

Наблюдаемость — не "фича", а необходимость

Здесь важно подчеркнуть: инструменты вроде pprof помогают только в моменте, тогда как системный мониторинг показывает динамику.

Я интегрировал в сервис Prometheus-метрики через мидлварь: статистика по вызовам роутов, текущее потребление памяти, скорость аллокаций, поведение GC и количество горутин. Эти метрики легко визуализируются в Grafana, и на графиках чётко видно, когда начинается рост, когда он останавливается, и как ведёт себя сервис с течением времени.

Такой подход к observability должен быть в любой команде, независимо от языка или инфраструктуры. Если вы не видите во времени, вы не сможете понять, что именно происходит.

Если вы не можете сделать даже этого, то хотя бы периодически снимайте метрики через curl или kubectl top pod <your-pod>. Вы уже сможете видеть подозрительное изменение ресурсов.

Наглядно видно, что левая часть уходит в космос, а правая после второго обновления на устойчивом плато

Финальные мысли

Go действительно имеет мощные средства профилирования, и в большинстве случаев они удобнее, чем эквиваленты для Python. Но это не отменяет того, что хорошо бы задумываться о наблюдаемости еще до первой проблемы.

Наличие подробных метрик, возможность заглянуть в поведение сервиса под нагрузкой и быстро понять, где именно копится память — это то, что позволяет ловить проблемы раньше, чем они превратятся в инциденты на продакшене.