jobsphp

Fuites de mémoire PHP : mes erreurs sur des workers 24/7

Ingénierie Web. Fuites de mémoire PHP : mes erreurs sur des workers 24/7

Les fuites de mémoire PHP dans les scripts longs ne ressemblent presque jamais à la belle fuite de manuel, celle qu’on repère en cinq minutes avec un tableau qui grossit à chaque tour de boucle.

Fuites de mémoire PHP: mes erreurs sur des workers 24/7

En production, c’est plus sournois: un processus de file de messages démarre à 70 Mo, traite tranquillement quelques milliers de tâches, grimpe à 300 Mo, puis se fait tuer au moment où le trafic décide — évidemment — de devenir intéressant.

Le piège, c’est notre vieux réflexe HTTP. On a appris PHP avec le cycle « requête, réponse, nettoyage, fin ». Et ça marche très bien… tant qu’un processus ne vit que le temps d’une requête. Mais un consommateur Symfony Messenger, une commande Laravel, un démon maison ou un traitement d’import qui tourne 24 heures sur 24 n’a pas ce luxe. Il garde des objets, des connexions, des caches, parfois des résultats SQL entiers. Bref: tout ce qu’on croyait temporaire devient potentiellement du legacy vivant.

Un script PHP long n’est pas une requête HTTP qui dure plus longtemps: c’est une autre espèce de programme.

Pourquoi un processus persistant finit-il par dériver?

Le point de départ est simple: un processus CLI persistant initialise l’application une fois, puis enchaîne les tâches. Conteneur de services, configuration, clients HTTP, connexions Doctrine, gestionnaires d’événements, buffers de journalisation: tout cela peut survivre au message précédent.

Ce n’est pas forcément une fuite. Et c’est là qu’on se fait souvent avoir.

Une fuite, au sens utile pour nous, c’est de la mémoire qui reste retenue alors que le travail qui l’a produite est terminé. Mais une hausse de mémoire peut aussi venir:

  • du gestionnaire mémoire de PHP, qui conserve des pages pour les réutiliser plus tard;
  • d’une extension native ou d’une bibliothèque qui alloue en dehors de ce que PHP expose clairement;
  • d’un cache applicatif volontairement persistant;
  • d’un énorme résultat SQL chargé en mémoire;
  • d’un service qui accumule de l’état entre deux messages;
  • d’une tâche exceptionnellement lourde, suivie de tâches minuscules: le processus ne redescend pas forcément comme on l’imagine.

Le grand classique? Un gestionnaire de message qui hydrate plusieurs milliers d’entités Doctrine, transmet ces objets à un service qui les mémorise « juste pour le diagnostic », puis passe au message suivant. À la centième itération, on n’a plus un diagnostic: on a un musée de graphes d’objets.

Symfony est très clair sur ce point avec Messenger: les mêmes instances de services peuvent être réutilisées entre les messages. Un service doit donc idéalement rester sans état. S’il ne peut pas l’être, il doit participer à la réinitialisation via ResetInterface et sa méthode reset().

Côté Laravel, même combat: les processus de file d’attente sont persistants et gardent l’état initialisé de l’application. Ce n’est pas un défaut du framework, c’est précisément ce qui les rend rapides. Rebooter tout le framework entre deux tâches serait une très mauvaise blague pour les performances. Mais cela implique qu’on doit assumer le cycle de vie du processus.

Et non, augmenter memory_limit n’est pas une stratégie de stabilité. C’est parfois une rustine légitime pour une tâche réellement gourmande; ce n’est pas une réponse à une mémoire qui monte sans plafond. En CLI, max_execution_time vaut par défaut 0: aucune limite de durée ne viendra sauver un processus lancé vendredi soir et encore vivant lundi matin.

Que mesure-t-on vraiment avec les outils PHP?

La détection d’une fuite mémoire PHP commence par une discipline un peu moins glamour que la refacto: mesurer avant de conclure.

memory_get_usage() renvoie la mémoire actuellement allouée au script, en octets. C’est très pratique pour instrumenter un processus. Mais ce chiffre n’est pas la mémoire résidente complète du processus vue par le système ou le conteneur. Il ne couvre notamment pas toutes les allocations faites hors du mécanisme emalloc() de PHP.

