Гайд по поиску и устранению утечек памяти в Go сервисах

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Как обнаружить утечку?

Все просто, посмотреть на график изменения потребления памяти вашим сервисом в системе мониторинга.

Серверу плоха
Серверу плоха
Один и тот же сервис запущенный с разными настройками
Один и тот же сервис запущенный с разными настройками

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

Как настроить мониторинг?

Тестировать наш сервис мы будем локально без помощи DevOps-инженеров. Для работы нам понадобиться git, Docker и терминал, а разворачивать мы будем связку Grafana и Prometheus.

Grafana это интерфейс для построения красивых графиков, диаграмм и dashboard-ов из них. Prometheus это система, которая включает в себя базу данных временных рядов и специального агента, который занимается сбором метрик с ваших сервисов.

Для того что бы быстро развернуть это всё на локальной машине воспользуемся готовым решением - https://github.com/vegasbrianc/prometheus

$ git clone git@github.com:vegasbrianc/prometheus.git
$ cd prometheus
$ HOSTNAME=$(hostname) docker stack deploy -c docker-stack.yml prom

После запуска по ссылке http://<Host IP Address>:3000 у нас должна открыться Grafana. Читайте README в репозитории, там всё подробно расписано.

Prometheus client

Теперь нам нужно научить наш сервис отдавать метрики, для этого нам понадобиться Prometheus client.

Код примера из официального репозитория

package main

