PHP-FPM: les erreurs de configuration de mes débuts
Le symptôme arrive sous charge: le CPU n’est pas forcément saturé, Nginx répond encore, mais la latence explose. La file FPM monte. Puis le noyau commence à arbitrer entre des dizaines de workers qui se battent pour une RAM qui n’existe plus. Selon la pression mémoire, cela finit en swap, en OOM killer, ou en une application qui répond à 502 alors que le serveur affichait une charge « raisonnable » cinq minutes plus tôt.
Les erreurs de configuration PHP-FPM en production ne sont généralement pas des erreurs de syntaxe. Le service démarre. Le socket écoute. Une page PHP s’affiche. C’est précisément le problème: une configuration invalide échoue immédiatement; une configuration médiocre attend le trafic réel pour devenir visible.
pm.max_children: le plafond qui décide de la concurrence réelle
PHP-FPM ne sert pas un nombre abstrait de requêtes. Un processus enfant traite une requête à la fois. pm.max_children fixe donc le plafond de requêtes PHP simultanées qu’un pool peut exécuter — directement en mode static, comme limite supérieure en modes dynamic et ondemand.
C’est la première variable de capacité. Ce n’est pas un bouton de performance.
La logique erronée est triviale: plus de workers, donc plus de débit. Elle oublie l’allocation mémoire. Un worker PHP ne consomme pas seulement le script courant. Il porte un interpréteur, des extensions, des structures de données, parfois un ORM chargé, des réponses sérialisées trop volumineuses, des clients HTTP, des buffers et des effets de fragmentation. Sur une application Symfony ou Laravel, l’écart entre le processus au repos et un worker qui traverse une requête lourde peut être considérable.
La seule méthode défendable consiste à partir de mesures de production ou d’un environnement strictement comparable:
- mesurer la RSS des workers pendant une charge représentative, pas juste après le démarrage;
- réserver la mémoire du système, de Nginx, d’OPcache, de l’agent de supervision, du cache local et, si elle est présente sur la machine, de MySQL;
- déterminer la mémoire réellement disponible pour le pool;
- fixer un plafond qui ne pousse pas l’hôte vers le swap au premier pic;
- vérifier ensuite la file d’attente FPM et les latences applicatives.
La formule importe moins que les données. Utiliser la mémoire moyenne d’un worker est optimiste. Utiliser son pic absolu est parfois trop conservateur. Il faut donc observer une distribution de consommation sous des requêtes réelles: pages de catalogue, exports, authentification, endpoints API, uploads, tâches qui abusent de Doctrine ou d’Eloquent.
Un pool FPM trop grand ne crée pas de capacité. Il transforme la RAM en contention.
Les trois modes de gestion ne répondent pas au même profil de charge.
Mode pm | Comportement | Coût principal | Cas d’usage cohérent |
|---|---|---|---|
static | Tous les workers existent dès le démarrage | RAM réservée en permanence | Charge stable, capacité connue, hôte dédié |
dynamic | FPM maintient un stock de workers inactifs et ajuste le pool | Variations de processus, réglage plus sensible | Applications web avec trafic régulier et pics modérés |
ondemand | Un worker démarre à l’arrivée d’une requête et disparaît après inactivité | Latence de création, burst moins propre | Faible trafic, nombreux sites isolés, ressources contraintes |
En mode dynamic, pm.start_servers, pm.min_spare_servers et pm.max_spare_servers contrôlent les processus inactifs. La valeur par défaut de pm.start_servers correspond à la moyenne entre les seuils minimal et maximal de workers disponibles. Ce n’est pas une stratégie de capacité. C’est un comportement de démarrage générique.
Autre détail souvent ignoré: pm.max_spawn_rate vaut 32 par défaut en mode dynamique. Si un trafic brutal exige plusieurs centaines de nouveaux workers et que le pool part trop bas, la montée en charge est mécaniquement bornée. Augmenter ce paramètre peut réduire un délai de montée, mais ne corrige ni une RAM insuffisante ni une requête applicative lente. Il faut distinguer le burst de trafic d’un engorgement en aval sur MySQL, Redis ou une API externe.
Le mode ondemand mérite une méfiance supplémentaire. Son pm.process_idle_timeout est de 10 secondes par défaut. Sur une instance peu fréquentée, ce comportement économise de la mémoire. Sur un service recevant des rafales courtes, il peut multiplier les créations et destructions de workers. La facture se lit dans les percentiles de latence, pas dans le taux CPU moyen.
Le socket FastCGI est une surface d’attaque, pas un simple tuyau local
Un déploiement PHP-FPM est souvent décrit en deux blocs: Nginx en frontal, FPM derrière. C’est incomplet. Entre les deux se trouve une interface FastCGI capable de transmettre des paramètres qui influencent l’exécution PHP. La protéger est une exigence d’architecture.
Un socket Unix bien permissionné convient fréquemment à une topologie Nginx et FPM sur le même hôte. Un socket TCP est tout aussi exploitable, mais son exposition doit être traitée comme une décision réseau, pas comme une commodité de configuration.
Avec TCP, l’absence de listen.allowed_clients signifie que FPM accepte les connexions depuis n’importe quelle adresse IP. Si le processus est lié à une adresse accessible hors du périmètre prévu, le problème dépasse le simple déni de service: un tiers peut transmettre des paramètres FastCGI et modifier des réglages PHP par en-têtes FastCGI.
Le raisonnement correct est simple:
1. si Nginx et FPM partagent l’hôte, un socket Unix avec propriétaire et permissions restrictives réduit le périmètre;
2. si TCP est requis, l’écoute doit être limitée à l’adresse strictement nécessaire;
3. listen.allowed_clients doit restreindre les pairs autorisés;
4. le pare-feu et la segmentation réseau doivent confirmer cette restriction, pas la contredire;
5. chaque pool doit avoir son identité, son socket et, si nécessaire, ses limites propres.
Le second piège est l’exécution de fichiers inattendus. security.limit_extensions définit les extensions que FPM accepte d’analyser. La valeur documentée par défaut inclut .php et .phar. Pour un serveur web classique qui ne doit exécuter que des scripts PHP, limiter explicitement ce réglage à .php ferme une classe d’erreurs de routage Nginx.
Côté Nginx, la localisation PHP doit vérifier que le fichier demandé existe avant de déléguer à FPM. try_files remplit précisément cette fonction. Sans ce contrôle, le frontal peut transmettre à PHP-FPM des chemins qui ne correspondent à aucun fichier PHP réel. Le résultat varie selon les autres paramètres FastCGI et selon le framework, mais l’absence de garde est inutilement risquée.
Le point le plus vicieux se situe dans l’héritage de fastcgi_param. Nginx n’hérite les paramètres FastCGI du niveau supérieur que si aucun fastcgi_param n’est défini dans le contexte courant. Ajouter une directive dans un bloc location peut donc faire disparaître silencieusement les paramètres attendus, dont SCRIPT_FILENAME.
Or SCRIPT_FILENAME sert à PHP pour déterminer le script à exécuter. Une configuration peut paraître propre, répondre sur la page d’accueil, puis casser sur un sous-répertoire, un front controller ou un endpoint qui dépend d’un chemin reconstruit différemment. Ce n’est pas un bug PHP. C’est un contrat FastCGI amputé par héritage.
Une localisation Nginx qui « marche » n’est pas forcément une localisation qui transmet le bon script à PHP-FPM.
Les délais: fastcgi_read_timeout ne limite pas une requête
La confusion entre délais Nginx et délais FPM produit des incidents particulièrement longs. Le genre qui laisse des workers occupés, des clients en attente et une file qui grossit jusqu’à l’échec global.
fastcgi_read_timeout vaut 60 secondes par défaut. Il ne limite pas la durée totale d’une réponse FastCGI. Il mesure l’intervalle maximal entre deux opérations de lecture depuis FastCGI. Une réponse qui continue à produire des données peut donc dépasser cette durée totale sans violer ce délai.
request_terminate_timeout, lui, agit côté FPM. Lorsqu’il est défini, FPM tue le worker qui traite une requête au-delà du délai fixé. Sa valeur par défaut est 0: aucun arrêt forcé.
Les deux mécanismes n’ont ni le même point d’application, ni le même effet opérationnel.
| Réglage | Où il agit | Ce qu’il mesure réellement | Erreur classique |
|---|---|---|---|
fastcgi_connect_timeout | Nginx vers FPM | Établissement de connexion FastCGI | Le confondre avec le délai d’exécution |
fastcgi_read_timeout | Nginx en lecture FastCGI | Intervalle entre deux lectures | Le prendre pour une durée totale de requête |
request_terminate_timeout | Worker PHP-FPM | Durée de traitement par FPM | Le régler sans distinguer HTTP, export et tâche longue |
max_execution_time | Runtime PHP | Temps d’exécution PHP selon son contexte | Croire qu’il couvre tous les blocages I/O de façon identique |
Une chaîne cohérente commence par la nature des routes. Une API transactionnelle ne doit pas partager sans réflexion les mêmes budgets qu’un export CSV, un traitement d’image ou une génération de facture. Si ces charges vivent dans le même pool et sous les mêmes délais, l’une finit par dégrader l’autre.
Un endpoint HTTP qui peut légitimement durer plusieurs minutes est rarement un bon endpoint HTTP. Il doit généralement devenir un travail asynchrone avec un état, une file et une restitution différée. Augmenter fastcgi_read_timeout à plusieurs minutes ne fait que réserver plus longtemps des connexions, des workers et des buffers à une architecture qui n’a pas été conçue pour cette durée.
Les buffers FastCGI ajoutent une autre couche. fastcgi_buffering est activé par défaut. Nginx utilise par défaut 8 4k ou 8 8k pour fastcgi_buffers, selon la taille de page mémoire de la plateforme. Il n’existe pas de valeur magique à appliquer. Une réponse JSON de quelques kilo-octets, un HTML rendu par Twig et un export massif ne présentent pas le même profil.
Désactiver le buffering par réflexe est aussi mauvais que le surdimensionner. Avec le buffering actif, Nginx peut absorber une réponse FastCGI avant de l’envoyer à un client lent. Avec des buffers mal calibrés, il peut générer des écritures temporaires ou conserver davantage de mémoire par requête. Le bon réglage dépend du volume de réponse, de la vitesse des clients et du niveau de concurrence. Il se mesure dans les logs et les métriques, pas dans un article de configuration copié-collé.
La page d’état FPM doit être utile sans devenir publique
Un pool sans observabilité est un pool administré à l’intuition. L’intuition voit souvent le CPU. Elle ne voit pas forcément les workers occupés, les processus inactifs, le maximum de processus actifs, la file d’attente ou le compteur max children reached.
pm.status_path expose ces données. Le format OpenMetrics est disponible depuis PHP 8.1, ce qui simplifie l’intégration dans une chaîne de supervision moderne. Mais cette page n’est pas un endpoint public. Elle peut révéler des URL de requêtes et des informations sur les ressources du pool, particulièrement en mode détaillé.
La configuration correcte ne consiste pas à publier /status derrière une URL peu évidente. Elle consiste à la restreindre aux requêtes internes ou à des adresses IP explicitement autorisées. Nginx doit filtrer avant le passage vers FPM. Le réseau doit confirmer ce filtrage. Une URL masquée n’est pas un contrôle d’accès.
Les indicateurs qui comptent réellement sont peu nombreux:
- la longueur de la file d’attente: elle indique que les requêtes arrivent plus vite que le pool ne les absorbe;
max children reached: si ce compteur progresse, le plafond de concurrence a été atteint;- le nombre de processus actifs et inactifs: il permet de lire le comportement réel du mode
dynamicouondemand; - le maximum historique de processus actifs: utile pour confronter le dimensionnement aux pics;
- les requêtes lentes: elles relient la saturation à des traces exploitables.
Le slowlog FPM est l’outil complémentaire. request_slowlog_timeout déclenche l’écriture d’une trace PHP lorsqu’une requête dépasse le délai défini. À 0, il est désactivé. request_slowlog_trace_depth contrôle la profondeur de trace; la valeur par défaut est 20.
Le slowlog ne remplace pas un profileur, mais il répond à une question fondamentale: quel chemin d’exécution conserve un worker trop longtemps? Une boucle de sérialisation, un appel SQL sans index, un appel HTTP bloquant, une hydratation ORM excessive ou un verrou applicatif deviennent alors des faits observables.
Il faut régler le seuil au-dessus de la latence normale, sans le placer si haut qu’il ne capture que les catastrophes. Une route dont le percentile élevé est déjà dégradé exige d’abord une analyse métier et applicative. Le slowlog sert à remonter la pile. Il ne transforme pas une requête inefficace en requête acceptable.
pm.max_requests: recyclage contrôlé, pas réparation de fuite mémoire
pm.max_requests fixe le nombre de requêtes traitées par un worker avant sa recréation. À 0, la valeur par défaut, un worker ne possède aucune limite de requêtes.
Cette directive est régulièrement vendue comme une protection contre les fuites mémoire. C’est imprécis. Elle peut contourner une dérive mémoire dans une bibliothèque tierce en recyclant les processus. Elle ne supprime ni la fuite, ni son coût, ni le risque de saturation entre deux recyclages.
Le mécanisme reste utile lorsqu’il est assumé comme tel. Certaines extensions, SDK ou bibliothèques conservent des allocations qui ne redescendent pas proprement au fil des requêtes. Le garbage collector PHP ne peut pas libérer une mémoire encore référencée, et il ne corrige pas une extension native qui garde son propre état. Recréer le worker remet alors le processus à zéro.
Mais un réglage arbitraire peut produire l’effet inverse: trop faible, il crée une rotation permanente, du cold state applicatif et une pression de démarrage; trop élevé, il masque un problème trop longtemps. Il faut observer la RSS par worker en fonction du nombre de requêtes traitées, puis vérifier que le recyclage ne provoque pas de chute de débit pendant les pics.
Le même raisonnement s’applique à memory_limit, dont la documentation PHP affiche 128M par défaut, et à max_execution_time, fixé à 30 secondes par défaut. Ces valeurs ne décrivent pas les besoins d’une application. Elles décrivent des valeurs génériques livrées par défaut. Un memory_limit trop large peut laisser une requête pathologique monopoliser la RAM. Un plafond trop bas peut casser une opération légitime. Dans les deux cas, il faut d’abord savoir quelle route consomme quoi.
La gestion processus PHP-FPM devient fiable quand elle sépare les charges. Un pool web synchrone, limité et observable ne devrait pas absorber les mêmes traitements qu’un worker de queue, un import massif ou une génération de documents. Mélanger ces profils dans le même budget de RAM et de délais revient à demander au même mécanisme d’être à la fois réactif et indifférent à la durée. Cela finit mal.
Le verdict
Les réglages par défaut de PHP-FPM et Nginx sont des valeurs de démarrage. Pas une configuration de production.
Un pool dimensionné par mesure, un socket restreint, des paramètres FastCGI complets, des délais cohérents et une supervision protégée: à utiliser en production. Un pm.max_children choisi parce qu’il « semble raisonnable », une page de statut publique et des timeouts gonflés pour faire disparaître les erreurs: à refuser en production.