Avec memory_get_usage(true), on demande une vision incluant la mémoire totale obtenue auprès du système, y compris des pages qui ne sont plus activement utilisées par notre code. C’est utile pour comprendre pourquoi le processus ne redescend pas, mais ce n’est pas une preuve automatique de fuite non plus. Sous le capot, le gestionnaire mémoire peut garder de la place pour accélérer les allocations suivantes.

Voici le minimum que j’aime journaliser à intervalles réguliers dans un consommateur sensible:

MesureCe qu’elle raconteCe qu’elle ne prouve pas
memory_get_usage()La mémoire allouée au script PHPLa mémoire totale résidente du processus
memory_get_usage(true)La mémoire obtenue du système par PHP, y compris certaines pages réutilisablesUne fuite applicative certaine
memory_get_peak_usage()Le plus haut niveau atteint depuis le démarrage ou la dernière remise à zéroLa consommation actuelle
Compteur de tâches traitéesLe rapport entre volume de travail et dérive mémoireL’origine de la rétention
Durée par tâcheUne dégradation progressive ou un traitement atypiqueLe détail des allocations

Depuis PHP 8.2, memory_reset_peak_usage() permet de remettre à zéro le pic mesuré. Pour des tâches hétérogènes, c’est franchement précieux. On peut ainsi comparer le pic de chaque message au lieu de traîner le souvenir du plus gros import de la journée comme un boulet statistique.

Un exemple d’instrumentation sobre, dans l’esprit:

1. Mesurer avant le traitement d’une tâche.

2. Mesurer après les écritures, les appels réseau et le travail lourd.

3. Journaliser l’écart, le pic et le nombre de tâches déjà traitées.

4. Regarder la pente sur plusieurs centaines ou milliers d’exécutions.

5. Croiser avec le type de message, la taille des données et les erreurs éventuelles.

Le bon signal n’est pas « le processus utilise 180 Mo ». Il n’existe pas de seuil universel magique à 128, 256 ou 512 Mo — désolé pour les dashboards tout verts. Le bon signal, c’est une progression corrélée au nombre de tâches, qui ne se stabilise pas malgré des charges comparables.

Une valeur isolée fait peur; une courbe expliquée par le nombre de messages fait avancer le débogage.

Pour le monitoring mémoire PHP, je préfère aussi séparer les métriques applicatives des métriques d’infrastructure. L’application sait combien de tâches elle a traitées, quel message elle exécute et combien de mémoire PHP elle déclare. Le conteneur ou le système sait quelle mémoire résidente est réellement consommée, et si le processus approche sa limite. Les deux vues ne racontent pas la même histoire. Les confondre, c’est ouvrir un ticket intitulé « PHP fuit » avec à peu près autant de précision que « Internet est lent ».

Le ramasse-miettes peut-il tout régler? Spoiler: non

PHP dispose d’un ramasse-miettes activé par défaut, capable de détecter et de collecter les références circulaires. gc_collect_cycles() force une collecte des cycles présents et retourne le nombre de cycles récupérés.

C’est utile. Ce n’est pas un bouton « réparer les fuites ».

Si un objet est encore référencé par un tableau global, un singleton, un cache de service, une fermeture conservée trop longtemps ou une collection Doctrine jamais vidée, le ramasse-miettes n’a rien à collecter. L’objet est vivant selon les règles du langage, même si fonctionnellement il aurait dû mourir il y a 10 000 messages.

À partir de PHP 8.3, gc_status() devient beaucoup plus intéressant pour observer ce qui se passe: on y trouve notamment les compteurs de collectes, les racines détectées, mais aussi des informations comme buffer_size, application_time, collector_time, destructor_time et free_time. Pour les équipes qui maintiennent un vrai service de traitement asynchrone, ce sont des données de diagnostic, pas de la déco pour tableau de bord.

Le schéma sain ressemble à ceci:

  • on libère les références applicatives devenues inutiles;
  • on réinitialise les services qui ont accumulé un état transitoire;
  • on vide ou limite les unités de travail ORM quand on traite par lots;
  • on laisse le ramasse-miettes faire son métier;
  • on force ponctuellement une collecte si l’observation le justifie;
  • on redémarre le processus selon une politique assumée.

unset() peut casser une référence locale. gc_collect_cycles() peut récupérer des cycles devenus inaccessibles. gc_mem_caches() peut demander au gestionnaire Zend de libérer des caches mémoire et retourne le nombre d’octets libérés. Très bien. Mais aucun de ces appels ne garantit une baisse de la mémoire résidente observable par l’OS, ni ne corrige une rétention dans une extension, un client externe ou un cache applicatif.

