Обнаружение утечек памяти в Go через Pyroscope

ep_nx1c_pxrxtyd56yms55nqko8.png

Для обнаружения аномально высокой длительности выполнения отдельных функций (а также избыточного выделения или утечек памяти) используются инструменты профилирования над виртуальной машиной (например, JProfiler или Visual VM для JVM) или интегрированные в выполняемый код, например встроенный механизм при компиляции Go-приложений. Альтернативой может стать использование универсальных механизмов профилирования, которые интегрируются со средой выполнения и отправляют результаты профилирования на сервер, который может анализировать аномальное поведение и визуализировать выделение памяти и время выполнения отдельных функций (и построить flame graph по результатам анализа приложения во время выполнения). В этой статье мы рассмотрим использование Pyroscope совместно с Go для обнаружения утечек памяти.

Прежде всего, нужно отметить что Pyroscope может интегрироваться с разными средами выполнения и поддерживает JVM, PHP, Ruby, CLR (.Net), Python и Go. Архитектурно Pyroscope состоит из агента (который одновременно является launcher’ом для запуска исследуемого приложения) и сервера, который накапливает данные и позволяет их анализировать после сбора. Сейчас Pyroscope стал частью экосистемы Grafana и позволяет интегрироваться в другие компоненты Observability (включая визуализацию метрик, распределенной трассировки на Grafana Tempo и др.).

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

package main

import (
	"io"
	"math"
	"net/http"
	"runtime/debug"
    "sync"
)

var globalSlice = make([][]byte, 0, 0)

func leak() {
    h, _ := http.Get("https://www.google.com")
    body, _ := io.ReadAll(h.Body)
    globalSlice = append(globalSlice, body)
    go leak()
}

func main() {
	// disable GC
	debug.SetGCPercent(-1)
	debug.SetMemoryLimit(math.MaxInt64)

    var wg sync.WaitGroup
    wg.Add(1)
	go leak()
    wg.Wait()     
}

Для установки агента Pyroscope в Go установим его как часть нашего приложения, это поможет правильно сконфигурировать сбор данных. Добавим модуль:

go get github.com/pyroscope-io/client/pyroscope

И выполним инициализацию для захвата профилирования (например, в функции main):

package main

import (
	"io"
	"math"
	"net/http"
	"runtime/debug"
    "github.com/pyroscope-io/client/pyroscope"
    "sync"
)

func main() {
    pyroscope.Start(pyroscope.Config{
        ApplicationName: "simple.golang.app",
        ServerAddress:   "http://localhost:4040",
        Logger:          pyroscope.StandardLogger,
        // список поддерживаемых профилировщиков
        ProfileTypes: []pyroscope.ProfileType{
            pyroscope.ProfileCPU,
            pyroscope.ProfileAllocObjects,
            pyroscope.ProfileAllocSpace,
            pyroscope.ProfileInuseObjects,
            pyroscope.ProfileInuseSpace,
        },
    })
	// disable GC
	debug.SetGCPercent(-1)
	debug.SetMemoryLimit(math.MaxInt64)

    var wg sync.WaitGroup
    wg.Add(1)
	go leak()
    wg.Wait()     
}

Для сбора данных Pyroscope использует API, которое публикуется на тот же порт, что и веб-интерфейс:

docker run -d -p 4040:4040 pyroscope/pyroscope - server

Pyroscope может получать данные как непосредственно от агента (как в нашем случае, данные профилирования будут загружаться периодически на указанный адрес сервера, по умолчанию один раз в 10 секунд), так и извлекаться со стороны сервера (через механизм поллинга). В случае поллинга поддерживается любая библиотека, которая может отправлять данные в http-ответе в формате pprof (например, в Go это может быть net/http/pprof). Конфигурация поллинга определяется в файле server.yml (при запуске в docker — /etc/pyroscope/server.yml):

scrape-configs:
  - job-name: pyroscope
    scrape-interval: 10s
    enabled-profiles: [cpu, mem, goroutines, mutex, block]
    static-configs:      
    - application: example-app        
      spy-name: gospy        
      targets:          
      - app:8080        
      labels:       
        env: dev

В веб-интерфейсе Pyroscope выберем наше приложение и метрику inuse_space:

Flamegraph

Flamegraph

Для анализа конкретной функции мы также можем использовать тэги:

pyroscope.TagWrapper(context.Background(), pyroscope.Labels("leak"), func(c context.Context) {
  go leak()
})

Также тэги можно задавать при запуске агента pyroscope (например, для агрегации информации в микросервисной архитектуре). На графике изменения метрики можно также добавить аннотации (например, пометить события запуска GC). Исследовать метрику можно как с помощью диаграммы Flamegraph (где показана иерархия использования метрики во вложенных вызовах), так и визуализацию на графе (через graphviz):

Graphviz

Graphviz

Также можно сохранить результаты замеров как baseline и сравнить его в дальнейшем с новыми замерами. Pyroscope также предоставляет возможность анализа ранее сохраненного файла в формате pprof (Adhoc Profiling).

Анализируя изменение в течении времени используемой памяти с привязкой к иерархии функций можно обнаружить проблему с выделением ресурсов и исследовать первопричину ее возникновения. Pyroscope позволяет визуализировать как метрики использования памяти (alloc_space, inuse_space), так и количества выделенных ресурсов (alloc_objects, inuse_objects) и использование процессора (cpu), что позволяет выявлять длительные операции и выполнять оптимизацию кода по замерам в реальных условиях.

Статья подготовлена в преддверии старта курса Golang Developer. Professional.

© Habrahabr.ru