Aller au contenu principal
PHP-FPM

Calculer pm.max_children avec la mémoire réellement consommée

Le nombre de travailleurs d'un pool ne se choisit pas dans un tutoriel : il se déduit de la mémoire que la machine peut réellement céder à PHP, divisée par ce qu'un travailleur consomme en service. Encore faut-il mesurer la bonne grandeur, car memory_limit décrit une allocation par requête, pas la résidence d'un processus. Voici une méthode reproductible : mesurer sous charge, séparer pages partagées et mémoire privée, réserver la part du système, du serveur web, de la base et du cache, puis vérifier sur la machine.

Une balance mécanique et cinq bocaux de verre remplis de sable à des hauteurs différentes sur un établi
Un plafond de pool se pèse : des unités identiques, un total disponible, et le nombre qui tient dans ce total.

memory_limit décrit une allocation, pas une résidence

La directive memory_limit, modifiable à l'exécution et fixée par défaut à 128 Mo, plafonne les octets qu'un script peut allouer pendant une requête. C'est un garde-fou par requête, pas une réservation par processus : rien n'oblige un script à consommer ce plafond, et rien ne garantit qu'un travailleur qui l'atteint pèse cette valeur en mémoire. Confondre les deux grandeurs est l'erreur de dimensionnement la plus fréquente, parce que le chiffre est écrit dans un fichier de configuration et prend l'apparence d'une mesure.

Le compteur de memory_limit ne voit qu'une partie du processus. Il ignore le socle de l'interpréteur, binaire PHP, extensions chargées et structures internes, il ignore la mémoire partagée d'OPcache, qui vit hors de l'allocateur du langage, et il ignore ce que certaines extensions réservent directement auprès du système. Le noyau, lui, ne connaît que des pages : /proc/PID/status expose VmRSS, défini par proc_pid_status(5) comme la somme de RssAnon, RssFile et RssShmem. C'est cette résidence qu'un plafond de pool doit absorber.

Un même travailleur peut donc afficher 60 Mo de mémoire privée avec memory_limit à 128 Mo, et 200 Mo avec la même valeur si une extension réserve des tampons hors allocateur. Aucune des deux mesures ne se déduit du fichier de configuration : il faut lire la machine. memory_limit reste utile, car il coupe un script qui déraille avant qu'il n'entraîne le processus entier, mais il ne dimensionne pas un pool. Le chiffre qui compte pour pm.max_children est celui qu'on relève sur des processus en train de servir.

Une éprouvette graduée à moitié remplie d'eau et un bécher sur un plan de travail en inox
La mesure utile est celle du travailleur en service, pas celle du récipient vide au démarrage.

Mesurer sous charge : une mesure au repos trompe

La mesure se fait sur un travailleur du pool, pendant qu'il travaille. On repère les processus du pool avec ps, par exemple ps -o pid,rss,pmem,stat,cmd -u suivi de l'utilisateur déclaré dans le pool : la commande ne lit que des compteurs du noyau et ne modifie rien. Au repos, elle renvoie le plancher du processus, pages jamais touchées, tas initial, allocateur qui n'a pas encore grandi. Un pool fraîchement démarré affiche ainsi des valeurs basses qui ne disent rien du pic à venir.

Sous charge, le chiffre monte par paliers. Les pages anonymes ne sont allouées qu'au premier accès en écriture ; après un fork, le travailleur partage les pages du maître en copie-sur-écriture et ne les duplique qu'au moment d'écrire. Un travailleur qui vient de servir une page lourde conserve en outre des arènes d'allocateur vides mais résidentes, que PHP ne rend pas toujours au système. Il faut donc relever RssAnon dans /proc/PID/status à la fin des requêtes les plus lourdes, pas au démarrage ni après une page d'accueil statique.

Deux précautions changent le résultat. D'abord le recyclage : pm.max_requests fait renaître un travailleur après un certain nombre de requêtes, et un processus neuf repart du plancher ; mesurer un travailleur âgé de trois requêtes sous-estime, le mesurer juste avant son remplacement donne la borne haute utile. Ensuite la charge : une campagne synthétique sur une seule adresse ne reproduit pas le mélange réel. Mieux vaut instrumenter le pic réel et retenir la valeur la plus haute observée sur plusieurs jours plutôt qu'une moyenne.

Ce qui se multiplie et ce qui se partage entre travailleurs

Un chiffre de RSS lu sur un travailleur ne s'additionne pas. La résidence d'un processus compte toutes les pages qu'il touche, y compris celles qu'il partage avec ses voisins : le binaire PHP, les bibliothèques système, les segments des extensions chargés au démarrage. Ces pages n'existent qu'une fois en mémoire physique, mais apparaissent en entier dans le RSS de chaque travailleur. Multiplier le RSS par le nombre de travailleurs compte donc plusieurs fois le même socle et surestime le besoin réel.

Les champs de /proc/PID/status séparent les deux masses. RssAnon est la mémoire anonyme résidente, celle du tas et de la pile, qui se multiplie réellement avec le nombre de processus. RssFile est la partie résidente des fichiers projetés en mémoire, largement partagée entre travailleurs. Quand la finesse compte, /proc/PID/smaps_rollup fournit Pss, qui répartit chaque page partagée au prorata du nombre de processus qui la projettent ; la somme des Pss d'un pool approche alors la consommation physique réelle de ce pool.