Autrement dit: si vous appelez les trois à la fin de chaque message comme un rituel vaudou, vous avez peut-être créé un nouveau problème de performance sans toucher au vrai coupable.

Et les services Symfony dans tout ça?

Avec Messenger, la bonne question n’est pas « comment forcer PHP à oublier? », mais « qu’est-ce que mon conteneur garde entre deux messages? ».

Un client API qui conserve une petite connexion réutilisable? Très bien. Un service de génération de documents qui garde l’intégralité du dernier document, ses images binaires et son arbre de rendu? Beaucoup moins drôle. Un collecteur de traces ou un logger qui empile les contextes de chaque tâche? Bingo.

L’implémentation de ResetInterface donne un contrat explicite: après un message, un service peut revenir à un état propre. C’est plus robuste qu’une succession d’unset() dispersés dans les gestionnaires. Et désactiver cette remise à zéro pour gagner quelques millisecondes, c’est typiquement l’optimisation qui paraît maligne jusqu’au premier week-end de soldes.

Pourquoi les gros flux SQL explosent-ils si vite?

Une énorme partie des prétendues fuites de mémoire PHP sur les scripts longs est simplement un résultat de base de données chargé trop largement.

Avec MySQL, les requêtes tamponnées sont le comportement habituel: le résultat est transféré en mémoire dans le processus PHP. Tant qu’on conserve une référence au résultat — ou qu’on travaille avec une structure qui le retient — cette mémoire reste occupée. Sur une requête analytique ou un export un peu généreux, ça peut monter très vite.

La première erreur est donc de faire ceci conceptuellement: récupérer toutes les lignes, les convertir en tableau, transformer le tableau, puis seulement commencer à écrire. Cela fonctionne sur un jeu de test de 300 lignes. En production, la mémoire prend feu avec beaucoup de dignité.

Les générateurs PHP sont une réponse élégante pour les flux séquentiels. Ils évitent de construire à l’avance un tableau complet: le générateur conserve son état courant et produit les éléments un par un. Le manuel PHP rappelle qu’un range(0, 1000000) peut dépasser largement 100 Mo selon le contexte, là où une itération paresseuse reste radicalement plus frugale.

Pour un traitement massif, on cherche donc une chaîne continue: lire une ligne, transformer, écrire, oublier. Pas lire cent mille lignes, transformer cent mille lignes, prier.

Les requêtes MySQL non tamponnées peuvent aussi réduire la mémoire côté PHP puisqu’elles transmettent les lignes au fil de l’itération. Mais elles ont une contrainte sérieuse: tant que le résultat n’a pas été entièrement consommé, la même connexion ne peut pas exécuter une nouvelle requête. C’est le genre de détail qui transforme un correctif mémoire en étrange blocage de production si on ne l’anticipe pas.

La question à poser avant d’activer ce mode n’est donc pas « est-ce plus optimisé? ». C’est: « pendant que j’itère, mon code a-t-il besoin de refaire une requête sur cette connexion? » Si la réponse est oui, il faut revoir le découpage, utiliser une autre connexion ou travailler par lots raisonnables.

Faut-il redémarrer ses processus? Oui, et ce n’est pas un aveu d’échec

Dans les équipes qui découvrent les processus persistants, le redémarrage contrôlé est parfois vu comme une honte: « si on redémarre, c’est qu’on n’a pas corrigé la fuite ». Non. C’est une frontière opérationnelle saine.

Même un code propre s’appuie sur des bibliothèques, des extensions, des pilotes, des caches et des comportements d’allocation qu’on ne maîtrise pas intégralement. Un processus qui vit éternellement est une promesse beaucoup trop ambitieuse pour une application web réelle. Le but n’est pas l’immortalité. Le but est un service stable, observable et remplaçable sans interruption.

Laravel fournit directement des options comme --max-jobs=1000, qui fait quitter le processus après 1 000 tâches, ou --max-time=3600, qui le fait sortir après une heure. Un superviseur redémarre ensuite un nouveau processus propre. Ces valeurs sont des exemples, pas des commandements gravés dans le marbre.

Le calibrage dépend notamment:

  • de la taille et de la variabilité des messages;
  • du rythme d’arrivée dans la file;
  • de la mémoire disponible dans le conteneur;
  • de la présence d’ORM, de génération de fichiers ou de traitement d’images;
  • de la capacité du superviseur à remplacer un processus sans créer de trou de service;
  • de la pente mémoire réellement observée.

