YOUR DIGITAL HUB
← Retour au blog

OPcache en production : configuration, preloading, JIT et pièges

· 10 min de lecture
Visuel de couverture — OPcache en production

Sans OPcache, aucune optimisation ne compte

OPcache est la mesure de performance la plus rentable qu'on puisse appliquer à une application PHP. Gratuit, bundlé avec PHP depuis la version 5.5, activé par défaut, impact mesuré de 3× à 5× sur Symfony. Et pourtant, sur un tiers des audits de performance que nous menons, OPcache est mal configuré, voire désactivé.

Cet article détaille ce que fait OPcache, la configuration que nous déployons systématiquement en production, l'usage de preloading et de JIT, le monitoring, la purge au déploiement et les pièges qui plombent silencieusement les performances.

Ce que fait OPcache

PHP est un langage interprété avec compilation à l'exécution. Sans OPcache, chaque requête :

  1. Lit les fichiers PHP depuis le disque.
  2. Parse le texte en AST (Abstract Syntax Tree).
  3. Compile l'AST en opcodes (bytecode de la Zend VM).
  4. Exécute les opcodes.

Les étapes 1 à 3 sont redondantes : les fichiers ne changent pas entre deux requêtes (sauf en développement). OPcache met en cache le bytecode compilé en mémoire partagée (SHM). Les requêtes suivantes court-circuitent directement à l'étape 4.

Impact mesuré sur une application Symfony typique chargeant 300 fichiers PHP par requête :

Scénario Temps par requête Facteur
Sans OPcache 180 ms
OPcache activé, config défaut 45 ms
OPcache + config tunée + preload 32 ms 5,6×
OPcache + preload + JIT tracing 28 ms 6,4×

Les chiffres varient selon l'application mais l'ordre de grandeur est stable. Aucune optimisation applicative ne rivalise avec ce simple switch d'infrastructure.

Configuration production recommandée

Fichier dédié /etc/php/8.3/fpm/conf.d/10-opcache.ini que nous déployons quasi tel quel.

; ========================================
; OPcache - configuration production YDH
; ========================================

; Activation
opcache.enable = 1
opcache.enable_cli = 0

; Taille SHM pour le bytecode (en MB)
; Règle : 128 Mo par 1000 classes chargées
opcache.memory_consumption = 256

; Buffer pour les interned strings (noms de classes, constantes)
; Règle : 16 à 32 Mo pour Symfony
opcache.interned_strings_buffer = 32

; Nombre max de fichiers cachables
; Règle : prévoir 2× le nombre de fichiers réels
opcache.max_accelerated_files = 20000

; CRITIQUE en prod : ne pas revalider les timestamps
; Économie mesurée : 10 à 30% de temps CPU
opcache.validate_timestamps = 0

; Si validate_timestamps=1, fréquence en secondes (en dev uniquement)
; opcache.revalidate_freq = 2

; Garder les commentaires : OBLIGATOIRE pour Symfony/Doctrine
opcache.save_comments = 1

; Optimisations avancées
opcache.enable_file_override = 0
opcache.huge_code_pages = 1
opcache.fast_shutdown = 1

; Rapport d'erreurs
opcache.log_verbosity_level = 1

; JIT (PHP 8.0+)
opcache.jit = tracing
opcache.jit_buffer_size = 64M

; Preloading (PHP 7.4+)
opcache.preload = /var/www/html/config/preload.php
opcache.preload_user = www-data

Chaque paramètre mérite un mot.

memory_consumption

La taille du cache SHM. Trop petit, le cache se remplit et commence à évincer du bytecode : on voit apparaître des cache miss aléatoires. Trop grand, on gâche de la RAM. 256 Mo couvre 90% des applications Symfony. Pour un projet avec beaucoup de dépendances (5000+ classes), monter à 384 ou 512 Mo.

validate_timestamps

Le paramètre le plus mal configuré en production. Avec 1, PHP stat chaque fichier à chaque requête pour vérifier s'il a changé sur le disque. Sur 300 fichiers et 1000 requêtes/seconde, c'est 300 000 syscalls stat() par seconde inutiles.

En production, le code ne change qu'au déploiement. On désactive la revalidation (validate_timestamps = 0) et on purge explicitement au déploiement.

save_comments

