Annexe B. Optimisation des Performances pour les Applications Zend Framework

Table des matières

B.1. Introduction
B.2. Evitez l'Optimisation Prématurée
B.3. Mesurer les Performances
B.3.1. Mesures du CPU et de la Mémoire
B.3.2. Requêtes par Seconde
B.4. Localiser la Cause des Mauvaises Performances
B.4.1. Profiling de Code
B.4.2. Analyse des Opérations en Base de Données
B.5. Optimisation Générale de PHP
B.5.1. Cache d'Opcode
B.5.2. Cache de Realpath
B.6. Optimisation Générale de Zend Framework
B.6.1. Optimisation du Chargement des Classes
B.6.2. Configurer les Caches par Défaut
B.6.3. Cachez au Bon Niveau
B.6.4. Minimiser les Include Paths
B.7. Optimisation du Server HTTP
B.7.1. Optimiser la Configuration d'Apache
B.7.2. Complétement Eviter Apache
B.8. Conclusion

B.1. Introduction

L'optimisation des performances n'est pas une histoire de remplacement de tous vos guillemets doubles par des guillemets simples, en priant que quelques nano-secondes gagnées empécheront Apache d'aller travailler en swap, en envoyant votre serveur mordre la poussière le jour où vous serez listé sur Digg. Il s'agit plutôt d'un processus réfléchi, qui passe par la définition d'objectifs de performance, la réalisation de benchmarks pour mesurer les performances actuelles, l'identification de points inefficaces et de goulots d'étranglement, l'achat de serveurs physiques appropriés, et la recherche de nouvelles tactiques auxquelles vous n'auriez jamais pensé. En d'autres mots, beaucoup de fun en prévision !

Au cours de cette annexe, nous verrons l'optimisation des performances sous la forme de plusieurs approches et techniques souvent utilisées dans le monde réel ; nous ne soucierons pas vraiment de points comme les guillemets doubles (sauf en cas de résultat perceptible). La plupart des points que nous verrons devraient devenir familiers, s'ils ne le sont pas déjà.

Puisque détailler les options orientées performances est une tâche d'envergure, cette annexe sera plutôt longue, mais j'espère qu'elle sera utile. Je n'ai pas l'intention de limiter cette annexe à Zend Framework uniquement, et beaucoup de points que nous aborderons ici s'appliquent à n'importe quelle application PHP.

B.2. Evitez l'Optimisation Prématurée

Avant que nous ne fassions quoi que ce soit, soyons clair sur le fait que vous ne devriez pas sacrifier une conception d'application solide sur l'autel de l'optimisation prématurée. L'optimisation prématurée est, comme Sr. Tony Hoare l'a initialement dit, la cause de tous les maux[1]. Cela dit, ne sortons pas non plus cette phrase de son contexte ! Toujours repousser l'optimisation revient finalement à ne jamais obtenir une application qui soit efficace. Eventuellement, la tentation peut devenir des plus fortes, mais le moment où réaliser les optimisations est important. Optimiser trop tôt peut amener à une conception restrictive, qui ne causera que plus de problème, donc il vaut mieux attendre jusqu'à ce que l'application soit dans un état propice à l'optimisation.

Optimiser est le jeu de l'élimination des inefficacités mesurables. Le mot important, ici, est "mesurable". Si vous ne pouvez pas mesurer ou prédire de façon fiable l'impact d'une optimisation, comment saurez-vous si elle en valait la peine ? Un bête remplacement de quelques guillemets doubles par des guillemets simples prenant des heures de temps à un développeur pourrait vous apporter un gain de performances complétement non-remarquable. Vous avez juste perdu une heure, en n'avez quasiment rien gagné, ou, en tout rien, rien qui mérite fierté ! Pendant ce temps, il y a une requête SQL lente qui, en appliquant un mécanisme de cache en quelques minutes de travail avec Zend_Cache, vous apporterait un gain d'une seconde. De toute évidence, la seconde option mérite bien plus votre attention. Concentrez vos efforts sur les optimisations qui apportent le plus grand bénéfice.

Il existe trois approches à l'optimisation : suivre vos croyances aveugles, suivre votre intuition expérimentée, et utiliser des outils logiciels pour analyser les applications à la recherche d'opportunités d'optimisation. La troisième est la seule option réellement valable. Les autres sont incertaines, et vous risquez de passer à côté d'optimisations intéressantes, ou de tout simplement gaspiller votre temps sur des points qui n'en valent pas le coup. Ceci ne rend pas les deux premières options totalement invalides, mais elles relèvent souvent du type d'optimisations que n'importe quel programmeur raisonable depuis appliquer tout au long du développement ; pas de domaines qui devraient nécessiter des heures d'attention après-coup.

Un programmeur expérimenté ne créera que rarement une application horriblement inefficace remplie de portions de code source incorrectes. Quelques éléments déjà optimisés niveau performances seront déjà présent dans le design existant, et, donc, ce qu'il reste à faire est de pousser les choses plus loin, de manière réfléchie. Beaucoup d'optimisations relèvent du bon sens, puisqu'une fois que vous avez compris le problème, il n'y a pas tant de solutions différentes entre lesquelles choisir, et la plupart de celles-ci auront déjà été blogguées par la moitié de la planète.

B.3. Mesurer les Performances

Optimiser ne fonctionne que si vous pouvez obtenir des résultats mesurables et significatifs. Significatifs, ben entendu, est relatif : quelques millisecondes gagnées pour un développeur peuvent représenter des secondes pour un autre. Au fur et à mesure que vous optimiserez les problèmes de performance les plus évidents, moins de problèmes seront visibles facilement, jusqu'à ce que vous atteignez un état dans lequel optimiser plus aille de pair avec un coût qui devienne supérieur au bénéfice final ou à celui de tout simplement ajouter plus de machines. Dans tous les cas, vous devriez avoir un objectif de performance à l'esprit, et atteindre cet objectif requiert de mesurer les performances actuelles pour définir un point de départ, ainsi que de mesurer les performances futures en vue de déterminer si vous avez effectué quelque amélioration significative que ce soit par rapport à ce point de départ.

Les mesures de performances reviennent généralement à quelques métriques. Les utilisations mémoire et CPU sont celles qui sont évidentes, en particulier sur un seul serveur où les ressources sont limitées. Mais même dans un environnement où vous scalez votre matériel, que ce soit verticalement ou horizontalement, maximiser les performances peut vous éviter de dépenser inutilement en serveurs coûteux pour compenser les problèmes d'un code tout sauf optimal. Une autre mesure commune, à côté de ces deux points, est le nombre de requêtes par seconde ; cela revient à déterminer combien de requêtes une partie de votre application est capable de servir par seconde, considérant le matériel qui l'héberge actuellement.

B.3.1. Mesures du CPU et de la Mémoire

Mesurer l'occupation mémoire peut être fait aussi bien au niveau du serveur qu'au niveau de PHP. PHP fourni deux fonctions utiles qui mesurent combien de mémoire il utilise. memory_get_usage() et memory_get_peak_usage() peuvent être appelées pour déterminer combien d'octets sont utilisés soit à un instant précis, soit au maximum. Ces fonctions sont typiquement utilisées pour avoir une idée générale du profil d'occupation mémoire d'une application : déterminer où dans l'application la mémoire est perdue au-delà d'un seuil de tolérance acceptable. Déterminer ce seuil de tolérance n'est pas une tâche simple, puisque certaines fonctionnalités peuvent demander plus de mémoire que d'autres. Si votre php.ini inclut une limite de mémoire assez haute, c'est pour une bonne raison !

