Как стать автором
Обновить

Профилирование Node.js приложения с помощью Pyroscope (без автоинструментирования)

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров1K

Pyroscope — это мощный инструмент непрерывного профилирования, созданный для мониторинга производительности приложений в реальном времени. В этой статье мы рассмотрим, как быстро развернуть Pyroscope, подключить к нему Node.js приложение и проанализировать поведение кода без использования Grafana Alloy.

Что такое Pyroscope?

Pyroscope — это open-source решение от Grafana Labs для профилирования производительности, которое позволяет:

  • отслеживать использование CPU, памяти и других ресурсов в реальном времени;

  • поддерживать профилирование для языков Go, Python, Java, Ruby, Node.js и др.;

  • сравнивать профили за разные промежутки времени;

  • интегрироваться с Grafana, Kubernetes и другими инструментами мониторинга.

Цель эксперимента

В рамках этого примера мы:

  • Запустим Pyroscope как в Docker, так и в Kubernetes (включая Yandex Cloud).

  • Подключим тестовое Node.js приложение с преднамеренно медленной функцией и функцией с утечкой памяти.

  • Сгенерируем нагрузку и проанализируем результаты профилирования.

Важно: Grafana Alloy в этом примере не используется — данные отправляются напрямую из Node.js приложения в Pyroscope.

Быстрый старт с Docker Compose

git clone https://github.com/patsevanton/profiling-nodejs-app-by-pyroscope-with-sdk
docker-compose up -d
Docker-Compose.yaml
# Версия формата файла Docker Compose
version: '3'

# Определение сервисов/контейнеров
services:
  # Сервис сервера Pyroscope (система для анализа производительности)
  pyroscope-server:
    # Используем официальный образ Pyroscope от Grafana с конкретной версией
    image: grafana/pyroscope:1.13.2
    # Пробрасываем порты: порт 4040 контейнера на порт 4040 хоста
    ports:
      - "4040:4040"

  # Сервис Node.js приложения
  node-app:
    # Собираем образ из Dockerfile в текущей директории
    build: .
    # Пробрасываем порты: порт 3000 контейнера на порт 3000 хоста
    ports:
      - "3000:3000"
    # Переменные окружения для конфигурации приложения
    environment:
      # Адрес сервера Pyroscope для отправки данных профилирования
      PYROSCOPE_SERVER_ADDRESS: "http://pyroscope-server:4040"
    # Зависимости: этот сервис зависит от pyroscope-server
    # и будет запущен только после его успешного старта
    depends_on:
      - pyroscope-server

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

while true; do \
  curl http://localhost:3000/fast; \
  curl http://localhost:3000/slow; \
  curl http://localhost:3000/leak; \
done

Развёртывание в Kubernetes (Yandex Cloud)

1. Клонируем репозиторий с конфигурацией

git clone https://github.com/patsevanton/profiling-nodejs-app-by-pyroscope-with-sdk
cd pyroscope-nodejs

2. Настраиваем инфраструктуру через Terraform

export YC_FOLDER_ID='ваш_folder_id'
terraform apply
Terraform код
# Создание внешнего IP-адреса в Yandex Cloud
resource "yandex_vpc_address" "addr" {
  name = "pyroscope-pip"  # Имя ресурса внешнего IP-адреса

  external_ipv4_address {
    zone_id = yandex_vpc_subnet.pyroscope-a.zone  # Зона доступности, где будет выделен IP-адрес
  }
}

# Создание публичной DNS-зоны в Yandex Cloud DNS
resource "yandex_dns_zone" "apatsev-org-ru" {
  name  = "apatsev-org-ru-zone"  # Имя ресурса DNS-зоны

  zone  = "apatsev.org.ru."      # Доменное имя зоны (с точкой в конце)
  public = true                  # Указание, что зона является публичной

  # Привязка зоны к VPC-сети, чтобы можно было использовать приватный DNS внутри сети
  private_networks = [yandex_vpc_network.pyroscope.id]
}

