Pendant des années, l’observabilité s’est résumée à trois signaux : métriques, journaux, traces. En 2026, un quatrième signal s’est installé dans les stacks de production : les profils continus. Au lieu d’attendre qu’un incident force un perf record manuel à 3 h du matin, on collecte en permanence des échantillons de CPU, de mémoire et de blocage, on les agrège sur des fenêtres glissantes, et on les affiche sous forme de flame graphs consultables comme on consulte un dashboard Grafana.
Ce tutoriel monte une chaîne complète de profiling continu auto-hébergée. On déploie Pyroscope 2.0, la base de données de profils de Grafana Labs entièrement rearchitecturée en avril 2026. On branche Grafana 13 dessus, on instrumente une application Node.js et une application Go avec les SDK officiels, puis on ajoute du profiling sans modifier le code grâce au composant pyroscope.ebpf de Grafana Alloy, qui attache des sondes eBPF directement au noyau Linux. On termine par Parca, l’alternative Apache 2.0 du projet parca-dev, qui auto-découvre les cibles dans Kubernetes et systemd via parca-agent.
Cet article complète la vue d’ensemble OpenTelemetry et stack LGTM : la même boucle metrics-logs-traces, plus le signal profils qui pointe directement la ligne de code responsable d’un pic CPU.
Le profiling continu, quatrième signal d’observabilité
Un profil capture la pile d’appels d’un processus à intervalles réguliers. En agrégeant des centaines de milliers d’échantillons, on reconstitue le temps CPU consommé par chaque fonction, la mémoire allouée par chaque chemin d’appel, le temps passé bloqué sur un mutex. Le rendu en flame graph montre les piles empilées : la largeur d’une barre représente le coût relatif d’une fonction, sa hauteur la profondeur d’appel.
La différence avec un profiler ponctuel (perf, pprof, py-spy ad hoc) tient en trois points. D’abord la continuité : les données restent disponibles plusieurs jours, on peut comparer la version 1.4.2 déployée mardi avec la 1.4.3 déployée hier sur exactement le même endpoint. Ensuite le coût : les agents continus visent une overhead inférieure à 1 % CPU, contre 5 à 20 % pour un profiler classique. Enfin l’agrégation : on profile l’intégralité d’un cluster et on agrège par service, par pod, par version, sans lancer une session sur chaque machine.
Trois familles d’outils ouverts dominent en mai 2026. Pyroscope (Grafana Labs, AGPLv3) est devenu la base de données de référence depuis la sortie de la version 2.0 le 21 avril 2026 : storage stateless, query path découplé de l’ingestion, support natif du nouveau signal profiles de l’OpenTelemetry Protocol. Parca (parca-dev, Apache 2.0) reste l’option entièrement permissive, avec parca-agent qui découvre seul les processus dans Kubernetes et systemd ; la 0.28.0 du 7 mai 2026 ajoute le support du profil eBPF Profiler standardisé par OTel. Pour la collecte côté hôte, Grafana Alloy embarque le composant pyroscope.ebpf qui attache des sondes eBPF aux processus cibles et pousse les profils vers Pyroscope sans toucher au code applicatif. À ne pas confondre avec OpenTelemetry eBPF Instrumentation (OBI), projet voisin qui produit des traces HTTP/gRPC et des métriques RED, mais qui n’émet pas de profils.
Pyroscope, Parca, Alloy : choisir le bon outil pour la bonne couche
Les trois outils ne se substituent pas, ils s’empilent. Pyroscope est le serveur où les profils sont écrits et requêtés. Parca peut jouer le même rôle, mais avec un format de stockage différent (sample store FrostDB plus meta store Badger). Grafana Alloy et parca-agent ne stockent rien : ce sont des collecteurs qui produisent des profils et les envoient à Pyroscope ou à Parca.
Dans une stack auto-hébergée typique, on retient deux patterns. Pattern A : Pyroscope serveur + SDK applicatifs. Le code expose explicitement un endpoint /debug/pprof ou pousse les profils via le SDK Pyroscope. Le contrôle est fin, on cible précisément les fonctions à instrumenter. Pattern B : Pyroscope serveur + Grafana Alloy avec pyroscope.ebpf. L’agent tourne sur l’hôte, attache des programmes eBPF aux processus cibles, et envoie les profils sans modifier une ligne d’application. Plus lourd à mettre en place côté noyau (Linux 5.8 minimum, BTF activé), mais zéro intrusion code et excellent pour les binaires propriétaires.
Parca devient pertinent quand la licence AGPL de Pyroscope pose problème (SaaS commercial offert au public, redistribution modifiée du code serveur) ou quand on veut une seule base technique avec parca-agent en orchestration Kubernetes. Pour un homelab ou une PME qui auto-héberge sa stack interne, Pyroscope 2.0 reste le défaut le plus pragmatique.
Étape 1 — Déployer Pyroscope 2.0 avec Docker Compose
On commence par un déploiement single-binary, suffisant pour ingérer plusieurs millions d’échantillons par jour sur un VPS 4 Go. Pyroscope 2.0 a réorganisé son architecture : un seul binaire embarque distributor, ingester, store-gateway et query frontend, et tout est stateless dès qu’on branche un object storage. Pour démarrer simple, le mode « all-in-one » écrit sur le disque local et fonctionne dès le premier docker compose up.
Créer un dossier de travail puis un fichier docker-compose.yml minimal qui démarre Pyroscope et expose le port 4040, le seul utilisé pour l’ingestion et la requête :
services:
pyroscope:
image: grafana/pyroscope:2.0.2
container_name: pyroscope
ports:
- "4040:4040"
volumes:
- pyroscope-data:/data
command:
- "-config.file=/etc/pyroscope.yaml"
configs:
- source: pyroscope_cfg
target: /etc/pyroscope.yaml
configs:
pyroscope_cfg:
content: |
target: all
server:
http_listen_port: 4040
volumes:
pyroscope-data:
Lancer docker compose up -d puis vérifier la réponse HTTP : curl -s http://localhost:4040/ready doit renvoyer la chaîne ready. Si le port ne répond pas, docker compose logs pyroscope trace généralement l’erreur de configuration : une indentation YAML cassée empêche le chargement de la cible all et le processus quitte immédiatement avec un code 2.
Par défaut, Pyroscope scrappe sa propre instance et écrit ses propres profils CPU dans /data. C’est pratique pour valider que la chaîne fonctionne : ouvrir http://localhost:4040/ dans un navigateur affiche l’application pyroscope dans la liste des services, avec un flame graph qui se remplit en quelques minutes. Si rien n’apparaît, vérifier que l’horloge système est juste : Pyroscope rejette les échantillons trop décalés dans le temps, et un drift NTP de plusieurs minutes suffit à invalider l’ingestion silencieusement.
Étape 2 — Connecter Grafana 13 au datasource Pyroscope
L’UI native de Pyroscope est correcte pour explorer rapidement, mais la corrélation traces ↔ profils ne se fait qu’à travers Grafana. On ajoute donc Pyroscope comme datasource à côté de Loki, Mimir et Tempo. Pour un déploiement adjacent, ajouter au même docker-compose.yml :
grafana:
image: grafana/grafana:13.0.1
container_name: grafana
ports:
- "3000:3000"
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin"
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
Et provisionner le datasource via le fichier grafana-datasources.yaml à côté :
apiVersion: 1
datasources:
- name: Pyroscope
type: grafana-pyroscope-datasource
access: proxy
url: http://pyroscope:4040
isDefault: false
jsonData:
minStep: '15s'
Après un docker compose up -d grafana, ouvrir http://localhost:3000, aller dans Explore, sélectionner le datasource Pyroscope, choisir l’application pyroscope et le type de profil process_cpu. Un flame graph interactif apparaît : on peut zoomer sur une pile en cliquant, comparer deux fenêtres de temps avec le mode Diff, ou basculer en vue table pour exporter en CSV. Si le datasource reste rouge, vérifier que les deux conteneurs sont sur le même réseau Docker (par défaut, oui, si on est dans le même docker-compose.yml) et que l’URL utilise le nom de service http://pyroscope:4040, pas localhost.
Étape 3 — Instrumenter une application Node.js avec le SDK Pyroscope
Le SDK officiel @pyroscope/nodejs ajoute un profiler CPU et un profiler heap qui poussent leurs échantillons toutes les 60 secondes vers le serveur (valeur par défaut de flushIntervalMs). Le surcoût mesuré tient sous 1 % sur un service moyen ; le profiler heap, plus coûteux, s’active séparément. Installer le module et l’initialiser le plus tôt possible dans le bootstrap :
// pyroscope-init.js — à require() en tout premier
const Pyroscope = require('@pyroscope/nodejs');
Pyroscope.init({
serverAddress: process.env.PYROSCOPE_URL || 'http://pyroscope:4040',
appName: 'checkout-api',
tags: {
region: 'eu-west',
version: process.env.APP_VERSION || 'dev',
},
});
Pyroscope.start();
Dans le package.json, modifier la commande de démarrage pour précharger le module avant tout autre code :
"scripts": {
"start": "node -r ./pyroscope-init.js src/server.js"
}
Redémarrer le service, générer un peu de charge avec autocannon http://localhost:8080/api/items?limit=50, puis revenir dans Grafana. Le service checkout-api apparaît au bout d’environ une minute (le temps du premier flush), avec une étiquette version qui permet de comparer deux déploiements côte à côte. Si l’application ne remonte pas, ouvrir http://pyroscope:4040/api/apps pour vérifier que les pushes sont bien reçus ; un timeout ici trahit presque toujours un firewall ou un proxy qui bloque le verbe HTTP POST /ingest. Pour un service en démo où on veut un retour plus rapide, ajuster flushIntervalMs à 15000 (15 s) dans Pyroscope.init.
Étape 4 — Instrumenter une application Go avec pyroscope-go
Pour Go, on importe github.com/grafana/pyroscope-go. Le module exploite la machinerie standard runtime/pprof et se déclare via le champ go de son go.mod ; consulter ce fichier dans le dépôt officiel pour connaître la version Go minimum requise au moment du build. Dans main.go :
package main
import (
"os"
"github.com/grafana/pyroscope-go"
)
func main() {
pyroscope.Start(pyroscope.Config{
ApplicationName: "billing-worker",
ServerAddress: os.Getenv("PYROSCOPE_URL"),
Logger: pyroscope.StandardLogger,
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
},
Tags: map[string]string{"env": "prod"},
})
// ... reste du programme
}
Compiler avec go build ./cmd/billing-worker, lancer avec PYROSCOPE_URL=http://pyroscope:4040 ./billing-worker, et observer dans Grafana l’apparition de cinq types de profils distincts. ProfileAllocObjects est particulièrement utile pour traquer les allocations parasites dans les boucles chaudes : un flame graph qui montre encoding/json.Marshal en tête des allocations dénonce souvent une sérialisation inutile dans un chemin appelé des milliers de fois par requête. À noter : compiler avec -ldflags="-s -w" strip les symboles et les tables debug, ce qui empêche pyroscope-go de résoudre proprement les noms de fonctions ; pour la production où on veut conserver le profiling lisible, garder au moins les noms de fonctions ou produire un fichier debug séparé.
Étape 5 — Profiling sans modifier le code avec Grafana Alloy et eBPF
Quand le binaire n’est pas modifiable (vendor, langage non instrumenté, micro-service écrit par une équipe distante), un agent eBPF prend le relais. Grafana Alloy est le collecteur unifié de Grafana Labs, et son composant pyroscope.ebpf attache des sondes au noyau Linux pour lire les piles d’appels au moment où un événement se déclenche, sans toucher au processus cible. Prérequis : Linux 5.8 ou plus récent (ou famille RHEL 4.18 et au-dessus), BTF (BPF Type Format) activé, et exécution privilégiée sur l’hôte (root ou capacités CAP_BPF, CAP_PERFMON, CAP_SYS_PTRACE).
Créer un fichier alloy.config qui découvre les conteneurs Docker locaux, les profile en continu et pousse les profils vers Pyroscope :
discovery.docker "local_containers" {
host = "unix:///var/run/docker.sock"
}
pyroscope.write "default" {
endpoint {
url = "http://pyroscope:4040"
}
}
pyroscope.ebpf "containers" {
forward_to = [pyroscope.write.default.receiver]
targets = discovery.docker.local_containers.targets
}
Puis lancer Alloy en pointant sur ce fichier, avec les options indispensables pour qu’eBPF voie tous les processus de l’hôte :
docker run \
-v $PWD/alloy.config:/etc/alloy/alloy.config \
-v /var/run/docker.sock:/var/run/docker.sock \
--pid=host \
--privileged \
-p 12345:12345 \
grafana/alloy:latest \
run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/alloy.config
Au bout d’une à deux minutes, les profils CPU des conteneurs apparaissent dans Grafana sous le datasource Pyroscope, taggés automatiquement par nom de conteneur et image. La fréquence par défaut est de 19 échantillons par seconde et l’envoi se fait toutes les 15 secondes, valeurs qu’on peut ajuster via sample_rate et collect_interval dans le bloc pyroscope.ebpf. Si rien ne remonte, deux causes typiques : BTF absent (vérifier ls /sys/kernel/btf/vmlinux) ou capacités noyau insuffisantes (lancer Alloy sans --privileged sur un kernel récent demande la liste explicite des capacités). L’OpenTelemetry Collector et les flux OTLP traités dans le tutoriel Configurer un OpenTelemetry Collector pas-à-pas peuvent s’intercaler entre Alloy et Pyroscope si on veut centraliser l’enrichissement et le routing.
Le résultat dans Grafana est indiscernable d’un profil produit par le SDK Pyroscope, à un détail près : les fonctions internes du runtime (par exemple runtime.mallocgc en Go) apparaissent avec leurs symboles complets, parce qu’eBPF lit directement les piles natives.
Étape 6 — Parca et son agent eBPF en alternative Apache 2.0
Pour les environnements où la licence AGPLv3 de Pyroscope est un blocage légal (redistribution commerciale, SaaS multi-clients), Parca offre la même capacité sous Apache 2.0. Le serveur Parca s’exécute identiquement en Docker, mais le couple agent + serveur a été pensé en premier pour Kubernetes : parca-agent tourne en DaemonSet, découvre les pods et les services systemd sans configuration, et pousse les profils en gRPC.
services:
parca:
image: ghcr.io/parca-dev/parca:v0.28.0
container_name: parca
ports:
- "7070:7070"
volumes:
- parca-data:/var/lib/parca
command:
- "/parca"
- "--config-path=/etc/parca/parca.yaml"
- "--storage-path=/var/lib/parca"
configs:
- source: parca_cfg
target: /etc/parca/parca.yaml
configs:
parca_cfg:
content: |
object_storage:
bucket:
type: "FILESYSTEM"
config:
directory: "/var/lib/parca"
volumes:
parca-data:
Côté agent, lancer parca-agent sur l’hôte cible avec --remote-store-address=parca:7070. La 0.28.0 ajoute le support du profile type OpenTelemetry eBPF Profiler, ce qui signifie que les profils produits par parca-agent et ceux produits par Alloy deviennent interopérables : un Pyroscope 2.0 peut ingérer les profils d’un parca-agent, et inversement un Parca peut recevoir un flux OTLP. Cette convergence est le grand chantier 2026 du SIG OpenTelemetry Profiling, accéléré par l’entrée en Alpha publique du signal profiles et par la donation à la CNCF du profiler eBPF (anciennement Elastic Universal Profiling Agent).
Étape 7 — Lire un flame graph et comparer deux versions
La lecture d’un flame graph repose sur deux règles simples. Premièrement, l’axe horizontal n’est pas le temps : c’est l’agrégation. Une barre large représente une fonction qui consomme beaucoup de CPU sur l’ensemble de la fenêtre, pas une fonction qui s’exécute longtemps une fois. Deuxièmement, l’axe vertical est la profondeur d’appel : tout ce qui s’empile au-dessus d’une fonction est appelé par cette fonction. Une pile haute n’est pas un signe d’inefficacité, c’est un signe de modularité.
Pour identifier un vrai problème, on cherche les plateaux : des barres larges qui apparaissent loin du bas de la pile. Ces plateaux marquent les fonctions où le CPU se consume directement, sans déléguer ailleurs. Une boucle JSON mal écrite, une regex compilée à chaque requête, un appel synchrone à une lib crypto en chemin chaud apparaissent comme des plateaux nets. À l’inverse, une grosse barre en bas de pile (main, http.ListenAndServe) est un faux positif : on attend qu’elle soit large, c’est l’entrée du programme.
Le mode Diff de Grafana Pyroscope est l’outil le plus rentable au quotidien. Avant un déploiement, on fige une fenêtre baseline d’une heure. Après déploiement, on compare la nouvelle fenêtre à la baseline. Les barres rouges signalent les fonctions qui consomment plus de CPU dans la nouvelle version, les vertes celles qui en consomment moins. Un déploiement qui se contente d’ajouter une nouvelle feature ne devrait colorer le flame graph qu’au niveau des fonctions nouvellement appelées ; si le diff rougit en cascade sur des fonctions inchangées, le suspect numéro un est une dépendance qui s’est mise à jour avec une régression de perf.
Étape 8 — Envoyer les profils via OpenTelemetry Protocol
Depuis la version 2.0, Pyroscope accepte directement les profils au format OTLP. C’est la voie recommandée pour les nouveaux déploiements : on configure un OpenTelemetry Collector qui reçoit les profils des SDK ou de l’agent eBPF, applique des transformations (filtrage par nom d’application, ajout de resource attributes), puis exporte vers Pyroscope. Extrait minimal du otelcol.yaml :
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch: {}
resource:
attributes:
- key: deployment.environment
value: production
action: upsert
exporters:
otlphttp/pyroscope:
endpoint: http://pyroscope:4040
service:
pipelines:
profiles:
receivers: [otlp]
processors: [resource, batch]
exporters: [otlphttp/pyroscope]
La pipeline profiles est encore expérimentale (le signal Profiles d’OpenTelemetry est officiellement entré en Alpha publique en 2026), mais elle est utilisée en production par plusieurs grands éditeurs. Le bénéfice immédiat est d’unifier la chaîne : un seul collector reçoit traces, metrics, logs et profils, et la corrélation trace ↔ profil dans Grafana fonctionne nativement grâce au lien span.context. Vérifier impérativement que la distribution opentelemetry-collector-contrib utilisée embarque la pipeline profiles ; sur les versions trop anciennes, le démarrage échoue avec un message unknown pipeline type.
Erreurs fréquentes et leur diagnostic
| Symptôme | Cause probable | Action |
|---|---|---|
| Aucune application dans l’UI Pyroscope | Horloge décalée, ingestion rejetée silencieusement | Vérifier timedatectl sur tous les hôtes, activer NTP, vérifier POST /ingest dans les logs Pyroscope |
| Alloy démarre puis quitte | BTF non disponible ou noyau < 5.8 | ls /sys/kernel/btf/vmlinux doit exister ; sinon, recompiler le noyau avec CONFIG_DEBUG_INFO_BTF=y ou installer un kernel récent |
| Flame graph vide après instrumentation Node.js | SDK initialisé après le serveur HTTP, premiers échantillons perdus | Utiliser node -r ./pyroscope-init.js au démarrage, pas un import au milieu du code ; attendre 60 s pour le premier flush |
| Datasource Pyroscope rouge dans Grafana | Réseau Docker isolé ou URL externe au lieu du nom de service | Mettre http://pyroscope:4040, pas http://localhost:4040, depuis le conteneur Grafana |
| Profils Go absents alors que le SDK est chargé | Symboles strippés par -ldflags="-s -w" empêchent la résolution |
Conserver les symboles : retirer -s et -w ou activer le debug info séparé |
| Surcoût CPU supérieur à 5 % avec SDK Node.js | Heap profiler activé par défaut sur micro-services à fort taux d’allocation | Désactiver le profiler heap dans Pyroscope.init ou ajuster flushIntervalMs à une valeur plus haute (par défaut 60 s) |
| OTLP Profiles refusés par le collector | Distribution OTel Collector sans pipeline profiles | Mettre à jour otel/opentelemetry-collector-contrib vers une version récente, vérifier la liste des composants embarqués avec otelcol --components |
Aller plus loin avec le reste de la chaîne d’observabilité
Le profiling continu ne remplace ni les traces, ni les métriques, ni les journaux : il les complète. Une trace OpenTelemetry pointe l’endpoint lent, les métriques Prometheus chiffrent l’amplitude, les journaux Loki donnent le message d’erreur, et le profil dit quelle ligne de code consomme le CPU. Pour boucler la chaîne, deux références internes utiles : la vue d’ensemble OpenTelemetry et stack LGTM pour la philosophie générale, et Envoyer les logs applicatifs vers Loki via OTLP pour la pipeline de logs structurés. Côté noyau, les bases d’eBPF et observabilité kernel-level permettent de comprendre comment Alloy lit les piles d’appels sans modifier les binaires. Pour la couche dashboards/alerting qui consomme les profils, le tutoriel Grafana, Loki et Prometheus pose les fondations.
Ressources officielles et références
- Documentation Grafana Pyroscope — guide d’installation, configuration et SDKs maintenus par Grafana Labs.
- Pyroscope releases — historique des versions, notes de mise à jour, signatures des binaires.
- Profiling eBPF avec Grafana Alloy — page officielle dédiée à la collecte eBPF côté Pyroscope.
- Composant pyroscope.ebpf dans Alloy — référence complète du composant, paramètres et exigences.
- Documentation Parca — guide d’installation, schéma de stockage, intégration Kubernetes.
- Parca releases — historique des versions, notamment 0.28.0 et le support eBPF Profiler OTel.
- parca-agent — code source et documentation des flags du DaemonSet eBPF.
- OpenTelemetry eBPF Profiler — projet CNCF du profiler système (à distinguer d’OBI qui produit des traces, pas des profils).
- OpenTelemetry Profiles signal — spécification du quatrième signal OTLP, statut Alpha public 2026.
- SDK Node.js Pyroscope — code source, options de
init()et exemples d’intégration. - SDK Go Pyroscope — code source, types de profils supportés et configuration.