Oubli classique qui casse Symfony silencieusement. Les attributs PHP 8 (#[Route], #[ORM\Entity]) sont lus par réflexion à partir du bytecode. Avec save_comments = 0, ils disparaissent et Symfony ne trouve plus les routes, Doctrine ne trouve plus les mappings. Toujours 1.

interned_strings_buffer

Les noms de classes, constantes et fonctions sont stockés une seule fois en mémoire partagée. Sur Symfony, 32 Mo suffisent. Si on voit des messages OPcache interned strings buffer exhausted, augmenter à 48 ou 64.

Preloading (PHP 7.4+)

Le preloading charge des fichiers PHP au démarrage de FPM, avant toute requête, et les rend disponibles pour tous les workers sans compilation supplémentaire. Le bytecode plus les structures de classes sont en mémoire partagée.

Gain supplémentaire mesuré sur Symfony : 5 à 15% par rapport à OPcache seul. Principalement visible sur le cold start (première requête après un restart FPM).

Script de preload Symfony

<?php
// config/preload.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload_runtime.php';

if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
    require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';
}

Symfony génère automatiquement un fichier .preload.php qui liste les classes à précharger lors du cache:warmup. C'est aussi simple que pointer opcache.preload dessus.

Pièges du preloading

  • Les classes préloadées ne peuvent pas être rechargées sans restart. Si on modifie une classe préloadée, il faut systemctl restart php8.3-fpm. D'où l'importance d'inclure le preload uniquement en production, pas en dev.
  • Les classes avec dépendances runtime externes échouent au preload. Exemple : une classe qui instancie un client HTTP dans son constructeur. À éviter, sinon écarter du preload.
  • Le preload_user doit correspondre à l'utilisateur FPM. Sur Debian/Ubuntu : www-data. Sinon permission denied.
  • Incompatibilité avec certaines extensions. Avant PHP 8.1, le preload ne fonctionnait pas avec Xdebug. Vérifier.

JIT (PHP 8.0+)

Le JIT compile le bytecode en code machine natif (x86_64, ARM64) à l'exécution. Deux modes principaux.

Tracing JIT (opcache.jit=tracing)

Le plus utile : compile les chemins chauds détectés dynamiquement. Gain typique sur une app web PHP : 5 à 15% sur du I/O-bound (la majorité des web apps). Peu d'impact sur les workloads déjà CPU-bound (calcul scientifique, crypto) où le JIT brille à 50 à 100%.

Function JIT (opcache.jit=function)

Compile toutes les fonctions au chargement. Plus simple mais moins agressif.

Quand le JIT aide, quand il ne change rien

Type de workload Gain JIT typique
Web app Symfony classique (I/O-bound) +5 à +15%
API REST CRUD (I/O-bound) +3 à +10%
Génération de PDF (CPU-bound) +30 à +80%
Parsing XML ou JSON massif +20 à +40%
Calcul matriciel pur PHP +100 à +300%
Workers async longs (Messenger) +10 à +25%

Règle : activer le JIT par défaut en mode tracing, il n'a jamais été nuisible dans nos benchmarks. Le gain réel dépend du workload, et il faut le mesurer (Blackfire avant/après). Budget mémoire : opcache.jit_buffer_size = 64M couvre la plupart des cas.

Bug JIT en production

Historiquement, le JIT a eu des bugs (segfault, valeurs incorrectes) jusqu'à PHP 8.1.x. Depuis PHP 8.2 il est stable en production. Nous l'activons maintenant sans hésitation, mais avec un monitoring APM qui alerte sur les 5xx.

Monitoring d'OPcache

Sans monitoring, on ne sait pas si OPcache fonctionne correctement.

Via opcache_get_status()

La fonction PHP retourne un tableau complet. À exposer via un endpoint admin ou un sidecar Prometheus.

<?php
// public/_admin/opcache-status.php
header('Content-Type: application/json');
$status = opcache_get_status(false);
echo json_encode([
    'enabled' => $status['opcache_enabled'],
    'memory_used_mb' => round($status['memory_usage']['used_memory'] / 1024 / 1024, 2),
    'memory_free_mb' => round($status['memory_usage']['free_memory'] / 1024 / 1024, 2),
    'memory_used_pct' => round(
        $status['memory_usage']['used_memory']
        / ($status['memory_usage']['used_memory'] + $status['memory_usage']['free_memory']) * 100,
        1
    ),
    'cached_scripts' => $status['opcache_statistics']['num_cached_scripts'],
    'max_cached_files' => $status['opcache_statistics']['max_cached_keys'],
    'hits' => $status['opcache_statistics']['hits'],
    'misses' => $status['opcache_statistics']['misses'],
    'hit_rate' => round($status['opcache_statistics']['opcache_hit_rate'], 2),
    'oom_restarts' => $status['opcache_statistics']['oom_restarts'],
    'hash_restarts' => $status['opcache_statistics']['hash_restarts'],
    'manual_restarts' => $status['opcache_statistics']['manual_restarts'],
]);

Protéger l'endpoint par authentification admin. Brancher Prometheus ou Datadog pour scraper et alerter.

Métriques à surveiller