# Создание DNS-записи типа A, указывающей на внешний IP
resource "yandex_dns_recordset" "rs1" {
  zone_id = yandex_dns_zone.apatsev-org-ru.id       # ID зоны, к которой принадлежит запись
  name    = "pyroscope.apatsev.org.ru."                # Полное имя записи (поддомен)
  type    = "A"                                     # Тип записи — A (IPv4-адрес)
  ttl     = 200                                     # Время жизни записи в секундах
  data    = [yandex_vpc_address.addr.external_ipv4_address[0].address]  # Значение — внешний IP-адрес, полученный ранее
}

# Создание DNS-записи типа A, указывающей на внешний IP
resource "yandex_dns_recordset" "grafana" {
  zone_id = yandex_dns_zone.apatsev-org-ru.id       # ID зоны, к которой принадлежит запись
  name    = "grafana.apatsev.org.ru."                # Полное имя записи (поддомен)
  type    = "A"                                     # Тип записи — A (IPv4-адрес)
  ttl     = 200                                     # Время жизни записи в секундах
  data    = [yandex_vpc_address.addr.external_ipv4_address[0].address]  # Значение — внешний IP-адрес, полученный ранее
}

# Создание DNS-записи типа A, указывающей на внешний IP
resource "yandex_dns_recordset" "nodejs-app" {
  zone_id = yandex_dns_zone.apatsev-org-ru.id       # ID зоны, к которой принадлежит запись
  name    = "nodejs-app.apatsev.org.ru."                # Полное имя записи (поддомен)
  type    = "A"                                     # Тип записи — A (IPv4-адрес)
  ttl     = 200                                     # Время жизни записи в секундах
  data    = [yandex_vpc_address.addr.external_ipv4_address[0].address]  # Значение — внешний IP-адрес, полученный ранее
}
# Получаем информацию о конфигурации клиента Yandex
data "yandex_client_config" "client" {}

# Создание сервисного аккаунта для управления Kubernetes
resource "yandex_iam_service_account" "sa-k8s-editor" {
  name      = "sa-k8s-editor"  # Имя сервисного аккаунта
}

# Назначение роли "editor" сервисному аккаунту на уровне папки
resource "yandex_resourcemanager_folder_iam_member" "sa-k8s-editor-permissions" {
  role      = "editor"  # Роль, дающая полные права на ресурсы папки
  folder_id = data.yandex_client_config.client.folder_id
  member = "serviceAccount:${yandex_iam_service_account.sa-k8s-editor.id}"  # Назначаемый участник
}

# Пауза, чтобы изменения IAM успели примениться до создания кластера
resource "time_sleep" "wait_sa" {
  create_duration = "20s"
  depends_on      = [
    yandex_iam_service_account.sa-k8s-editor,
    yandex_resourcemanager_folder_iam_member.sa-k8s-editor-permissions
  ]
}

# Создание Kubernetes-кластера в Yandex Cloud
resource "yandex_kubernetes_cluster" "pyroscope" {
  name       = "pyroscope"  # Имя кластера
  network_id = yandex_vpc_network.pyroscope.id  # Сеть, к которой подключается кластер

  master {
    version = "1.30"  # Версия Kubernetes мастера
    zonal {
      zone      = yandex_vpc_subnet.pyroscope-a.zone  # Зона размещения мастера
      subnet_id = yandex_vpc_subnet.pyroscope-a.id     # Подсеть для мастера
    }

    public_ip = true  # Включение публичного IP для доступа к мастеру
  }

  # Сервисный аккаунт для управления кластером и нодами
  service_account_id      = yandex_iam_service_account.sa-k8s-editor.id
  node_service_account_id = yandex_iam_service_account.sa-k8s-editor.id

  release_channel = "STABLE"  # Канал обновлений

  # Зависимость от ожидания применения IAM-ролей
  depends_on = [time_sleep.wait_sa]
}