import (
	"flag"
	"log"
	"net/http"

	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var addr = flag.String("listen-address", ":8080", "The address to listen on for HTTP requests.")

func main() {
	flag.Parse()
	http.Handle("/metrics", promhttp.Handler())
	log.Fatal(http.ListenAndServe(*addr, nil))
}

Самые важные строчки это 8 и 15 в 90% случаев их будет достаточно.

После запуска проверьте, что на endpoint-е /metrics появились данные.

Добавляем job в Prometheus agent

В папке репозитория prometheus найдите файлик prometheus.yml, там есть секция scrape_configs, добавьте туда свою job-у

scrape_configs:
  - job_name: 'my-service'
    scrape_interval: 5s
    static_configs:
         - targets: ['192.168.1.4:8080']

194.168.1.4 это IP адрес вашей локальной машины. Узнать его можно командой ipconfig, ifconfig обычно интерфейс называется en0

$ ifconfig
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
	options=6463<RXCSUM,TXCSUM,TSO4,TSO6,CHANNEL_IO,PARTIAL_CSUM,ZEROINVERT_CSUM>
	ether f4:d4:88:7a:99:ce
	inet6 fe80::1413:b61f:c073:6a8e%en0 prefixlen 64 secured scopeid 0xe
	inet 192.168.1.4 netmask 0xffffff00 broadcast 192.168.1.255
	nd6 options=201<PERFORMNUD,DAD>
	media: autoselect
	status: active

Так же не забудьте поменять в своём сервисе IP адрес запуска, сейчас там прописано ":8080" что по факту означает IP адрес текущей машины 127.0.0.1, для верности напишите "192.168.1.4:8080" (см. строку 11).

Зачем прописывать IP?

Дело в том что docker stack в данной конфигурации запускает Grafana и Prometheus в своем изолированном network-е, они не видят ваш localhost, он у них свой. Конечно можно запустить ваш сервис в контексте сети docker-а, но прописать прямой IP локального интерфейса это самый простой путь подружить контейнеры запущенные внутри сети докера с вашими сервисами запущенными локально.

Применяем настройки

В папке с репозиторием выполняем команды

$ docker stack rm prom
$ HOSTNAME=$(hostname) docker stack deploy -c docker-stack.yml prom

Убедиться, что все заработало после перезапуска, можно посмотрев на панель targets prometheus-а по адресу http://localhost:9090/

Настраиваем Grafana

Тут всё просто, надо поискать красивый dashboard для Go на официальном сайте https://grafana.com/grafana/dashboards/ и импортировать его себе.

Лично мне понравился этот https://grafana.com/grafana/dashboards/14061

Нагрузочное тестирование

Что бы выявить утечку сервис нужно нагрузить реальной работой. В веб разработке основные транспортные протоколы между беком и фронтом это HTTP и Web Sockets. Для них существует масса утилит нагрузочного тестирования, например

$ ab -n 10000 -kc 100 http://192.168.1.4:8080/endpoint
$ wrk -c 100 -d 10 -t 2 http://192.168.1.4:8080/endpoint

или тот же Jmeter, но мы будем использовать artillery.io так как у нас web socket-ы и мне было необходимо воспроизвести определенный сценарий, что бы словить memory leak.

Для artillery понадобиться node и npm, а так как я когда-то я программировал на node.js мне нравится проект volta.sh, это что-то вроде виртуального окружения в python, позволяет для каждого проекта иметь свою версию node.js и его утилит, но выбор за вами.

Ставим артиллерию

$ npm install -g artillery@latest
$ artillery -v

Пишем сценарий нагрузочного тестирования test.yml

config:
    target: "ws://192.168.1.4:8080/v1/ws"
    phases:
        # - duration: 60
        #  arrivalRate: 5
        # - duration: 120
        #   arrivalRate: 5
        #   rampTo: 50
        - duration: 600
          arrivalRate: 50
scenarios:
    - engine: "ws"
      name: "Get current state"
      flow:
        - think: 0.5

Последняя фаза добавляет 50 виртуальных пользователей каждую секунду в течение десяти минут. Каждый из которых сидит и думает 0.5 секунд, а можно сделать какие-нибудь запросы по сокетам или даже несколько.

Запускаем и смотрим графики в Grafana

artillery run test.yml
Графики здорового человека
Графики здорового человека

На что стоит обратить внимание

Кроме памяти стоит посмотреть, как ведут себя ваши goroutine. Часто проблемы бывают из-за того что рутина остается "висеть" и вся память выделенная ей и все переменные указатели на которые попали в нее остаются "висеть" мертвым грузом в памяти и не удаляются garbage collector-ом. Вы так же увидите это на графике. Банальный пример обработчик запроса в котором запускается рутина для "тяжелых" вычислений

func (s *Service) Debug(w http.ResponseWriter, r *http.Request) {
  go func() { ... }()
  w.WriteHeader(http.StatusOK)
  ...
}

Эту проблему можно решить например через context, waitGroup, errGroup

func (s *Service) Debug(w http.ResponseWriter, r *http.Request) {
  ctx, cancel := context.WithCancel(r.Context())
  go func() {
		for {
			select {
			case <-ctx.Done():
				return
			default:
			}
			...
		}
	}()
  w.WriteHeader(http.StatusOK)
  ...
}

Либо при регистрации клиента вы выделяете под него память, например данные его сессии, но не освобождаете её, когда клиент отключился от вас аварийно. Что-то типа

type clientID string
type session struct {
  role string
  refreshToken string
  ...
}
var clients map[clientID]*clientSession

Следите за тем как вы передаете аргументы в функцию, по указателю или по значению. Используете ли вы глобальные переменные внутри пакетов? Не зависают ли в каналах и рутинах указатели на структуры данных? Graceful shutdown это про вас?

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

Источник: https://habr.com/ru/post/658085/


Интересные статьи

Интересные статьи

Перевод обновлённого гайда Android по архитектуре приложений. Это — первая часть из пяти: обзор рекомендаций по архитектуре.
Прим. Wunder Fund: мы занимаемся высокочастотной торговлей и это заставляет нас часто думать об оптимизации кода, но в основном, конечно, плюсового. В этой короткой статье описаны несколько подходов к...
Всем привет. Текст состоит из двух частей:1. Небольшая шпаргалка по параметрам настроек по умолчанию;2. Текст о том, почему вообще существование такой шпаргалки может кому-то понадобится.
Когда я писал в начале года статью “Кто есть кто в мировой микроэлектронике”, меня удивило, что в десятке самых больших полупроводниковых компаний пять занимаются произво...
Маркетплейс – это сервис от 1С-Битрикс, который позволяет разработчикам делиться своими решениями с широкой аудиторией, состоящей из клиентов и других разработчиков.