Métrique Seuil d'alerte
memory_used_pct > 90% (risque d'éviction)
hit_rate < 99%
oom_restarts > 0 sur 1 heure
hash_restarts > 0 sur 1 heure
cached_scripts Plateau : plus de montée après deploy

Les oom_restarts (out of memory) signalent que memory_consumption est sous-dimensionné. Les hash_restarts que max_accelerated_files est trop bas.

Outils

  • OPcache GUI (amnuts/opcache-gui) : interface web simple, à sécuriser.
  • Tideways : monitoring OPcache intégré dans le dashboard.
  • Datadog PHP integration : collecte les métriques OPcache via l'agent.
  • Prometheus via opcache-exporter : export au format standard.

Purge au déploiement

Avec validate_timestamps = 0, OPcache ne voit jamais les changements de fichiers. Il faut le purger explicitement au déploiement. Trois méthodes.

1. Restart FPM

Le plus sûr, le plus brutal. Purge tout, y compris le preload.

sudo systemctl reload php8.3-fpm   # reload préserve les connexions

En réalité reload sur FPM recharge la config et les workers. Graceful, pas de coupure.

2. Endpoint HTTP de purge

Un endpoint qui appelle opcache_reset(). Déclenché par le pipeline CI/CD après déploiement.

// public/_admin/opcache-reset.php
&#x3C;?php
$token = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '';
if (!hash_equals(getenv('ADMIN_RESET_TOKEN'), $token)) {
    http_response_code(403);
    exit;
}
if (opcache_reset()) {
    echo 'OPcache reset';
} else {
    http_response_code(500);
    echo 'Reset failed';
}

Appeler depuis le runner de déploiement :

curl -X POST "https://app.example.com/_admin/opcache-reset.php" \
  -H "X-Admin-Token: ${ADMIN_RESET_TOKEN}"

Attention : cet endpoint doit être absolument protégé. Un opcache_reset() arbitraire est une attaque DOS simple (multiplie les cold starts).

3. Cachetool

gordalina/cachetool expose un CLI qui communique avec FPM via la socket. Pas d'endpoint HTTP à exposer.

# Dans le runner de deploy
cachetool opcache:reset --fcgi=/var/run/php/php8.3-fpm.sock

Le plus propre quand on a un accès shell sur le serveur.

Container rebuild vs live reload

En Docker/Kubernetes, la question se pose différemment. Deux approches.

Immutable infrastructure (recommandé)

Chaque déploiement construit une nouvelle image Docker avec le code. Le déploiement remplace les containers un par un (rolling update). Chaque nouveau container démarre avec un cache vide, se remplit à la première requête. Jamais de purge à gérer.

FROM php:8.3-fpm-alpine

# Code + preload script
COPY . /var/www/html
WORKDIR /var/www/html

# Config OPcache
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/

Live reload (legacy)

Sur VPS traditionnels, on push le code, on purge OPcache, on ne redémarre pas FPM. Moins propre, plus rapide. Utilisé encore sur beaucoup d'infras hébergées.

Pièges fréquents vus en audit

Les erreurs récurrentes avec leur impact.

  • validate_timestamps = 1 en prod. 10 à 30% de CPU gaspillé. Vérifier avec php -i | grep validate_timestamps.
  • save_comments = 0. Symfony annotations cassent, routes introuvables. On s'en aperçoit au premier cache warmup.
  • max_accelerated_files trop bas. Quand OPcache atteint la limite, il ne cache plus les nouveaux fichiers. cached_scripts plafonne à 10000 alors qu'on devrait voir 15000.
  • SHM saturée silencieusement. memory_used_pct à 100%, hit rate s'effondre, performance aussi. Alerting indispensable.
  • Pas de purge au deploy. Le code change mais OPcache continue de servir l'ancien bytecode. Bugs impossibles à diagnostiquer.
  • Preload sur classes avec side effects. Le preload appelle le constructeur au démarrage : appels réseau, écritures disque, échec.
  • OPcache désactivé pour le CLI (bon) mais oublié pour les workers longs (Messenger). Les workers async méritent OPcache activé aussi, avec opcache.enable_cli = 1 uniquement pour eux.

Conclusion

OPcache est l'optimisation à faire en premier, avant toute autre. Quinze lignes de configuration bien choisies, une purge automatisée au déploiement, un monitoring basique des hits et de la mémoire : trois fois plus rapide, sans changer une ligne de code applicatif.

Pour un audit de configuration PHP, un tuning serveur complet avant pic de trafic ou une mise en place de chaîne d'observabilité, contactez-nous à contact@your-digital-hub.com ou découvrez notre service performance et scalabilité. Voir aussi notre article Stratégies de cache PHP pour la suite du raisonnement.