jobsphp

Fuites de mémoire en PHP : le calvaire de Thomas sur ses workers

Ingénierie Web. Fuites de mémoire en PHP : le calvaire de Thomas sur ses workers

memory_get_usage(false) reste stable. memory_get_usage(true) continue de monter. Après plusieurs heures, le worker PHP consomme toute la RAM disponible.

Fuites de mémoire en PHP: le calvaire de Thomas sur ses workers

Ce scénario est courant dans les processus longue durée: workers Symfony Messenger, files Laravel, consommateurs RabbitMQ, traitements CLI massifs ou applications exécutées avec RoadRunner. Le processus ne meurt pas nécessairement à cause d’une fuite classique. Il peut simplement conserver des allocations dans le Zend Memory Manager, accumuler des objets référencés par un ORM ou attendre trop longtemps avant son recyclage.

Thomas cherche une ligne fautive. Le problème est souvent plus bas niveau: le modèle d’exécution de PHP a changé. Un script qui démarre, traite une requête et se termine remet naturellement son état au processus suivant. Un worker qui reste actif pendant des heures conserve son contexte, ses références, ses allocations et parfois ses erreurs de conception.

La gestion des fuites de mémoire PHP dans les processus longue durée ne repose donc pas sur unset() appliqué au hasard. Elle exige de distinguer trois phénomènes: la mémoire réellement utilisée par les structures PHP, la mémoire réservée par le gestionnaire interne et la mémoire retenue par l’application elle-même.

Le piège du Zend Memory Manager: une RAM qui ne redescend jamais

PHP gère ses allocations avec le Zend Memory Manager, ou ZMM. Celui-ci ne demande pas systématiquement au système d’exploitation de rendre chaque bloc dès qu’une variable disparaît. Il travaille avec des blocs de mémoire, souvent appelés chunks, dont la taille se situe généralement entre 2 et 4 Mo.

Le mécanisme est rationnel. Rendre puis redemander constamment de petites zones mémoire coûte du temps et augmente la fragmentation. Le ZMM conserve donc une partie des allocations libérées pour les réutiliser lors des opérations suivantes. Dans une requête HTTP classique, cette stratégie est presque invisible: le processus PHP-FPM traite la requête, puis le cycle de vie du processus limite naturellement l’accumulation.

Dans un worker CLI, la situation est différente. Le processus ne repart pas à zéro après chaque message. Les allocations libérées peuvent rester réservées par le ZMM. La valeur retournée par memory_get_usage(true) demeure alors supérieure à celle de memory_get_usage(false).

Ces deux mesures ne répondent pas à la même question:

MesureCe qu’elle indiqueUtilité
memory_get_usage(false)Mémoire effectivement utilisée par les allocations PHP activesObserver les structures encore vivantes
memory_get_usage(true)Mémoire réellement réservée par PHP auprès de son gestionnaire interneÉvaluer la pression exercée sur le processus
Limite memory_limitPlafond appliqué à la mémoire utilisable par PHPÉviter qu’un traitement dépasse la capacité prévue
Mémoire du processus au niveau systèmeEmpreinte globale du processus, incluant plusieurs composants internesMesurer l’impact réel sur la machine

Un écart durable entre les deux valeurs n’est pas automatiquement une fuite. Si memory_get_usage(false) redescend après chaque message mais que memory_get_usage(true) reste élevé, le ZMM conserve probablement des blocs pour de futures allocations. Le processus peut rester fonctionnel sans que le système récupère immédiatement cette mémoire.

À l’inverse, si les deux courbes progressent régulièrement pendant que le worker traite des messages comparables, la cause se trouve généralement dans l’application: références conservées, tableaux qui grossissent, objets attachés à un contexte global, cache sans éviction ou ressources externes non libérées.

Une mémoire réservée n’est pas nécessairement une mémoire perdue. Dans un worker, le signal critique est la progression de la mémoire active sur un flux de travail répétitif.

Les valeurs de memory_limit fréquemment rencontrées dans php.ini, comme 128M ou 256M, donnent une fausse impression de sécurité. Elles protègent le processus PHP, pas l’hôte complet. Plusieurs workers exécutés en parallèle peuvent chacun approcher leur plafond. Avec PHP-FPM, les processus enfants s’additionnent. Avec un worker CLI, un seul processus peut monopoliser sa limite pendant plusieurs heures avant son arrêt.

