PHP-FPM, OPcache et preload Symfony : qui charge quoi ?

Schéma du fonctionnement de PHP-FPM, OPcache et Symfony
Chaîne d’exécution : PHP-FPM distribue les requêtes aux processus PHP, qui partagent le bytecode via OPcache avant d’exécuter Symfony.

Quand une application Symfony affiche l’erreur suivante, plusieurs notions se mélangent facilement :

Fatal Error: Not enough shared memory for preloading.
Consider increasing the value for the opcache.memory_consumption directive in php.ini.

Qui reçoit la requête ? Qui exécute PHP ? Que contient OPcache ? Le conteneur de services Symfony est-il mis en mémoire ? Et que change réellement container.no_preload ?

Cet article construit le mécanisme étape par étape, avec un modèle volontairement simple.

Il prolonge deux articles plus anciens du blog :

  • Cache Bytecode explique pourquoi PHP transforme le code source en opcodes avant de l’exécuter ;
  • Extension OPcache présente l’extension qui conserve ces opcodes pour éviter de les recompiler.

Ici, nous ajoutons les couches manquantes autour d’OPcache : serveur web, PHP-FPM, moteur PHP, autoload Composer, Kernel Symfony, conteneur de services et objets réellement créés.

La chaîne complète, couche par couche

Navigateur
    │ requête HTTP
    ▼
Nginx ou Apache
    │ transmet la requête PHP avec FastCGI
    ▼
PHP-FPM
    │ choisit un processus PHP-FPM disponible
    ▼
Processus PHP-FPM + moteur PHP
    │ exécute public/index.php
    │
    ├──────── consulte OPcache
    │         pour obtenir du bytecode déjà compilé
    │
    ▼
Autoloader Composer
    │ trouve le fichier correspondant à chaque classe demandée
    ▼
Kernel Symfony
    │ traite la requête
    ▼
Conteneur Symfony
    │ construit uniquement les services nécessaires
    ▼
Objets PHP en mémoire dans le processus PHP-FPM
    │ produisent la réponse
    ▼
Navigateur

Chaque couche a une responsabilité précise :

Couche Son rôle Ce qu’elle ne fait pas
Nginx ou Apache Reçoit HTTP, sert les fichiers statiques et transmet PHP N’exécute pas Symfony
PHP-FPM Maintient les processus PHP et distribue les requêtes Ne compile pas lui-même le code
Moteur PHP Lit, compile et exécute le PHP Ne connaît pas la notion de service Symfony
OPcache Partage le bytecode compilé entre les processus PHP-FPM Ne stocke pas les objets Symfony vivants
Autoloader Composer Associe un nom de classe à son fichier PHP Ne construit pas les services
Kernel Symfony Pilote le traitement applicatif de la requête N’est pas OPcache
Conteneur Symfony Sait comment construire les services et leurs dépendances N’est pas le conteneur Docker
Objet service Exécute la logique métier pendant la requête N’est pas automatiquement partagé entre tous les processus PHP-FPM

OPcache se trouve donc au niveau du moteur PHP. Symfony en bénéficie, mais Symfony ne vient pas « lire OPcache » directement : il demande une classe à PHP, et PHP décide d’utiliser le bytecode déjà présent.

Le trajet d’une requête avec PHP-FPM

Dans une architecture classique avec Nginx ou Apache :

Navigateur
    ↓ requête HTTP
Nginx ou Apache
    ↓ FastCGI
PHP-FPM
    ↓ choisit un processus disponible
Processus PHP-FPM
    ↓ exécute public/index.php
Symfony
    ↓ construit la réponse
Navigateur

PHP-FPM est un gestionnaire de processus PHP. Il maintient un groupe de processus PHP-FPM et confie chaque requête à un processus disponible :

PHP-FPM
├── processus PHP-FPM 1
├── processus PHP-FPM 2
└── processus PHP-FPM 3

Chaque processus PHP-FPM embarque le moteur PHP. PHP-FPM organise le travail ; le moteur PHP exécute réellement le code.

Pourquoi PHP doit compiler le code

Le processeur n’exécute pas directement le texte d’un fichier PHP. Le moteur PHP commence par le transformer en instructions intermédiaires appelées opcodes, ou bytecode.

Service.php
    ↓ lecture et analyse
Moteur PHP
    ↓ compilation
Bytecode PHP
    ↓ exécution
Résultat

Sans cache, répéter cette compilation à chaque requête gaspillerait du temps.

Le rôle d’OPcache

OPcache conserve le bytecode compilé dans une mémoire partagée. Les processus PHP-FPM peuvent alors réutiliser le même code :

                    Mémoire OPcache partagée
                 ┌──────────────────────────┐
                 │ bytecode de Kernel.php   │
                 │ bytecode de Service.php  │
                 │ bytecode de Router.php   │
                 └──────────────────────────┘
                       ▲       ▲       ▲
                       │       │       │
                processus  processus  processus
                 PHP-FPM 1  PHP-FPM 2  PHP-FPM 3

Lorsqu’un processus PHP-FPM a besoin d’un fichier PHP :