Une technique utile fait référence à la futilité que représentent des mesures manuelles. Plutôt que de cliquer sur tous les liens possibles et de soumettre tous les formulaires imaginables à la main, définissez simplement quelques emplacements dans le code source où ces mesures peuvent être effectuées. Que vous fassiez cela en utilisant des outils logiciels qui attaquent automatiquement tous les chemins possibles (les Tests Fonctionnels Automatisés peuvent être un bon point de départ, si vous en avez mis en place sur votre projet), ou en collant une étiquette Bêta sur un site de production et en le laissant tourner pour une courte période est un choix qui vous revient.

Une solution possible, bien qu'un peu brute, est d'écrire un petit Plugin de Contrôleur, qui implémente la méthode Zend_Controller_Plugin_Abstract::dispatchLoopShutdown() :

  • <?php
  • class ZFExt_Controller_Plugin_MemoryPeakUsageLog
  • extends Zend_Controller_Plugin_Abstract
  • {
  • protected $_log = null;
  • public function __construct(Zend_Log $log)
  • {
  • $this->_log = $log;
  • }
  • public function dispatchLoopShutdown()
  • {
  • $peakUsage = memory_get_peak_usage(true);
  • $url = $this->getRequest()->getRequestUri();
  • $this->_log->info($peakUsage . ' bytes ' . $url);
  • }
  • }

Vous pouvez l'activer depuis votre Bootstrap lorsque vous voulez l'utiliser :

  • <?php
  • class ZFExt_Bootstrap
  • {
  • // ...
  • public function enableMemoryUsageLogging()
  • {
  • $writer = new Zend_Log_Writer_Stream(
  • self::$root . '/logs/memory_usage');
  • $log = new Zend_Log($writer);
  • $plugin = new ZFExt_Controller_Plugin_MemoryPeakUsageLog($log);
  • /**
  • * Use a high stack index to delay execution until other
  • * plugins are finished, and their memory can also be accounted
  • * for.
  • */
  • self::$frontController->registerPlugin($plugin, 100);
  • }
  • // ...
  • }

C'est un exemple assez basique, et vous pourriez facilement adapter quelque chose de similaire pour logger des informations similaires à propos de la requête initiale, pour que vous ayez suffisament d'informations pour pouvoir re-jouer ladite requête dans un environnement mieux contrôlé, afin de déterminer précisément la raison expliquant les fuites mémoires. Voici quelques lignes de sorties obtenues depuis le log activé au-dessus :

2009-01-09T15:41:46+00:00 INFO (6): 4102728 bytes /
2009-01-09T15:42:57+00:00 INFO (6): 4103608 bytes /index/comments

Si vous préférez observer l'occupation mémoire à la volée, vous pouvez utiliser un stream PHP différent pour la stratégie d'écriture, ou même utiliser le Writer Firebug pour regarder les résultats en direct lorsque vous utilisez Firefox avec FirePHP.

Une fois que vous avez un profil mémoire un peu grossier, vous pouvez utiliser une approche systématique, comme du profiling de code, pour accumuler des détails sur les points qui expliquent toutes les allocations mémoire. Le Profiling de Code est aussi une bonne solution pour déterminer où du temps est passé à l'exécution.

En dehors des solutions intégrées à l'application, vous pouvez aussi suivre les occupations mémoire et CPU sur le serveur où est déployée l'application, en utilisant un grand nombre d'applications variées. Les systèmes Linux fournissent les outils top, free, vmstat, et bien d'autres. Un de mes favoris est htop, qui affiche un résumé fréquemment mis à jour de la mémoire, du CPU, et des données de chaque processus, avec quelques fonctionnalités pour ordonner les processus en fonction de différentes statistiques.

Ces points sont particulièrement utiles lorsque vous configurez des serveurs pour qu'ils hébergent votre application, mais, de toute évidence, ils peuvent aussi vous aider à voir comment votre application se comporte sur un serveur de test pré-optimisé, lorsque vous lancez des tirs de performance : combien de mémoire est-ce que les processus Apache utilisent, pouvous-nous lancer plus ou moins de clients Apache, quelles parties de l'application consomment le maximum de CPU plutôt que de RAM ?

B.3.2. Requêtes par Seconde