La première erreur de diagnostic consiste donc à regarder uniquement la mémoire à la fin du traitement. Il faut mesurer le profil complet: avant le message, après l’hydratation, après le traitement métier, après la sérialisation, puis après le nettoyage.

Références circulaires: quand le comptage de références ne suffit plus

Le modèle mémoire de PHP repose largement sur le comptage de références. Tant qu’une valeur n’est plus référencée, elle peut être libérée. Le problème apparaît lorsqu’un groupe d’objets se référence en boucle.

Un objet parent contient une collection d’enfants. Chaque enfant conserve une référence vers le parent. Le compteur de références de chaque objet ne tombe jamais à zéro, même lorsque le reste de l’application ne possède plus de référence exploitable vers cet ensemble.

Le cycle reste alors inaccessible pour le comptage classique. Le garbage collector, ou GC, intervient pour détecter ces cycles et récupérer les structures concernées. Le mécanisme existe depuis PHP 5.3, avec une évolution du garbage collector destinée à traiter ce type de graphe d’objets.

Dans un script court, un cycle temporaire a souvent un impact négligeable. Dans un worker, le même comportement répété plusieurs milliers de fois devient une pente mémoire. Chaque message crée un nouveau graphe. Une partie est libérée. Une autre reste détectable uniquement par le GC. Une autre encore peut être conservée par une référence applicative persistante.

gc_collect_cycles() peut forcer une collecte. Cela ne signifie pas que toute la mémoire récupérée sera rendue au système d’exploitation. Le ZMM peut la conserver afin de la réutiliser. L’appel sert à supprimer des cycles encore vivants, pas à garantir une baisse immédiate de l’empreinte mémoire du processus.

Ce qu’il faut isoler pendant le débogage

Le débogage d’un memory leak PHP doit s’appuyer sur des points de mesure reproductibles. Un worker traité avec des messages hétérogènes produit des courbes difficiles à interpréter. Il faut d’abord utiliser un lot homogène: même commande, volume comparable, même stratégie d’hydratation, même taille de charge utile.

Mesurez ensuite plusieurs compteurs:

  • la mémoire avant le traitement du message;
  • la mémoire après la création des objets métier;
  • la mémoire après l’appel à l’ORM;
  • la mémoire après la suppression des références temporaires;
  • le nombre de cycles collectés par gc_collect_cycles();
  • la mémoire réservée avec memory_get_usage(true);
  • la mémoire active avec memory_get_usage(false).

Un compteur brut ne suffit pas. Une hausse ponctuelle suivie d’une stabilisation indique une allocation de travail ou une réserve interne. Une hausse monotone, message après message, indique une rétention qui n’est pas résorbée.

Les tableaux PHP constituent un cas classique. Un tableau qui reçoit de nouvelles entrées à chaque itération conserve toute sa croissance. La référence peut être indirecte: propriété statique, registre global, cache applicatif, objet de service partagé ou journalisation en mémoire. Le code métier paraît local. La durée de vie réelle de la donnée ne l’est pas.

Les fermetures et les callbacks compliquent aussi l’analyse. Une fermeture peut capturer un objet volumineux et rester stockée dans une file interne, un événement ou une collection. Le traitement principal est terminé, mais la référence existe encore. unset() sur la variable locale ne suffit pas si une autre structure possède toujours l’objet.

Le même raisonnement s’applique aux ressources: descripteurs de fichiers, flux réseau, réponses HTTP, images, structures issues d’extensions ou objets manipulés par des bibliothèques natives. La mémoire n’est pas uniquement celle des variables visibles dans le code PHP. Le profil système peut rester élevé alors que les compteurs PHP semblent acceptables.

Doctrine et hydratation: l’EntityManager comme rétention persistante

Le traitement de masse avec Doctrine expose une autre classe de problèmes. L’ORM hydrate des entités, les rattache à l’EntityManager et conserve leur état pour suivre les changements. Ce comportement est utile dans une unité de travail classique. Il devient coûteux lorsque le script parcourt des dizaines de milliers d’enregistrements dans le même processus.

Chaque entité hydratée peut rester connue de l’EntityManager. Le traitement courant se termine, mais l’identité de l’objet et son état peuvent continuer à être conservés. La mémoire active progresse alors même que les variables locales ont été supprimées.

Dans ce contexte, detach() permet de retirer explicitement une entité du contexte de persistance. Il ne faut pas le considérer comme une formule magique: les collections associées, les proxys, les tableaux intermédiaires et les résultats déjà accumulés peuvent conserver des références. Mais sans détachement, le contexte Doctrine devient rapidement le principal réservoir d’objets du worker.