PHP demande Service.php
          │
     ┌────┴────┐
     │         │
présent      absent
dans OPcache dans OPcache
     │         │
     │         └→ lire le fichier → compiler → mettre en cache
     │
     └──────────────→ exécuter le bytecode

OPcache ne stocke pas les objets métier vivants. Il stocke le code compilé des classes et des fonctions.

Le rôle de Composer dans cette séquence est seulement de retrouver le fichier :

Symfony demande App\Service\ReportService
                ↓
Composer retrouve src/Service/ReportService.php
                ↓
PHP demande le code de ce fichier
                ↓
OPcache fournit le bytecode s'il est déjà disponible
                ↓
PHP exécute ce bytecode

Trois notions à ne pas confondre

Prenons un service Symfony :

final class ReportService
{
    public function generate(): string
    {
        return 'Rapport prêt';
    }
}

Il faut distinguer trois éléments :

OPcache
= code compilé de ReportService

Conteneur de services Symfony
= instructions pour construire ReportService et ses dépendances

Objet ReportService
= instance créée en mémoire quand l'application en a besoin

Le conteneur Symfony ressemble conceptuellement à une usine :

protected function getReportService(): ReportService
{
    return new ReportService(
        $this->getRepository(),
        $this->getLogger(),
    );
}

Symfony compile ces instructions dans des fichiers PHP. Ces fichiers peuvent eux aussi bénéficier d’OPcache, puisqu’ils restent du code PHP. Mais OPcache ne contient pas le service déjà construit : l’objet est créé ensuite, dans la mémoire du processus PHP-FPM qui traite la requête.

Bytecode disponible dans OPcache
              ↓
Le conteneur Symfony exécute new ReportService(...)
              ↓
Objet créé dans la mémoire du processus PHP-FPM
              ↓
La méthode generate() est appelée

OPcache et preload ne sont pas la même chose

OPcache fonctionne déjà sans preload. Lorsqu’une classe est rencontrée pour la première fois, PHP la compile puis garde son bytecode en cache pour les requêtes suivantes.

Le preload avance cette opération au démarrage du serveur :

OPcache sans preload
--------------------
Démarrage PHP
    ↓
Première requête utilisant ReportService
    ↓
Compilation puis stockage dans OPcache
    ↓
Requêtes suivantes plus rapides

OPcache avec preload
--------------------
Démarrage PHP
    ↓
Compilation immédiate de ReportService
    ↓
Stockage dans OPcache
    ↓
Première requête déjà prête

Le preload améliore donc surtout le premier chargement, au prix d’une consommation mémoire imposée dès le démarrage.

Il existe cependant une différence importante : une classe préchargée reste disponible jusqu’à l’arrêt du processus serveur. Un script simplement ajouté au cache lors d’une requête suit le cycle normal d’OPcache, qui peut invalider le script ou redémarrer son cache selon sa configuration. Le preload réserve donc de la mémoire de façon beaucoup plus rigide.

Comment Symfony prépare le preload

Lors de la compilation du conteneur, Symfony génère un fichier recensant les classes à précharger. Le nom exact dépend de la version et de l’environnement, mais il ressemble à ceci :

var/cache/prod/App_KernelProdContainer.preload.php

Le fichier config/preload.php, installé par Symfony Flex, charge ensuite cette liste. PHP l’exécute au démarrage lorsque php.ini contient :

opcache.preload=/app/config/preload.php
opcache.preload_user=www-data

La chaîne complète devient :

AU BUILD OU AU WARMUP

Configuration Symfony
        ↓
Compilation du conteneur de services
        ↓
Génération du fichier *.preload.php


AU DÉMARRAGE DU SERVEUR PHP

PHP lit opcache.preload dans php.ini
        ↓
PHP exécute config/preload.php
        ↓
Le fichier Symfony charge les classes sélectionnées
        ↓
PHP compile leur code
        ↓
Le bytecode entre dans OPcache

Symfony choisit la liste ; le moteur PHP réalise la compilation et remplit OPcache.

Pourquoi le démarrage peut échouer

La directive suivante fixe la taille de la mémoire partagée d’OPcache :

opcache.memory_consumption=256

La valeur est exprimée en mégaoctets. Si les classes préchargées ne tiennent pas dans cet espace, PHP peut échouer avant même de servir une requête :

Classes à précharger
        ↓
Compilation progressive
        ↓
┌──────────────────────────┐
│ OPcache : 256 Mo         │
│ ████████████████████████ │
│ plus aucune place        │
└──────────────────────────┘
        ↓
Not enough shared memory for preloading
        ↓
Le serveur PHP ne démarre pas

Augmenter la mémoire peut débloquer le démarrage, mais ce n’est pas toujours la meilleure première correction. Une application peut précharger des commandes d’administration, des traitements asynchrones ou des clients d’API rarement utilisés.

Le rôle de container.no_preload

Symfony permet d’exclure une classe du preload sans désactiver son service :

services:
    App\Service\MonthlyReportService:
        tags:
            - container.no_preload

Le résultat est important :

container.no_preload
≠ supprimer le service
≠ interdire OPcache
≠ désactiver la fonctionnalité