Les tests de tenue à la charge sont un autre outil utile lorsqu'il s'agit de mesurer les performances d'une application. Les résultats sont influencés, de manière importante, par le matériel sur lequel les tests sont effectués, donc, pour conserver des résultats comparables entre chaque lancement, vous aurez besoin d'un système de test dont les spécifications et la charge générale restent contantes. Leur objectif est de mesurer à combien de requêtes votre application (une URL spécifique, ou un ensemble d'URLs) peut répondre par seconde en moyenne. Le temps pris est souvent moins important (il varie en fonction du matériel), donc c'est la variation relative entre les mesures qui indique si votre application devient plus ou moins performante. Vous remarquerez que la plupart des Benchmarks de Frameworks ont tendance à être obsédés par cette valeur.

L'approche, relativement simple, est de simuler l'effet d'être touché par un nombre spécifique de requêtes réparties entre un nombre donné d'utilisateurs concurrents. Des fois, l'élément de base est une période de temps, à la place d'un nombre fixe de requêtes. Le nombre total de requêtes est ensuite divisé par le temps en secondes pris pour répondre à ce nombre de requêtes. Cette idée est extrêmement efficace lorsqu'il s'agit de noter comment les performances d'une application évoluent alors que vous optimisez l'application et/ou le serveur qui l'héberge.

Deux outils fréquemment utilisés pour cela sont ApacheBench (ab) et Siege.

L'outil ApacheBench est normalement installé avec les exécutables du serveur HTTP. S'il manque, parfois, il vous faudra installer un paquet nommé Apache Utils. Sous Ubuntu, il est généralement enregistré sous /usr/sbin/ab, qui peut ne pas être inclu dans le PATH d'Ubuntu par défaut, ce qui signifie que vous devrez soit ajouter /usr/sbin au PATH de votre utilisateur courant, ou l'appeler en utilisant son chemin absolu.

Voici la commande ApacheBench permettant de lancer 10,000 requêtes réparties sur 100 utilisateurs concurents (Le slash final est important pour les URLs nues) :

ab -n 10000 -c 100 http://www.survivethedeepend.com/

La sortie obtenue ressemblera à quelque chose de ce type :

Server Software:        apache2
Server Hostname:        www.survivethedeepend.com
Server Port:            80

Document Path:          /
Document Length:        9929 bytes

Concurrency Level:      100
Time taken for tests:   341.355 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      101160000 bytes
HTML transferred:       99290000 bytes
Requests per second:    29.29 [#/sec] (mean)
Time per request:       3413.555 [ms] (mean)
Time per request:       34.136 [ms] (mean, across all concurrent requests)
Transfer rate:          289.40 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.7      0      52
Processing:   124 3409 831.1   3312   10745
Waiting:      120 3267 818.5   3168   10745
Total:        124 3409 831.1   3312   10745

Percentage of the requests served within a certain time (ms)
  50%   3312
  66%   3592
  75%   3784
  80%   3924
  90%   4344
  95%   4828
  98%   5576
  99%   6284
 100%  10745 (longest request)

Voici un exemple similaire pour Siege, où vous définissez le nombre total de requêtes en configurant le nombre d'utilisateurs concurrents, avec un facteur de répétition pour chaque. Donc, 100 utilisateurs en concurrence répétant des requêtes 10 fois vous donnera un total de 1,000 requêtes. L'option supplémentaire indique un délai d'une seconde entre les accès concurrents, ce qui est suffisant pour des tests de performance.

siege -c 100 -r 10 -d 1 http://www.survivethedeepend.com

Les résultats sont un brin plus court, mais montrent toujours le nombre de requêtes par seconde, sous l'entrée "Transaction rate" :

Transactions:                   1000 hits
Availability:                 100.00 %
Elapsed time:                  43.77 secs
Data transferred:               3.06 MB
Response time:                  3.20 secs
Transaction rate:              22.85 trans/sec
Throughput:                     0.07 MB/sec
Concurrency:                   73.08
Successful transactions:        1000
Failed transactions:               0
Longest transaction:            8.78
Shortest transaction:           0.05

Ces deux outils de test de charge viennent avec une grande variété d'autres options qui méritent de fouiller plus en profondeur. Lorsque vous arrivez au moment de déployer sur un serveur, méler ce type de tests avec le monitoring des ressources de votre serveur comme la mémoire et le CPU peut vous apporter pas mal d'informations utiles à l'optimisation de la configuration Apache.

Et oui, les performances du site web du livre était catastrophiques au moment où j'ai joué ces tests de charge ! Si j'avais caché les pages correctement à ce moment là, les nombres de requêtes par seconde auraient été largement plus élevés.

Notons que les tests de charge prennent en compte à la fois les aspects logiciels et matériels, et sont donc un outil utile pour l'optimisation d'un serveur, en plus de la mesure des performances d'une application.

B.4. Localiser la Cause des Mauvaises Performances

Plutôt que de sélectionner aléatoirement des portions de code source à optimiser, en croisant les doigts pour la chance, voici quelques méthodes qui permettent d'identifier des problèmes de performance spécifiques.

B.4.1. Profiling de Code

Un regard d'ensemble, en lançant des tirs de performance et en monitorant l'utilisation des ressources, vous donnera une idée de base des parties de l'applications qui se comportent mieux, ou moins bien, que la moyenne. Cela dit, si vous trouvez un problème de performances, la seule manière qui vous permette de localiser sa cause réelle est de rechercher tout ce qui peut coûter, niveau performances, à travers l'ensemble de tout le code source exécuté. Dans une application simple, lire le source peut être envisageable, mais une application plus complexe pourrait effectuer des opérations en base de données, des traitements sur des fichiers, ou un nombre incalculable d'autres opérations qui ont un effet sur les performances. En considérant que votre application peut aussi dépendre de bibliothèques externes, lire son code source n'est pas aussi simple qu'il paraît.

Le Profiling de Code, ou le Timing de Méthodes, est un moyen de découper une requête de manière à pouvoir examiner le temps d'exécution et l'utilisation mémoire de chaque appel de fonction ou de méthode effectués pour cette requête. C'est une solution qui apporte une aide inestimable lorsqu'il s'agit de diagnostiquer les problèmes de performances et d'identifier des cibles pour l'optimisation.

Un Profiler communément utilisé en PHP est l'extension Xdebug développée par Derick Rethans. Xdebug fournit des fonctionnalités pour le débuggage, le profiling, et l'analyse de couverture de code[2]. La fonctionnalité de Profiling génére des fichiers compatibles avec cachegrind, qui peuvent être analysés sous forme de données lisibles par n'importe quelle application compatible avec cachegrind, comme KCacheGrind (KDE), WinCacheGrind (Win32), ou l'application Web WebGrind.

En fonction de l'application cachegrind que vous choisirez, elle proposera plusieurs méthodes de visualisation du temps passé au sein de chaque fonction, méthode, et mots-clefs. De toute évidence, ce sont les points avec le temps ou l'occupation mémoire les plus importants qui méritent un examen le plus approfondi, afin de déterminer s'ils peuvent être optimisés de manière à obtenir un gain non-négligeable.

La version courant est disponible via PECL, et vous pouvez l'installer avec un simple :

pecl install xdebug

Cette commande téléchargera et compilera Xdebug. Si vous travaillez sous Windows, des binaires précompilés sont proposés au téléchargement sur la page téléchargements du site de Xdebug.

Pour terminer l'installation, nous pouvons configurer Xdebug pour le profiling en ajoutant les lignes qui suivent à votre fichier php.ini, en veillant à remplacer ou à commenter toute ligne utilisant les options de configuration zend_extension ou zend_extension_ts qui s'y trouvait déjà. Voici un exemple de configuration ajouté à un système Ubuntu dans /etc/php/conf.d/xdebug.ini (Ubuntu accepte des fichiers spécifiques aux extensions, en plus de /etc/php5/apache2/php.ini) :

; Xdebug
zend_extension = /usr/lib/php5/20060613/xdebug.so
;xdebug.profiler_enable = 1
xdebug.profiler_output_dir = "/tmp"
xdebug.profiler_output_name = "cachegrind.out.%t.%p"

Lorsque vous voulez commencer à profiler, supprimez le point-virgule au début de la ligne de configuration de xdebug.profiler_enable. Remettez le une fois que vous avez terminé, à moins que vous n'aimiez avoir des fichiers cachegrind gigantesques qui remplissent votre disque dur. Voici un exemple de sortie obtenu via Webgrind sur une machine Windows, qui indique le pourcentage du temps d'exécution qui est passé pour chaque fonction/méthode :

Analyse de la sortie de Cachegrind à l'aide de Webgrind

Installer Webgrind ne demande que de copier ce que vous avez téléchargé vers votre Document Root Apache, et de modifier le fichier config.php pour supprimer les options $storageDir et $storageProfiler (la configuration de Xdebug fournie plus haut est suffisante pour que Webgrind détecte ces valeurs automatiquement). Utiliser la configuration reproduite plus haut avec xdebug.profiler_output_name devrait être suffisant pour pouvoir lire n'importe quel fichier de sortie cachegrind généré par Xdebug.

En utilisant du profiling de code, et en générant des fichiers cachegrind pour une large plage d'URIs de notre application, nous pouvons maintenant fouiller dans le code source pour trouver qui est méchant et prend le plus de temps à s'exécuter. Comme nous le remarquerez depuis la capture d'écran au-dessus, Zend_Loader semble être plutôt gourmand, puisqu'il prend quasiment 25% du temps de génération de la page d'index. De toute évidence, 25% est assez pour mériter notre attention !

B.4.2. Analyse des Opérations en Base de Données

Xdebug vous permettra d'analyser l'exécution de votre code, mais il existe d'autres alternatives ou solutions complémentaires pour vous aider à localiser des opportunités d'optimisation. L'une des cibles les plus évidentes est la base de données.

Se connecter à une base de données implique plusieurs types de problématiques. Tout d'abord, les opérations en base de données sont extrêmement coûteuses. Votre application va devoir ouvrir une connexion vers le serveur de base de données, envoyer la requête, attendre que celle-ci ait été traitée, lire les résultats renvoyés, effectuer quelques traitements de plus, et vraiment... C'est un sacré foutoir ! Dans la plupart des applications web, la base de données sera un goulot d'étranglement majeur, qui prendra une bonne partie de votre temps d'exécution.

Des bénéfices liés à l'optimisation peuvent être réalisés en examinant la méthode d'accès à la base de données, qui, dans le cas de Zend Framework, va inclure Zend_Db_Table et vos Modèles, la complexité et les besoins en traitements des requêtes SQL, ainsi que la taille des données récupérées depuis la base de données.

Dans les trois cas ou presque, où vous avez des cibles d'optimisation évidentes et mesurées, utiliser du cache est une stratégie qui apportera probablement des gains, dans les cas où les données obtenues ne changent que peu fréquemment, et où les données elles-mêmes sont de taille importante, ou lorsque le résultat est obtenu en jouant une requête identifiée comme étant lente. Pourquoi constamment bombarder la base de données pour des données qui ne changent que toutes les quelques heures, jours, ou même semaines ? Mettez-les en cache ! Cacher en mémoire en utilisant APC ou memcached serait de toute évidence préférable, mais un cache à base de fichiers fera l'affaire si les ressources mémoire sont limitées. Définissez une durée de vie appropriées ou effacez manuellement la donnée cachée lorsqu'elle change. Zend_Cache rend l'organisation de la gestion du cache aussi simple qu'une balade au parc. Voici un exemple utilisant un simple Modèle Comments, où la configuration du cache est gérée depuis le fichier de configuration config.ini principal de l'application :

[general]
; SQL Query Cache
cache.sql.frontend.name=Core
cache.sql.frontend.options.lifetime=7200
cache.sql.frontend.options.automatic_serialization=true
cache.sql.backend.name=File
cache.sql.backend.options.cache_dir=/cache/sql
  • <?php
  • class Comments extends Zend_Db_Table
  • {
  • protected $_name = 'comments';
  • protected $_cache = null;
  • public function init() {
  • /**
  • * config.ini general sections stored into Zend_Registry
  • * Ideally the static calls would be replaced with dependency
  • * injection
  • */
  • $options = Zend_Registry::get('configuration')->cache->sql;
  • $options->backend->options->cache_dir =
  • ZFExt_Bootstrap::$root . $options->backend->options->cache_dir;
  • $this->_cache = Zend_Cache::factory(
  • $options->frontend->name,
  • $options->backend->name,
  • $options->frontend->options->toArray(),
  • $options->backend->options->toArray()
  • );
  • }
  • public function getComments($id)
  • {
  • // pull data from cache, or else hit database and cache results
  • if (!$result = $this->_cache->load('ENTRY_COMMENTS_' . $id)) {
  • $select = $this->select()
  • ->where('entry_id = ?',$id)
  • ->where('status = ?','approved')
  • ->where('type = ?','comment')
  • ->order('date ASC');
  • $result = $this->fetchAll($select);
  • $this->_cache->save($result, 'ENTRY_COMMENTS_' . $id);
  • }
  • return $result;
  • }
  • public function insert($data) {
  • // invalidate existing cache when saving new comments
  • $this->_cache->remove('ENTRY_COMMENTS_' . $data['entry_id']);
  • return parent::insert($data);
  • }
  • }

Si les données changent en permanence, une situation habituelle lorsque le contenu est dynamique, généré par l'utilisateur ou dépendant des préférences de celui-ci, utiliser du cache n'apportera pas un gain important. Le cache peut devenir invalide avant même que Zend_Cache ne termine de l'écrire ! Dans ce genre de situation, vous devrez voir si remplacer l'utilisation de Zend_Db_Table par du SQL écrit à la main, ou si revoir le SQL lui-même pour améliorer ses performances, peut avoir un effet perceptible. Lorsque vous travaillez avec un large jeu de données, assurez-vous que seules les données utiles soient récupérées. Il est inutile de charger des champs supplémentaires s'ils ne sont jamais utilisés, et que la seule chose qu'ils feront est consommer de la mémoire inutilement, privant ainsi votre serveur d'une quantité non-négligeable de précieuse RAM.

Un point auquel vous pouvez particulièrement prêter attention, par exemple, est le Log des requêtes lentes de MySQL. Cette fonctionnalité vous permet de définir, en utilisant l'option de configuration long_query_time, le nombre de secondes à partir duquel une requête doit être considérée comme lente. Toutes les requêtes lentes peuvent être logguées, afin de pouvoir être examinées plus en détails par la suite. Puisque les requêtes lentes sont mesurées en temps réel, il est intéressant de noter que des tirs de performance sur une application vont augmenter son taux de détection, puisque les ressources CPU et mémoire de la machine seront fortement utilisées. Utilisez une valeur plus basse pour long_query_time si vous voulez faire confiance aux logs générés sur une machine de développement.

Zend Framework inclut sa propre solution de logging pour toutes les requêtes SQL, pour profiler celles qui sont jouées, et combien de temps elles prennent, en utilisant Zend_Db_Profiler. Le profiler peut être activé en passant une option nommée "profiler" à la valeur booléenne TRUE avec les autres options, lors de la construction d'un adapter de base de données. Ensuite, vous pouvez instancier la classe Zend_Db_Profiler, et utiliser un ensemble de méthodes pour voir combien de requêtes ont été jouées, combien de temps elles ont pris au total pour être exécutées, et obtenir une liste de profils de requêtes sur lesquels effectuer une analyse plus en profondeur, en utilisant les filtres appropriés. Il existe même une classe spéciale, nommée Zend_Db_Profiler_Firebug, que vous pouvez utiliser pour voir les données de profiling à la volée, via la console de Firebug dans Firefox.

B.5. Optimisation Générale de PHP

PHP a un grand nombre d'optimisations bien connues, et d'autres moins connues, qui sont maintenant perçues comme des habitudes standard. Quelques-unes de celles-ci sont devenues plus importantes pendant la durée de vie de PHP 5.2.

B.5.1. Cache d'Opcode

L'utilisation d'un cache d'opcode, comme l'extension Alternative PHP Cache (APC), peut avoir un impact significatif sur les performances de toute application PHP, en cachant le code intermédiaire obtenu lors du parsing d'un code source PHP. Sauter cette étape de traitement et réutiliser le cache dans les requêtes suivantes économise de la mémoire et améliore les temps d'exécution. Installer un mécanisme de cache d'opcode devrait être une pratique standard, à moins que vous ne soyez coincé sur un hébergement mutualisé limité.

Installer APC est un processus relativement simple en utilisant pecl. Sous Linux, utilisez :

pecl install apc

Les utilisateurs de Windows peuvent télécharger une DLL pré-compilée depuis http://pecl4win.php.net.

La dernière étape est d'ajouter la configuration d'APC à votre fichier php.ini, ou de créer un nouveau fichier apc.ini, sous l'installation standard d'Ubuntu, dans /etc/php5/conf.d/apc.ini :

;APC
extension=apc.so
apc.shm_size = 50

La configuration que vous définissez peut avoir des effets supplémentaires sur les performances, et je vous suggère de lire la documentation accessible à http://php.net/manual/en/apc.configuration.php. Faites tout particulièrement attention à apc.shm_size, apc.slam_defense (ou apc.write_lock), apc.stat et apc.include_once_override. Vous devriez vous assurer que apc.shm_size soit, au minimum, configuré à une valeur suffisante pour mettre en cache toutes les classes utilisées par votre application (et quoi que ce soit d'autre que vous hébergiez !). N'oubliez pas de prendre en compte les autres données que vous pourriez stocker là via Zend_Cache ou les fonctions d'apc.

B.5.2. Cache de Realpath

Une des nouveautés dont la configuration a été le plus récemment ajoutée au fichier php.ini est un cache de chemins réels[3], introduit avec PHP 5.2.0. Il a été conçu pour mettre en cache les valeurs de chemins réels des chemins relatifs utilisés pour les instructions include_once et require_once. Avant cela, à chaque fois que vous appeliez require_once avec un chemin relatif, toute une série de traitements de fichiers étaient effectués pour trouver le fichier auquel vous faisiez référence. Maintenant, ceci n'est plus fait qu'une seule fois, et mis en cache pendant la durée spécifiée, pour les prochaines recherches.

Les options de configuration qui correspondent sont realpath_cache_size and realpath_cache_ttl. La première définit la taille du cache, et vaut par défaut 16K. Ce n'est de toute évidence pas une grande valeur, et elle devrait être augmentée pour les applications chargeant un grand nombre de fichiers. La durée de vie dépend de la fréquence à laquelle les emplacements de fichiers changent. Si ce n'est que rarement, vous pouvez envisager d'augmenter cette valeur au-delà des 120 secondes par défaut. J'ai augmenté cette valeur largement au-delà de plusieurs minutes sans effet négatif, mais je me montrerais plus prudent si j'avais de grandes quantités de fichiers à déplacer.

B.6. Optimisation Générale de Zend Framework

Jusqu'à présent, nous avons vu pas mal de points assez généralistes. Zend Framework, en tant que cible d'optimisations, a de quoi faire. Puisqu'il s'agit d'un Framework qui assure la compatibilité antérieure, certaines optimisations sont omises dans la version actuelle, pour maintenir la rétro-compatibilité et supporter une version mimale de PHP.

Cela ne signifie pas que nous devrions passer notre chemin et ne rien faire !

B.6.1. Optimisation du Chargement des Classes

A cause de la façon dont Zend Framework est structuré, le code source regorge d'une quantité d'instructions require_once. Bien que cela puisse ne pas donner l'impression d'être important, considérant la taille du Framework, cela signifie souvent avoir un grand nombre de fichiers inutiles chargés, analysés, et préparés pour exécution... Alors qu'ils ne sont jamais utilisés.

Limiter le nombre de classes chargées peut apporter un léger gain. C'est obtenu en utilisant la fonctionnalité d'autoload de PHP, qui charge dynamiquement les classes à la demande (pour faire simple, c'est une forme de chargement paresseux). Supprimer l'inclusion des classes inutiles réduit la charge de travail de PHP.

Au sein de Zend Framework, il y a une légère complication, dans le sens que la fonctionnalité d'autoloading la plus utilisée est fournie par Zend_Loader. Zend_Loader a son propre agenda mystérieux qui exige qu'elle effectue des vérifications de fichiers supplémentaires et d'autres opérations sur tous les auto-chargements. En dehors de quelques cas particuliers, toutes celles-ci sont complétement superflues et inutiles.

A partir de Zend Framework 1.8.0, Zend_Loader::autoload() est devenu une fonctionnalité dépréciée, ce qui signifie que vous ne devriez plus l'utiliser dans de nouveaux projets, et que vous devriez sérieusement envisager de la remplacer par la nouvelle solution Zend_loader_Autoloader avant la sortie de Zend Framework 2.0.

Utiliser Zend_Loader peut être utile, d'une certaine façon, mais puisque Zend Framework respecte la convention PEAR, qui est des plus prévisibles, ce qui suit fonctionne tout aussi bien, et évite les opérations de vérification plus coûteuses de Zend_Loader :

  • function __autoload($path) {
  • include str_replace('_','/',$path) . '.php';
  • return $path;
  • }

Vous pourriez aussi utiliser ceci sous forme d'une méthode statique pour votre classe de Bootstrap :

  • class ZFExt_Bootstrap
  • {
  • // ...
  • public static function autoload($path)
  • {
  • include str_replace('_','/',$path) . '.php';
  • return $path;
  • }
  • // ...
  • }

Une fonction de quatre lignes ou une classe... Encore une fois, de David et Goliath, qui a gagné ? Notons tout de même que certaines bibliothèques externes auront besoin d'un traitement spécifique si elles ne respectent pas la convention PEAR.

Avec Zend Framework 1.8 et les versions suivantes, une nouvelle solution nommée Zend_Loader_Autoloader existe, dont les fonctionnalités méritent que vous l'adoptiez, même si elle semble plus complexe que le simple remplacement d'autoload que je viens de décrire. Malgré la complexité de cette nouvelle classe, vous pouvez toujours gagner en performances en apportant une simple modification sur la façon dont Zend_Loader_Autoload::autoload() fonctionne. C'est possible parce que cette nouvelle classe permet aux développeurs de remplacer l'autoloader par défaut, qui est Zend_Loader::loadClass(), par une version plus légère pour éviter toutes les vérifications de fichiers qui sont généralement inutiles. Notez que si vous utilisez Zend_Application, cela doit être fait avant le bootstrapping, ce qui signifie dans index.php.

  • $autoloader = Zend_Loader_Autoloader::getInstance();
  • $autoloader->setDefaultAutoloader(array('ZFExt_Bootstrap', 'autoload'));

Utiliser de l'autoloading n'évitera pas l'impact de toutes ces instructions require_once dans le code source de Zend Framework lui-même. Pour maximimer les gains de l'auto-chargement, supprimer toutes les occurences de require_once des fichiers de classes n'est pas une mauvaise idée. Vous pouvez effectuer ceci dans une tâche Phing, depuis la ligne de commandes, ou à l'aide de n'importe quel script PHP, que vous lancerez sur une copie de Zend Framework.

Depuis la ligne de commande, vous pouvez commenter ces appels en utilisant :

cd path/to/ZendFramework/library

find . -name '*.php' -not -wholename '*/Loader/Autoloader.php' -print0 | \
  xargs -0 sed --regexp-extended --in-place 's/(require_once)/\/\/ \1/g'

B.6.2. Configurer les Caches par Défaut

Au sein du Framework, quelques classes impliquent des opérations coûteuses. Par exemple, Zend_Db_Table lance une requête DESCRIBE TABLE en arrière-plan à chaque fois que vous créez une nouvelle instance d'un de ses types. Considérant que vous pourriez utiliser au moins quelques unes de celles-ci à chaque chargement de page, ces requêtes supplémentaires vont vite devenir nombreuses. Un autre exemple est Zend_Translate, qui va en permanence ré-analyser les sources de traductions lorsqu'il sera utilisé.

Heureusement, ces deux composants vous permettent de définir un cache, donc, pour éviter de vous sentir bête à l'avenir, assurez-vous de les avoir configurés.

Pour Zend_Db_Table, vous pouvez définir un cache par défaut en passant une instance de Zend_Cache à la méthode Zend_Db_Table_Abstract::setDefaultMetadataCache(). Zend_Translate offre la même fonctionnalité en utilisant Zend_Translate::setCache(), tout comme Zend_Locale utilise Zend_Locale::setCache(). Tout ceci peut être configuré depuis votre bootstrap, pour que ces caches soient disponibles pour toutes les requêtes.

B.6.3. Cachez au Bon Niveau

En optimisation des performances, cacher est important, puisque cela peut signifier des gains significatifs de performance en cachant les résultats d'opérations coûteuses pour une période de temps donnée. Cependant, savoir quand mettre en cache lève aussi la question de où mettre en cache.

Prenez l'exemple d'une page dynamique où les éléments dynamiques (peut-être une liste des nouveautés les plus récentes, mise à jour une fois par heure) ne changent pas trop souvent. L'idée la plus évidente est de mettre en cache les données dynamiques pour les nouveautés les plus récentes (au sein d'un Modèle ou d'une aide de Vue, éventuellement). Puisque les modifications se font toutes les heures, la durée de vie du cache serait aux environs de 3600 secondes. Maintenant, les éléments dynamiques ne sont plus mis à jour à partir des nouvelles données que toutes les heures. Cela dit, pour utiliser ce cache, nous devons encore appeler l'application, qui lancera la génération de la Vue qui utilise le cache : chaque requête lance encore l'application, comme précédemment.

Est-ce vraiment nécessaire ? Si les seuls éléments dynamiques de la page sont mis à jour une fois par heure, pourquoi ne cacherions-nous pas la page entière pour la durée de cette heure ? Vous pouvez effectuer de la mise en page de cache depuis votre Bootstrap en utilisant Zend_Cache :

  • <?php
  • class ZFExt_Bootstrap
  • {
  • // ...
  • public function run()
  • {
  • $this->setupEnvironment();
  • /**
  • * Implement Page Caching At Bootstrap Level
  • */
  • $this->usePageCache();
  • $this->prepare();
  • $response = self::$frontController->dispatch();
  • $this->sendResponse($response);
  • }
  • public function usePageCache()
  • {
  • $frontendOptions = array(
  • 'lifetime' => 3600,
  • 'default_options' => array(
  • // disable default caching for all requests
  • 'cache' => false
  • ),
  • // cache routes to Index and News controllers
  • 'regexps' => array(
  • '^/$' => array('cache' => true),
  • '^/news/' => array('cache' => true)
  • )
  • );
  • $cache = Zend_Cache::factory(
  • 'Page',
  • 'Apc',
  • $frontendOptions
  • );
  • // serve cached page (if it exists) and exit
  • $cache->start();
  • }
  • // ...
  • }

Mais est-ce que ce niveau de caching est suffisant ? Nous devons toujours appeler PHP pour le Bootstrap et effectuer au moins un peu de travail, et cela utilise un processus Apache au passage. En fait, la solution de mise en cache la plus rapide serait de cacher ces pages dynamiques pour une heure sous forme de fichiers HTML statiques qui n'ont pas besoin de PHP, et qui, avec une installation utilisant un reverse proxy, peuvent complétement éviter de passer par Apache et être servies par une alternative plus légère, comme lighttpd ou nginx. Bien sûr, puisque les requêtes n'atteignent jamais l'application ni le bootstrap, expirer les caches devient un peu plus compliqué !

Avec ou sans cache statique physique, une pratique complémentaire serait de déléguer une partie du cache au client, via l'utilisation d'en-têtes Etag et Last-Modified. Bien qu'utiles lorsque vous avez beaucoup de visiteurs qui reviennent régulièrement, ce n'est pas une solution aussi efficace que la mise en cache de HTML statique lorsque des visiteurs uniques ou non-réguliers sont la norme.

Cette discussion illustre le fait que, alors que nous déployons des systèmes de cache à travers une application, il est important d'identifier où cette mise en cache peut être utilisée pour apporter le plus grand bénéfice. Rappelez-vous que l'objectif, lorsque l'on utilise du cache, est d'éviter des calculs et utilisations mémoire inutiles ; plus les caches sont proches des couches externes de l'application, plus il y a de chances de nous évitions des traitements.

B.6.4. Minimiser les Include Paths

Dans une application Zend Framework typique, vous pouvez vous attendre à trouver des classes un peu partout. Il n'est pas inhabituel d'utiliser des classes depuis tellement d'emplacements différents que votre include_path fini par ne plus ressembler à grand chose. Cela a un impact sur les performances, puisqu'à chaque fois que vous incluez une classe (que ce soit directement ou via une fonction d'autoload) en utilisant un chemin relatif, PHP doit rechercher un fichier correspondant en itérant à travers chacun des chemins enregistrés dans l'include_path.

Puisque ceci n'est pas désirable, voici deux règles simples à appliquer.

Tout d'abord, minimisez vos chemins d'inclusion. Lorsque c'est possible, installez les bibliothèques et même Zend Framework dans des répertoires partagés. Puisqu'un grand nombre de bibliothèques PHP 5 ont adopté la convention PEAR, cela ne devrait pas poser de problème. L'exception sera lorsque vous utilisez des bibliothèques via, par exemple, svn:externals sous Subversion, lorsque la bibliothèque externe a des fichiers dans son répertoire de plus haut niveau. Un exemple d'exception de ce type est HTMLPurifier, qui a des fichiers au même niveau que le répertoire HTMLPurifier.

Mais, au-delà de ces quelques exceptions, gardez autant que possible les bibliothèques dans un répertoire partagé. Ainsi, plutôt que d'avoir dix millions d'entrées dans votre include_path, vous pouvez rester à une liste plus efficace, contenant entre 2 et 4 emplacements au maximum.

La seconde règle est de vous assurer que les chemins les plus fréquemment utilisés sont au début de la liste de répertoires d'inclusion. Cela assure qu'ils seront trouvés plus rapidement, et que parcourir les autres emplacements possibles n'est fait que pour les classes plus rarement utilisées.

B.7. Optimisation du Server HTTP

Peu importe à quel point vous optimisez votre application, un jour ou l'autre, votre serveur arrivera à sa limite en terme de traffic qu'il est capable de gérer. Un des facteurs qui a tendance à être le plus limitant sur un serveur est la quantité de mémoire disponible. Au fur et à mesure que le traffic de votre application augmente, de plus en mémoire sera consommée par les processus Apache et MySQL, jusqu'à ce que le serveur n'ait d'autre possibilité que de commencer à utiliser du swap sur disque.

Utiliser l'espace de swap est incroyablement lent (en fait, toutes les opérations disques sont coûteuses par rapport à l'utilisation de la RAM), et l'éviter autant que possible est fortement souhaitable. Avoir des processus Apache dépendant du swap, ou, pire, avoir un processus Apache père qui crée ses enfants depuis le swap, peut ralentir votre application à un point qui ne se traduira pas par une bonne expérience utilisateur. Ceci est tout particulièrement vrai pour les applications Ajax, où une interface utilisateur réactive est extrêmement importante.

La solution la plus évidente qui soit pour ces problématiques serveur est tout simplement d'investir dans plus de matériel pour résoudre le problème ; c'est un procédé nommé scaling ("mise à l'échelle"). Vous pourriez augmenter la quantité de RAM, ou obtenir un serveur plus puissant (scaling vertical) ou utiliser plusieurs serveurs pour répartir la charge (scaling horizontal). Cela dit, avant que vous ne vous lanciez prématurément dans une mise à l'échelle dans un sens ou dans l'autre, il est toujours important de faire le meilleur usage qui soit des ressources serveur à votre disposition, pour minimiser les dépences en nouveau matériel. Oui, l'optimisation vous permet de réaliser des économies, en même temps qu'elle rend vos utilisateur satisfaits.

En examinant Apache, notre serveur HTTP, il y a plusieurs points qui méritent notre attention. Le fichier de configuration d'Apache, un éternel mystère pour de nombreux développeurs, est là où beaucoup de problèmes liés à la gestion de la mémoire trouvent leur origine. Le problème est qu'Apache a tendance à aimer disposer d'une bonne quantité de mémoire, et ne s'en laisse pas priver sans combattre. L'autre problème est qu'Apache, bien qu'étant un serveur rapide à très grand succès, a des concurrents plus agiles qui nécessitent nettement moins de mémoire (hérésie !).

Une stratégie où nous configurons à la fois Apache pour qu'il soit aussi bon que possible compte-tenu des ressources serveur disponible, et où nous le contournons lorsqu'il n'est pas réellement nécessaire, peut mener à des améliorations conséquentes au niveau des performances. Il existe aussi deux autres facteurs : le premier est de laisser les clients garder du contenu en cache, et l'autre est un effet secondaire de la marche vers les systèmes d'exploitation 64 bits. Commençons par la configuration avant que je ne me retrouve encerclé par des gens portant des torches enflammés et des fourches.

B.7.1. Optimiser la Configuration d'Apache

Une vérité de base d'Apache est que votre serveur ne peut efficacement supporter que le nombre de processus enfants qui peuvent tenir en mémoire. Typiquement, un serveur mal configuré permettra à beaucoup trop (ou pas assez) de processus Apache enfants de tourner sans contrôle, utilisant tellement de mémoire que la rencontre avec l'espace de swap sera inévitable. Fixer une limite sur le nombre de processus Apache fils, et garder un oeil sur la consommation mémoire toujours croissante permettra à votre serveur de fonctionner le plus efficacement possible, sans l'envoyer creuser sa tombe prématurément lorsque l'effet Digg viendra frapper à sa porte.

Malheureusement, il n'existe pas de modèle parfait pour la configuration d'Apache. Certains serveurs ont plus de mémoire que d'autres, ou passent plus de temps à servir des fichiers statiques que des contenus dynamiques. Votre configuration devra être ajustée et testée (nous avons parlé de Apache Bench et de Siege plus haut) en fonction du profil de votre propre application, et des logiciels déployés sur votre serveur. Les points cités ci-dessous sont des suggestions ouvertes sur les directions dans lesquelles regarder lorsque vous commencez.

Pour ce qui est de la gestion de la mémoire, les options de configuration les plus importantes sont StartServers, MinSpareServers, MaxSpareServers, MaxClients, et MaxRequestsPerChild. Puisque PHP fonctionne souvent sous Apache en mode prefork, chaque requête reçue par le serveur nécessite un processus fils. Heureusement, les processus enfant peuvent rester disponibles et servir plusieurs requêtes (ce qui signifie moins d'attente pour que de nouveaux soient lancés). Cela signifie que chaque enfant passe du temps à attendre, en occupant une certaine quantité de mémoire. Pour faire simple, donc, si votre serveur a suffisament de mémoire (après avoir pris en compte les autres processus comme MySQL, ssh, ...) pour que 40 processus Apache puissent exister sans commencer à swapper (directive MaxClients), vous devriez vous assurer de ne jamais en avoir plus que cela.

Calculer combien de clients autoriser n'est pas forcément évident. Le calcul simple est le suivant :

Mémoire Dédiée à Apache / Mémoire Consommée en Moyenne par un Processus Apache = MaxClients

Ca peut être un calcul qui se fait rapidement, mais Apache consomme de la mémoire différemment en fonction des scénarios. Une requête simple basée sur Zend Framework peut consommer entre 17 et 20 Mo par processues (ce qui correspondrait à 40 processus pour un serveur avec 1 Go de RAM, dont 800 seraient considérés comme disponibles pour Apache). Servir une page statique ou cachée consommerait nettement moins, de l'ordre de 2 à 4 Mo par processus. Ajuster la configuration d'Apache signifie avoir quelques idées sur la façon dont les visiteurs utilisent l'application, à quelle fréquence les pages cachées sont demandées par rapport aux pages dynamiques, et quel niveau de cache est employé (total ou partiel).

A vous de configurer les options citées plus haut, en monitorant l'utilisation mémoire (éventuellement, en utilisant un mix entre top, free, ou l'excellent htop) et en effectuant quelques attaques avec Apache Bench et Siege, en commençant par des valeurs modestes (générallement, par défaut après installation). En vous basant sur le niveau d'utilisation mémoire obtenu, vous pouvez commencer à ajuster pour autoriser plus ou moins de processus fils au total (MaxClients) tout en variant les nombres de départ (StartServers, MinSpareServers). MaxRequestsPerChild est généralement initialisée avec une valeur assez élevée, mais vous pouvez envisager de la diminuer si les processus fils commencent à grossir d'un point de vue utilisation mémoire au bout d'un certain temps ; réglez cette option à une valeur qui tue les processus fils s'ils commencent à trop grossir : son but, après tout, est de combattre les éventuelles fuites mémoire. Assurez-vous que la valeur de configuration ServerLimit reste plus élevée que MaxClients.

Voici une capture d'écran montrant htop au travail (bien plus joli que top, avec pas mal de fonctionnalités interactives).

Monitoring de ressources serveur avec htop

D'autres points auxquels vous devriez prêter attention incluent KeepAlive et KeepAliveTimeout. Assurez-vous qu'ils soient activés, puisqu'ils permettent aux clients de réutiliser les connexions pour plusieurs requêtes HTTP. Bien entendu, si votre client n'a pas besoin d'effectuer beaucoup de requêtes supplémentaires, KeepAliveTimeout devrait être configuré à une valeur qui empêche les connexions de rester actives trop longtemps lorsqu'elles pourraient être affectées à d'autres clients en réclamant votre attention. Commencer avec une valeur de 2 pour KeepAliveTimeout est généralement suffisant. Certains sites web avec du code HTML optimisé se comportent même encore mieux lorsque cette directive est désactivée ou configurée à 1.

Vient ensuite un point que l'on retrouve chez tout le monde : les fichiers .htaccess. Si vous contrôlez vos propres Virtual Hosts, vous devriez envisager de déplacer les directives présentes dans vos fichiers .htaccess vers le conteneur Directory approprié de httpd.conf ou du fichier externe de vhosts. Cela évitera à Apache d'avoir à sans cesse analyser les fichiers .htaccess.

Mes derniers mots sont réservés pour le module mod_expire de Apache, qui contôle les en-têtes HTTP Expire et Cache-Control. Ces en-têtes indiquent aux clients qu'ils doivent cacher le contenu statique, et ne pas prendre la peine de les demander à nouveau au serveur jusqu'à ce qu'ils aient... expiré ! C'est utile pour les images, CSS, javascript, et autres contenus statiques. Les options de configuration d'Apache pour ceux-ci peuvent être définies au niveau du serveur, d'un Virtual Host, ou au niveau d'un Directory, ce qui permet une configuration précise. Son utilité réside dans le fait de réduire le nombre de requêtes que les clients effectuent : moins de requêtes signifie laisser de la mémoire et du CPU libre pour servir les autres.

Voici un exemple qui concerne les fichiers CSS : il ajoute les en-têtes HTTP pour que les fichiers servis depuis le répertoire donné expirent 30 jours (2592000 secondes) après la date de modification (M) de chaque fichier :

<Directory /home/mrweb/public_html/example.com/css>
    ExpiresActive On
    ExpiresByType text/css M2592000
</Directory>

Après optimisation, vous pourrez remarquer que vous avez toujours l'impression d'être aux environs de 35 à 40 requêtes par seconde, avec autant de processus Apache que la mémoire peut en contenir, mais htop ou free insistent sur le fait qu'il vous reste de la mémoire libre inutilisée même avec Apache Bench en train d'attaquer votre serveur. Malheureusement, des fois, votre capacité mémoire dépasse largement la capacité de votre CPU à répondre, créant un goulot d'étranglement. Il n'y a pas vraiment grand chose que vous puissiez faire dans cette situation sans investir dans plus de puissance CPU, sauf peut-être vous assurer que votre application est optimisée pour ce qui est de l'utilisation du CPU. Mettre en cache joue un rôle évident ici. Dans ce genre de situations, j'ai tendance à commencer à déplacer des données d'un cache disque vers des caches en mémoire (par exemple, en utilisant les backend APC ou memcached pour Zend_Cache). Pourquoi pas, s'il y a de la mémoire libre dont vous pouvez profiter ? Les caches en mémoire sont très rapides, et peuvent être utilisés pour cache le résultat d'opérations coûteuses en CPU (même si le résultat est quelque chose de petite taille) pour gagner encore un peu au niveau du CPU.

B.7.2. Complétement Eviter Apache

Parfois, vous avez juste besoin d'éviter Apache. Apache est rapide, mais il utilise beaucoup plus de mémoire, et peut être plus lent, qu'un certain nombre de solutions alternatives, comme Lighttpd et Nginx. Pour cette section, nous verrons comment utiliser Nginx en tant que reverse proxy devant Apache. Cela signifie que Nginx est utilisé comme serveur HTTP en première ligne, faisant passer toutes les requêtes pour du contenu dynamique à Apache en arrière-plan, mais gérant tout le contenu statique lui-même. Cela signifie aussi que Apache enverra tous les contenus à Nginx qui les servira aux utilisateurs, ce qui permettra à Apache de plus rapidement passer au traitement d'une autre requête.

Cette approche apporte un certain nombre d'avantages. Nginx a une occupation mémoire minimale par rapport à celle d'Apache, ce qui signifie que remplacer une utilisation d'Apache par Nginx apporte immédiatement des gains au niveau de la mémoire. Cela est particulièrement vrai lorsque nous servons du contenu statique, pour lequel Nginx est plus rapide et léger que passer par Apache. C'est aussi utile lorsque les clients lents sont fréquents. Les clients lents sont ceux qui prennent plus de temps que la normale pour terminer les requêtes de téléchargement. Pendant que le client travaille, le processus Apache continue à tourner sur le serveur, attendant plusieurs secondes que le client termine. Est-ce que vous préféreriez avec un processus qui occupe plus de 15 Mo pendant une éternité, ou, à la place, avoir Nginx et son occupation mémoire minimale qui attend, laissant le processus Apache libre de passer à une autre requête de contenu dynamique ? La réponse devrait être évidente !

En restant dans la logique Ubuntu de ce livre, installer Nginx est un simple :

sudo aptitude install nginx

Vous pouvez omettre sudo si vous travaillez en tant que root, mais, sérieusement, pourquoi auriez-vous root ne serait-ce que accessible ?

Lorsque l'on configure un reverse proxy, il est important de prendre en compte les rôles de Nginx et d'Apache. Apache n'écoutera plus sur le port 80, puisque Nginx va prendre le relai en tant que serveur frontal ; nous devrions donc modifier la configuration d'Apache pour, à la place, écouter sur un autre port tel que 8080. Cela se fait en modifiant le fichier de configuration principal, ou, lorsqu'il est utilisé, ports.conf :

NameVirtualHost *:8080
Listen 8080

<IfModule mod_ssl.c>
    Listen 443
</IfModule>

Vous devriez aussi modifier les configurations de vos Virtual Hosts pour utiliser aussi le port 8080 :

<VirtualHost *:8080>
  ServerAdmin webmaster@example.com
  ServerName  example.com.com
  ServerAlias www.example.com
  DocumentRoot /var/www
</VirtualHost>

En passant à Nginx, nous utilisons une configuration du style Ubuntu avec un fichier de configuration principal, et des sous-configurations pour les Hôtes Virtuels (Les servers Nginx). Il y a un peu de duplication ici, puisque Nginx aura souvent besoin d'un conteneur de configuration "server" pour chaque Virtual Host Apache pour lequel nous voulons utiliser le reverse proxy. Voici un exemple de fichier de configuration principal, enregistré sous /etc/nginx/nginx.conf sous Ubuntu :

# Ubuntu Intrepid

user                www-data www-data;
worker_processes    2;
error_log           /var/log/nginx/error.log warn;
pid                 /var/run/nginx.pid;

events {
    worker_connections  1024;
    use epoll;
}

http {
    server_names_hash_bucket_size 64;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log          /var/log/nginx/access.log;

    client_body_temp_path /var/spool/nginx-client-body 1 2;
    client_max_body_size 32m;
    client_body_buffer_size    128k;

    server_tokens       off;
    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         off;
    keepalive_timeout   2;

    # include per-server (Virtual Host) configurations for enabled sites
    include             /etc/nginx/sites-enabled/*;

}

Nginx a tendance à avoir réputation de documentations obscures, puisque les textes originaux sont tous écrits en Russe. Les traductions anglaises sont disponibles sur http://wiki.codemongers.com/. Vous ne trouverez ici pas de directive MaxClients, mais l'équivalent est déterminé par la multiplication des options worker_processes et worker_connections. La documentation traite des autres options citées ici, et certaines sembleront familières pour un utilsateur ayant déjà travaillé avec Apache.

A la fin de ce fichier de configuration, ou dans un fichier séparé placé dans le répertoire /etc/nginx/sites-enabled/, vous pouvez créer des configurations par serveur. Celles-ci sont proches de ce que nous mettons en place pour la configuration des Virtual Hosts Apache :

server {
    listen       80;
    server_name  example.com www.example.com;

    # Default Gzip Configuration (Set Exceptions Per Location)
    gzip on;
    gzip_comp_level 2;
    gzip_proxied any;
    gzip_types text/plain text/html text/css text/xml application/xml application/xml+rss \
        application/xml+atom text/javascript application/x-javascript application/javascript;

    # Handle Static Content Here
    location ~* ^.+\.(jpg|jpeg|gif|png|ico)$  {
        root    /var/www;
        access_log off;
        gzip off;
        expires 30d;
    }
    location ~* ^.+\.(css|js)$ {
        root    /var/www;
        access_log off;
        expires 1d;
    }
    location ~* ^.+\.(pdf|gz|bz2|exe|rar|zip|7z)$ {
        root    /var/www;
        gzip off;
    }

    # Proxy Non-Static Requests to Apache
    location / {
        # Proxy Configuration
        proxy_pass         http://your-servers-ip:8080/;
        proxy_redirect     off;

        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        proxy_max_temp_file_size 0;

        client_max_body_size       10m;
        client_body_buffer_size    128k;

        proxy_connect_timeout      90;
        proxy_send_timeout         90;
        proxy_read_timeout         90;

        proxy_buffer_size          4k;
        proxy_buffers              4 32k;
        proxy_busy_buffers_size    64k;
        proxy_temp_file_write_size 64k;
    }
}
[Attention]Attention

Les lignes qui se terminent par un \ correspondent en fait à une seule ligne, qui a été découpée ici pour des raisons de lisibilité, et devraient en fait être une seule ligne dans le fichier de configuration.

La configuration du server pointe vers le même document root que le Virtual Host Apache que nous utilisions précédement. Pensez à ceci comme à un filtre et une passerelle. La toute première partie de la configuration de notre server définit la compression des contenus comme utilisant gzip par défaut. J'ai ajouté trois conteneurs location qui utilisent des expressions rationnelles pour intercepter les requêtes sur des types de contenus spécifiques. Lorsqu'ils sont détectés, ceux-ci seront servis directement par Nginx sans aller déranger Apache. En fonction du type de fichier, j'ai aussi indiqué à Nginx s'il devait ou non gzipper le contenu, et quel en-tête Expires indiquer (si nécessaire). Ces trois conteneurs permettent de s'assurer que Apache n'aura jamais besoin d'utiliser de la mémoire, coûteuse, pour du contenu statique que Nginx peut servir.

Le quatrième et dernier conteneur location affecte tout ce que les conteneurs précédent n'ont pas géré, c'est-à-dire les contenus dynamiques et les fichiers statiques que les expressions rationnelles de Nginx n'auront pas pris en compte. Ici, nous configurons Nginx pour qu'il transmette la requête vers le port 8080 du serveur local, où Apache écoute. Une fois qu'Apache a terminé la requête, la réponse est immédiatement renvoyée à Nginx (ce qui laisse Apache libre de faire autre chose) qui la prendra en charge.

B.8. Conclusion

Nous avons vu toute une série d'astuces d'optimisations pour des applications basées sur Zend Framework, et avons même jeté un coup d'oeil à quelques problématiques matérielles. Il y a sans aucun doute beaucoup d'autres idées d'optimisations que nous n'avons pas mentionné ici, puisqu'il s'agit, comme vous pouvez le voir, d'un vaste domaine.

Le message principal à retenir de cette annexe est que optimiser demande de mesurer les progrès effectués par rapport à un benchmark des performances de départ et à d'autres métriques. Ce n'est qu'en utilisant une méthodologie carrée que les véritables opportunités d'optimisation sont identifiées et leurs gains potentiels jugés. Perdre votre temps sur des optimisatons aléatoires n'apportant qu'un gain minimal est quelque chose qui vaut le coup d'être évité !



[1] NdT : "The root of all evil"

[2] NdT : Couverture de Code = Code Coverage

[3] NdT : Realpath cache