La stratégie dépend du traitement:

1. Hydrater uniquement les colonnes nécessaires au lieu de charger des graphes d’entités complets. Une requête qui ramène plusieurs associations produit une structure mémoire sans rapport avec le volume réellement utile au calcul.

2. Traiter les résultats par lots. La taille d’un lot doit être observée avec un profil mémoire réel, pas choisie sur une valeur arbitraire.

3. Détacher les entités après leur traitement avec detach().

4. Réinitialiser le contexte de persistance lorsque le traitement le permet.

5. Éviter de conserver les entités dans un tableau de résultats global ou dans un objet de rapport qui vit jusqu’à la fin du worker.

6. Nettoyer les références de collection avant de passer au lot suivant.

7. Séparer la lecture et l’écriture lorsque l’unité de travail devient trop volumineuse.

Le piège est souvent visible dans la SQL mais pas dans la mémoire. La requête est rapide. L’hydratation est correcte. Le traitement métier est linéaire. Pourtant, le worker finit par dépasser sa limite parce que la totalité des entités reste attachée au contexte.

Une requête paginée ne règle pas automatiquement le problème. Si chaque page ajoute ses objets à une collection conservée par le service, la pagination limite le volume SQL mais pas la durée de vie des références. Le système lit par blocs, puis reconstitue progressivement une fuite applicative.

Le coût caché des messages

Les workers de messagerie ajoutent une autre couche de rétention. Un message peut contenir une charge utile sérialisée, des métadonnées, des exceptions, des traces ou des objets enrichis pendant son traitement. Si une exception conserve une chaîne de cause, un contexte ou une trace volumineuse, l’objet peut rester accessible plus longtemps que prévu.

Les événements et middleware doivent donc être examinés avec la même rigueur que le code métier. Un middleware qui ajoute des informations à une structure partagée à chaque message finit par transformer une opération de journalisation en accumulation mémoire.

Le traitement doit revenir à un état minimal après chaque unité de travail. Cette exigence ne signifie pas que unset() doit apparaître partout. Elle signifie que les objets dont la portée est limitée au message ne doivent pas être stockés dans des services à durée de vie supérieure.

Recyclage des workers: une mesure d’architecture, pas un aveu d’échec

Un processus PHP longue exécution n’est pas équivalent à une requête PHP classique. Même avec un code correctement nettoyé, le ZMM, les extensions, les bibliothèques natives et les variations de charge peuvent empêcher l’empreinte mémoire de revenir à son niveau initial.

Le recyclage périodique est donc une stratégie normale. Il limite la durée d’exposition du processus et remet à zéro l’état mémoire en détruisant le worker. L’objectif n’est pas de masquer tous les défauts. L’objectif est de contenir la dérive résiduelle et de garantir une consommation bornée.

Symfony Messenger fournit deux mécanismes directs:

  • --time-limit arrête le worker après une durée définie;
  • --memory-limit force son arrêt lorsque le seuil mémoire est atteint.

Le redémarrage doit être propre. Un worker ne doit pas être tué au milieu d’un traitement sans stratégie de reprise. La configuration de l’acknowledgement, de la visibilité du message et des tentatives doit correspondre au mode d’arrêt utilisé.

Un processus qui redémarre toutes les quelques secondes signale une limite trop basse, un message pathologique ou une rétention massive. Un processus qui reste actif pendant des jours malgré une progression lente de la mémoire peut dépasser la marge opérationnelle disponible. La bonne valeur n’est pas universelle. Elle se déduit de la courbe observée, de la taille des messages, du nombre de workers et de la RAM réellement allouée.

PHP-FPM: le même principe avec pm.max_requests

Pour les applications HTTP, PHP-FPM applique une logique comparable avec pm.max_requests. Ce paramètre indique après combien de requêtes un processus enfant doit être détruit puis recréé.

La valeur pm.max_requests = 200 est souvent utilisée comme point de départ dans des configurations rencontrées en production, mais elle ne constitue pas une recommandation générale. Le bon réglage dépend du trafic, de la dispersion des requêtes, des extensions chargées et de la courbe RSS des processus.

Un seuil trop faible augmente les créations de processus et peut dégrader la stabilité sous charge. Un seuil trop élevé laisse une dérive mémoire s’installer. Le réglage doit être corrélé avec les métriques PHP-FPM, la latence, le taux de remplacement des enfants et l’empreinte mémoire par processus.