# Группа узлов для Kubernetes-кластера
resource "yandex_kubernetes_node_group" "k8s-node-group" {
  description = "Node group for the Managed Service for Kubernetes cluster"
  name        = "k8s-node-group"
  cluster_id  = yandex_kubernetes_cluster.pyroscope.id
  version     = "1.30"  # Версия Kubernetes на нодах

  scale_policy {
    fixed_scale {
      size = 3  # Фиксированное количество нод
    }
  }

  allocation_policy {
    # Распределение нод по зонам отказоустойчивости
    location { zone = yandex_vpc_subnet.pyroscope-a.zone }
    location { zone = yandex_vpc_subnet.pyroscope-b.zone }
    location { zone = yandex_vpc_subnet.pyroscope-d.zone }
  }

  instance_template {
    platform_id = "standard-v2"  # Тип виртуальной машины

    network_interface {
      nat = true  # Включение NAT для доступа в интернет
      subnet_ids = [
        yandex_vpc_subnet.pyroscope-a.id,
        yandex_vpc_subnet.pyroscope-b.id,
        yandex_vpc_subnet.pyroscope-d.id
      ]
    }

    resources {
      memory = 20  # ОЗУ
      cores  = 4   # Кол-во ядер CPU
    }

    boot_disk {
      type = "network-ssd"         # Тип диска
      size = 128                   # Размер диска
    }
  }
}

# Настройка провайдера Helm для установки чарта в Kubernetes
provider "helm" {
  kubernetes {
    host                   = yandex_kubernetes_cluster.pyroscope.master[0].external_v4_endpoint  # Адрес API Kubernetes
    cluster_ca_certificate = yandex_kubernetes_cluster.pyroscope.master[0].cluster_ca_certificate  # CA-сертификат

    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      args        = ["k8s", "create-token"]  # Команда получения токена через CLI Yandex.Cloud
      command     = "yc"
    }
  }
}

# Установка ingress-nginx через Helm
resource "helm_release" "ingress_nginx" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  version          = "4.10.6"
  namespace        = "ingress-nginx"
  create_namespace = true
  depends_on       = [yandex_kubernetes_cluster.pyroscope]

  set {
    name  = "controller.service.loadBalancerIP"
    value = yandex_vpc_address.addr.external_ipv4_address[0].address  # Присвоение внешнего IP ingress-контроллеру
  }
}

# Вывод команды для получения kubeconfig
output "k8s_cluster_credentials_command" {
  value = "yc managed-kubernetes cluster get-credentials --id ${yandex_kubernetes_cluster.pyroscope.id} --external --force"
}
# Ресурс для создания сети VPC в Yandex Cloud
resource "yandex_vpc_network" "pyroscope" {
  name      = "vpc"  # Имя сети VPC
}

# Ресурс для создания подсети в зоне "ru-central1-a"
resource "yandex_vpc_subnet" "pyroscope-a" {
  v4_cidr_blocks = ["10.0.1.0/24"]  # CIDR блок для подсети (IP-диапазон)
  zone           = "ru-central1-a"  # Зона, где будет размещена подсеть
  network_id     = yandex_vpc_network.pyroscope.id  # ID сети, к которой будет привязана подсеть
}

# Ресурс для создания подсети в зоне "ru-central1-b"
resource "yandex_vpc_subnet" "pyroscope-b" {
  v4_cidr_blocks = ["10.0.2.0/24"]  # CIDR блок для подсети (IP-диапазон)
  zone           = "ru-central1-b"  # Зона, где будет размещена подсеть
  network_id     = yandex_vpc_network.pyroscope.id  # ID сети, к которой будет привязана подсеть
}

# Ресурс для создания подсети в зоне "ru-central1-d"
resource "yandex_vpc_subnet" "pyroscope-d" {
  v4_cidr_blocks = ["10.0.3.0/24"]  # CIDR блок для подсети (IP-диапазон)
  zone           = "ru-central1-d"  # Зона, где будет размещена подсеть
  network_id     = yandex_vpc_network.pyroscope.id  # ID сети, к которой будет привязана подсеть
}
terraform {
  required_providers {
    yandex = {
      source  = "yandex-cloud/yandex"
      version = ">= 0.72.0"
    }
  }
  required_version = ">= 1.3"
}

Получаем доступ к кластеру:

yc managed-kubernetes cluster get-credentials --id xxxx --force

Установка Pyroscope и Grafana через Helm

Добавляем Helm репозиторий Grafana

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

Устанавливаем Pyroscope

helm upgrade -n pyroscope --create-namespace --install pyroscope \
  grafana/pyroscope --values values_pyroscope.yaml