La méthode qui en découle est simple : additionner les Pss de tous les travailleurs d'un pool sous charge, diviser par leur nombre, et retenir ce quotient comme coût unitaire. Le résultat inclut déjà la part de socle partagé, sans la compter autant de fois qu'il y a de processus. Il n'est pas constant : avec davantage de travailleurs, la même part fixe se répartit plus finement et le coût unitaire baisse un peu. Le calcul donne donc un point de départ, à refaire une fois le plafond appliqué.

Un carnet à spirale ouvert sur un établi, couvert d'une écriture manuscrite illisible, avec un crayon et un réglet
Le calcul et sa date se notent à côté du fichier de pool : c'est le raisonnement qui se transmet, pas le chiffre.

Le calcul : réserver avant de diviser

Le calcul tient en une division, mais le numérateur demande de l'honnêteté. On part de la mémoire physique totale et on en retire ce que les autres services occupent réellement : noyau et système, serveur web, moteur de base de données avec son cache de tampons, cache mémoire externe s'il en existe un, puis une marge de sécurité pour les pics. Le cache de pages du noyau n'est pas une réserve à soustraire : la documentation du noyau le classe parmi les pages récupérables, et il cède la place dès que la pression monte.

Exemple d'arithmétique, à refaire avec vos propres chiffres : sur une machine de 8 192 Mo qui héberge aussi la base, on retire 512 Mo pour le système, 256 Mo pour le serveur web, 2 048 Mo pour la base, 768 Mo pour un cache externe et 512 Mo de marge, soit 4 096 Mo de réserve. Il reste 4 096 Mo pour PHP. Si la mesure sous charge donne 56 Mo par travailleur, la division donne 73,1 : on retient 73, car on arrondit toujours vers le bas. Vérification : 73 fois 56 font 4 088 Mo, et il reste 8 Mo dans l'enveloppe calculée.

Le même calcul nourri d'une mesure au repos montre l'écart. À 24 Mo par travailleur, la division donnerait 170 processus ; à 56 Mo chacun, ces 170 travailleurs pèseraient 9 520 Mo, soit 1 328 Mo de plus que la mémoire physique de la machine. Le plafond obtenu doit ensuite être confronté à la concurrence réellement observée : le fixer très au-dessus du pic mesuré n'apporte aucune capacité supplémentaire, il supprime seulement le garde-fou qui empêche la machine de sombrer pendant une pointe.

Plafond trop haut, plafond trop bas : deux pannes différentes

Un plafond trop haut ne se voit pas tout de suite. Quand les allocations dépassent la mémoire libre, le noyau récupère les pages récupérables, puis écrit les pages anonymes sur l'espace d'échange. Les travailleurs concernés passent en attente d'entrée-sortie : ils ne consomment presque pas de processeur, mais ils ne répondent plus, et les requêtes s'empilent pendant que la machine paraît tranquille. Si la récupération ne suffit pas, le noyau déclenche l'OOM killer, qui choisit une tâche à sacrifier pour sauver le reste du système, sans garantie que ce soit un travailleur PHP.

Un plafond trop bas se manifeste autrement, et plus tôt. Le pool sature, le journal de PHP-FPM signale que pm.max_children est atteint, et les connexions entrantes attendent dans la file d'écoute, dont la valeur par défaut sous Linux est de 511 d'après le manuel. Tant que la file absorbe, la requête est seulement lente ; quand elle déborde, le serveur web ne peut plus se connecter au socket et renvoie une 502 immédiate. Une 502 instantanée et une lenteur générale n'ont donc pas la même cause, et un même plafond peut produire l'une ou l'autre.

L'asymétrie des deux pannes doit guider le réglage. Un plafond trop haut dégrade sans le dire et fait accuser la base de données ou le réseau ; un plafond trop bas se plaint franchement, dans les journaux et dans les réponses. C'est pourquoi on calcule à partir d'une mesure, on garde une enveloppe légèrement supérieure au pic de concurrence observé, et on agit d'abord sur ce que consomme un travailleur, par le code ou par memory_limit, plutôt que de relever indéfiniment le nombre de processus autorisés.

Vérifier après réglage, puis refaire la mesure

Après modification, la première vérification est celle du pool lui-même. La directive pm.status_path expose une adresse de statut, à restreindre au serveur local, qui donne le nombre de processus actifs, la taille de la file d'écoute et le compteur de fois où le plafond a été atteint. Ce dernier compteur reste à zéro sur un pool correctement dimensionné. Si le pool sature au point de ne plus répondre, pm.status_listen, disponible depuis PHP 8.0, ouvre une écoute séparée qui permet de consulter l'état malgré tout.

La seconde vérification porte sur la mémoire de la machine. free -m donne la colonne available, la mémoire réellement disponible, et non la colonne free, qui ignore le cache récupérable. vmstat 5 affiche les colonnes si et so : une activité d'échange non nulle alors que le pool tourne signale un plafond encore trop haut. Ces commandes ne font que lire des compteurs et sont sans effet sur le service ; le journal de PHP-FPM et celui du noyau, consultés avec dmesg ou journalctl, complètent le tableau en cas de doute.

Reste à refaire le calcul quand la machine change : ajout d'une extension, agrandissement d'OPcache, nouvelle version de l'application, déplacement de la base sur un autre hôte. Le coût unitaire d'un travailleur n'est pas une propriété stable du serveur, c'est le résultat d'une mesure datée. Le raisonnement et les chiffres relevés méritent d'être notés à côté du fichier de pool, avec leur date, parce que la personne qui reprendra le serveur aura besoin du calcul pour refaire la division, pas du nombre tout fait.