Для обнаружения аномально высокой длительности выполнения отдельных функций (а также избыточного выделения или утечек памяти) используются инструменты профилирования над виртуальной машиной (например, 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.

Комментарии (1)


  1. KrasPvP
    19.05.2023 13:13

    Конечно, у pyroscope есть и пример https://github.com/grafana/pyroscope/tree/main/examples/golang-push, и документация https://pyroscope.io/docs/golang/, которые раскрывают такую же суть статьи, но всё равно спасибо, скопипастил, запустил код, потыкал 5 минут. Новый опыт :)