values_pyroscope.yaml
# Конфигурация Alloy
alloy:
  enabled: false  # Отключаем интеграцию с Alloy

# Настройки Ingress для внешнего доступа
ingress:
  enabled: true    # Включаем Ingress контроллер
  className: "nginx"  # Указываем использовать nginx ingress controller
  hosts:
    - pyroscope.apatsev.org.ru  # Доменное имя для доступа к Pyroscope

Устанавливаем Grafana с поддержкой Pyroscope

helm upgrade -n grafana --create-namespace --install grafana \
  grafana/grafana -f values_grafana.yaml
values_grafana.yaml
# Настройки окружения Grafana
env:
  # Устанавливаем плагин grafana-pyroscope-app при инициализации Grafana
  GF_INSTALL_PLUGINS: grafana-pyroscope-app
  
  # Включаем анонимный доступ (для демонстрационных/тестовых сред)
  GF_AUTH_ANONYMOUS_ENABLED: "true"
  
  # Устанавливаем роль анонимного пользователя как Admin (полный доступ)
  GF_AUTH_ANONYMOUS_ORG_ROLE: Admin

# Настройки Ingress для доступа к Grafana извне
ingress:
  # Включаем создание Ingress ресурса
  enabled: true

  # Указываем, что используем nginx ingress controller
  ingressClassName: nginx

  # Доменные имена, по которым будет доступна Grafana
  hosts:
    - grafana.apatsev.org.ru
  
  # Пути, по которым будет доступен сервис (корень домена)
  paths:
    - /

# Конфигурация источников данных Grafana
datasources:
  # Файл конфигурации datasources.yaml
  datasources.yaml:
    # Версия API конфигурации
    apiVersion: 1
    
    # Список источников данных
    datasources:
      # Конфигурация источника данных Pyroscope
      - name: Grafana Pyroscope  # Отображаемое имя источника
        type: grafana-pyroscope-datasource  # Тип источника данных
        
        # URL для подключения к серверу Pyroscope
        # Формат: http://<service-name>.<namespace>:<port>
        url: http://pyroscope.pyroscope:4040
        
        # Дополнительные параметры конфигурации
        jsonData:
          minStep: '15s'  # Минимальный интервал между точками данных (15 секунд)

Развёртывание Node.js приложения

Приложение содержит три эндпоинта:

  • /fast — быстрый ответ,

  • /slow — медленная функция,

  • /leak — функция с утечкой памяти

NodeJS код
//app.js
// Импорт необходимых модулей
const Pyroscope = require('@pyroscope/nodejs'); // Модуль для профилирования
const http = require('http'); // Нативный HTTP модуль Node.js

// Инициализация Pyroscope для сбора метрик производительности
Pyroscope.init({
  // Адрес сервера Pyroscope (берется из переменных окружения или используется значение по умолчанию)
  serverAddress: process.env.PYROSCOPE_SERVER_ADDRESS || 'http://pyroscope-server:4040',
  // Имя приложения для идентификации в Pyroscope
  appName: 'nodejs-example-app',
  // Теги для дополнительной классификации данных
  tags: {
    environment: 'development', // Среда выполнения (разработка)
    version: '1.0.0'           // Версия приложения
  },
  // Настройки сбора данных о процессорном времени
  wall: {
    collectCpuTime: true // Включаем сбор данных о времени CPU
  }
});

// Запуск сбора метрик
Pyroscope.start();

// Глобальный массив для демонстрации утечки памяти
const memoryLeak = [];

// Создаем HTTP сервер с разными эндпоинтами для демонстрации различных сценариев
const server = http.createServer((req, res) => {
  // Маршрутизация запросов
  if (req.url === '/fast') {
    fastRoute(req, res);       // Быстрый эндпоинт
  } else if (req.url === '/slow') {
    slowRoute(req, res);       // Медленный эндпоинт (искусственная нагрузка)
  } else if (req.url === '/leak') {
    leakMemoryRoute(req, res); // Эндпоинт с утечкой памяти
  } else {
    // Дефолтный эндпоинт
    res.writeHead(200);
    res.end('Hello from Node.js!\n');
  }
});

// Обработчик быстрого маршрута
function fastRoute(req, res) {
  res.writeHead(200);
  res.end('Fast response!\n'); // Простой быстрый ответ
}