Le recyclage ne corrige pas une accumulation dans Redis, une requête SQL non maîtrisée ou un cache externe mal configuré. Il agit uniquement sur le cycle de vie du processus local. Cette distinction évite de chercher une correction PHP à un problème d’architecture distribuée.

Le recyclage périodique n’est pas un pansement honteux. Pour un worker PHP, c’est une frontière de mémoire explicite entre deux états d’exécution.

Détecter une fuite mémoire PHP sans confondre symptôme et cause

La surveillance doit distinguer l’usage mémoire PHP, la mémoire réservée par le ZMM et l’empreinte du processus au niveau du système. Ces métriques évoluent différemment.

Un worker peut afficher une mémoire PHP stable tout en conservant une empreinte système élevée. Il peut aussi avoir une empreinte raisonnable au démarrage puis progresser à chaque lot à cause d’un objet encore référencé. Sans séries temporelles, ces deux scénarios produisent le même incident: la machine finit sous pression.

Le diagnostic efficace suit une séquence courte:

1. Reproduire avec une charge homogène. Traitez le même type de message suffisamment longtemps pour faire apparaître la tendance, sans mélanger plusieurs problèmes.

2. Journaliser les deux valeurs de memory_get_usage(). La différence entre mémoire active et mémoire réservée indique si le problème se situe dans les allocations encore vivantes ou dans la réserve du gestionnaire.

3. Mesurer par étapes. Ajoutez des points après l’hydratation, le calcul, l’écriture et le nettoyage. La pente apparaît rarement au niveau du message complet.

4. Forcer une collecte GC à titre de test. Si gc_collect_cycles() récupère régulièrement des cycles, le graphe d’objets doit être inspecté. L’appel ne garantit pas une restitution de la mémoire à l’OS.

5. Désactiver temporairement les composants suspects. ORM, cache local, système d’événements, télémétrie et sérialisation doivent pouvoir être comparés séparément.

6. Comparer avec un redémarrage périodique. Si le remplacement du processus fait chuter l’empreinte, la rétention est bien liée à la durée de vie du worker, même si sa cause précise reste à identifier.

7. Observer le système d’exploitation. La mémoire RSS, le nombre de processus et le swap complètent les mesures internes à PHP.

Les profilers permettent d’aller plus loin, mais une instrumentation minimale produit déjà beaucoup d’informations. Le point clé est de mesurer la pente, pas seulement la valeur absolue. Un worker à 220 Mo stable est souvent moins dangereux qu’un worker à 90 Mo qui gagne plusieurs mégaoctets à chaque message.

La fréquence de la collecte GC mérite également une analyse. Lancer gc_collect_cycles() à chaque message peut ajouter un coût CPU inutile si aucun cycle ne s’accumule. Ne jamais l’appeler peut laisser une charge différée dans un traitement qui construit de nombreux graphes d’objets. Le réglage dépend du profil réel du worker, pas d’une règle copiée dans une configuration.

Les erreurs de diagnostic les plus fréquentes

Certaines conclusions reviennent régulièrement et conduisent à des corrections inefficaces:

  • Accuser unset() de ne rien faire. unset() supprime une référence donnée. Il ne supprime pas les autres références et ne promet pas que le ZMM rende la mémoire à l’OS.
  • Appeler le GC comme un compacteur universel. Le garbage collector traite les cycles. Il ne purge pas un tableau encore référencé, un EntityManager actif ou un cache sans limite.
  • Réduire memory_limit pour corriger la cause. Un plafond plus bas déclenche l’échec plus tôt. Il ne libère aucune référence.
  • Augmenter la RAM sans mesurer. La capacité supplémentaire reporte l’incident et peut multiplier le coût d’exploitation si plusieurs workers dérivent simultanément.
  • Surveiller uniquement les erreurs fatales. Une dérive lente dégrade la capacité de traitement avant d’atteindre memory_limit.
  • Traiter tous les workers comme des scripts temporaires. Leur durée de vie transforme chaque état global en dette mémoire potentielle.

Le code doit donc être conçu avec une notion explicite de portée. Une donnée liée à un message ne doit pas survivre au message sans raison. Un objet de service partagé ne doit pas devenir un conteneur de résultats. Un cache local doit avoir une politique d’éviction. Un ORM doit recevoir un contexte de travail borné.

