Profiler un consumer avec Blackfire
Quand on parle de profiling PHP avec Blackfire, on pense généralement à une requête HTTP : un clic sur l’extension navigateur, et hop, le profil apparaît. Mais que faire quand le code à profiler tourne dans un consumer ou un worker ; une commande Symfony qui boucle indéfiniment, consommant une queue ou traitant des tâches en background ? La bonne nouvelle : Blackfire peut fonctionner dans ce contexte. La moins bonne : on ne peut pas déclencher le profiling au moment où ça nous arrange… sauf si on utilise les signaux POSIX.
Info
Cet article s’appuie sur les signaux POSIX. Si vous n’êtes pas familier avec ce concept, je vous invite à lire d’abord Les signaux POSIX et PHP qui pose les bases nécessaires.
Section intitulée le-problemeLe problème
Un consumer PHP ressemble souvent à ça :
while (true) {
$message = $queue->consume();
if (!$message) {
sleep(1);
continue;
}
$this->process($message);
}
Il tourne en permanence. On ne contrôle pas quand l’itération commence ni quand elle finit. Déclencher Blackfire depuis l’extérieur sans interrompre le traitement en cours, c’est précisément le défi.
La solution : envoyer un signal UNIX au processus pour lui dire « commence à profiler » puis un second signal pour lui dire « arrête-toi et envoie le profil ».
Section intitulée installation-de-blackfireInstallation de Blackfire
L’installation de Blackfire se fait via Composer :
composer require blackfire/php-sdk
Section intitulée code-signalablecommandinterface-code-gerer-les-signaux-dans-une-commande-symfonySignalableCommandInterface : gérer les signaux dans une commande Symfony
Depuis Symfony 5.2, le composant Console expose l’interface SignalableCommandInterface.
Elle permet à une commande de s’abonner à des signaux POSIX et de réagir proprement sans passer par pcntl_signal() manuellement.
L’interface oblige à implémenter deux méthodes :
use Symfony\Component\Console\Command\SignalableCommandInterface;
class MyWorkerCommand extends Command implements SignalableCommandInterface
{
public function getSubscribedSignals(): array
{
return [\SIGTERM, \SIGINT, \SIGUSR2];
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
// ...
return false;
}
}
Section intitulée code-getsubscribedsignals-codegetSubscribedSignals()
Cette méthode retourne la liste des signaux que la commande veut intercepter. Les signaux sont des constantes entières définies par le système (voir kill -l pour la liste complète). Les plus courants :
| Signal | Valeur | Usage habituel |
|---|---|---|
SIGTERM |
15 | Arrêt demandé (e.g. kill <pid>) |
SIGINT |
2 | Interruption clavier (Ctrl+C) |
SIGUSR1 |
10 | Signal utilisateur libre n°1 |
SIGUSR2 |
12 | Signal utilisateur libre n°2 |
Section intitulée code-handlesignal-code-decrypter-la-signaturehandleSignal() : décrypter la signature
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
Trois éléments méritent attention :
int $signal: Le numéro du signal reçu. C’est lui qu’on inspecte avecmatchouin_arraypour savoir quoi faire ;int|false $previousExitCode = 0: Code de sortie renvoyé par le signal handler précédent, oufalsesi c’est le premier signal traité dans cette invocation. Utile si plusieurs signal handlers s’accumulent ;- Valeur de retour
int|false: C’est là que ça devient intéressant :- retourner
falsesignifie « je ne veux pas quitter, laisse le process continuer » ; - retourner un entier (typiquement
0ou un code d’erreur) signifie « termine le process avec ce code de sortie ».
- retourner
Autrement dit, pour un consumer qui doit continuer à tourner après avoir traité le signal (comme démarrer ou stopper un profil Blackfire), on retourne false. Pour un signal d’arrêt (SIGTERM, SIGINT), on peut soit retourner un code de sortie, soit armer un flag shouldStop et retourner false pour finir proprement l’itération en cours.
Section intitulée integrer-blackfire-via-code-sigusr2-codeIntégrer Blackfire via SIGUSR2
L’idée : on utilise SIGUSR2 comme interrupteur. Premier signal → on démarre la probe Blackfire. Second signal → on l’arrête et on affiche le profil.
use Blackfire\Client;
use Blackfire\ClientConfiguration;
use Blackfire\Probe;
class ResourceAnalyserCommand extends Command implements SignalableCommandInterface
{
private bool $shouldStop = false;
public function getSubscribedSignals(): array
{
$signals = [\SIGTERM, \SIGINT];
// On n'ajoute SIGUSR2 que si la lib Blackfire est disponible
if (class_exists(Client::class)) {
$signals[] = \SIGUSR2;
}
return $signals;
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
if (\in_array($signal, [\SIGTERM, \SIGINT])) {
$signalName = SignalMap::getSignalName($signal);
$this->stop("Signal {$signalName} received.");
}
if (\SIGUSR2 === $signal) {
$client = new Client(new ClientConfiguration(
$_SERVER['BLACKFIRE_CLIENT_ID'],
$_SERVER['BLACKFIRE_CLIENT_TOKEN'],
));
static $probe = null;
if (null === $probe) {
$this->logger->notice('Blackfire profile started.');
$probe = $client->createProbe();
} else {
$profile = $client->endProbe($probe);
$this->logger->notice('Blackfire profile finished.', [
'url' => $profile->getUrl(),
]);
$probe = null;
}
}
return false;
}
}
Quelques points notables :
- La variable
$probeeststatic: elle survit entre les appels àhandleSignal(). Sans ça, le probe serait perdu entre le premier et le second signal ; class_exists(Client::class): le packageblackfire/php-sdkn’est pas forcément installé en production. Cette garde permet d’utiliserSIGUSR2uniquement quand Blackfire est disponible, sans planter si ce n’est pas le cas ;- On retourne
false: le worker ne doit pas s’arrêter après un signalSIGUSR2. On continue la boucle normalement pendant que Blackfire collecte les données.
Section intitulée utilisation-concreteUtilisation concrète
# Trouver le PID du worker
ps aux | grep redirectionio:crawler:analyze-resources
# Premier signal : démarrer le profiling
kill -USR2 <pid>
# Laisser tourner quelques itérations...
# Second signal : arrêter le profiling et envoyer le profil
kill -USR2 <pid>
L’URL du profil apparaît dans les logs du worker. On peut ensuite l’ouvrir dans l’interface Blackfire pour analyser les flamegraphs, les appels de fonctions, la mémoire, etc. Exactement comme pour une requête HTTP classique.
Si on ne veut pas ouvrir un deuxième terminal, nous pouvons passer le worker courant en mode « background » avec Ctrl+Z puis bg, et envoyer les signaux depuis le même terminal.
$ bin/console redirection:crawler:analyze-resources -vv
15:00:28 NOTICE [crawler] Crawl analyzer started.
^Z #### CTRL+Z au clavier
[1]+ Stopped bin/console redirection:crawler:analyze-resources -vv
$ bg #### On passe le process en background
[1]+ bin/console redirection:crawler:analyze-resources -vv &
$ kill -SIGUSR2 %1 #### On envoi le signal SIGUSR2 au premier processus en arrière-plan
15:00:50 NOTICE [crawler] Profile Blackfire started.
$ kill -SIGUSR2 %1 #### On envoi le signal SIGUSR2 au premier processus en arrière-plan
15:01:00 NOTICE [crawler] Profile Blackfire finished. ["url" => "https://blackfire.io/XXXXXXXX/graph"]
$ fg #### On repasse le process en avant-plan
[1]+ bin/console redirection:crawler:analyze-resources -vv
### ....
Section intitulée aparte-gerer-l-arret-proprement-avec-code-shouldstop-codeAparté : gérer l’arrêt proprement avec shouldStop
Un consumer ne peut pas s’arrêter n’importe quand. Si un message est en cours de traitement au moment où SIGTERM arrive (déploiement, restart Kubernetes…), couper brutalement le process risque de laisser des données dans un état incohérent.
Le pattern classique : un flag booléen qu’on arme à la réception du signal, qu’on consulte entre chaque itération.
class ResourceAnalyserCommand extends Command implements SignalableCommandInterface
{
private bool $shouldStop = false;
protected function execute(InputInterface $input, OutputInterface $output): int
{
while (true) {
// On vérifie le flag en début d'itération, pas pendant
if ($this->shouldStop) {
return Command::SUCCESS;
}
$message = $this->queue->consume();
if (!$message) {
sleep(1);
continue;
}
// Traitement atomique : on ne sera pas interrompu ici
$this->process($message);
}
}
public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
if (\in_array($signal, [\SIGTERM, \SIGINT])) {
$this->stop('Signal received.');
}
return false; // On ne quitte pas immédiatement !
}
private function stop(string $reason): void
{
$this->logger->notice("Worker will stop after current task. {$reason}");
$this->shouldStop = true;
}
}
L’astuce est dans le return false de handleSignal() : on ne quitte pas immédiatement.
On arme juste le flag, et on laisse l’itération en cours se terminer normalement. Le check if ($this->shouldStop) en tête de boucle assure qu’on sort proprement entre deux tâches.
Cette approche est particulièrement importante dans des contextes où le traitement a des effets de bord (écriture en base, appel à une API externe, mise à jour d’un état distribué) et où une interruption au milieu laisserait des données incohérentes.
Section intitulée conclusionConclusion
Profiler un consumer avec Blackfire se résume à trois ingrédients :
- Implémenter
SignalableCommandInterfacepour intercepterSIGUSR2proprement ; - Utiliser une variable
staticdanshandleSignal()pour alterner entre « start probe » et « end probe » d’un signal à l’autre ; - Retourner
falsepour que le worker continue à tourner pendant et après le profiling.
Le résultat : on peut déclencher un profil Blackfire sur un consumer en production (ou en staging) sans le redémarrer, sans modifier le code, et sans interrompre le traitement en cours. Un kill -USR2 <pid> suffit.
Le SDK Blackfire fournit ensuite l’URL du profil, qu’on peut analyser dans l’interface Blackfire comme pour n’importe quelle requête HTTP.
Enfin, le SDK propose d’autres fonctionnalités avancées, comme la possibilité de créer des probes avec des paramètres spécifiques, de gérer des scénarios complexes, ou d’intégrer le profiling dans des pipelines CI/CD. Pour plus de détails, consultez la documentation officielle de Blackfire.
Commentaires et discussions
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Après avoir monté une nouvelle équipe de développement, nous avons procédé à la migration de toute l’infrastructure technique sur une nouvelle architecture fortement dynamique à base de Symfony2, RabbitMQ, Elasticsearch et Chef. Les gains en performance, en stabilité et en capacité de développement permettent à l’entreprise d’engager de nouveaux marchés…
Pour renforcer Ouranos, le référentiel de données dédié à la signalisation ferroviaire, SNCF Réseau a sollicité JoliCode afin d’auditer et améliorer la qualité technique de l’application. Développée en Symfony et API Platform, Ouranos est au cœur des processus de production, utilisé aussi bien en interne que par l’industrie. Notre intervention s’est…
Refonte complète de la plateforme d’annonces immobilières de Cushman & Wakefield France. Connecté aux outils historiques, cette nouvelle vitrine permet une bien meilleure visibilité SEO et permet la mise en avant d’actifs qui ne pouvaient pas l’être auparavant.