Cet article fait suite à notre dernier billet ou nous vous parlions d' Apache pour ses 25 ans.
Old school
Prenez une Debian stable (9/stretch au moment où ces mots sont écrits), installez Apache et PHP sans trop réfléchir :
$ docker run --rm -ti debian:stretch bash # apt update && apt install apache2 libapache2-mod-php curl # echo '<?php echo "Working.\n"; ?>' >/var/www/html/test.php # service apache2 start # curl -s localhost/test.php Working.
Mais si vous essayez d'avoir plus de 150 connexions TCP simultanées, ça marchera mal. Notez qu'en HTTP/1.1 la norme pour les navigateurs consiste à utiliser 'Keep-Alive' (maintenir une connexion TCP(+TLS) ouverte le plus longtemps possible pour y faire passer N requêtes HTTP) et en moyenne 6 de ces connexions simultanées sont maintenues par navigateur . A partir de HTTP/2 la donne change, mais HTTP/1.1 reste largement déployé et utilisé et cet effet multiplicateur de 6 n'est pas à négliger : 150 connexions simultanées, c'est peut être 25 utilisateurs simultanés maximum .
Ca coince
Nous allons faire un test simple en générant un fichier quelconque de 1 MB et en générant 300 clients HTTP simultanés qui téléchargent ce fichier en boucle :
# dd if=/dev/urandom of=/var/www/html/dummy bs=1M count=1 # curl -sL https://storage.googleapis.com/hey-release/hey_linux_amd64 >hey && chmod +x hey # ./hey -c 300 -z 30s -t 10 http://localhost/dummy ... Response time histogram: 0.001 [1] | 0.987 [106660] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 1.974 [378] | 2.961 [362] | ... Status code distribution: [200] 107824 responses Error distribution: [278] Get http://localhost/dummy: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
Il y a des erreurs : leur taux semble faible, mais en fait statistiquement la moitié des clients n'a pas pu obtenir de connexion (Apache n'en accepte que 150); plus exactement ils ont attendu 10s avant de décider qu'il s'agissait d'un échec et ceci est arrivé 278 fois en 30 secondes.
Reformulons : ce que ab/hey ne vous disent pas c'est que beaucoup de clients ne sont pas satisfaits par le temps de réponse voire la disponibilité de ce serveur; seuls ceux qui ont obtenu une connexion (dans les temps) ont reçu un service acceptable.
La distribution du temps de réponse est bien concentrée sur une valeur - servir un fichier statique déjà en cache est une activité stable et prévisible, ouf - et vous verrez dans le test suivant que ce système dont la file d'attente des connexions est toujours pleine est déjà deux fois plus lent.
La particularité de cette situation est que seuls les clients perçoivent le problème, le serveur ne loggera pas des connexions qui n'ont jamais été établies. Vous aurez par contre un signal à ne pas rater :
# tail /var/log/apache2/error.log [Tue Mar 03 13:56:00.673702 2020] [mpm_prefork:error] [pid 10333] AH00161: server reached MaxRequestWorkers setting, consider raising the MaxRequestWorkers setting
Ca explose
Dans le cas des fichiers statiques, vos utilisateurs souffrent mais à l'issue du test votre conteneur/système se porte très bien. Avec une application PHP vous devriez vous attendre à voir également votre serveur souffrir, voir être menacé de :
- La mort par le CPU : il n'y a pas de magie, si vous avez par exemple 16 interpréteurs PHP qui combattent pour l'accès à 8 CPUs, leur exécution sera 2 fois plus lente qu'en régime normal. Les applications PHP ne sont pas des consommatrices à 100% de CPU (elles attendent aussi souvent une réponse du serveur SQL), mais 50% est assez courant. La prudence consiste à ne pas faire tourner plus d'interpréteurs PHP que 2 fois le nombre de CPUs; avec Apache MaxClient=150 par défaut, nous vous souhaitons d'avoir un serveur avec ~75 CPUs !
- La mort par la RAM : chaque interpréteur n'ira pas forcément consommer jusqu'à PHP's max_memory (128M par défaut), mais si votre serveur n'a pas quelque chose de l'ordre de 150*128M - 19 GB de RAM - il peut être sujet à d'atroces souffrances appelées swapping .
New school
Ceci demande un tout petit plus de configuration. Nous vous présentons ici la recette "FastCGI", il y a aussi la "PHP-FPM" qui est proche mais demande plus de configuration. Il y a des pour et contre subtils entre FastCGI et PHP-FPM, mais leur architecture est essentiellement la même.
La différence consiste à utiliser mod_fcgid pour lancer les interpréteurs PHP (qui ne sont donc plus embarqués dans Apache) :
$ docker run --rm -ti debian:stretch bash # apt update && apt install apache2 libapache2-mod-fcgid php-cgi curl # cat >/etc/apache2/mods-enabled/fcgid.conf FcgidMaxProcesses 16 FcgidWrapper /usr/bin/php-cgi AddHandler fcgid-script .php <EOF> # cat >/etc/apache2/mods-enabled/mpm_event.conf ServerLimit 128 ThreadLimit 64 ThreadsPerChild 64 MaxRequestWorkers 8192 <EOF> # cat >>/etc/apache2/sites-available/000-default.conf <Directory /var/www/html> Options +ExecCGI </Directory> <EOF> # echo '<?php echo "Working.\n"; ?>' >/var/www/html/test.php # service apache2 start # curl -s localhost/test.php Working.
Cette configuration est minimaliste, prenez le temps de lire la documentation complète de mod_fcgid et ajustez les limites à vos usages - certains paramètres on en particulier des valeurs par défaut un peu fantaisistes (comme FcgidMaxProcesses = 1000
ou FcgidMaxRequestLen = 128kB
).
Vous noterez que dans cette situation Debian a le bon goût de choisir par défaut le MPM Event et non le "vieux" MPM Worker . Par contre il vient avec des réglages conservateurs et toujours 150 connexions simultanées max, d'où les ajustements proposés ci-dessus. De même, consultez la documentation de ce MPM et ne mettez pas aveuglément en production cet exemple incomplet.
# dd if=/dev/urandom of=/var/www/html/dummy bs=1M count=1 # curl -sL https://storage.googleapis.com/hey-release/hey_linux_amd64 >hey && chmod +x hey # ./hey -c 300 -z 30s -t 10 http://localhost/dummy ... Response time histogram: 0.001 [1] | 0.527 [102783] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 1.053 [64] | 1.579 [13] | ... Status code distribution: [200] 102879 responses
Il n'y a plus d'erreur, toutes les requêtes ont trouvé une réponse 200 dans le temps imparti. Plus précisément 99,99% ont été servies en environ une demi-seconde. C'est deux fois plus rapide que le test précédent (car il n'y quasiment pas de file d'attente qui se forme) mais surtout tous les clients sont servis .
Changement de paradigme
Nous n'avons pas fait de mesure sur une application PHP (il faudrait un article nettement plus long). Si vous le faites, vous allez probablement être déçu en première approche avec le modèle Event/FastCGI ou Event/FPM : vos mesures montreront moins de clients servis et pas mal d'erreurs 503. Par contre votre serveur se portera comme un charme, quel que soit la charge que vous lui soumettez.
Bienvenue dans le monde de la régulation. Vous avez le contrôle, vous pouvez enfin résoudre la contrainte impossible : je veux accepter plein de connexions simultanées (MaxClients > 5000) mais je veux limiter le nombre d'interpréteur PHP simultanés car ma capacité CPU/RAM est finie (MaxClients < 50).
Par contre les limites sont plus nettes : avant vos clients formaient des files d'attentes plus ou moins longues et les surcharges étaient amorties progressivement, en ralentissant l'ensemble de votre serveur - souvent jusqu'à des profondeurs insondables.
Maintenant si le lot d'interpréteur PHP que vous allouez est entièrement occupé, Apache répondra immédiatement 503 et vous aurez un message explicite par requête rejetée dans vos logs :
[Tue Mar 03 13:49:01.591711 2020] [fcgid:warn] [pid 11675:tid 139776078243584] [client 127.0.0.1:42182] mod_fcgid: can't apply process slot for /usr/bin/php-cgi
Vous avez donc le contrôle et les informations sur ce que perçoivent les utilisateurs. Ce n'est que le début de la solution. Il vous reste à analyser, optimiser, architecturer, "scaler"... Tout un métier. Celui de Bearstech.