Une architecture de worker qui reste stable

La stabilité ne vient pas d’un seul appel PHP. Elle résulte de plusieurs limites superposées: taille des lots, durée du processus, seuil mémoire, volume de données hydratées et comportement des dépendances.

Un worker robuste traite une unité de travail, libère ses références temporaires, écrit ses métriques, puis revient à un état connu. Il ne conserve pas une collection de résultats pour produire un rapport final si ce rapport peut être écrit progressivement. Il ne charge pas un graphe Doctrine complet quand trois colonnes suffisent. Il ne réutilise pas indéfiniment un cache de convenance sous prétexte que la mémoire est disponible.

La configuration doit refléter le budget mémoire réel. Avec plusieurs workers, le budget ne se calcule pas sur un seul processus. Il faut réserver de la marge pour PHP-FPM, la base de données, Redis, le système, les agents de supervision et les pics temporaires. Une configuration qui remplit la RAM nominale en régime normal n’est pas une configuration optimisée. C’est une configuration sans marge.

Les workers Symfony Messenger, les files Laravel ou les exécutions via RoadRunner ne suppriment pas les contraintes de PHP. Ils changent la durée de vie des processus et rendent visibles des comportements qui restent masqués dans le modèle requête-réponse.

Le seuil opérationnel doit donc être testé en conditions proches de la production:

  • messages de petite et de grande taille;
  • succès et exceptions;
  • entités Doctrine présentes et absentes;
  • appels réseau lents;
  • lots incomplets;
  • redémarrage propre;
  • plusieurs workers en concurrence;
  • pression mémoire sur l’hôte.

Le benchmark pertinent n’est pas seulement le temps de traitement. Il inclut la courbe mémoire, le nombre de messages traités avant recyclage, la latence au moment de la collecte GC et la variation de l’empreinte RSS.

Une optimisation qui gagne quelques millisecondes mais ajoute une référence persistante à chaque message est un mauvais échange. Dans un processus longue durée, la scalabilité se mesure aussi en stabilité mémoire. Un traitement légèrement plus lent mais borné vaut mieux qu’un worker rapide qui finit par tuer la machine.

Verdict

Les fuites de mémoire PHP dans les processus longue durée ne se corrigent pas avec une recette unique. Il faut séparer la mémoire active de la mémoire réservée, inspecter les références circulaires, contrôler l’EntityManager de Doctrine, limiter les structures persistantes et recycler les processus avant l’épuisement de la RAM.

Pour un worker CLI, --time-limit, --memory-limit, le détachement des entités et une instrumentation avec memory_get_usage(true) et memory_get_usage(false) constituent une base opérationnelle solide. gc_collect_cycles() complète le dispositif lorsqu’un graphe d’objets cyclique est confirmé. Il ne remplace ni le profilage ni le recyclage.

Verdict binaire: à utiliser en production, avec des limites de durée et de mémoire explicites. À ne pas utiliser en production, si le worker dépend d’un état global qui grossit sans borne et si sa consommation n’est pas mesurée.

Questions fréquentes

Quelle est la différence entre memory_get_usage(false) et memory_get_usage(true) ?
La première mesure la mémoire réellement utilisée par les allocations PHP actives, tandis que la seconde indique la mémoire totale réservée par le gestionnaire interne de PHP auprès du système.
Pourquoi la mémoire d'un worker PHP ne redescend-elle pas après un unset() ?
Le Zend Memory Manager conserve souvent les blocs de mémoire libérés pour les réutiliser ultérieurement afin d'éviter la fragmentation, ce qui signifie que la mémoire n'est pas systématiquement rendue au système d'exploitation.
Comment éviter les fuites de mémoire avec Doctrine dans un worker ?
Il est recommandé d'hydrater uniquement les colonnes nécessaires, de traiter les données par lots, de détacher les entités avec detach() après leur traitement et de réinitialiser le contexte de persistance.
L'appel à gc_collect_cycles() permet-il de libérer de la mémoire vers le système ?
Non, cet appel sert uniquement à détecter et supprimer les cycles de références entre objets, mais il ne garantit pas que la mémoire récupérée sera immédiatement restituée au système d'exploitation par le gestionnaire interne.
Comment configurer le recyclage d'un worker Symfony Messenger ?
Vous pouvez utiliser les options --time-limit pour arrêter le worker après une durée définie ou --memory-limit pour forcer son arrêt lorsqu'un seuil de consommation mémoire est atteint.