// Обработчик медленного маршрута
function slowRoute(req, res) {
  // Искусственно создаем CPU нагрузку для демонстрации профилирования
  let sum = 0;
  for (let i = 0; i < 100000000; i++) {
    sum += Math.random(); // Тяжелые вычисления
  }

  res.writeHead(200);
  res.end(`Slow response! Sum: ${sum}\n`);
}

// Обработчик маршрута с утечкой памяти
function leakMemoryRoute(req, res) {
  // Добавляем большие объекты в глобальный массив, который никогда не очищается
  for (let i = 0; i < 1000; i++) {
    memoryLeak.push({
      id: Date.now(), // Уникальный идентификатор
      data: new Array(1000).fill('leak-data').join(''), // Большая строка
      timestamp: new Date().toISOString() // Временная метка
    });
  }

  res.writeHead(200);
  res.end(`Added 1000 objects to memory leak. Total: ${memoryLeak.length} objects\n`);
}

// Запуск сервера на порту 3000
server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});
//package.json
{
  "name": "nodejs-pyroscope-example",
  "version": "1.0.0",
  "main": "app.js",
  "dependencies": {
    "@pyroscope/nodejs": "0.4.5"
  }
}

Деплой в Kubernetes

kubectl apply -f kubernetes/deployment.yaml
kubectl apply -f kubernetes/service.yaml
kubectl apply -f kubernetes/ingress.yaml
Скрытый текст
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nodejs-app
  labels:
    app: nodejs-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nodejs-app
  template:
    metadata:
      labels:
        app: nodejs-app
    spec:
      containers:
        - name: nodejs-app
          image: ghcr.io/patsevanton/pyroscope-nodejs:latest
          ports:
            - containerPort: 3000
          env:
            - name: PYROSCOPE_SERVER_ADDRESS
              value: "http://pyroscope.pyroscope:4040"
          livenessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nodejs-app-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: nodejs-app.apatsev.org.ru
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nodejs-app-service
                port:
                  number: 80
apiVersion: v1
kind: Service
metadata:
  name: nodejs-app-service
spec:
  selector:
    app: nodejs-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000
  type: ClusterIP

Генерация нагрузки

После деплоя можно запустить нагрузку через Ingress:

while true; do \
  curl nodejs-app.apatsev.org.ru/fast; \
  curl nodejs-app.apatsev.org.ru/slow; \
  curl nodejs-app.apatsev.org.ru/leak; \
done

Мониторинг использования ресурсов

Проверим, сколько памяти потребляют pod-ы:

kubectl top pod

Пример вывода:

NAME                          CPU(cores)   MEMORY(bytes)   
nodejs-app-77f7b96899-7cff6   19m          1652Mi          
nodejs-app-77f7b96899-hvfrl   32m          1676Mi 

Анализ профиля

Pyroscope предоставляет как собственный web-интерфейс, так и интеграцию с Grafana. Вот как это выглядит:

Встроенный UI Pyroscope

UI 1
UI 1

 

UI 2
UI 2

Pyroscope в Grafana

Grafana 1
Grafana 1

 

Grafana 2
Grafana 2

 

Grafana 3
Grafana 3

 

Grafana 4
Grafana 4

 

Grafana 5
Grafana 5

Что показывает профилирование?

После анализа профиля можно сделать следующие выводы:

  • Функция leakMemoryRoute в файле ./app.js на строке 63 потребляет 223 MB — явная утечка памяти.

  • Функция slowRoute на строке 51 работает почти 7 минут — требует оптимизации.

Заключение

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+5
Комментарии2

Публикации

Истории

Работа

DevOps инженер
32 вакансии

Ближайшие события

19 марта – 28 апреля
Экспедиция «Рэйдикс»
Нижний НовгородЕкатеринбургНовосибирскВладивостокИжевскКазаньТюменьУфаИркутскЧелябинскСамараХабаровскКрасноярскОмск
24 апреля
VK Go Meetup 2025
Санкт-ПетербургОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
14 мая
LinkMeetup
Москва
5 июня
Конференция TechRec AI&HR 2025
МоскваОнлайн
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область