Un consommateur qui prend 20 Mo sur 1 000 tâches homogènes peut être redémarré toutes les 5 000 tâches sans drame. Un autre qui traite des pièces jointes, des exports et des images devra peut-être partir après quelques centaines seulement. On mesure, on choisit une marge, on observe à nouveau. Pas de folklore, pas de chiffre magique copié dans un dépôt Git il y a quatre ans.

Quelle politique tient vraiment en production?

Ma stratégie préférée combine plusieurs garde-fous plutôt qu’un unique plafond brutal:

1. Des messages conçus pour être finis et rejouables. Si un processus s’arrête, la reprise ne doit pas dépendre d’un état local mystérieux.

2. Une remise à zéro applicative après chaque tâche. Services sans état en priorité, réinitialisation explicite pour les exceptions.

3. Des traitements en flux ou par lots. On évite les collections géantes, les hydratations inutiles et les tableaux intermédiaires monumentaux.

4. Des métriques corrélées au travail réel. Mémoire, pic par tâche, durée, volume traité, erreurs et redémarrages.

5. Une sortie volontaire du processus. Limite de tâches, durée maximale, ou seuil interne défini après observation.

6. Un superviseur fiable. Redémarrer un processus qui sort proprement doit être banal, pas un incident.

Le dernier point est crucial. Si le redémarrage est douloureux, on va le repousser, puis augmenter les limites, puis se retrouver à 3 heures du matin devant un graphe vertical. La maturité, ce n’est pas de prétendre qu’un processus ne meurt jamais. C’est de faire en sorte que sa mort soit prévisible, rapide et sans théâtre.

Ce qu’on arrête de faire dès maintenant

Les fuites de mémoire PHP dans les scripts longs ne se combattent pas avec une incantation unique. Elles demandent une lecture honnête du cycle de vie: ce qui est créé, ce qui reste référencé, ce qui est mis en cache, ce qui est réellement mesuré et ce qui doit disparaître avec le processus.

On gagne beaucoup en arrêtant trois habitudes tenaces: croire que PHP se nettoie comme en HTTP, charger des jeux de données entiers « parce que ça passait en local », et considérer un redémarrage contrôlé comme un échec technique. C’est exactement l’inverse: un processus borné, instrumenté et remplaçable est un processus qu’on peut faire grandir sans sueur froide.

PHP moderne nous donne les outils: métriques mémoire, générateurs, ramasse-miettes observable, réinitialisation des services, limites de durée et de tâches. À nous de les assembler avec un peu moins de magie, et beaucoup plus de terrain. Parce que le vrai game changer, ce n’est pas un consommateur qui tourne pour toujours. C’est un consommateur dont on sait précisément pourquoi il tourne — et quand il doit s’arrêter.

Questions fréquentes

Pourquoi mon processus PHP consomme-t-il de plus en plus de mémoire au fil du temps ?
Le processus conserve probablement des objets, des connexions ou des caches entre les messages, car contrairement au cycle HTTP, il ne se réinitialise pas automatiquement après chaque tâche.
Est-il suffisant d'augmenter la memory_limit pour résoudre une fuite de mémoire ?
Non, augmenter cette limite n'est qu'une solution temporaire qui ne corrige pas la rétention mémoire et ne fait que retarder l'arrêt inévitable du processus.
Comment savoir si mon service Symfony Messenger retient trop de mémoire ?
Vous devez instrumenter votre code pour mesurer la mémoire avant et après chaque tâche, puis observer si la consommation augmente de manière corrélée au nombre de messages traités.
Quelle est la différence entre memory_get_usage() et memory_get_usage(true) ?
La première mesure la mémoire allouée au script PHP, tandis que la seconde inclut la mémoire totale obtenue auprès du système, incluant les pages potentiellement réutilisables.
Le ramasse-miettes (garbage collector) peut-il supprimer toutes les fuites de mémoire ?
Non, le ramasse-miettes ne peut collecter que les références circulaires ; il ne peut pas supprimer des objets qui sont encore activement référencés par des caches, des singletons ou des collections persistantes.
Est-ce un échec technique de redémarrer régulièrement un processus de file d'attente ?
Absolument pas, c'est une pratique recommandée pour maintenir la stabilité, car elle permet de libérer les ressources accumulées par les bibliothèques, les extensions ou le framework.