container.no_preload
= ne pas compiler obligatoirement cette classe au démarrage

Au premier appel, le fonctionnement normal reprend :

Démarrage
    ↓
MonthlyReportService absent du preload
    ↓
Première utilisation
    ↓
PHP charge et compile la classe
    ↓
Le bytecode entre dans OPcache
    ↓
Utilisations suivantes servies depuis OPcache

Le tag reporte donc le coût à la première utilisation. Il ne prive pas les appels suivants du cache.

Cela suppose évidemment qu’il reste de la place dans le cache normal. container.no_preload libère la partie occupée obligatoirement au démarrage ; ce n’est pas une solution magique si l’ensemble du code finit ensuite par saturer OPcache.

Quand tout un fichier de configuration contient des services froids, le tag peut être appliqué avec _defaults :

services:
    _defaults:
        autowire: true
        autoconfigure: true
        tags:
            - container.no_preload

Il faut rester prudent : Symfony propage aussi container.no_preload dans certains graphes de dépendances. La bonne méthode consiste à mesurer le fichier généré, appliquer le tag à une famille cohérente, reconstruire le cache puis comparer.

Quels services exclure en premier ?

Les meilleurs candidats sont les services rarement utilisés pendant une requête web normale :

  • commandes de maintenance ;
  • écrans d’administration peu fréquentés ;
  • génération de rapports ;
  • imports et exports ;
  • traitements Messenger ;
  • clients d’API lourds utilisés ponctuellement ;
  • outils de contrôle qualité ou de réindexation.

Les services utilisés sur presque chaque requête sont de moins bons candidats : routeur, sécurité, rendu Twig, écouteurs globaux ou services nécessaires à la page d’accueil.

Le poids du fichier source n’est pas le seul critère. Une petite classe peut entraîner de nombreuses interfaces, classes parentes et dépendances dans le graphe de preload.

Diagnostiquer avant de modifier

Afficher la configuration active :

php --ini
php -i | grep -E 'opcache.enable|opcache.memory_consumption|opcache.preload'

Observer l’état d’OPcache depuis la même SAPI que le serveur web :

<?php

$status = opcache_get_status(false);

var_dump($status['memory_usage'] ?? null);

Attention : le CLI et le serveur web n’utilisent généralement pas la même instance OPcache. Une commande lancée dans un terminal ne décrit donc pas forcément la mémoire du serveur.

Comparer deux fichiers de preload :

grep -oE "/[^']+\.php" preload-before.php | sort > before.txt
grep -oE "/[^']+\.php" preload-after.php  | sort > after.txt
comm -13 before.txt after.txt

Cette comparaison permet d’identifier les classes réellement ajoutées avant d’augmenter la mémoire ou d’exclure des services au hasard.

Et avec FrankenPHP ?

FrankenPHP remplace le couple serveur web traditionnel plus PHP-FPM par un serveur d’applications unique construit sur Caddy :

Architecture PHP-FPM
Navigateur → Nginx/Apache → PHP-FPM → moteur PHP → Symfony

Architecture FrankenPHP
Navigateur → FrankenPHP/Caddy → moteur PHP → Symfony

Il n’y a donc plus de processus PHP-FPM dans l’architecture FrankenPHP. Le moteur PHP et OPcache restent toutefois des notions utiles : FrankenPHP utilise l’interpréteur PHP officiel.

En mode classique, l’application Symfony redémarre logiquement à chaque requête. En mode Worker, Symfony démarre une seule fois et son Kernel reste en mémoire :

FrankenPHP classique
requête 1 → boot Symfony → réponse → nettoyage
requête 2 → boot Symfony → réponse → nettoyage

FrankenPHP Worker
boot Symfony une fois
    ├── requête 1
    ├── requête 2
    └── requête 3

Le Worker va plus loin qu’OPcache : il ne conserve pas seulement du code compilé, mais aussi l’application démarrée et certains objets entre les requêtes. C’est plus rapide, avec une contrainte supplémentaire : aucun état propre à un utilisateur ne doit survivre dans un service partagé.

Le modèle mental à retenir

OPcache
= bibliothèque de code PHP déjà compilé

Preload
= remplissage anticipé de cette bibliothèque au démarrage

Conteneur Symfony
= usine qui sait construire les services

Service Symfony
= objet construit par cette usine quand il est demandé

PHP-FPM
= gestionnaire des processus PHP dans une architecture classique

FrankenPHP
= serveur d'applications qui remplace serveur web + PHP-FPM

Conclusion

OPcache, preload et conteneur Symfony travaillent ensemble, mais ne stockent pas la même chose. OPcache conserve le bytecode. Le preload décide de charger certaines classes dès le démarrage. Le conteneur Symfony décrit comment construire les services. Enfin, les objets sont créés lorsque l’application en a besoin.

container.no_preload est donc un outil de sélection, pas un interrupteur fonctionnel : le service reste disponible et son code pourra toujours rejoindre OPcache à sa première utilisation. Cette distinction permet de réduire un preload trop ambitieux sans sacrifier les fonctionnalités de l’application.

Références